From 60a774c30caeb3130362a6059f970b78bcec5c3b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:47:50 -0400 Subject: [PATCH 001/224] feat(mobile): client operations and dev probe for the desktop-served mobile web bundle (OTA phase A, 5/5) (#21374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 c519c2027d, the last commit on this branch to touch a fenced path, and the whole corpus is re-recorded from it. Delta against the pin, by the README's four classes: 786 header-only, 1 body moved, 0 added, 0 deleted. The only key that moved on the 786 is `baseline`; neither `recorderSha256` nor any `adapterSha256` moved, so nothing this branch touched is inside a hashed recorder path. The one body move is the disclosed behaviour change. `matrix-mobileweb.bundle-fetch-app-js.json` is the reply matrix at the app-js binding: where a partition leaves the app-js chunk without a result, the fetch now stops the other workers mid-asset, so the sender list loses the chunk calls they used to make for a bundle nobody would read. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the rendered tree and the captured signal in boxes Assigning to a `let` inside a callback leaves it narrowed to `null`, which the harness was answering with two type assertions. A one-property box is a checked type and the casting gate no longer has anything to report. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus to the branch's final fenced commit Removing the two type assertions touched a test file under `mobile/src`, which is inside the fence, so the pin moves to cae8f4a318 and the corpus is recorded again from it. Header-only, as a repin with no behaviour change should be: 787 header-only, 0 body moved, 0 added, 0 deleted, and `baseline` is the only key that moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let runRpcOperation send a params-less method A3 declares `mobileWeb.bundle.manifest` with `params: null`, so the generated catalog types its send params as `void` and the two call sites that pass an explicit `null` stopped compiling. `bindDeferredRpcOperation.request` already solved this: `RpcSendArguments` admits `null` exactly where the catalog declares no params, because `params: null` is not the frame that omits the key and narrowing it would rewrite bytes shipped senders already put on the wire. `runRpcOperation` was the one send entry point that never adopted the tuple, having had no params-less caller until now. The compile fence pins all three accepted shapes and that a params-bearing object is still refused. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus after merging main The merge brought A3's host methods and the generated catalog, and the follow-up widened runRpcOperation, so `mobile/src` and `src/shared` both moved. Repins `baseline` to 5be50beb414, the last commit to touch a fenced path, and re-records everything. Delta against that commit: 787 header-only, 0 body moved, 0 added, 0 deleted. The only header key that moves is `baseline` — the transport change is type-only, so nothing a screen observes changed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound the bundle a manifest can make the client allocate M1: the loose client reader kept every ceiling A1 declared except the one that bounds their product. A manifest could pass `totalBytes` 0 alongside 256 assets of 10 MiB each and the fetch would allocate 2560 MiB against a 32 MiB contract. The reader now sums `assets[].byteLength` against MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES. A ceiling rather than the host's sum === totalBytes equality, because this client never trusts `totalBytes` for anything and bounds what it will actually allocate instead. L1: a tap dials the host, and nothing bounded that wait. A host whose client never arrives left the row reading `Connecting…` with its button disabled for the life of the screen. A deadline through the diagnostics folder's own `startDiagnosticFetchTimeout` settles it to a failure and drops the acquisition. Ten seconds, because acquiring a client is local work: the connect and request timeouts live below this and only apply once one exists. L2, four survivors now pinned: the eof break against a zero-byte asset end to end, the offset half of the chunk echo check on its own, the anchor that keeps `rpc (mobile_web_bundle_unavailable)` from reading as a code, and both `abandoned` guards against a run the screen moved on from. Also: the stop check moves above the per-asset buffer, which makes the worker loop's copy redundant; drops the unreferenced chunk reply type; and restores the comment pairing in operation-mutations.ts, where the bundle entry had been inserted between the catalog mutation's comment and its entry. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus after the round-2 fixes Repins `baseline` to 3252779fa7, the round-2 product commit, and re-records everything. Delta against that commit: 787 header-only, 0 body moved, 0 added, 0 deleted, and `baseline` is the only header key that moves. `recorderSha256` holds even though `mutants/operation-mutations.ts` changed, because the mutant directory is excluded from the recorder digest on purpose — nothing on the recording path reads it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus onto the merge that carries A4 A4 (#21376) added a mobile/src file inside the recorder fence, so the pin has to name a commit that contains it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- mobile/app/troubleshoot.tsx | 9 + .../aivault-history-scan-fulfilled.json | 2 +- .../aivault-history-scan-unsupported.json | 2 +- .../aivault-history-scan-worktrees-late.json | 2 +- .../aivault-history-screen-listed.json | 2 +- .../aivault-history-screen-worktrees.json | 2 +- .../aivault-resume-launch-create-refused.json | 2 +- .../aivault-resume-launch-invalid-tab.json | 2 +- .../goldens/aivault-resume-launch-locked.json | 2 +- .../goldens/aivault-resume-launch-sent.json | 2 +- .../aivault-resume-prepare-refused.json | 2 +- .../goldens/aivault-resume-prepare-repin.json | 2 +- .../aivault-resume-prepare-skipped.json | 2 +- .../aivault-resume-prepare-unavailable.json | 2 +- mobile/rpc-foundation/goldens/b1.json | 2 +- mobile/rpc-foundation/goldens/b2.json | 2 +- mobile/rpc-foundation/goldens/b3.json | 2 +- .../goldens/browser-dialog-accepted.json | 2 +- .../goldens/browser-dialog-dismissed.json | 2 +- .../goldens/browser-keyboard-input.json | 2 +- .../browser-pointer-click-accepted.json | 2 +- .../browser-pointer-click-fallback.json | 2 +- .../goldens/browser-wheel-scrolled.json | 2 +- .../clipboard-image-attachment-anonymous.json | 2 +- ...-image-attachment-blocked-before-send.json | 2 +- .../clipboard-image-attachment-cancelled.json | 2 +- .../clipboard-image-attachment-pasted.json | 2 +- ...board-image-attachment-upload-refused.json | 2 +- ...-image-upload-aborts-on-chunk-failure.json | 2 +- .../clipboard-image-upload-chunked.json | 2 +- ...rd-image-upload-single-frame-fallback.json | 2 +- .../clipboard-image-upload-start-refused.json | 2 +- .../goldens/codex-reset-credit-consumed.json | 2 +- .../goldens/codex-reset-credit-resumed.json | 2 +- .../goldens/components-codex-capability.json | 2 +- .../goldens/components-setup-ask.json | 2 +- .../goldens/components-target-local.json | 2 +- .../goldens/components-target-ssh.json | 2 +- .../goldens/diff-review-branch-compare.json | 2 +- .../goldens/diff-review-branch-file-diff.json | 2 +- ...f-review-notes-refused-before-compare.json | 2 +- .../diff-review-refused-file-diff.json | 2 +- .../goldens/diff-review-snapshot.json | 2 +- .../diff-review-status-unavailable.json | 2 +- .../diff-review-worktree-file-diff.json | 2 +- .../goldens/file-tap-open-refused.json | 2 +- .../goldens/file-tap-opens-worktree-file.json | 2 +- .../file-tap-previews-absolute-artifact.json | 2 +- .../goldens/file-tap-resolve-miss.json | 2 +- .../goldens/file-tap-resolve-refused.json | 2 +- .../files-explorer-legacy-fallback.json | 2 +- .../goldens/files-explorer-readdir.json | 2 +- .../goldens/files-ownership-local.json | 2 +- .../goldens/files-ownership-ssh.json | 2 +- .../files-preview-artifact-direct.json | 2 +- .../files-preview-artifact-image-read.json | 2 +- .../goldens/files-preview-artifact-image.json | 2 +- .../goldens/files-preview-grant-refresh.json | 2 +- .../files-preview-worktree-image-read.json | 2 +- .../goldens/files-preview-worktree-image.json | 2 +- .../files-preview-worktree-text-read.json | 2 +- .../goldens/files-preview-worktree.json | 2 +- .../goldens/files-save-blind.json | 2 +- .../goldens/files-save-verified.json | 2 +- .../goldens/files-tab-doc-shapes.json | 2 +- .../goldens/home-host-accounts.json | 2 +- .../goldens/home-host-stats.json | 2 +- .../goldens/host-view-settings-sync.json | 2 +- ...host-worktree-actions-pin-open-delete.json | 2 +- .../goldens/host-worktree-delete-refused.json | 2 +- .../goldens/host-worktree-refresh-stream.json | 2 +- .../interruptions-inventory-lifecycle.json | 2 +- ...ions-settings-bot-overrides-fulfilled.json | 2 +- .../goldens/inventory-lifecycle.json | 2 +- .../goldens/inventory-repeat-query.json | 2 +- .../rpc-foundation/goldens/lifecycle-b3.json | 2 +- .../lifecycle-inventory-lifecycle.json | 2 +- ...ycle-settings-bot-overrides-fulfilled.json | 2 +- ...cle-settings-task-hydration-fulfilled.json | 2 +- ...-settings-workspace-context-fulfilled.json | 2 +- .../goldens/linear-select-workspace.json | 2 +- .../goldens/live-worktree-name-stream.json | 2 +- ...ructured-create-agentsession.create-1.json | 2 +- ...d-create-agentsession.createsupport-1.json | 2 +- ...d-launch-agentsession.createsupport-1.json | 2 +- ...ivault.history-aivault.listsessions-1.json | 2 +- ...ivault.history-screen-platform-status.json | 2 +- ...x-aivault.history-screen-status.get-2.json | 2 +- ...-aivault.history-screen-worktree.ps-1.json | 2 +- .../matrix-aivault.history-status.get-1.json | 2 +- ...-launch-session.tabs.createterminal-1.json | 2 +- ...aivault.resume-launch-terminal.send-1.json | 2 +- ...ration-aivault.preparesessionresume-1.json | 2 +- ...browser.dialog-browser.dialogaccept-1.json | 2 +- ...keyboard-browser.keyboardinserttext-1.json | 2 +- ...x-browser.keyboard-browser.keypress-1.json | 2 +- ...er.pointer-click-browser.mouseclick-1.json | 2 +- ...ser.pointer-click-browser.mousedown-1.json | 2 +- ...ser.pointer-click-browser.mousemove-1.json | 2 +- ...owser.pointer-click-browser.mouseup-1.json | 2 +- ...rix-browser.wheel-browser.mousemove-1.json | 2 +- ...ix-browser.wheel-browser.mousewheel-1.json | 2 +- ...tachment-clipboard.startimageupload-1.json | 2 +- ...pload-clipboard.saveimageastempfile-1.json | 2 +- ...e-upload-clipboard.startimageupload-1.json | 2 +- ...s.codex-reset-capability-status.get-1.json | 2 +- ...it-accounts.consumecodexresetcredit-1.json | 2 +- ...target-local-preflight.detectagents-1.json | 2 +- ...target-preflight.detectremoteagents-1.json | 2 +- ...onents.execution-target-ssh.connect-1.json | 2 +- ...nents.execution-target-ssh.getstate-1.json | 2 +- ...ew-workspace-repositories-repo.list-1.json | 2 +- ...-components.setup-script-repo.hooks-1.json | 2 +- ...ix-files.explorer-screen-files.list-1.json | 2 +- ...files.explorer-screen-files.readdir-1.json | 2 +- ...les.mutation-ownership-ssh.getstate-1.json | 2 +- ...files.mutation-ownership-status.get-1.json | 2 +- ...es.mutation-ownership-worktree.show-1.json | 2 +- ...e-files.readterminalartifactpreview-1.json | 2 +- ...iew-load-files.readterminalartifact-1.json | 2 +- ...iew-load-files.readterminalartifact-2.json | 2 +- ...view-load-files.resolveterminalpath-1.json | 2 +- ...iew-save-files.readterminalartifact-1.json | 2 +- ...ew-save-files.writeterminalartifact-1.json | 2 +- ...ew-worktree-image-files.readpreview-1.json | 2 +- ...es.preview-worktree-text-files.read-1.json | 2 +- .../matrix-files.tab-doc-files.read-1.json | 2 +- ...rix-files.tab-doc-files.readpreview-1.json | 2 +- .../matrix-files.tab-doc-git.diff-1.json | 2 +- ...-files.terminal-path-tap-files.open-1.json | 2 +- ...-path-tap-files.resolveterminalpath-1.json | 2 +- ....base-ref-chain-repo.baserefdefault-1.json | 2 +- ...matrix-git.base-ref-chain-repo.list-1.json | 2 +- ...ix-git.base-ref-chain-worktree.show-1.json | 2 +- ....branch-diff-preview-git.branchdiff-1.json | 2 +- ...-git.changes-load-git.branchcompare-1.json | 2 +- .../matrix-git.changes-load-git.status-1.json | 2 +- .../matrix-git.changes-load-repo.list-1.json | 2 +- ...trix-git.changes-load-worktree.show-1.json | 2 +- ...essage-ai-git.generatecommitmessage-1.json | 2 +- ...tory-commit-files-git.commitcompare-1.json | 2 +- ...it.history-commit-files-git.history-1.json | 2 +- ...matrix-git.history-read-git.history-1.json | 2 +- ...ix-git.remote-prerequisite-git.push-1.json | 2 +- ...x-git.review-preparation-git.status-1.json | 2 +- ...ent-mutation-github.addissuecomment-1.json | 2 +- ...tion-github.addprreviewcommentreply-1.json | 2 +- ...ub.project.deleteissuecommentbyslug-1.json | 2 +- ...ub.project.updateissuecommentbyslug-1.json | 2 +- ...mutation-github.resolvereviewthread-1.json | 2 +- ...x-github.pr-mutation-github.mergepr-1.json | 2 +- ...r-mutation-github.removeprreviewers-1.json | 2 +- ...-mutation-github.requestprreviewers-1.json | 2 +- ...ub.pr-mutation-github.rerunprchecks-1.json | 2 +- ...b.pr-mutation-github.setprautomerge-1.json | 2 +- ...ub.pr-mutation-github.updateprstate-1.json | 2 +- ....pr-read-github.listassignableusers-1.json | 2 +- ...ithub.pr-read-github.prcheckdetails-1.json | 2 +- ...trix-github.pr-read-github.prchecks-1.json | 2 +- ...x-github.pr-read-github.prforbranch-1.json | 2 +- ...trix-github.pr-read-github.reposlug-1.json | 2 +- ...thub.pr-read-github.workitemdetails-1.json | 2 +- ...thub.pr-read-hostedreview.forbranch-1.json | 2 +- ...title-mutation-github.updateprtitle-1.json | 2 +- ...ix-home.host-accounts-accounts.list-1.json | 2 +- ...atrix-home.host-stats-stats.summary-1.json | 2 +- ...sh-runtime.clientevents.subscribe-1-1.json | 2 +- ...sh-runtime.clientevents.subscribe-1-2.json | 2 +- ...sh-runtime.clientevents.subscribe-1-3.json | 2 +- ...sh-runtime.clientevents.subscribe-2-1.json | 2 +- .../matrix-host.view-settings-ui.get-1.json | 2 +- .../matrix-host.view-settings-ui.set-1.json | 2 +- ....worktree-actions-worktree.activate-1.json | 2 +- ...x-host.worktree-actions-worktree.rm-1.json | 2 +- ...-host.worktree-actions-worktree.set-1.json | 2 +- ...-hostedreview.create-chain-git.push-1.json | 2 +- ...ew.create-chain-hostedreview.create-1.json | 2 +- ...tedreview.create-chain-worktree.set-1.json | 2 +- ...dreview.create-intent-git.bulkstage-1.json | 2 +- ...stedreview.create-intent-git.commit-1.json | 2 +- ...te-intent-git.generatecommitmessage-1.json | 2 +- ...hostedreview.create-intent-git.push-1.json | 2 +- ...stedreview.create-intent-git.status-1.json | 2 +- ...stedreview.create-intent-git.status-2.json | 2 +- ...stedreview.create-intent-git.status-3.json | 2 +- ...stedreview.create-intent-git.status-4.json | 2 +- ...w.create-intent-hostedreview.create-1.json | 2 +- ...hostedreview.getcreationeligibility-1.json | 2 +- ...hostedreview.getcreationeligibility-2.json | 2 +- ...edreview.create-intent-worktree.set-1.json | 2 +- ...hostedreview.getcreationeligibility-1.json | 2 +- ...-legacy-inventory-files.searchpaths-1.json | 2 +- ...-legacy-inventory-files.searchpaths-2.json | 2 +- ...trix-legacy-inventory-fresh-inventory.json | 2 +- ...matrix-legacy-inventory-old-inventory.json | 2 +- ...near-detail-barrier-linear.getissue-1.json | 2 +- ...detail-barrier-linear.issuecomments-1.json | 2 +- ...space-picker-linear.selectworkspace-1.json | 2 +- ...me-runtime.clientevents.subscribe-1-1.json | 2 +- ...me-runtime.clientevents.subscribe-1-2.json | 2 +- ...me-runtime.clientevents.subscribe-2-1.json | 2 +- ...ix-live-worktree-name-worktree.show-1.json | 2 +- ...ix-live-worktree-name-worktree.show-2.json | 2 +- ...ix-live-worktree-name-worktree.show-3.json | 2 +- .../matrix-mobileweb.bundle-fetch-app-js.json | 853 ++++++++++++++++++ ...rix-mobileweb.bundle-fetch-index-head.json | 853 ++++++++++++++++++ ...rix-mobileweb.bundle-fetch-index-tail.json | 853 ++++++++++++++++++ ...dle-fetch-mobileweb.bundle.manifest-1.json | 833 +++++++++++++++++ ...-manifest-mobileweb.bundle.manifest-1.json | 674 ++++++++++++++ ...ativechat.image-paste-terminal.send-1.json | 2 +- ...ativechat.image-paste-terminal.send-2.json | 2 +- ...e-upload-clipboard.startimageupload-1.json | 2 +- ...ings.mutatenativechatsessionoptions-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...vechat.terminal-write-terminal.send-1.json | 2 +- ...stream-notifications.getmissedsince-1.json | 2 +- ...op-stream-notifications.subscribe-1-1.json | 2 +- ...op-stream-notifications.subscribe-1-2.json | 2 +- ...op-stream-notifications.unsubscribe-1.json | 2 +- ...-test-screen-notifications.testpush-1.json | 2 +- ...missal-notifications.getmissedsince-1.json | 2 +- ...stration-notifications.registerpush-1.json | 2 +- ...ration-notifications.unregisterpush-1.json | 2 +- ...rix-pairing.pre-profile-direct-status.json | 2 +- ...ng.pre-profile-pairing.getendpoints-1.json | 2 +- ....pre-profile-pairing.provisionrelay-1.json | 2 +- ...trix-pairing.pre-profile-relay-status.json | 2 +- ...se-github.project.updateissuebyslug-1.json | 2 +- ...ntial-rotation-pairing.getendpoints-1.json | 2 +- ...ntial-rotation-pairing.getendpoints-2.json | 2 +- ...ial-rotation-pairing.provisionrelay-1.json | 2 +- ...direct-upgrade-pairing.getendpoints-1.json | 2 +- ...direct-upgrade-pairing.getendpoints-2.json | 2 +- ...rect-upgrade-pairing.provisionrelay-1.json | 2 +- ...iring-recovery-pairing.getendpoints-1.json | 2 +- ...rowser-tab-create-browser.tabcreate-1.json | 2 +- ...ion.content-create-files.createfile-1.json | 2 +- ...x-session.content-create-files.open-1.json | 2 +- ...x-session.content-create-status.get-1.json | 2 +- ...ession.content-create-worktree.show-1.json | 2 +- ...erminal-session.tabs.createterminal-1.json | 2 +- ...ssion.create-terminal-terminal.send-1.json | 2 +- ...ix-session.diff-notes-worktree.show-1.json | 2 +- ...on.diff-review-actions-worktree.set-1.json | 2 +- ...rix-session.diff-review-base-ref-show.json | 2 +- ...ssion.diff-review-git.branchcompare-1.json | 2 +- ...trix-session.diff-review-git.status-1.json | 2 +- ...atrix-session.diff-review-repo.list-1.json | 2 +- ...atrix-session.diff-review-review-show.json | 2 +- ...n.markdown-disk-fallback-files.read-1.json | 2 +- ...down-disk-fallback-markdown.readtab-1.json | 2 +- ...sion.markdown-save-markdown.savetab-1.json | 2 +- ...ve-chat-page-nativechat.readsession-1.json | 2 +- ...ve-chat-page-nativechat.subscribe-1-1.json | 2 +- ...ve-chat-page-nativechat.subscribe-2-1.json | 2 +- ...n.native-chat-readability-repo.list-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...sion.native-chat-stop-terminal.send-1.json | 2 +- ...sion.native-chat-stop-terminal.send-2.json | 2 +- ...pr-branch-context-git.branchcompare-1.json | 2 +- ...ession.pr-branch-context-git.status-1.json | 2 +- ...session.pr-branch-context-repo.list-1.json | 2 +- ...ion.pr-branch-context-worktree.show-1.json | 2 +- ...-session.pr-sidebar-github.prchecks-1.json | 2 +- ...ssion.pr-sidebar-github.prforbranch-1.json | 2 +- ...n.pr-sidebar-hostedreview.forbranch-1.json | 2 +- ...ix-session.pr-sidebar-worktree.show-1.json | 2 +- ...-triage-session.tabs.createterminal-1.json | 2 +- ...rix-session.pr-triage-terminal.send-1.json | 2 +- ...n.review-branch-diff-git.branchdiff-1.json | 2 +- ...x-session.review-file-diff-git.diff-1.json | 2 +- ...x-session.review-file-diff-git.diff-2.json | 2 +- ...x-session.review-file-diff-git.diff-3.json | 2 +- ...on.review-git-mutations-git.discard-1.json | 2 +- ...sion.review-git-mutations-git.stage-1.json | 2 +- ...sion.review-git-mutations-git.stage-2.json | 2 +- ...review-send-sheet-session.tabs.list-1.json | 2 +- ...x-session.startup-worktree.activate-1.json | 2 +- ...x-session.startup-worktree.activate-2.json | 2 +- ...ab-activation-session.tabs.activate-1.json | 2 +- ...ssion.tab-activation-terminal.focus-1.json | 2 +- ...ab-close-session-session.tabs.close-1.json | 2 +- ...ix-session.tab-close-terminal.close-1.json | 2 +- ...sion.tab-documents-markdown.readtab-1.json | 2 +- ...-session.tab-rename-terminal.rename-1.json | 2 +- ...on.tab-reveal-session.tabs.activate-1.json | 2 +- ...ession.tab-reveal-session.tabs.list-1.json | 2 +- ...abs-stream-health-session.tabs.list-1.json | 2 +- ...isplay-mode-terminal.setdisplaymode-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...-gesture-input-terminal.clearbuffer-1.json | 2 +- ...erminal-gesture-input-terminal.send-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...n.terminal-input-send-terminal.send-1.json | 2 +- ...on.terminal-inventory-terminal.list-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...session.terminal-paste-settings.get-1.json | 2 +- ...ession.terminal-paste-terminal.send-1.json | 2 +- ...ssion.worktree-connection-repo.list-1.json | 2 +- ...on.worktree-connection-settings.get-1.json | 2 +- ...t-read-preflight.detectremoteagents-1.json | 2 +- ...atrix-settings-agent-read-repo.list-1.json | 2 +- ...ix-settings-agent-read-settings.get-1.json | 2 +- ...ettings-best-effort-settings.update-1.json | 2 +- ...settings.bot-overrides-settings.get-1.json | 2 +- ...ttings.home-providers-linear.status-1.json | 2 +- ...ings.home-providers-preflight.check-1.json | 2 +- ...ettings.home-providers-settings.get-1.json | 2 +- ...local-agents-preflight.detectagents-1.json | 2 +- ...ings.new-tab-local-agents-repo.list-1.json | 2 +- ...s.new-tab-local-agents-settings.get-1.json | 2 +- ...s-settings.getterminalquickcommands-1.json | 2 +- ...ettings.updateterminalquickcommands-1.json | 2 +- ...ettings.repo-metadata-host.platform-1.json | 2 +- ...ix-settings.repo-metadata-repo.list-1.json | 2 +- ...settings.repo-metadata-settings.get-1.json | 2 +- ...po-metadata-ssh.listtargetsummaries-1.json | 2 +- ...esume-metadata-folderworkspace.list-1.json | 2 +- ...s.resume-metadata-projectgroup.list-1.json | 2 +- ...-settings.resume-metadata-repo.list-1.json | 2 +- ...ttings.resume-metadata-settings.get-1.json | 2 +- ...ettings.resume-metadata-worktree.ps-1.json | 2 +- ...ttings.task-hydration-linear.status-1.json | 2 +- ...ings.task-hydration-preflight.check-1.json | 2 +- ...ettings.task-hydration-settings.get-1.json | 2 +- ...-settings.task-hydration-status.get-1.json | 2 +- ...trix-settings.task-hydration-ui.get-1.json | 2 +- ....task-workspace-create-settings.get-1.json | 2 +- ...sk-workspace-create-worktree.create-1.json | 2 +- ...ettings.task-workspace-settings.get-1.json | 2 +- ...ngs.workspace-context-linear.status-1.json | 2 +- ...s.workspace-context-preflight.check-1.json | 2 +- ...ings.workspace-context-settings.get-1.json | 2 +- ...x-settings.workspace-context-ui.get-1.json | 2 +- ...tings.workspace-submit-settings.get-1.json | 2 +- ...tation-chunk-speech.dictation.chunk-1.json | 2 +- ...ion-session-speech.dictation.finish-1.json | 2 +- ...tion-session-speech.dictation.start-1.json | 2 +- ...ation-start-speech.dictation.cancel-1.json | 2 +- ...tation-start-speech.dictation.start-1.json | 2 +- ....setup-sheet-speech.dictation.setup-1.json | 2 +- ...ch.setup-sheet-speech.models.delete-1.json | 2 +- ....setup-sheet-speech.models.download-1.json | 2 +- ...eech.setup-sheet-speech.models.list-1.json | 2 +- ...cks-files-github.addprreviewcomment-1.json | 2 +- ...-checks-files-github.prfilecontents-1.json | 2 +- ...m-checks-files-github.rerunprchecks-1.json | 2 +- ...ks-files-github.resolvereviewthread-1.json | 2 +- ...checks-files-github.setprfileviewed-1.json | 2 +- ...mment-github-github.addissuecomment-1.json | 2 +- ...mment-gitlab-gitlab.addissuecomment-1.json | 2 +- ...mment-gitlab-mr-gitlab.addmrcomment-1.json | 2 +- ...etail-github-github.workitemdetails-1.json | 2 +- ...etail-gitlab-gitlab.workitemdetails-1.json | 2 +- ....item-detail-linear-linear.getissue-1.json | 2 +- ...-detail-linear-linear.issuecomments-1.json | 2 +- ...metadata-github.listassignableusers-1.json | 2 +- ...m-detail-metadata-github.listlabels-1.json | 2 +- ...ks.item-merge-gitlab-gitlab.mergemr-1.json | 2 +- ...tem-metadata-github-github.updatepr-1.json | 2 +- ...-metadata-gitlab-gitlab.updateissue-1.json | 2 +- ...-metadata-gitlab-mr-gitlab.updatemr-1.json | 2 +- ...-reply-merge-github.addissuecomment-1.json | 2 +- ...erge-github.addprreviewcommentreply-1.json | 2 +- ...sks.item-reply-merge-github.mergepr-1.json | 2 +- ...item-reply-merge-linear.updateissue-1.json | 2 +- ....item-review-github-github.prchecks-1.json | 2 +- ...ew-github-github.requestprreviewers-1.json | 2 +- ...em-status-gitlab-github.updateissue-1.json | 2 +- ...em-status-gitlab-gitlab.updateissue-1.json | 2 +- ...atus-gitlab-mr-gitlab.updatemrstate-1.json | 2 +- ...tasks.linear-connect-linear.connect-1.json | 2 +- ....linear-item-linear.addissuecomment-1.json | 2 +- ...asks.linear-item-linear.createissue-1.json | 2 +- ...x-tasks.linear-item-linear.getissue-1.json | 2 +- ...inear-team-context-linear.listteams-1.json | 2 +- ...near-team-context-linear.teamstates-1.json | 2 +- ...-tasks.paste-lookup-github.reposlug-1.json | 2 +- ...-tasks.paste-lookup-github.workitem-1.json | 2 +- ...e-lookup-github.workitembyownerrepo-1.json | 2 +- ....paste-lookup-gitlab.workitembypath-1.json | 2 +- ...-load-github.project.listaccessible-1.json | 2 +- ...board-load-github.project.listviews-1.json | 2 +- ...board-load-github.project.listviews-2.json | 2 +- ...oard-load-github.project.resolveref-1.json | 2 +- ...board-load-github.project.viewtable-1.json | 2 +- ....project-repo-slugs-github.reposlug-1.json | 2 +- ...ithub.project.addissuecommentbyslug-1.json | 2 +- ...ue-github.project.updateissuebyslug-1.json | 2 +- ...ub.project.updateissuecommentbyslug-1.json | 2 +- ...hub.project.updatepullrequestbyslug-1.json | 2 +- ...ithub.project.workitemdetailsbyslug-1.json | 2 +- ...ields-github.project.clearitemfield-1.json | 2 +- ...ithub.project.updateissuetypebyslug-1.json | 2 +- ...elds-github.project.updateitemfield-1.json | 2 +- ...les-merge-github.addprreviewcomment-1.json | 2 +- ...ject-row-files-merge-github.mergepr-1.json | 2 +- ...w-files-merge-github.prfilecontents-1.json | 2 +- ...-row-files-merge-github.updateissue-1.json | 2 +- ...ow-files-merge-github.updateprstate-1.json | 2 +- ...b.project.listassignableusersbyslug-1.json | 2 +- ...github.project.listissuetypesbyslug-1.json | 2 +- ...oad-github.project.listlabelsbyslug-1.json | 2 +- ...t-row-review-checks-github.prchecks-1.json | 2 +- ...ew-checks-github.requestprreviewers-1.json | 2 +- ...-review-checks-github.rerunprchecks-1.json | 2 +- ...eview-checks-github.setprfileviewed-1.json | 2 +- ...-row-threads-github.addissuecomment-1.json | 2 +- ...eads-github.addprreviewcommentreply-1.json | 2 +- ...ub.project.deleteissuecommentbyslug-1.json | 2 +- ...-threads-github.resolvereviewthread-1.json | 2 +- ...provider-load-github.countworkitems-1.json | 2 +- ....provider-load-github.listworkitems-1.json | 2 +- ...asks.provider-load-linear.listteams-1.json | 2 +- ...x-tasks.provider-load-linear.status-1.json | 2 +- ...tasks.provider-load-settings.update-1.json | 2 +- ...rix-tasks.route-repo-list-repo.list-1.json | 2 +- ...-source-search-github.listworkitems-1.json | 2 +- ...-source-search-gitlab.listworkitems-1.json | 2 +- ...art-source-search-linear.listissues-1.json | 2 +- ...t-source-search-linear.searchissues-1.json | 2 +- ...smart-source-search-repo.searchrefs-1.json | 2 +- ...sk-create-github-github.createissue-1.json | 2 +- ...asks.task-create-github-repo.update-1.json | 2 +- ...sk-create-gitlab-gitlab.createissue-1.json | 2 +- ...sk-create-linear-linear.createissue-1.json | 2 +- ...t-gitlab-items-gitlab.listworkitems-1.json | 2 +- ...task-list-gitlab-todos-gitlab.todos-1.json | 2 +- ....task-list-linear-linear.listissues-1.json | 2 +- ...ask-list-linear-linear.searchissues-1.json | 2 +- ...ks.workspace-source-repo.searchrefs-1.json | 2 +- ...workspace-source-repo.sparsepresets-1.json | 2 +- ...kspace-sparse-repo.savesparsepreset-1.json | 2 +- ...tasks.workspace-sparse-ssh.getstate-1.json | 2 +- ...ce-ssh-local-preflight.detectagents-1.json | 2 +- ...ce-ssh-preflight.detectremoteagents-1.json | 2 +- ...trix-tasks.workspace-ssh-repo.hooks-1.json | 2 +- ...rix-tasks.workspace-ssh-ssh.connect-1.json | 2 +- ...-terminal.query-reply-terminal.send-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...ix-terminal.raw-input-terminal.send-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...chestration.workerterminaluserinput-2.json | 2 +- ...wport-refit-terminal.updateviewport-1.json | 2 +- ...ansport.capability-probe-status.get-1.json | 2 +- ...nsport.host-status-gates-status.get-1.json | 2 +- ...-transport.pairing-race-direct-status.json | 2 +- ...x-transport.pairing-race-relay-status.json | 2 +- ...ee.agent-launch-create-agent.launch-1.json | 2 +- ...rktree.catalog-snapshot-worktree.ps-1.json | 2 +- ...rktree.create-retry-worktree.create-1.json | 2 +- ...x-worktree.home-catalog-worktree.ps-1.json | 2 +- ....hosted-base-worktree.resolvemrbase-1.json | 2 +- ....hosted-base-worktree.resolveprbase-1.json | 2 +- ...red-names-worktree.listretirednames-1.json | 2 +- ...x-worktree.review-link-worktree.set-1.json | 2 +- ...ree.runtime-capabilities-status.get-1.json | 2 +- ...ix-worktree.setup-hook-trust-ui.set-1.json | 2 +- .../mobile-web-bundle-build-changed.json | 210 +++++ .../mobile-web-bundle-fetch-paged.json | 276 ++++++ .../mobile-web-bundle-manifest-read.json | 135 +++ .../mobile-web-bundle-unavailable.json | 90 ++ .../native-chat-image-paste-single.json | 2 +- ...e-chat-image-paste-stops-on-rejection.json | 2 +- ...ative-chat-image-paste-trailing-image.json | 2 +- .../native-chat-image-paste-two-images.json | 2 +- .../native-chat-image-upload-cancelled.json | 2 +- ...native-chat-image-upload-second-fails.json | 2 +- .../native-chat-image-upload-single.json | 2 +- ...ative-chat-image-upload-start-refused.json | 2 +- .../goldens/native-chat-image-upload-two.json | 2 +- .../goldens/native-chat-page-earlier.json | 2 +- .../native-chat-readability-local-repo.json | 2 +- .../native-chat-readability-refused.json | 2 +- .../native-chat-readability-remote-repo.json | 2 +- ...native-chat-session-option-pick-empty.json | 2 +- ...tive-chat-session-option-pick-refused.json | 2 +- ...tive-chat-session-option-pick-written.json | 2 +- .../goldens/native-chat-stop-accepted.json | 2 +- .../native-chat-stop-both-rejected.json | 2 +- .../native-chat-stop-delivery-unknown.json | 2 +- .../goldens/native-chat-write-accepted.json | 2 +- .../goldens/native-chat-write-clear-line.json | 2 +- .../native-chat-write-delivery-unknown.json | 2 +- .../goldens/native-chat-write-rejected.json | 2 +- .../native-chat-write-typed-command.json | 2 +- .../goldens/new-tab-local-agents.json | 2 +- .../new-workspace-repositories-fulfilled.json | 2 +- .../notifications-desktop-stream-closed.json | 2 +- ...notifications-desktop-stream-replayed.json | 2 +- .../goldens/notifications-desktop-stream.json | 2 +- .../notifications-display-test-accepted.json | 2 +- ...fications-display-test-not-registered.json | 2 +- ...tifications-display-test-rate-limited.json | 2 +- ...fications-display-test-unknown-reason.json | 2 +- .../notifications-push-gateway-rejected.json | 2 +- .../notifications-push-registered.json | 2 +- ...re-profile-direct-wins-and-provisions.json | 2 +- ...ovision-unsupported-saves-direct-host.json | 2 +- .../pairing-pre-profile-times-out.json | 2 +- .../goldens/pr-branch-identity.json | 2 +- .../goldens/pr-branch-repo-context.json | 2 +- .../goldens/pr-comment-mutation.json | 2 +- .../pr-comment-resolve-unconfirmed.json | 2 +- .../goldens/pr-mutation-in-band-failure.json | 2 +- .../goldens/pr-mutation-status.json | 2 +- .../goldens/pr-read-fork-routing.json | 2 +- .../goldens/pr-read-surface.json | 2 +- .../goldens/pr-read-upstream-error.json | 2 +- .../goldens/pr-sidebar-checks-refused.json | 2 +- .../goldens/pr-sidebar-load.json | 2 +- .../goldens/pr-title-mutation.json | 2 +- .../goldens/pr-title-unconfirmed.json | 2 +- .../goldens/pr-triage-invalid-terminal.json | 2 +- .../goldens/pr-triage-launch.json | 2 +- .../goldens/pr-triage-send-locked.json | 2 +- .../goldens/probe-new-tab-both-refused.json | 2 +- .../probe-new-tab-null-sibling-refused.json | 2 +- ...probe-new-tab-refused-sibling-rejects.json | 2 +- ...probe-new-tab-rejects-sibling-refused.json | 2 +- .../push-dismissal-tray-reconciled.json | 2 +- .../goldens/quick-commands-load-refused.json | 2 +- .../quick-commands-loaded-and-saved.json | 2 +- ...uick-commands-save-refused-rolls-back.json | 2 +- .../goldens/relay-direct-upgrade-commits.json | 2 +- ...ect-upgrade-unsupported-host-declines.json | 2 +- ...ay-pairing-recovery-invite-authorizes.json | 2 +- ...lay-pairing-recovery-resume-committed.json | 2 +- .../relay-rotation-installs-and-commits.json | 2 +- ...ay-rotation-resumes-committed-pending.json | 2 +- .../goldens/review-branch-diff-shapes.json | 2 +- .../review-create-terminal-refused.json | 2 +- .../goldens/review-file-diff-shapes.json | 2 +- .../goldens/review-git-mutations-run.json | 2 +- .../review-mark-reviewed-persists.json | 2 +- .../review-mark-reviewed-rolls-back.json | 2 +- .../goldens/review-open-in-session.json | 2 +- .../review-send-notes-heals-stale-input.json | 2 +- .../review-send-sheet-lists-terminals.json | 2 +- .../goldens/review-stage-file.json | 2 +- .../goldens/review-stage-refused.json | 2 +- .../goldens/sc-base-ref-default.json | 2 +- .../goldens/sc-base-ref-repo-fallback.json | 2 +- .../goldens/sc-base-ref-unavailable.json | 2 +- .../goldens/sc-base-ref-worktree-hit.json | 2 +- .../goldens/sc-branch-diff-previewed.json | 2 +- .../goldens/sc-changes-loaded.json | 2 +- .../sc-commit-message-cancel-rejected.json | 2 +- .../goldens/sc-commit-message-canceled.json | 2 +- .../goldens/sc-commit-message-generated.json | 2 +- .../goldens/sc-create-existing-review.json | 2 +- ...reate-intent-stage-commit-push-create.json | 2 +- .../sc-create-intent-unlisted-provider.json | 2 +- .../sc-create-link-failure-is-non-fatal.json | 2 +- .../sc-create-pushes-then-creates.json | 2 +- .../sc-create-refused-empty-message.json | 2 +- .../sc-create-rejected-empty-message.json | 2 +- .../goldens/sc-eligibility-fetched.json | 2 +- .../goldens/sc-history-commit-files.json | 2 +- .../goldens/sc-history-loaded.json | 2 +- .../goldens/sc-pr-link-hosted-review.json | 2 +- .../goldens/sc-pr-link-read.json | 2 +- .../goldens/sc-pr-link-set.json | 2 +- .../sc-prefill-unavailable-on-refusal.json | 2 +- .../sc-prefill-unavailable-on-rejection.json | 2 +- .../sc-prerequisite-force-with-lease.json | 2 +- .../goldens/sc-prerequisite-publish.json | 2 +- .../goldens/sc-prerequisite-push.json | 2 +- .../goldens/sc-prerequisite-skipped.json | 2 +- .../goldens/sc-reveal-first-poll.json | 2 +- .../goldens/sc-reveal-timeout.json | 2 +- .../sc-review-commit-inner-failure.json | 2 +- ...c-review-commit-refused-empty-message.json | 2 +- .../goldens/sc-review-commit-rejected.json | 2 +- .../goldens/sc-review-commit.json | 2 +- .../sc-review-status-entries-not-array.json | 2 +- .../goldens/sc-review-status-normalized.json | 2 +- .../rpc-foundation/goldens/schedules-b3.json | 2 +- ...les-settings-home-providers-fulfilled.json | 2 +- .../schedules-settings-new-tab-ssh.json | 2 +- ...ules-settings-repo-metadata-fulfilled.json | 2 +- ...es-settings-resume-metadata-fulfilled.json | 2 +- ...les-settings-task-hydration-fulfilled.json | 2 +- ...-settings-workspace-context-fulfilled.json | 2 +- .../goldens/session-browser-tab-created.json | 2 +- .../session-create-browser-refused.json | 2 +- .../goldens/session-create-browser-tab.json | 2 +- ...ession-create-markdown-name-collision.json | 2 +- .../goldens/session-create-markdown-note.json | 2 +- ...nal-ignores-a-second-create-in-flight.json | 2 +- ...minal-launches-an-agent-quick-command.json | 2 +- .../session-create-terminal-refused.json | 2 +- ...ssion-create-terminal-replaces-active.json | 2 +- ...-create-terminal-runs-a-quick-command.json | 2 +- .../session-create-terminal-with-prompt.json | 2 +- ...on-create-terminal-without-active-tab.json | 2 +- ...ession-create-terminal-without-handle.json | 2 +- .../session-diff-notes-load-refused.json | 2 +- .../goldens/session-diff-notes-loaded.json | 2 +- .../goldens/session-file-tab-read.json | 2 +- .../goldens/session-markdown-disk-read.json | 2 +- .../goldens/session-markdown-disk-served.json | 2 +- .../session-markdown-save-conflict.json | 2 +- .../goldens/session-markdown-saved.json | 2 +- .../session-markdown-tab-disk-fallback.json | 2 +- .../goldens/session-markdown-tab-read.json | 2 +- .../goldens/session-markdown-tab-refused.json | 2 +- ...session-startup-both-activation-sites.json | 2 +- ...artup-floating-route-skips-activation.json | 2 +- ...-keeps-terminals-visible-on-reconnect.json | 2 +- ...efused-tab-load-still-loads-terminals.json | 2 +- ...ion-tab-activation-focus-and-activate.json | 2 +- .../session-tab-activation-refused.json | 2 +- ...ession-tab-activation-transport-error.json | 2 +- .../session-tab-close-refused-keeps-tab.json | 2 +- .../session-tab-close-session-tab.json | 2 +- .../goldens/session-tab-close-terminal.json | 2 +- .../goldens/session-tab-closed.json | 2 +- .../goldens/session-tab-rename.json | 2 +- .../goldens/session-tab-renamed.json | 2 +- .../goldens/session-tabs-health-errored.json | 2 +- .../session-tabs-health-reconciled.json | 2 +- .../goldens/session-tabs-health-refused.json | 2 +- ...abs-health-stale-application-revision.json | 2 +- ...terminal-display-mode-auto-take-floor.json | 2 +- ...isplay-mode-auto-without-device-token.json | 2 +- ...al-display-mode-auto-without-viewport.json | 2 +- ...inal-display-mode-drops-second-toggle.json | 2 +- ...sion-terminal-display-mode-to-desktop.json | 2 +- ...session-terminal-list-dedupes-handles.json | 2 +- .../session-terminal-list-empty-guarded.json | 2 +- .../goldens/session-terminal-list-merged.json | 2 +- .../session-terminal-list-refused.json | 2 +- .../settings-bot-overrides-fulfilled.json | 2 +- ...ettings-bot-overrides-refresh-refused.json | 2 +- .../settings-bot-overrides-refused.json | 2 +- ...ettings-bot-overrides-transport-error.json | 2 +- .../goldens/settings-home-coalesced.json | 2 +- .../settings-home-providers-fulfilled.json | 2 +- ...ings-home-providers-refuse-after-data.json | 2 +- .../settings-home-providers-refused.json | 2 +- ...ttings-home-providers-transport-error.json | 2 +- .../goldens/settings-new-tab-refused.json | 2 +- .../goldens/settings-new-tab-ssh.json | 2 +- .../settings-new-tab-transport-error.json | 2 +- .../goldens/settings-repo-cache-expiry.json | 2 +- .../settings-repo-metadata-fulfilled.json | 2 +- .../goldens/settings-repo-metadata-icons.json | 2 +- ...tings-repo-metadata-refuse-after-data.json | 2 +- .../settings-repo-metadata-refused.json | 2 +- .../settings-repo-metadata-single-host.json | 2 +- ...ettings-repo-metadata-transport-error.json | 2 +- .../settings-resume-metadata-fulfilled.json | 2 +- ...ngs-resume-metadata-refuse-after-data.json | 2 +- .../settings-resume-metadata-refused.json | 2 +- ...tings-resume-metadata-transport-error.json | 2 +- .../settings-task-hydration-fulfilled.json | 2 +- ...ings-task-hydration-refuse-after-data.json | 2 +- .../settings-task-hydration-refused.json | 2 +- ...ttings-task-hydration-transport-error.json | 2 +- ...settings-task-workspace-create-linear.json | 2 +- ...-task-workspace-create-pr-start-point.json | 2 +- .../settings-task-workspace-fulfilled.json | 2 +- .../settings-task-workspace-refused.json | 2 +- ...ttings-task-workspace-transport-error.json | 2 +- .../goldens/settings-task-write.json | 2 +- .../settings-workspace-context-fulfilled.json | 2 +- ...s-workspace-context-refuse-after-data.json | 2 +- .../settings-workspace-context-refused.json | 2 +- ...ngs-workspace-context-transport-error.json | 2 +- .../settings-workspace-submit-fulfilled.json | 2 +- .../settings-workspace-submit-refused.json | 2 +- ...ings-workspace-submit-transport-error.json | 2 +- .../speech-audio-chunk-acknowledged.json | 2 +- .../speech-desktop-start-fulfilled.json | 2 +- ...speech-desktop-start-recording-failed.json | 2 +- .../speech-desktop-start-superseded.json | 2 +- .../speech-dictation-session-cancelled.json | 2 +- .../speech-dictation-session-transcript.json | 2 +- .../speech-setup-sheet-denied-to-mobile.json | 2 +- .../goldens/speech-setup-sheet-fulfilled.json | 2 +- .../speech-setup-sheet-legacy-desktop.json | 2 +- .../speech-setup-sheet-model-vocabulary.json | 2 +- .../structured-agent-session-created.json | 2 +- .../goldens/structured-launch-created.json | 2 +- .../structured-launch-definitive-refusal.json | 2 +- ...uctured-launch-replays-dropped-create.json | 2 +- .../structured-launch-support-refused.json | 2 +- .../structured-launch-unsupported.json | 2 +- .../goldens/tasks-route-repo-list.json | 2 +- .../terminal-gesture-flush-and-clear.json | 2 +- .../goldens/terminal-input-send-accepted.json | 2 +- .../goldens/terminal-input-send-refused.json | 2 +- .../goldens/terminal-live-input-accepted.json | 2 +- .../goldens/terminal-paste-accepted.json | 2 +- .../goldens/terminal-paste-refused.json | 2 +- .../terminal-query-reply-accepted.json | 2 +- .../terminal-query-reply-unsubscribed.json | 2 +- .../goldens/terminal-raw-input-refused.json | 2 +- .../goldens/terminal-raw-input-reported.json | 2 +- .../terminal-takeover-report-accepted.json | 2 +- .../terminal-takeover-report-retried.json | 2 +- .../terminal-viewport-refit-applied.json | 2 +- ...erminal-viewport-refit-legacy-desktop.json | 2 +- ...terminal-worktree-connection-resolved.json | 2 +- .../goldens/tk-create-github.json | 2 +- .../goldens/tk-create-gitlab.json | 2 +- .../goldens/tk-create-linear.json | 2 +- .../goldens/tk-item-checks-files.json | 2 +- .../goldens/tk-item-comment-github.json | 2 +- .../goldens/tk-item-comment-gitlab-mr.json | 2 +- .../goldens/tk-item-comment-gitlab.json | 2 +- .../tk-item-detail-github-reactions.json | 2 +- .../goldens/tk-item-detail-github.json | 2 +- .../tk-item-detail-gitlab-reactions.json | 2 +- .../goldens/tk-item-detail-gitlab.json | 2 +- .../goldens/tk-item-detail-linear.json | 2 +- .../goldens/tk-item-detail-metadata.json | 2 +- .../goldens/tk-item-merge-gitlab.json | 2 +- .../goldens/tk-item-metadata-github.json | 2 +- .../goldens/tk-item-metadata-gitlab-mr.json | 2 +- .../goldens/tk-item-metadata-gitlab.json | 2 +- .../goldens/tk-item-reply-merge.json | 2 +- .../goldens/tk-item-review-github.json | 2 +- .../goldens/tk-item-status-gitlab-mr.json | 2 +- .../goldens/tk-item-status-gitlab.json | 2 +- .../goldens/tk-linear-connect.json | 2 +- .../goldens/tk-linear-item.json | 2 +- .../goldens/tk-linear-team-context.json | 2 +- .../goldens/tk-list-gitlab-items.json | 2 +- .../goldens/tk-list-gitlab-todos.json | 2 +- .../goldens/tk-list-linear.json | 2 +- .../goldens/tk-project-board-load.json | 2 +- .../goldens/tk-project-repo-slugs.json | 2 +- .../tk-project-row-comments-issue.json | 2 +- .../goldens/tk-project-row-comments-pr.json | 2 +- .../goldens/tk-project-row-detail.json | 2 +- .../goldens/tk-project-row-fields.json | 2 +- .../goldens/tk-project-row-files-merge.json | 2 +- .../goldens/tk-project-row-metadata-load.json | 2 +- .../goldens/tk-project-row-review-checks.json | 2 +- .../goldens/tk-project-row-threads.json | 2 +- .../goldens/tk-provider-load.json | 2 +- ...-capability-probe-cutover-reasks-fast.json | 2 +- ...ty-probe-non-string-capabilities-drop.json | 2 +- .../transport-capability-probe-publishes.json | 2 +- ...rt-capability-probe-refused-backs-off.json | 2 +- ...-status-gates-drop-keeps-capabilities.json | 2 +- .../transport-host-status-gates-ready.json | 2 +- ...rt-host-status-gates-refused-degrades.json | 2 +- .../transport-pairing-race-both-refused.json | 2 +- ...t-pairing-race-direct-completes-first.json | 2 +- ...rt-pairing-race-relay-completes-first.json | 2 +- ...g-race-relay-wins-when-direct-refused.json | 2 +- .../goldens/tw-capabilities-advertised.json | 2 +- .../tw-capabilities-cutover-retried.json | 2 +- .../tw-capabilities-legacy-idempotency.json | 2 +- .../tw-create-retry-agent-launched.json | 2 +- .../tw-create-retry-ambiguous-after-drop.json | 2 +- ...reate-retry-ambiguous-while-connected.json | 2 +- ...e-retry-ambiguous-without-idempotency.json | 2 +- .../goldens/tw-create-retry-created.json | 2 +- .../tw-create-retry-name-collision.json | 2 +- .../tw-create-retry-unretryable-refusal.json | 2 +- .../goldens/tw-create-retry-warning-kept.json | 2 +- .../goldens/tw-hosted-base-resolved.json | 2 +- .../goldens/tw-hosted-base-soft-error.json | 2 +- .../goldens/tw-paste-lookup-resolved.json | 2 +- .../goldens/tw-paste-lookup-slug-refused.json | 2 +- .../tw-paste-lookup-slug-unsupported.json | 2 +- .../goldens/tw-setup-hook-trust-always.json | 2 +- .../goldens/tw-setup-hook-trust-approved.json | 2 +- .../tw-smart-search-all-providers.json | 2 +- ...tw-smart-search-gitlab-provider-error.json | 2 +- .../tw-smart-search-linear-listed.json | 2 +- .../tw-task-preferences-resume-write.json | 2 +- .../tw-workspace-source-presets-refused.json | 2 +- .../goldens/tw-workspace-source-presets.json | 2 +- .../tw-workspace-sparse-missing-preset.json | 2 +- .../goldens/tw-workspace-sparse-saved.json | 2 +- .../tw-workspace-ssh-connect-refused.json | 2 +- .../goldens/tw-workspace-ssh-connected.json | 2 +- .../tw-workspace-ssh-local-agents.json | 2 +- .../goldens/tw-workspace-ssh-not-ready.json | 2 +- .../worktree-catalog-snapshot-unreadable.json | 2 +- .../goldens/worktree-catalog-snapshot.json | 2 +- .../goldens/worktree-home-catalog.json | 2 +- .../goldens/worktree-retired-names.json | 2 +- mobile/rpc-foundation/pilot-scenarios.json | 329 ++++++- .../mobile-web-bundle-probe-row.tsx | 106 +++ mobile/src/diagnostics/troubleshoot-view.tsx | 9 +- .../use-mobile-web-bundle-probe.test.tsx | 305 +++++++ .../use-mobile-web-bundle-probe.ts | 121 +++ .../src/test-support/rpc-recording/README.md | 8 +- .../mobile-web-bundle-mount-adapters.ts | 101 +++ .../adapters/mounted-operation-modules.ts | 2 + .../mutants/operation-mutations.ts | 8 + .../mutants/pilot-mutants.test.ts | 3 +- .../transport/mobile-web-bundle-fetch.test.ts | 530 +++++++++++ .../src/transport/mobile-web-bundle-fetch.ts | 192 ++++ .../transport/mobile-web-bundle-operations.ts | 77 ++ .../mobile-web-bundle-reply-schemas.test.ts | 301 ++++++ .../mobile-web-bundle-reply-schemas.ts | 82 ++ .../transport/rpc-operation-compile-fence.ts | 12 + .../transport/rpc-operation-test-families.ts | 8 + mobile/src/transport/rpc-operation.ts | 9 +- 806 files changed, 7756 insertions(+), 789 deletions(-) create mode 100644 mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json create mode 100644 mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json create mode 100644 mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json create mode 100644 mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json create mode 100644 mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json create mode 100644 mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json create mode 100644 mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json create mode 100644 mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json create mode 100644 mobile/src/diagnostics/mobile-web-bundle-probe-row.tsx create mode 100644 mobile/src/diagnostics/use-mobile-web-bundle-probe.test.tsx create mode 100644 mobile/src/diagnostics/use-mobile-web-bundle-probe.ts create mode 100644 mobile/src/test-support/rpc-recording/adapters/mobile-web-bundle-mount-adapters.ts create mode 100644 mobile/src/transport/mobile-web-bundle-fetch.test.ts create mode 100644 mobile/src/transport/mobile-web-bundle-fetch.ts create mode 100644 mobile/src/transport/mobile-web-bundle-operations.ts create mode 100644 mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts create mode 100644 mobile/src/transport/mobile-web-bundle-reply-schemas.ts diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index d07368b21f9..d64b238b577 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -1,7 +1,15 @@ import { useRouter } from 'expo-router' +import { MobileWebBundleProbeRow } from '../src/diagnostics/mobile-web-bundle-probe-row' import { TroubleshootView } from '../src/diagnostics/troubleshoot-view' import { useTroubleshootDiagnostics } from '../src/diagnostics/use-troubleshoot-diagnostics' +// Same guard as push-token.ts: `__DEV__` is undefined outside the React Native runtime. The import +// above is static, so a release bundle still carries the row's graph and evaluates its hoisted +// schemas at load; nothing mounts, no host is looked up and no request is made. This repo has no +// `__DEV__`-conditional `require` idiom to trim it with — every `require` in `mobile/src` is a Metro +// asset path — so introducing one is a change for the shell in Phase B, not for this row. +const isDevelopmentBuild = typeof __DEV__ !== 'undefined' && __DEV__ + export default function NativeTroubleshootRoute() { const router = useRouter() const { rootRef, diagnosticStatus, checks, runDiagnostics } = useTroubleshootDiagnostics() @@ -13,6 +21,7 @@ export default function NativeTroubleshootRoute() { runDiagnostics={() => void runDiagnostics()} onBack={() => router.back()} onConnectionLog={() => router.push('/connection-log')} + developerRow={isDevelopmentBuild ? : null} /> ) } diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json index ee3189f2216..9c087a374be 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json index b517233d432..638829922ee 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json index 56bd6c33543..9978849b8a6 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json index 3c5c89093ad..eab9430cd59 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json index 83c59238a20..3ad575d4db6 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json index 0808828297a..e1486fd4f31 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json index 80faebdce14..38bc92f8e6b 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json index 0c08254868a..1ccb03de2fa 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json index 838bafe3ddb..8d6b4dc5bf3 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json index 4bd4642be41..a66ddfd8b42 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json index 0b169324f13..28508997823 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json index 2d7ccac51e3..ce575e1c76a 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json index 7a43e993eb5..cfdbc612399 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/b1.json b/mobile/rpc-foundation/goldens/b1.json index 424f72c16b3..8c137e1148b 100644 --- a/mobile/rpc-foundation/goldens/b1.json +++ b/mobile/rpc-foundation/goldens/b1.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/b2.json b/mobile/rpc-foundation/goldens/b2.json index aaddc937a7f..cee86516d62 100644 --- a/mobile/rpc-foundation/goldens/b2.json +++ b/mobile/rpc-foundation/goldens/b2.json @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/b3.json b/mobile/rpc-foundation/goldens/b3.json index a11a8c18f7b..0846bf836a6 100644 --- a/mobile/rpc-foundation/goldens/b3.json +++ b/mobile/rpc-foundation/goldens/b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json index b9422b20df3..2e32118b188 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json index f8a246ba8c8..ef6b8c81d0f 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-keyboard-input.json b/mobile/rpc-foundation/goldens/browser-keyboard-input.json index 5812dae044d..860c57da1af 100644 --- a/mobile/rpc-foundation/goldens/browser-keyboard-input.json +++ b/mobile/rpc-foundation/goldens/browser-keyboard-input.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json index 15ce598798e..10fd6455b47 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json index be0b8c92452..13a8575e2d2 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json index 783db0a2a6e..7f03d11b14c 100644 --- a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json +++ b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json index 7ccc43bcfe0..40499472cf9 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json index 3bea239bfab..6c87b9c24fc 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json index 01b4e22c3e4..1645ab66eee 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json index 23184e8aa59..9e9ef92db17 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json index 34fba5c6308..52e53f73ee7 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json index 86b73c156fc..4406c87bbba 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json index b69a45cda87..97803218f94 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json index 2a03971b35b..d70f943b27f 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json index 25d136c95ad..7d54ad13020 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json index 16d13527d53..95683cb0275 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json index 67ffe01d128..ade586d43d5 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/components-codex-capability.json b/mobile/rpc-foundation/goldens/components-codex-capability.json index 13211875513..9fcba28c07e 100644 --- a/mobile/rpc-foundation/goldens/components-codex-capability.json +++ b/mobile/rpc-foundation/goldens/components-codex-capability.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-setup-ask.json b/mobile/rpc-foundation/goldens/components-setup-ask.json index bfb612a8164..448bba08a94 100644 --- a/mobile/rpc-foundation/goldens/components-setup-ask.json +++ b/mobile/rpc-foundation/goldens/components-setup-ask.json @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-local.json b/mobile/rpc-foundation/goldens/components-target-local.json index a4052d7ef4f..584dff58131 100644 --- a/mobile/rpc-foundation/goldens/components-target-local.json +++ b/mobile/rpc-foundation/goldens/components-target-local.json @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-ssh.json b/mobile/rpc-foundation/goldens/components-target-ssh.json index 83581b3af71..3041922356f 100644 --- a/mobile/rpc-foundation/goldens/components-target-ssh.json +++ b/mobile/rpc-foundation/goldens/components-target-ssh.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json index dbbd4a250a8..62103ec119d 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json index 72d68f80f5e..82aca40040a 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json index db90689dc6f..52f956516b4 100644 --- a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json index 37f4dafd853..6cf14bfa6cf 100644 --- a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-snapshot.json b/mobile/rpc-foundation/goldens/diff-review-snapshot.json index 4d566df590b..2396ef2c684 100644 --- a/mobile/rpc-foundation/goldens/diff-review-snapshot.json +++ b/mobile/rpc-foundation/goldens/diff-review-snapshot.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json index f1275b88b4d..0a9c707944b 100644 --- a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json +++ b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json index 6586c18c453..9803138546f 100644 --- a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/file-tap-open-refused.json b/mobile/rpc-foundation/goldens/file-tap-open-refused.json index 7cf29b9a7f2..96a52c5a0ec 100644 --- a/mobile/rpc-foundation/goldens/file-tap-open-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-open-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json index 6f5cab9af62..9460ecdad88 100644 --- a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json +++ b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json index f029affc780..51af13c9b6a 100644 --- a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json +++ b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json index d2f90369eb0..c57d44a47fd 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json index b74a5018ded..e0782e72028 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json index cea0f5c91e0..bc8949b6ddc 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json +++ b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-explorer-readdir.json b/mobile/rpc-foundation/goldens/files-explorer-readdir.json index 84669eee874..3635407b85e 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-readdir.json +++ b/mobile/rpc-foundation/goldens/files-explorer-readdir.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-ownership-local.json b/mobile/rpc-foundation/goldens/files-ownership-local.json index c1b2a2b89ff..b6fee34f12f 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-local.json +++ b/mobile/rpc-foundation/goldens/files-ownership-local.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-ownership-ssh.json b/mobile/rpc-foundation/goldens/files-ownership-ssh.json index aea3fd487dc..1c7281ba01b 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-ssh.json +++ b/mobile/rpc-foundation/goldens/files-ownership-ssh.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json index 3541aa7ccb4..f51dbbd96b3 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json index b5f0e107b3f..d656c291e6b 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json index 74aa49f9220..8c11d914f1d 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json index dd57ba203de..a2643397928 100644 --- a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json +++ b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json index 8d16cefba74..3a934f629fe 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json index a3472a3c4a5..6205702638e 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json index 7c427d89318..e89fb7239b5 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree.json b/mobile/rpc-foundation/goldens/files-preview-worktree.json index 1d642951eb4..285dd5b2070 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-blind.json b/mobile/rpc-foundation/goldens/files-save-blind.json index e3e07a2b881..5a3abcf56fd 100644 --- a/mobile/rpc-foundation/goldens/files-save-blind.json +++ b/mobile/rpc-foundation/goldens/files-save-blind.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-verified.json b/mobile/rpc-foundation/goldens/files-save-verified.json index a9ab9936cf2..0421a3cba2b 100644 --- a/mobile/rpc-foundation/goldens/files-save-verified.json +++ b/mobile/rpc-foundation/goldens/files-save-verified.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json index 704f63849e6..a6a119288df 100644 --- a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json +++ b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/home-host-accounts.json b/mobile/rpc-foundation/goldens/home-host-accounts.json index 0b1dc5777a1..8206ebce25d 100644 --- a/mobile/rpc-foundation/goldens/home-host-accounts.json +++ b/mobile/rpc-foundation/goldens/home-host-accounts.json @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", diff --git a/mobile/rpc-foundation/goldens/home-host-stats.json b/mobile/rpc-foundation/goldens/home-host-stats.json index 524d1ab36b2..1e17e46e1ab 100644 --- a/mobile/rpc-foundation/goldens/home-host-stats.json +++ b/mobile/rpc-foundation/goldens/home-host-stats.json @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/host-view-settings-sync.json b/mobile/rpc-foundation/goldens/host-view-settings-sync.json index 7565fd9cbc3..b63a50b9e0b 100644 --- a/mobile/rpc-foundation/goldens/host-view-settings-sync.json +++ b/mobile/rpc-foundation/goldens/host-view-settings-sync.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json index 83ed50c9a95..7bfa1f93583 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json +++ b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json index 73c0867bb0d..52fd008c50c 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json +++ b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json index 3c9c1434487..9d28e5e2ebd 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json +++ b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json index 853310eb8b2..1634c69eff3 100644 --- a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json index 2c8c813bf43..0de13f9f0b6 100644 --- a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/inventory-lifecycle.json b/mobile/rpc-foundation/goldens/inventory-lifecycle.json index f34ecb55237..9c65d9e03f8 100644 --- a/mobile/rpc-foundation/goldens/inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/inventory-repeat-query.json b/mobile/rpc-foundation/goldens/inventory-repeat-query.json index c1719dc71c9..55e705fe802 100644 --- a/mobile/rpc-foundation/goldens/inventory-repeat-query.json +++ b/mobile/rpc-foundation/goldens/inventory-repeat-query.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/lifecycle-b3.json b/mobile/rpc-foundation/goldens/lifecycle-b3.json index 3df3403dd81..7faa0707243 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-b3.json +++ b/mobile/rpc-foundation/goldens/lifecycle-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index 738ac1d76d7..792d8d69e95 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json index e13d93bc00f..e6711bde5a0 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json index 344911618f1..d4b5bac6b88 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json index b92956af95c..ca3b5b30a64 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/linear-select-workspace.json b/mobile/rpc-foundation/goldens/linear-select-workspace.json index c05e32cb10f..a2bb58f3661 100644 --- a/mobile/rpc-foundation/goldens/linear-select-workspace.json +++ b/mobile/rpc-foundation/goldens/linear-select-workspace.json @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", diff --git a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json index 13eb852db99..9bff3a4e51b 100644 --- a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json +++ b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json index 70e9cd3a5c0..6f0f60122d4 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json index 0e2a209ad15..37896453de4 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json index 4d5b49f379a..85653cf50d7 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json index f555965c67a..201a59ce8c7 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json index 959c2f6ab7c..55caa0ee5b1 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json index a9a79e25f57..68ca8a29cfd 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json index 32ae31e6ce8..b2abd8acd44 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json index bd9253c7c0d..8053b35fc98 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json index 50ea47be769..421223a8d7f 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json index 6cdef2e7283..2868c463b3d 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json index 8ef05104d57..6f715931a9e 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json index 006be6ad564..6f893b8f5da 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json index 2dbd58984b4..2032bf4b1c4 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json index 078648f615f..7e582828527 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json index 9e47d02bddb..9253a936da4 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json index 0df087718f9..b7984e55abf 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json index fbdcd63f4da..9e6243891d2 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json index 56e9ae62833..fb4d631b251 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json index b0811e193a6..f151f3b3933 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json index 939488d91a9..93f0adb616f 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json index a93ce6407e2..b96753d8d24 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json index 4a554ca2aa2..6e7ac708fe1 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json index 1a19f87851b..6bf519616f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json index 78dc2c550d2..e1d74ee4acd 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json index 69a4d5612ab..775bd039507 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json index 1a8d82eefc7..c4d2cb8b35b 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json index 4ef5502e028..af2f982f16d 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json index 9e7ee19ac54..d83cf728358 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json index 5e1362ee1a2..582bcf99d25 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json index db08cb0a1cf..d62336945f6 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", diff --git a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json index 4e69f077ce1..efb3484c637 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json index eca6a71d703..89801f19313 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json index 1da89da354e..f6922f9ddcc 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json index 5be34b18290..026288fd2cd 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json index 3f5b4de815c..723f06a7683 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json index 05380376292..81bbf599592 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json index 5ec96e49f04..4e192f50fe9 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json index 971f35d057b..24f83afdffb 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json index 237069c4cd5..be9e5945688 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json index 1424a7b1067..1b07ca8e439 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json index 3c2c830bff2..810068a90fe 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json index 7de1dcc0f63..6d342cac764 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json index 365dd8d34ef..abff758344f 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json index a18c1b98594..d395395e190 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json index 1da1adef54c..24700019285 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json index 1736e6e9def..a9522036b8f 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json index 6821ae4aa11..40a3d75279d 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json index bdf424f3fa1..2d307403d23 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json index 31d3005d1b1..58906eb7ce8 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json index 4699d770255..2680929c395 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json index 9bc438eb63b..23eaf889f6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json index b02c66ed162..f21fba36b6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json index 091ca528d4a..811eeaad579 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json index 350cdbed16a..91ac30b8839 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json index caa43333149..57d24564b55 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json index ff02f789c5d..f3f06b5023b 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json index 7ac5b6b6913..d520d2e5e56 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json index 74b1fb9f1d8..76cb7144be5 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json index ace479c2f18..817329b6104 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json index 5f7dbda538d..cce23046c6f 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json index c359bdd3bb4..e66e92c4f18 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json index f566344209b..a5e3b14cb90 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json index e6358429049..8ad4a5faf20 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json index b5323514e47..1455515e450 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json index 9833d78fbc6..f43797fe303 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json index 62c0dd35043..53786d16204 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json index 4d6688102a4..b59a0b84edc 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json index aee0b2f0e33..8d64298ac3f 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json index c58ff8b756b..903bde1e5f0 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json index b395601f085..bcb49e675ca 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json index 0460fcc08a9..a0b3d9b73d4 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json index d6c67c17c12..b0b358e1f6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json index 9c8a420c3d2..3aa180ea704 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json index 7afdb86a91a..64f54c38e8c 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json index d905e9d39a3..af67fa26a13 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json index 1392b519efd..0a32cba06dd 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json index b4d000f621d..77af6982c86 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json index 23d074f7713..651579209c4 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json index df658791386..f9c63e81960 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json index e0628c7b1d1..9fe0850b18e 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json index ff82a429a31..4d3e118c942 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json index 80afc8eedf0..1c92dc53ce1 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json index bde0e6d333c..e63b82c445b 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json index ae04f1d198d..7f1b441ac75 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json index 34a07d4547a..5a0257c7801 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json index 81506b70b9e..733e07b77e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json index 6d5c1da6a05..68892d46f4f 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json index ef748abed79..f18b0bdc542 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json index 166a84f198d..59e8bfce095 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json index f303df5b630..ffd745529ec 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json index fc19a28e2d4..c94c38a4c3e 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json index 20fd2976f47..69225d9a8f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json index ef8d89d1b64..584b62fcd3e 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json index 76496ea77a8..c37161c6c30 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json index d61b23c1a07..dab55c61515 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json index 6e685e963d5..205224357f5 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json index 1a87319774b..8a2fa23cd82 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json index 93a76304c07..22df417024d 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json index a1b222a118b..7db76a5d39d 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json index 5c1baa3051f..390618e1fc1 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json index ad0b0f5a551..0777ac8a6d9 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json index 3a2346b2ae0..bb03a2497a8 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json index 4f5608befb9..adc516270d3 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json index 0b2ed8b9b6f..3f140726e98 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json index 3b367478bcd..ae23a6231d4 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json index f2e02ad33b5..dd74ad07fbd 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json index 9493e1620ad..a6082edeac0 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json index fbb4e6fafdc..51f082676e2 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json index 2ef8f40373d..daa10145bb7 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json index b644d6f0dc2..25406cd2d31 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json index 2b837f98190..57c38e3b259 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json index f11079a518e..869ddb226d0 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json index 2ab4cf73a6a..78ac874bc0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json index e37363ded8d..421ff6284b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json index cdfa4c40e1f..5b9a290d256 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json index 35773fef987..ffe62097acf 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json index 9a98a01a615..8941f661495 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json index 448f33ba903..d4224570656 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json index b991fea43be..0b24fca0839 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json index f10ec0f01cf..d48f69b5a83 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json index 1b64409e34d..b5bbc67ce27 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json index 4471240376d..c3568a8fdc1 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json new file mode 100644 index 00000000000..0f28580663d --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json @@ -0,0 +1,853 @@ +{ + "operation": "mobileWeb.bundle-fetch", + "family": "mobileWeb.bundle-fetch", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", + "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", + "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", + "scenarioSha256": "3fa9e96357f3df313cca9632bcbc79e0c41f14b055e89dd4b848d8c27ed5ff4d", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 5, + "values": { + "0675365edeab": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "11447b4712c1": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: " + }, + "19aa61aab0e5": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: refused: " + }, + "23871e324a00": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + } + } + }, + "2988ade7daff": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":0}}" + }, + "2bfa7c81f55f": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 9, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":16}}" + }, + "2ff2addcb1f6": { + "name": "bundle-progress", + "ordinal": 10, + "value": { + "completedAssets": 2, + "receivedBytes": 37, + "totalAssets": 2 + } + }, + "467f0030b66f": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: The host sent a reply this app could not read (mobileWeb.bundle.chunk)" + }, + "4e3b57d795cb": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: outer refused", + "isRpcDeliveryUnknown": false + } + }, + "573943fdb37d": { + "assets": { + "assets/app.js": "orca.boot()", + "index.html": "

orca

" + }, + "outcome": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "5f7979d761c6": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "6819e6630a90": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "70ad183ccf84": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: refused: outer refused" + }, + "714b5d0de2ed": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "76ca82e57d04": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "PCFkb2N0eXBlIGh0bWw+PA==", + "eof": false, + "offset": 0, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + }, + "775bfa42070b": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.manifest\",\"params\":null}" + }, + "782d48c615ad": { + "name": "bundle-progress", + "ordinal": 7, + "value": { + "completedAssets": 1, + "receivedBytes": 11, + "totalAssets": 2 + } + }, + "84a5b50c6206": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: method_not_found: Unknown method" + }, + "a0c050b31ee2": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 5, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"assets/app.js\",\"offset\":0}}" + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "ae7f1100e6e5": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "assetByteLength": 11, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "b3JjYS5ib290KCk=", + "eof": true, + "offset": 0, + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + } + } + } + }, + "b722dc19c21f": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "b9f5f0928755": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: transport failure" + }, + "bf3d420631ad": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "c2390891036b": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "c319ae866f13": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "c8c78c1816d5": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "RpcIncompatibleReplyError", + "message": "The host sent a reply this app could not read (mobileWeb.bundle.chunk)", + "isRpcDeliveryUnknown": false, + "code": "incompatible_reply" + } + }, + "ce7abd9f93bb": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "cD5vcmNhPC9wPg==", + "eof": true, + "offset": 16, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + }, + "d56bfdbce702": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: ", + "isRpcDeliveryUnknown": false + } + }, + "ec2ac0525db0": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "f33db2b3742d": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "f624ac81d963": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "method_not_found: Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "f7c0f62c1992": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + } + }, + "recording": { + "scenario": "matrix-mobileweb.bundle-fetch-app-js", + "checkpoints": [ + { + "id": "mobile-web-bundle-fetch-paged.normal:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "ce7abd9f93bb"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c319ae866f13" + }, + "state": "573943fdb37d", + "effects": ["782d48c615ad", "2ff2addcb1f6"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.result-absent:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "6819e6630a90", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.result-null:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "bf3d420631ad", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-ok-missing:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "5f7979d761c6", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-false-string-error:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "b722dc19c21f", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-false-object-error:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "f7c0f62c1992", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.outer-refused:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "c2390891036b", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "4e3b57d795cb" + }, + "state": "70ad183ccf84", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.outer-refused-no-message:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "714b5d0de2ed", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "d56bfdbce702" + }, + "state": "19aa61aab0e5", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.method-not-found:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "0675365edeab", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "f624ac81d963" + }, + "state": "84a5b50c6206", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.transport-rejection:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ec2ac0525db0", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "a947768bc0ed" + }, + "state": "b9f5f0928755", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.transport-rejection-no-message:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "f33db2b3742d", "76ca82e57d04"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c7584e82c72f" + }, + "state": "11447b4712c1", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json new file mode 100644 index 00000000000..b92312906ea --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json @@ -0,0 +1,853 @@ +{ + "operation": "mobileWeb.bundle-fetch", + "family": "mobileWeb.bundle-fetch", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", + "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", + "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", + "scenarioSha256": "73734d83e9bc272cba83255b64a21ccab62561d4b6c753b8e2bbb07d6d7b9cc4", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 5, + "values": { + "11447b4712c1": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: " + }, + "19aa61aab0e5": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: refused: " + }, + "1c53908ac349": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "2142bf483ffa": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-3", + "ok": false + } + } + }, + "23871e324a00": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + } + } + }, + "2988ade7daff": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":0}}" + }, + "2bfa7c81f55f": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 9, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":16}}" + }, + "2c0da0506922": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "2ff2addcb1f6": { + "name": "bundle-progress", + "ordinal": 10, + "value": { + "completedAssets": 2, + "receivedBytes": 37, + "totalAssets": 2 + } + }, + "44b044c3bcbb": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-3", + "ok": false + } + } + }, + "467f0030b66f": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: The host sent a reply this app could not read (mobileWeb.bundle.chunk)" + }, + "48a06fd06c9f": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-3", + "ok": false + } + } + }, + "4e3b57d795cb": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: outer refused", + "isRpcDeliveryUnknown": false + } + }, + "51aab07cd069": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "573943fdb37d": { + "assets": { + "assets/app.js": "orca.boot()", + "index.html": "

orca

" + }, + "outcome": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "5f506b45b4da": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "70ad183ccf84": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: refused: outer refused" + }, + "76ca82e57d04": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "PCFkb2N0eXBlIGh0bWw+PA==", + "eof": false, + "offset": 0, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + }, + "775bfa42070b": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.manifest\",\"params\":null}" + }, + "782d48c615ad": { + "name": "bundle-progress", + "ordinal": 7, + "value": { + "completedAssets": 1, + "receivedBytes": 11, + "totalAssets": 2 + } + }, + "78a565992eac": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "84a5b50c6206": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: method_not_found: Unknown method" + }, + "a0c050b31ee2": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 5, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"assets/app.js\",\"offset\":0}}" + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "ae7f1100e6e5": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "assetByteLength": 11, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "b3JjYS5ib290KCk=", + "eof": true, + "offset": 0, + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + } + } + } + }, + "b93a57c52fa0": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true + } + } + }, + "b9f5f0928755": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: transport failure" + }, + "c319ae866f13": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "c8c78c1816d5": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "RpcIncompatibleReplyError", + "message": "The host sent a reply this app could not read (mobileWeb.bundle.chunk)", + "isRpcDeliveryUnknown": false, + "code": "incompatible_reply" + } + }, + "ca40206d195c": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "ce7abd9f93bb": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "cD5vcmNhPC9wPg==", + "eof": true, + "offset": 16, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + }, + "d56bfdbce702": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: ", + "isRpcDeliveryUnknown": false + } + }, + "f624ac81d963": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "method_not_found: Unknown method", + "isRpcDeliveryUnknown": false + } + } + }, + "recording": { + "scenario": "matrix-mobileweb.bundle-fetch-index-head", + "checkpoints": [ + { + "id": "mobile-web-bundle-fetch-paged.normal:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "ce7abd9f93bb"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c319ae866f13" + }, + "state": "573943fdb37d", + "effects": ["782d48c615ad", "2ff2addcb1f6"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.result-absent:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "b93a57c52fa0"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.result-null:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "ca40206d195c"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-ok-missing:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "1c53908ac349"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-false-string-error:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "5f506b45b4da"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-false-object-error:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "2c0da0506922"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.outer-refused:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "48a06fd06c9f"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "4e3b57d795cb" + }, + "state": "70ad183ccf84", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.outer-refused-no-message:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "44b044c3bcbb"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "d56bfdbce702" + }, + "state": "19aa61aab0e5", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.method-not-found:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "2142bf483ffa"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "f624ac81d963" + }, + "state": "84a5b50c6206", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.transport-rejection:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "78a565992eac"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "a947768bc0ed" + }, + "state": "b9f5f0928755", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.transport-rejection-no-message:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "51aab07cd069"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "c7584e82c72f" + }, + "state": "11447b4712c1", + "effects": ["782d48c615ad"] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json new file mode 100644 index 00000000000..c323cf9fd41 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json @@ -0,0 +1,853 @@ +{ + "operation": "mobileWeb.bundle-fetch", + "family": "mobileWeb.bundle-fetch", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", + "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", + "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", + "scenarioSha256": "dd6d7a35c8ce229211f693ce7b581daecd9f1d7923efb09aff190ea6c46a8b66", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 5, + "values": { + "0e77845a234c": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "0fd8cd28042f": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "11447b4712c1": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: " + }, + "19aa61aab0e5": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: refused: " + }, + "23871e324a00": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + } + } + }, + "2988ade7daff": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":0}}" + }, + "2bfa7c81f55f": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 9, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":16}}" + }, + "2ff2addcb1f6": { + "name": "bundle-progress", + "ordinal": 10, + "value": { + "completedAssets": 2, + "receivedBytes": 37, + "totalAssets": 2 + } + }, + "31b995bd644d": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "467f0030b66f": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: The host sent a reply this app could not read (mobileWeb.bundle.chunk)" + }, + "4e3b57d795cb": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: outer refused", + "isRpcDeliveryUnknown": false + } + }, + "573943fdb37d": { + "assets": { + "assets/app.js": "orca.boot()", + "index.html": "

orca

" + }, + "outcome": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "582980d0a726": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "6a71630ead47": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "70ab70e5f72c": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "70ad183ccf84": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: refused: outer refused" + }, + "76ca82e57d04": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "PCFkb2N0eXBlIGh0bWw+PA==", + "eof": false, + "offset": 0, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + }, + "775bfa42070b": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.manifest\",\"params\":null}" + }, + "77da9e3f0b42": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true + } + } + }, + "782d48c615ad": { + "name": "bundle-progress", + "ordinal": 7, + "value": { + "completedAssets": 1, + "receivedBytes": 11, + "totalAssets": 2 + } + }, + "84a5b50c6206": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: method_not_found: Unknown method" + }, + "a0c050b31ee2": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 5, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"assets/app.js\",\"offset\":0}}" + }, + "a1a88d7a4c18": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-4", + "ok": false + } + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "ae7f1100e6e5": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "assetByteLength": 11, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "b3JjYS5ib290KCk=", + "eof": true, + "offset": 0, + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + } + } + } + }, + "b9f5f0928755": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: transport failure" + }, + "c319ae866f13": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "c8c78c1816d5": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "RpcIncompatibleReplyError", + "message": "The host sent a reply this app could not read (mobileWeb.bundle.chunk)", + "isRpcDeliveryUnknown": false, + "code": "incompatible_reply" + } + }, + "ce7abd9f93bb": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "cD5vcmNhPC9wPg==", + "eof": true, + "offset": 16, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + }, + "d1c2396755f2": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-4", + "ok": false + } + } + }, + "d3f6932f076d": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-4", + "ok": false + } + } + }, + "d56bfdbce702": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: ", + "isRpcDeliveryUnknown": false + } + }, + "f624ac81d963": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "method_not_found: Unknown method", + "isRpcDeliveryUnknown": false + } + } + }, + "recording": { + "scenario": "matrix-mobileweb.bundle-fetch-index-tail", + "checkpoints": [ + { + "id": "mobile-web-bundle-fetch-paged.normal:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "ce7abd9f93bb"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c319ae866f13" + }, + "state": "573943fdb37d", + "effects": ["782d48c615ad", "2ff2addcb1f6"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.result-absent:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "77da9e3f0b42"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.result-null:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "6a71630ead47"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-ok-missing:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "31b995bd644d"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-false-string-error:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "70ab70e5f72c"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-false-object-error:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "0e77845a234c"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c8c78c1816d5" + }, + "state": "467f0030b66f", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.outer-refused:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "d1c2396755f2"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "4e3b57d795cb" + }, + "state": "70ad183ccf84", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.outer-refused-no-message:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "d3f6932f076d"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "d56bfdbce702" + }, + "state": "19aa61aab0e5", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.method-not-found:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "a1a88d7a4c18"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "f624ac81d963" + }, + "state": "84a5b50c6206", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.transport-rejection:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "582980d0a726"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "a947768bc0ed" + }, + "state": "b9f5f0928755", + "effects": ["782d48c615ad"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.transport-rejection-no-message:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "0fd8cd28042f"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c7584e82c72f" + }, + "state": "11447b4712c1", + "effects": ["782d48c615ad"] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json new file mode 100644 index 00000000000..4aa6817740e --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json @@ -0,0 +1,833 @@ +{ + "operation": "mobileWeb.bundle-fetch", + "family": "mobileWeb.bundle-fetch", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", + "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", + "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", + "scenarioSha256": "d8e4c64949caef9f98ec67aad62a482d0ecbc4bcc611ba5e4d2e73764bf80ea3", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 5, + "values": { + "04c76c033ffb": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: The host sent a reply this app could not read (mobileWeb.bundle.manifest)" + }, + "11447b4712c1": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: " + }, + "19aa61aab0e5": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: refused: " + }, + "1f83ee5bcef4": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "23871e324a00": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + } + } + }, + "2988ade7daff": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":0}}" + }, + "2bfa7c81f55f": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 9, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":16}}" + }, + "2f77c40dc9fb": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "2ff2addcb1f6": { + "name": "bundle-progress", + "ordinal": 10, + "value": { + "completedAssets": 2, + "receivedBytes": 37, + "totalAssets": 2 + } + }, + "4e3b57d795cb": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: outer refused", + "isRpcDeliveryUnknown": false + } + }, + "573943fdb37d": { + "assets": { + "assets/app.js": "orca.boot()", + "index.html": "

orca

" + }, + "outcome": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "70ad183ccf84": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: refused: outer refused" + }, + "76ca82e57d04": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "PCFkb2N0eXBlIGh0bWw+PA==", + "eof": false, + "offset": 0, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + }, + "775bfa42070b": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.manifest\",\"params\":null}" + }, + "782d48c615ad": { + "name": "bundle-progress", + "ordinal": 7, + "value": { + "completedAssets": 1, + "receivedBytes": 11, + "totalAssets": 2 + } + }, + "849b557190c4": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "RpcIncompatibleReplyError", + "message": "The host sent a reply this app could not read (mobileWeb.bundle.manifest)", + "isRpcDeliveryUnknown": false, + "code": "incompatible_reply" + } + }, + "84a5b50c6206": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: method_not_found: Unknown method" + }, + "92180fc007fa": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "959e5b0d7569": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "9bd7a475c5b3": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "9f8f097c4318": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "a0c050b31ee2": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 5, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"assets/app.js\",\"offset\":0}}" + }, + "a0fb89567920": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "ad5c054356d4": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "ae7f1100e6e5": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "assetByteLength": 11, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "b3JjYS5ib290KCk=", + "eof": true, + "offset": 0, + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + } + } + } + }, + "b539b28b8552": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "b9f5f0928755": { + "assets": { + "$rpc": "null" + }, + "outcome": "failed: transport failure" + }, + "c319ae866f13": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "ce7abd9f93bb": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "cD5vcmNhPC9wPg==", + "eof": true, + "offset": 16, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + }, + "d54e9333d433": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "d56bfdbce702": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: ", + "isRpcDeliveryUnknown": false + } + }, + "f624ac81d963": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "method_not_found: Unknown method", + "isRpcDeliveryUnknown": false + } + } + }, + "recording": { + "scenario": "matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1", + "checkpoints": [ + { + "id": "mobile-web-bundle-fetch-paged.normal:bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "ce7abd9f93bb"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c319ae866f13" + }, + "state": "573943fdb37d", + "effects": ["782d48c615ad", "2ff2addcb1f6"] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.result-absent:bundle-fetched", + "observation": { + "sender": ["d54e9333d433"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "849b557190c4" + }, + "state": "04c76c033ffb", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.result-null:bundle-fetched", + "observation": { + "sender": ["1f83ee5bcef4"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "849b557190c4" + }, + "state": "04c76c033ffb", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-ok-missing:bundle-fetched", + "observation": { + "sender": ["b539b28b8552"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "849b557190c4" + }, + "state": "04c76c033ffb", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-false-string-error:bundle-fetched", + "observation": { + "sender": ["ad5c054356d4"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "849b557190c4" + }, + "state": "04c76c033ffb", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.inner-false-object-error:bundle-fetched", + "observation": { + "sender": ["2f77c40dc9fb"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "849b557190c4" + }, + "state": "04c76c033ffb", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.outer-refused:bundle-fetched", + "observation": { + "sender": ["9f8f097c4318"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "4e3b57d795cb" + }, + "state": "70ad183ccf84", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.outer-refused-no-message:bundle-fetched", + "observation": { + "sender": ["959e5b0d7569"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "d56bfdbce702" + }, + "state": "19aa61aab0e5", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.method-not-found:bundle-fetched", + "observation": { + "sender": ["9bd7a475c5b3"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "f624ac81d963" + }, + "state": "84a5b50c6206", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.transport-rejection:bundle-fetched", + "observation": { + "sender": ["a0fb89567920"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "a947768bc0ed" + }, + "state": "b9f5f0928755", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-fetch-paged.transport-rejection-no-message:bundle-fetched", + "observation": { + "sender": ["92180fc007fa"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "c7584e82c72f" + }, + "state": "11447b4712c1", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json new file mode 100644 index 00000000000..673c6bcde0c --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json @@ -0,0 +1,674 @@ +{ + "operation": "mobileWeb.bundle-manifest", + "family": "mobileWeb.bundle-manifest", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", + "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", + "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", + "scenarioSha256": "8f5211fd3de1d77d3ea139f22bd6fc6119c72c08aa016f252a0df14bedf23ab3", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 5, + "values": { + "12e970df3db8": { + "outcome": "failed: refused: outer refused" + }, + "1f83ee5bcef4": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "23871e324a00": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + } + } + }, + "2f77c40dc9fb": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "37fa2b81fff0": { + "outcome": "failed: method_not_found: Unknown method" + }, + "4e3b57d795cb": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: outer refused", + "isRpcDeliveryUnknown": false + } + }, + "533d41ae6475": { + "outcome": "failed: " + }, + "775bfa42070b": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.manifest\",\"params\":null}" + }, + "849b557190c4": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "RpcIncompatibleReplyError", + "message": "The host sent a reply this app could not read (mobileWeb.bundle.manifest)", + "isRpcDeliveryUnknown": false, + "code": "incompatible_reply" + } + }, + "92180fc007fa": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "959e5b0d7569": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "9bd7a475c5b3": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "9ca9b2d0fc07": { + "outcome": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "chunkBytes": 16, + "entrypoint": "index.html", + "paths": ["assets/app.js", "index.html"] + } + }, + "9d87b3b6bd74": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + }, + "9f8f097c4318": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "a0fb89567920": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "ad5c054356d4": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "b539b28b8552": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "ce45a7dbe0d4": { + "outcome": "failed: refused: " + }, + "ce8ef76590e2": { + "outcome": "failed: The host sent a reply this app could not read (mobileWeb.bundle.manifest)" + }, + "d54e9333d433": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "d56bfdbce702": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused: ", + "isRpcDeliveryUnknown": false + } + }, + "ded62b26901e": { + "outcome": "failed: transport failure" + }, + "f624ac81d963": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "method_not_found: Unknown method", + "isRpcDeliveryUnknown": false + } + } + }, + "recording": { + "scenario": "matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1", + "checkpoints": [ + { + "id": "mobile-web-bundle-manifest-read.normal:manifest-read", + "observation": { + "sender": ["23871e324a00"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "9d87b3b6bd74" + }, + "state": "9ca9b2d0fc07", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.result-absent:manifest-read", + "observation": { + "sender": ["d54e9333d433"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "849b557190c4" + }, + "state": "ce8ef76590e2", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.result-null:manifest-read", + "observation": { + "sender": ["1f83ee5bcef4"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "849b557190c4" + }, + "state": "ce8ef76590e2", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.inner-ok-missing:manifest-read", + "observation": { + "sender": ["b539b28b8552"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "849b557190c4" + }, + "state": "ce8ef76590e2", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.inner-false-string-error:manifest-read", + "observation": { + "sender": ["ad5c054356d4"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "849b557190c4" + }, + "state": "ce8ef76590e2", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.inner-false-object-error:manifest-read", + "observation": { + "sender": ["2f77c40dc9fb"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "849b557190c4" + }, + "state": "ce8ef76590e2", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.outer-refused:manifest-read", + "observation": { + "sender": ["9f8f097c4318"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "4e3b57d795cb" + }, + "state": "12e970df3db8", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.outer-refused-no-message:manifest-read", + "observation": { + "sender": ["959e5b0d7569"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "d56bfdbce702" + }, + "state": "ce45a7dbe0d4", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.method-not-found:manifest-read", + "observation": { + "sender": ["9bd7a475c5b3"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "f624ac81d963" + }, + "state": "37fa2b81fff0", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.transport-rejection:manifest-read", + "observation": { + "sender": ["a0fb89567920"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "a947768bc0ed" + }, + "state": "ded62b26901e", + "effects": [] + } + }, + { + "id": "mobile-web-bundle-manifest-read.transport-rejection-no-message:manifest-read", + "observation": { + "sender": ["92180fc007fa"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "c7584e82c72f" + }, + "state": "533d41ae6475", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json index c660212b892..eb9af3347dd 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json index 2408059a3de..3d5af994e58 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json index 6bcc68ae4e0..5a232e4778c 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json index d929a9ef4c8..ad1b01dc79a 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json index 0fd9dcb63fb..658c5fa15eb 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json index 79d9c60d60c..1ee62b745f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json index 6689bf37e41..418fb01baae 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json index c9da82f05f7..25df5c608b5 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json index e44bb6f2e78..2bfe76d3c6d 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json index dd286c55ebb..9b35fe7aded 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json index 171a2085f4a..4186fbf5cd4 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json index 5973d0e1d7e..e35222077a4 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json index c79adaa0255..8a760ce0715 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json index c58dcc2a5bb..0695c4c18f2 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json index c804c127297..e0b892d1427 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json index 0aa43d5ac1e..82852f532c0 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json index d77be27ac25..d1429543e20 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json index 597905dd562..bcbee6b1e5b 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json index 9e9ccbd4636..fc1a0cd87be 100644 --- a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json index feed9049c1f..8068d4e21fb 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json index 55ff37fdb72..5d23602cb01 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json index e9d546ccc3d..f18c7b9bee8 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json index 5eb4b2f1606..7b9a106276d 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json index fd4a564d8e4..d0a604a8cf8 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json index 9558cebba6c..0a5ff6b933d 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json index e172e779b0c..6d1daff5e4c 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json index 7937d423ee9..f1c04a310c8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json index d3adf585c3d..44d92581593 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json index e29e2155b0c..784482f03fa 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json index 85346ef670f..937553048c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json index 223c0188bad..9ea677cce7c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json index 43bd417113e..4f237e17ce4 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json index d35868af1f1..a9e337d8364 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json index 528342e6f45..623d827695a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json index a9092d652fa..79cec039bdb 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json index aacd6049e55..bdd1bf2cf58 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json index e40ae4fff9a..0fed2535239 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json index 15bece53a2d..1283e269962 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json index 9f33618b3ed..f2c7d076bf0 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json index 47cc5cc5173..1428f97fe04 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json index 28f0f997ed4..572bfd06aa8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json index ab5e7722e4f..889e6c9ebdf 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json index a16f19ae733..b22420593a7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json index 5b04ae97049..d6a66ef187e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json index 72511dc9e9b..ec3c0aa305f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json index 8f8f6f5ad03..90b310931e3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json index 24b26b23e4a..8426ae5057d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json index 5310701e356..10d0dcba8f2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json index 54b2fe96822..a26fe07d343 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json index 5925663b025..c28ba3987c8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json index ebca2119c20..c585a821bb3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json index 1ecf9606299..6085103ee5e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json index 49c0a004034..f7fd1adb4ff 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json index 4d05d74a774..e47fadcc518 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json index ddaa1535764..48c2cd863ef 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json index e61a24812e7..4eb0fc51ea0 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json index 32b7b02a6dc..b8feccb506a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json index ee7e575771e..57f913b1766 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json index d199564d62c..cf3815b4ab0 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json index 7d2bf88d581..6496fc23230 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json index 61badbcb9d0..9cdcf1f43be 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json index c807c216852..e8e190b32f5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json index a797d08f740..78194b5fc50 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json index 6f6b580715e..cb57e9b87c8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json index f5fd3188d83..69103951e33 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json index 26d33eb5d90..41bdb40f6c1 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json index caef72ecad2..83a13fc2172 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json index 7000bef1980..e6a2456adf8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json index c562aa4f903..9938c5feb91 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json index e510ceebcd3..eba51894d02 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json index d138fb695a3..35e05041f75 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json index f5d150d9972..b77e9434391 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json index 3f019077e7b..aa7e11b10c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json index db353bbcfa9..f32a7834ecc 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json index 932571e6155..895f01ac4e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json index 58b3884daa4..5dcdc830c19 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json index e4cf277de9b..4d4a36f6fac 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json index 2aa1e03d6f3..8ea5bd2be60 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json index b35e403bfae..6fd108933b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json index 9e29ad3fcfe..f4b4a6576e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json index d6dd86e5125..f1c39daf967 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json index e3e27458724..eec62b76a58 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json index 6fdd6c6e3c1..ca5f852b678 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json index 209707eb204..782caa89cbd 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json index 31c2c258b4e..dc7f2ebfc0b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json index 2485b957e56..c6bad41ea5c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json index ee23e454a0f..a5c0c85b66a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json index d888d9d8e97..2060916a4bf 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json index a374c85ed9e..a738ed53b3d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json index 3a4cc2a668c..fb0ac449993 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json index e731af4eded..0aa1365c328 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json index bd8fe086517..9bb778e5dbc 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json index 0c3f2ed82e7..d5e47a92f43 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json index 79cb2602334..0fc5441fb70 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json index 9141433148f..7b84a9ee3f1 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json index e4d2ec19192..317d05e89cd 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json index b7c68d25145..1d46b8cb543 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json index cc1107cc032..4e6900f9c3c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json index c8a1f547cb4..0632e412cea 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json index 06a5d830069..26e30bda713 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json index be297103994..2b59c43016e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json index 9efdf7628d9..dd3261fac6d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json index 970b1954702..1c4f6e8d449 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json index 2777711cbef..93de68eb77b 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json index 1c552208819..04fc071c991 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json index d4a67cf6721..1c946d451b4 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json index bdcdd23aa91..e573f898896 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json index c6398578bbb..beebf240ecc 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json index efdabb52dc8..3a33228ba13 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json index b7ab972ef14..e5096bd5609 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json index a352d673dd4..5412f0aa704 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json index 2f99dc6913e..6a0c4b2d20d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json index 317164e097a..5f566166d7c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json index 809cac0cb0f..f3cca3e83dc 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json index 27a97870acc..24a6b327ea8 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json index 1a36e67804b..9ac54d8234b 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json index 15e04f42c4a..25355ba2aa6 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json index 282aa51dd34..3d0950d887d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json index e7729076fdb..8edc55a3cae 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json index 25c39f1c4d2..4222003e02c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json index 1529b12da3e..9648600cd0b 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json index c1384aeefd1..78bd94371e2 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json index 333402f476d..aa38a079c17 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json index d96bc82e8fc..6d6ff546eb3 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json index 49a2f3599eb..2433a222fb3 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json index 016a3c123c1..5190e2f1243 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json index c09d52dbdb1..54d3406c0c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json index 4424aa41f05..38352cc271e 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json index cb8e2eb4f03..5e21ae38877 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json index ae47f95d589..f1987472c99 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json index e772c23079c..5419fa7776d 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json index ce3e047cc6c..e68efcd8310 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json index a990fcc3c9c..fc2f788c2ad 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json index 9f76b612d1f..e1990ed11a1 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json index c725717e4fd..f773a3353bf 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json index 969adfb7bd3..9bfd006843e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json index a90b60cd162..0cc70ca28ed 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json index 37abeda39f5..2c0d1191f59 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json index 0c1d9b8befd..c844f6d84b7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json index a9e6a86c142..b38705e3fc0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json index bb62ea8ebd8..2c347580e4e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json index 78c7295bf6a..f70aa0784aa 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json index 667a14ba996..05c987158a4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json index 39eefa4b43a..fe4ae7760da 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json index 93e8bbb406d..738700fa618 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json index 869b00d61cd..b2bc9a7c466 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json index faeea3c5558..1c9d549f4ee 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json index 95f7f49b8e7..cbfecde65a7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json index ac686c98310..d0a8beef27f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json index eed90edd81b..9ffa4fd09f5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json index 48bb2ba53d8..026535f9c5b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json index 45227c0f1e7..5df6ab8a123 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json index c84a464eacb..4acb4320ea2 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json index c2ceae9b35e..578685a5fb8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json index 3d41447db5c..d1a1f1075ee 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json index 9c151d4f4a3..8bf181851b8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json index bfad96ab441..4e4927dcef1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json index 6c6b01217e5..e2d2832b5ee 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json index fe47471b166..fb13f486e88 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json index 833e83874e4..3dab0676dbc 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json index c7daed8a6ff..bcb4ef849d4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json index b65e1ca6f32..a2058f3839c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json index 4d747f62258..05eddb607b6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json index 5f061261f78..e872951bdda 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json index ba8e76a170c..30252ff7779 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json index bfb0720ca99..35ca7259162 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json index 0e530995693..585a0ce1a53 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json index 5df25ca2886..a331c042414 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json index e8d6b37a7c8..e568ed7deb2 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json index 4dcc3ea9d23..09a8623e12d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json index 87b03e93480..3a9a431b0d3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json index 893e8f549a1..42cc25ea0f4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json index adf542f5234..e72e3708c06 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json index ab8b0eed755..56b5c985e08 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json index 14605033277..43ebd12480b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json index fde9517ee04..1df9758b390 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json index b674292d190..2d571a50b27 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json index 453a1561602..be2b0697498 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json index 20b67c2ab40..04dafc45c2e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json index 317f36d79c6..2a3fcf77a47 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json index 56c79020dce..b4431143e5c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json index 67d69a7ab6a..d71226e7392 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json index 1cfe4867f76..153999c8fa3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json index ed7a10ce717..670fbf334b6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json index a48f0052fc5..e9e8591fb4b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json index 00f3315e7d2..f2cde8c7876 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json index 36ed9fb613e..68f7c60b216 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json index 7f993ff28a8..93de2be604a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json index 24cf716a6b8..0a95358d600 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json index ffb1aedd812..295175d0694 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json index ba9a71949c7..d3c1183ceef 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json index e2b20f7f3b4..3912c126137 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json index 38a4cd1f9f0..60bf2d3acc4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json index e23de516bec..ebd38177b39 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json index 8fdf368f9d1..aafb025f855 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json index 3f80e0f8f5c..b9be225d833 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json index c04371a1c01..d98a5dd9112 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json index b515f4a7adf..935ae8eee83 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json index d5b98105954..7b6a0350757 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json index 57623a1bdc1..f91b2bb82a6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json index eb9dd2ff0b6..2c027586e2c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json index c19eafc7183..ed692389850 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json index defd19d552a..9efb85efffd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json index bb58c7b4f4a..4623c580bb8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json index f98dcbdd2a4..df9d28e0f9b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json index f1892f399c6..06b12c2163f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json index ddc44454e8a..7bdd0059243 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json index 32be0427f6a..30b6ad18272 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "feb6cee1ab7ecff1ba98bfba22d4924c748d3bb6b749db460cb617ee50b92f2c", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json index 9f7c7b6b74d..bb1f713f98c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json index 2fbd566d63e..15af2f98ce0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json index 61f9b2b1c1e..0b1bd8d5eaa 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json index 6a699c04bdd..ddfbeb459c5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json index c7d714c4294..6df34401109 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json index d04a37f6c51..09a3cd9056c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json index ce0a75442b8..8c3f5770e6b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json index 55ccc7feefb..0c257530c36 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json index e69245d7f2b..c11d58ddddd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json index 030012450ba..9dc4d447e03 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json index 7be4c82de44..db48d87ee35 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json index 9c3446138c2..293c629d0cc 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json index cd42a40e6bb..43b521f9190 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json index dbe8f008b87..65fe3e75195 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json index eda9b8d68c1..436c99c9db9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json index 14873e66b8d..a900f6a226e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json index 90982b7a0b4..5106704d7af 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json index 62484b9d432..6c6c50622a0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json index 3e268610e7c..8abb1282d7c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json index 651ff3e50f6..5e210ecbc2c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json index d0eaf4a2cd1..4e38810a165 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json index 19eafa4df51..8ca0604a783 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json index 9f098021d62..9dd92a3633d 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json index b3f99250f74..380472cd0d8 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json index 4e231605dec..3e239fa6b68 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json index 70e703015c5..bc489eca7de 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json index fc2b147383a..c6b765448c2 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json index 38bf5dc65c0..dcb62bbe991 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json index 432d19598b4..eda010626d0 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json index b2fb322c5ab..608b3649bd9 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json index 153490a530b..a8102b51879 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json index 8c11f56349f..e78faed10b4 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json @@ -3,7 +3,7 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json index cc0cde5daf3..f3ed566d096 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json index 41c9542e10b..1556bd80d2a 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json index d5c0be05c90..4575f60e48d 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json index a4a4449462f..822399e247f 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json index 371ef955a32..11edd88340b 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json index c79bbe5dd6d..72e04ff0a32 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json index f0bbd3b3330..507dcf8bd9e 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json index aea34ba1ebe..76cc9013278 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json index 09e7a292685..8a69535a78c 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json new file mode 100644 index 00000000000..27422acce04 --- /dev/null +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json @@ -0,0 +1,210 @@ +{ + "operation": "mobileWeb.bundle-fetch", + "family": "mobileWeb.bundle-fetch", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", + "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", + "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", + "scenarioSha256": "cef01677c0032d012985aa1be847dbe180f1df9aadd2d13f897c23dee1066d1a", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 5, + "values": { + "081e772e5b64": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "invalid_argument", + "message": "mobile_web_bundle_build_changed" + }, + "id": "frame-3", + "ok": false + } + } + }, + "23871e324a00": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + } + } + }, + "2988ade7daff": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":0}}" + }, + "775bfa42070b": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.manifest\",\"params\":null}" + }, + "782d48c615ad": { + "name": "bundle-progress", + "ordinal": 7, + "value": { + "completedAssets": 1, + "receivedBytes": 11, + "totalAssets": 2 + } + }, + "79725d734e7d": { + "assets": { + "$rpc": "null" + }, + "outcome": "refused: mobile_web_bundle_build_changed" + }, + "82b2a7971876": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "invalid_argument: mobile_web_bundle_build_changed", + "isRpcDeliveryUnknown": false + } + }, + "a0c050b31ee2": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 5, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"assets/app.js\",\"offset\":0}}" + }, + "ae7f1100e6e5": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "assetByteLength": 11, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "b3JjYS5ib290KCk=", + "eof": true, + "offset": 0, + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + } + } + } + } + }, + "recording": { + "scenario": "mobile-web-bundle-build-changed", + "checkpoints": [ + { + "id": "bundle-build-changed", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "081e772e5b64"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff"], + "settlements": { + "fetch": "82b2a7971876" + }, + "state": "79725d734e7d", + "effects": ["782d48c615ad"] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json new file mode 100644 index 00000000000..d6f0d35e6b4 --- /dev/null +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json @@ -0,0 +1,276 @@ +{ + "operation": "mobileWeb.bundle-fetch", + "family": "mobileWeb.bundle-fetch", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", + "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", + "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", + "scenarioSha256": "cec3f6a9dd09a2527f150626d15e3040b741bbf83a823f1656e0f3449c6ed80e", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 5, + "values": { + "23871e324a00": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + } + } + }, + "2988ade7daff": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":0}}" + }, + "2bfa7c81f55f": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 9, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"index.html\",\"offset\":16}}" + }, + "2ff2addcb1f6": { + "name": "bundle-progress", + "ordinal": 10, + "value": { + "completedAssets": 2, + "receivedBytes": 37, + "totalAssets": 2 + } + }, + "573943fdb37d": { + "assets": { + "assets/app.js": "orca.boot()", + "index.html": "

orca

" + }, + "outcome": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "76ca82e57d04": { + "name": "mobileWeb.bundle.chunk#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "PCFkb2N0eXBlIGh0bWw+PA==", + "eof": false, + "offset": 0, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + }, + "775bfa42070b": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.manifest\",\"params\":null}" + }, + "782d48c615ad": { + "name": "bundle-progress", + "ordinal": 7, + "value": { + "completedAssets": 1, + "receivedBytes": 11, + "totalAssets": 2 + } + }, + "a0c050b31ee2": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 5, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.chunk\",\"params\":{\"buildId\":\"973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177\",\"path\":\"assets/app.js\",\"offset\":0}}" + }, + "ae7f1100e6e5": { + "name": "mobileWeb.bundle.chunk#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 0, + "path": "assets/app.js" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "assetByteLength": 11, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "b3JjYS5ib290KCk=", + "eof": true, + "offset": 0, + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + } + } + } + }, + "c319ae866f13": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "assetCount": 2, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "totalBytes": 37 + } + }, + "ce7abd9f93bb": { + "name": "mobileWeb.bundle.chunk#3", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.chunk" + }, + { + "name": "params", + "value": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "offset": 16, + "path": "index.html" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "assetByteLength": 26, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "dataBase64": "cD5vcmNhPC9wPg==", + "eof": true, + "offset": 16, + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + } + } + } + }, + "recording": { + "scenario": "mobile-web-bundle-fetch-paged", + "checkpoints": [ + { + "id": "bundle-fetched", + "observation": { + "sender": ["23871e324a00", "ae7f1100e6e5", "76ca82e57d04", "ce7abd9f93bb"], + "payloads": ["775bfa42070b", "a0c050b31ee2", "2988ade7daff", "2bfa7c81f55f"], + "settlements": { + "fetch": "c319ae866f13" + }, + "state": "573943fdb37d", + "effects": ["782d48c615ad", "2ff2addcb1f6"] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json new file mode 100644 index 00000000000..956a5e9c8a7 --- /dev/null +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json @@ -0,0 +1,135 @@ +{ + "operation": "mobileWeb.bundle-manifest", + "family": "mobileWeb.bundle-manifest", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", + "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", + "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", + "scenarioSha256": "5e9f4e6878be3f534288a90f7ccabc9cfe4e968aa29da372a98c3064d8e4d89e", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 5, + "values": { + "23871e324a00": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + } + } + }, + "775bfa42070b": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.manifest\",\"params\":null}" + }, + "9ca9b2d0fc07": { + "outcome": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "chunkBytes": 16, + "entrypoint": "index.html", + "paths": ["assets/app.js", "index.html"] + } + }, + "9d87b3b6bd74": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "chunkBytes": 16, + "manifest": { + "assets": [ + { + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8", + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d" + }, + { + "byteLength": 26, + "contentType": "text/html; charset=utf-8", + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e" + } + ], + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "entrypoint": "index.html", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "schemaVersion": 1, + "totalBytes": 37 + } + } + } + }, + "recording": { + "scenario": "mobile-web-bundle-manifest-read", + "checkpoints": [ + { + "id": "manifest-read", + "observation": { + "sender": ["23871e324a00"], + "payloads": ["775bfa42070b"], + "settlements": { + "read": "9d87b3b6bd74" + }, + "state": "9ca9b2d0fc07", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json new file mode 100644 index 00000000000..b1401dfc86c --- /dev/null +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json @@ -0,0 +1,90 @@ +{ + "operation": "mobileWeb.bundle-fetch", + "family": "mobileWeb.bundle-fetch", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", + "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", + "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", + "scenarioSha256": "7333b1c83120bcfd9bdac8888ec1528c1db9b36325509f1328a551676df219c5", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 5, + "values": { + "6858fdcae414": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "invalid_argument: mobile_web_bundle_unavailable", + "isRpcDeliveryUnknown": false + } + }, + "775bfa42070b": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"mobileWeb.bundle.manifest\",\"params\":null}" + }, + "8ea0c6a90560": { + "assets": { + "$rpc": "null" + }, + "outcome": "refused: mobile_web_bundle_unavailable" + }, + "fe8605eb5d5d": { + "name": "mobileWeb.bundle.manifest#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "mobileWeb.bundle.manifest" + }, + { + "name": "params", + "value": { + "$rpc": "null" + } + }, + { + "name": "options", + "value": { + "$rpc": "undefined" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "invalid_argument", + "message": "mobile_web_bundle_unavailable" + }, + "id": "frame-1", + "ok": false + } + } + } + }, + "recording": { + "scenario": "mobile-web-bundle-unavailable", + "checkpoints": [ + { + "id": "bundle-unavailable", + "observation": { + "sender": ["fe8605eb5d5d"], + "payloads": ["775bfa42070b"], + "settlements": { + "fetch": "6858fdcae414" + }, + "state": "8ea0c6a90560", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json index 444671a68fd..34b7a3cde17 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json index cbffd086a94..69848989291 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json index 797b76573cd..c7c9110bc84 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json index 42e968007b1..fad3cf9c12e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json index efec3633436..1265fd5ce64 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json index ef7179a8681..33050390c03 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json index e54fa99f4f2..284e04c5b1f 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json index 51e2383d91c..3c13d7e7a46 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json index 92d33cf4403..3e9c0116c49 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json index 4cd32bd4dc4..e449bf6319c 100644 --- a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json +++ b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json index 9c5dd5bba2a..039fa9c363e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json index cd48baf6367..87908902563 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json index 208face6ecf..c1fa8c47da8 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json index 31eb6efeea2..1d8ece2ebad 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json index 7919382bf7c..14f75a8fbbb 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json index 1590b889ea6..724de3fe781 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json index 3e794edddcc..c679056c415 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json index d178b4f7aff..bb702bed6f4 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json index eea930a68ad..666f89b5879 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json index 965eec2bcd7..b7caeca64b2 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json index 82f6be70c62..f3debdaeb2c 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json index 9c509bd7690..2ee43c70bd8 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json index 900bd952534..f16bf79f939 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json index 86f6ca5debe..398a4e3206b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/new-tab-local-agents.json b/mobile/rpc-foundation/goldens/new-tab-local-agents.json index 1ef040d0833..7cad9cf79c3 100644 --- a/mobile/rpc-foundation/goldens/new-tab-local-agents.json +++ b/mobile/rpc-foundation/goldens/new-tab-local-agents.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json index aabf42cb85e..77b47e280f7 100644 --- a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json +++ b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json index 9dcc9cdd0ee..e4c4ae6304f 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json index 1777a97f59e..bf1f6f0c525 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json index 0371276e529..e363a8790b4 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json index 38d6dee8ea5..ae0a3deba66 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json index 347e627608d..b39af2d6b3f 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json index f22bf7203bc..ce2e3ad34c1 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json index b29c6619ea3..964660efa3d 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json index b054aab49c3..372503d08f4 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json +++ b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/notifications-push-registered.json b/mobile/rpc-foundation/goldens/notifications-push-registered.json index d5f4fb848b0..fc9ce141ee1 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-push-registered.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json index 767d60cd85c..719bbb2c806 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json index 1118719f3cf..20fcdbcf0bb 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json index c44d1ccfa25..ce47a62911a 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/pr-branch-identity.json b/mobile/rpc-foundation/goldens/pr-branch-identity.json index dcf51b1f0ef..8cbe1323348 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-identity.json +++ b/mobile/rpc-foundation/goldens/pr-branch-identity.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json index 64939c7fb24..81056ec0c87 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json +++ b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-comment-mutation.json b/mobile/rpc-foundation/goldens/pr-comment-mutation.json index c4f801666f1..7ae487db0c1 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-comment-mutation.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json index 03bc8591739..b91b86e8c63 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json index 81998fc8939..096dbf4bad6 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-status.json b/mobile/rpc-foundation/goldens/pr-mutation-status.json index 6f02667da82..0eeb24aeb2c 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-status.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-status.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json index dcb7df25732..b45fa26e9a6 100644 --- a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json +++ b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-read-surface.json b/mobile/rpc-foundation/goldens/pr-read-surface.json index cc681664739..43bf9c1bf2b 100644 --- a/mobile/rpc-foundation/goldens/pr-read-surface.json +++ b/mobile/rpc-foundation/goldens/pr-read-surface.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json index 4ff45e2295b..965bcc25200 100644 --- a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json +++ b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json index 2825650bd39..e50a91fbe0c 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-load.json b/mobile/rpc-foundation/goldens/pr-sidebar-load.json index c0bbccf2ab5..d22b3326902 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-load.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-load.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-title-mutation.json b/mobile/rpc-foundation/goldens/pr-title-mutation.json index c1c2f84ba33..26e35d30722 100644 --- a/mobile/rpc-foundation/goldens/pr-title-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-title-mutation.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json index 54038386532..310b7b63410 100644 --- a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json b/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json index ad09b4ece8b..6cd9f8fc76b 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json +++ b/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch.json b/mobile/rpc-foundation/goldens/pr-triage-launch.json index d353a66dd8b..5cf8796ee6c 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-triage-send-locked.json b/mobile/rpc-foundation/goldens/pr-triage-send-locked.json index 036f17e8fcf..0cd444dc8a8 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-send-locked.json +++ b/mobile/rpc-foundation/goldens/pr-triage-send-locked.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json index 2a7aeaaacdc..82377a1aaf0 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json index 66cfc6eb89f..46a5f4a8c13 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json index 65a6d3ed0b8..738a0c210b8 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json index 6b9bf370e48..736ad95562a 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json index 72190de61bd..808aacb3831 100644 --- a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json +++ b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", diff --git a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json index f11395b82d0..d3450316af3 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json +++ b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json index 63b48f8b515..37889fad0dd 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json +++ b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json index 0f8c1821178..bb270dfdf4f 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json +++ b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json index 4f77f8cbb5e..e5f09bb3c85 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json index 2e73220748d..22805bddef6 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json index 2cc867acd1d..388d1e5a313 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json index 83269b855a5..003fd578314 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json index 38ead4f641f..677042deaef 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json index a18c839256a..5d3f174cd90 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json index 96118a231d8..79f78d328b6 100644 --- a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json index b15f5ca4b18..158364c10f8 100644 --- a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json index ccf041d5824..93fa7bd8af2 100644 --- a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-git-mutations-run.json b/mobile/rpc-foundation/goldens/review-git-mutations-run.json index 8e307ffd1e3..b7d7028bc70 100644 --- a/mobile/rpc-foundation/goldens/review-git-mutations-run.json +++ b/mobile/rpc-foundation/goldens/review-git-mutations-run.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json index fa3c53fec13..512093321d8 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json index 461b89b9e62..515a7cc72b4 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-open-in-session.json b/mobile/rpc-foundation/goldens/review-open-in-session.json index 5a9cce71905..1e760d2300c 100644 --- a/mobile/rpc-foundation/goldens/review-open-in-session.json +++ b/mobile/rpc-foundation/goldens/review-open-in-session.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json index 385bb1f436d..d0f820f0bd6 100644 --- a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json +++ b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json index 1d16f9524be..de968c3a13b 100644 --- a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json +++ b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json @@ -3,7 +3,7 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-stage-file.json b/mobile/rpc-foundation/goldens/review-stage-file.json index cdcc87b0e74..74868cca6f0 100644 --- a/mobile/rpc-foundation/goldens/review-stage-file.json +++ b/mobile/rpc-foundation/goldens/review-stage-file.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-stage-refused.json b/mobile/rpc-foundation/goldens/review-stage-refused.json index 81e0b015ef1..a8680fcaf8f 100644 --- a/mobile/rpc-foundation/goldens/review-stage-refused.json +++ b/mobile/rpc-foundation/goldens/review-stage-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-default.json b/mobile/rpc-foundation/goldens/sc-base-ref-default.json index f4da33147c2..36189b62622 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-default.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-default.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json index 573f545ccac..72ef300b85d 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json index a2e6da4fbae..b9f121b5e68 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json index edebf722c35..907eb6e804b 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json index de497221ca0..dcdbd7dba6e 100644 --- a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json +++ b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-changes-loaded.json b/mobile/rpc-foundation/goldens/sc-changes-loaded.json index 533dc7323d3..66c03a1ce52 100644 --- a/mobile/rpc-foundation/goldens/sc-changes-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-changes-loaded.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json index 577c683ba99..c94829d8039 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json index 81b3651bfdd..5c26f36f6b9 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json index 56b69464f9e..2a472aec29a 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-create-existing-review.json b/mobile/rpc-foundation/goldens/sc-create-existing-review.json index 3f27c0436ec..01d6ffae0c3 100644 --- a/mobile/rpc-foundation/goldens/sc-create-existing-review.json +++ b/mobile/rpc-foundation/goldens/sc-create-existing-review.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json index e945cab4af0..e9f44f57bfd 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json index 704ac01a133..200c0c900fa 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json index f7d0300a1f7..64205c7cf8a 100644 --- a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json +++ b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json index f7d8e80e8be..73ce0ca2035 100644 --- a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json +++ b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json index b957d3a2c1d..9f8a45e5e04 100644 --- a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json index 6a6680c58d1..c847b221df3 100644 --- a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json index fad0224e499..d6a9f4bc894 100644 --- a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json +++ b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-history-commit-files.json b/mobile/rpc-foundation/goldens/sc-history-commit-files.json index 3383685d6d9..397c405c07e 100644 --- a/mobile/rpc-foundation/goldens/sc-history-commit-files.json +++ b/mobile/rpc-foundation/goldens/sc-history-commit-files.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-history-loaded.json b/mobile/rpc-foundation/goldens/sc-history-loaded.json index 55a6c262a32..e918b6aeadc 100644 --- a/mobile/rpc-foundation/goldens/sc-history-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-history-loaded.json @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json index 51102f75ca5..221039ca4ba 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-read.json b/mobile/rpc-foundation/goldens/sc-pr-link-read.json index 22302197454..0621f4938b9 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-read.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-read.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-set.json b/mobile/rpc-foundation/goldens/sc-pr-link-set.json index 68f0f34ec38..311cccfa9c2 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-set.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-set.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json index 20ded6677a4..772a182ba82 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json index 2ab275eb433..48b992d2d19 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json index 5954f75ebf2..8a04952f9bf 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json index fffe393ba23..8907e3e81c2 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json index e014c9f38ad..ec9d9073448 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json index adf34d160e5..6a11d6bfdf6 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json index 23c77605704..1a5b3349f6f 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json index cda536a1320..47816ddc2a5 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json index 0a4707a76ac..dcb8cd11b55 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json index 12b86567975..7d14be2af8e 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json index 9d83f1c8e9f..036416078dc 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit.json b/mobile/rpc-foundation/goldens/sc-review-commit.json index 9629b49e617..9e0a3d40baf 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json index 047d9cf7293..c465d93beff 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json index 23da3a48935..b2a2f9bac19 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/schedules-b3.json b/mobile/rpc-foundation/goldens/schedules-b3.json index 8568e3286db..c89136c1efc 100644 --- a/mobile/rpc-foundation/goldens/schedules-b3.json +++ b/mobile/rpc-foundation/goldens/schedules-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json index 6257be6ebce..b43ec58c4be 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json index fe478fb7b68..eaa46f1e337 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json index 8a9385d95c5..f92b11bdccc 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json index 902a4feaeee..1d70e1b046c 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json index 738e12719a2..d84fc521691 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json index eb89f74449f..50df5a1664f 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/session-browser-tab-created.json b/mobile/rpc-foundation/goldens/session-browser-tab-created.json index 9baae2fb90d..a98478db1f1 100644 --- a/mobile/rpc-foundation/goldens/session-browser-tab-created.json +++ b/mobile/rpc-foundation/goldens/session-browser-tab-created.json @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-refused.json b/mobile/rpc-foundation/goldens/session-create-browser-refused.json index c96e109c214..a3a448cd82c 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-refused.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-tab.json b/mobile/rpc-foundation/goldens/session-create-browser-tab.json index bc5e45cb68c..04ecd4dc1b4 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-tab.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json index e9110433925..db01505f56f 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-note.json b/mobile/rpc-foundation/goldens/session-create-markdown-note.json index 539dacb7b29..897c5db1e00 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-note.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-note.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json index 4b5a58b5396..19b65dfcb13 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json index a18ed3ff9cc..859c60a4576 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json index 8fdd4525efc..c8c87e93e2e 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json index 2e102650757..abc4f0ab3c0 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json index 32b34ec7df7..43bda3f3618 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json index 5d3749fce4a..78febc06d30 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json index d2044ee3466..8b94f47d641 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json index a63804fb41f..b02219910d5 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json index ba5f5442d25..fdb74097444 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json index 6571b445354..7887cdac1a8 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-file-tab-read.json b/mobile/rpc-foundation/goldens/session-file-tab-read.json index 397ab8947f0..379452e0d6d 100644 --- a/mobile/rpc-foundation/goldens/session-file-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-file-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json index 640b9369cbb..de82fdf8fa2 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json index 3256e5ee30c..b52f4e9a2c3 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json index c3bd07ef707..520513b7533 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json +++ b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-markdown-saved.json b/mobile/rpc-foundation/goldens/session-markdown-saved.json index 1eb556ffcee..c8a05d25441 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-saved.json +++ b/mobile/rpc-foundation/goldens/session-markdown-saved.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json index 9b7c04268f7..91707c3827d 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json index dbbe6e42d48..ab9580fb2be 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json index 5813d0bbabf..0881ac812f3 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json index 892d872144e..56ac593798e 100644 --- a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json +++ b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json index 11d41679307..87c42acaedc 100644 --- a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json +++ b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json index e11e0595888..4647e085c44 100644 --- a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json +++ b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json index bd939e6fb1e..7cf9af5e559 100644 --- a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json +++ b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json index bb06bebaa20..e6d67fb3c7c 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json index ade387c3881..83cdc6521ca 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json index a9ff3c07061..155d32f08e9 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json index a0a9b341926..6b017291d2a 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json index a8430e54a7e..6b60979b207 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json index 924ce4e9bc1..27cb3712c02 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-closed.json b/mobile/rpc-foundation/goldens/session-tab-closed.json index 6305b14ed17..b8c85ed3945 100644 --- a/mobile/rpc-foundation/goldens/session-tab-closed.json +++ b/mobile/rpc-foundation/goldens/session-tab-closed.json @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-rename.json b/mobile/rpc-foundation/goldens/session-tab-rename.json index b1c45b3bd8b..28c6d985885 100644 --- a/mobile/rpc-foundation/goldens/session-tab-rename.json +++ b/mobile/rpc-foundation/goldens/session-tab-rename.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-renamed.json b/mobile/rpc-foundation/goldens/session-tab-renamed.json index 58b625c326b..aef86e0995d 100644 --- a/mobile/rpc-foundation/goldens/session-tab-renamed.json +++ b/mobile/rpc-foundation/goldens/session-tab-renamed.json @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json index 0b11ce76cd1..cfa8953d600 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json index 9f34d55b1cd..ec52eac1992 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json index 714aa934548..330c8becc32 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json index 17d06716ff9..a544d94becf 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json index 37497a5dc28..65429ed3703 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json index 6d9edfa1bec..e0390068e7d 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json index dfa8f9f824b..929156e84e7 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json index fdfd778559e..6697f43c705 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json index 76621d9b57b..2449bb28d63 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json index 3e579906856..2bad4800947 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json index 8411ce561af..e2b209182a6 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json index af1bbc81802..a562b814680 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json index d4d5a7ba927..3719f1e6e0f 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json index 4f29c57f237..67600385781 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json index 60ab89a9660..a692328dc6b 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json index cd1fe52a77c..28d5aa7b492 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json index a9583652cc0..5acc7dc254f 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-coalesced.json b/mobile/rpc-foundation/goldens/settings-home-coalesced.json index 19407ee70dd..607ceb8f96e 100644 --- a/mobile/rpc-foundation/goldens/settings-home-coalesced.json +++ b/mobile/rpc-foundation/goldens/settings-home-coalesced.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json index d35a57ab17a..965b2a51383 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json index dbeed96cca4..5e678441b0a 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json index f1b19ad17bf..fa6d25c24f9 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json index 25d2207a07c..318203dc3b8 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json index d6385cce795..899f5a41bb2 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json index 2ba29ec2f2c..98f1ad0abb2 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json index 1c3c263ae7e..760fb48bd37 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index f5a5b48b1b3..a81fa8fc825 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index 6b1a6348154..84a4f4d8498 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json index ae1783278a5..6b078c2a563 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json index f9ed435f43d..c1e24d6d268 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index 6b339e7bf4e..a47257b0c59 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json index 62ef1dd5710..f3a7f6c397c 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json index 9eef882d2c3..a385b5cb36d 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json index e09dcfe7d7c..7f171eb0637 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json index 0ee5dcf3b65..b610203208b 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json index d433d72035d..058901c2d7c 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json index 3578ea196cc..0b42ea1231e 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json index 4b271b141f0..90949ed775a 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json index e66deccd577..c18e3069c45 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json index 69df8315f97..5c4f2b0a2f2 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json index 863438ba762..53c8b4b28a3 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json index 210aa3d107f..21abaa2256f 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json index 66ed6d4865e..7cddc3196a6 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json index 3fb03be495a..04481fdd1e0 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json index 2e4818627e6..7a9e5b06aed 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json index f9f05142440..25f63e2876d 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-write.json b/mobile/rpc-foundation/goldens/settings-task-write.json index 44657753f03..a4e31adf30e 100644 --- a/mobile/rpc-foundation/goldens/settings-task-write.json +++ b/mobile/rpc-foundation/goldens/settings-task-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json index d57da179268..20d79753238 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json index 030bd73b2e0..a2b4543745f 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json index f2efcefee29..8c40c37ea97 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json index cd3f9d80d9b..2808fb6c0f3 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json index 84747858014..72f0520ce70 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json index 201f7a6c7f4..82684ec2ec7 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json index 481b25bab50..33e1ca27c8a 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json index 0713771ad5d..f85a254717c 100644 --- a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json +++ b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json index 9153cc6ecf4..59a78b492fe 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json index b6414ad3be4..953ac766d09 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json index 0ecb2748b23..9f329ccd7cb 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json index 125eb6bc371..f8492c20525 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json index 431cdd64901..dc5a9db18db 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json index 9da4f838ded..39188439a58 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json index 0787dde585f..eda46d8d76d 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json index b5ce7d1ec5d..8c843be7ef4 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json index fe546b7898c..89ad5b48ce2 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/structured-agent-session-created.json b/mobile/rpc-foundation/goldens/structured-agent-session-created.json index c2ed99d4499..ae657fd7af0 100644 --- a/mobile/rpc-foundation/goldens/structured-agent-session-created.json +++ b/mobile/rpc-foundation/goldens/structured-agent-session-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-created.json b/mobile/rpc-foundation/goldens/structured-launch-created.json index f13e802cbb6..b5ff53f74bc 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-created.json +++ b/mobile/rpc-foundation/goldens/structured-launch-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json index 3e2024f42a9..d51c62db122 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json +++ b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json index 7e155fde891..9f9a9c51d46 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json +++ b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json index e821c7cea4f..a277bf59c43 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json +++ b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json index fca717c6f28..851a6426370 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json +++ b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json index 6881876182c..61e02631a16 100644 --- a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json +++ b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "feb6cee1ab7ecff1ba98bfba22d4924c748d3bb6b749db460cb617ee50b92f2c", diff --git a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json index 60a9844b2ff..4d4fae94526 100644 --- a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json +++ b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json index d045b82ec56..20b10bf9f51 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json index aeb64e780ef..6c79115b042 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json index 0a6205e6809..a5d891ad2da 100644 --- a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json index 5a4c3c39d3e..91c67bbc905 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-refused.json b/mobile/rpc-foundation/goldens/terminal-paste-refused.json index bfa4c1243a7..03437e393f1 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json index ed59187ec69..bbfe2e5f97a 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json index c1cb734d890..778fe54ab12 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json index dbf28103ca6..cffb8e0cebb 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json index 6f559f0d86b..982e02e225d 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json index 84cd3f18a6c..0016f896946 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json index 618b01659b4..d9b8b3a635f 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json index 8e5a75b6eda..291fc99b141 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json index e8e22d001f1..36f6d239604 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json index dd2bcf06bc1..a13462a4c1b 100644 --- a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json +++ b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/tk-create-github.json b/mobile/rpc-foundation/goldens/tk-create-github.json index 2a0814e1c1e..056be111f1f 100644 --- a/mobile/rpc-foundation/goldens/tk-create-github.json +++ b/mobile/rpc-foundation/goldens/tk-create-github.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-gitlab.json b/mobile/rpc-foundation/goldens/tk-create-gitlab.json index 84728f80ac4..f9c781b59cc 100644 --- a/mobile/rpc-foundation/goldens/tk-create-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-create-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-linear.json b/mobile/rpc-foundation/goldens/tk-create-linear.json index f862b2069a4..3c715ca7b0c 100644 --- a/mobile/rpc-foundation/goldens/tk-create-linear.json +++ b/mobile/rpc-foundation/goldens/tk-create-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-item-checks-files.json b/mobile/rpc-foundation/goldens/tk-item-checks-files.json index 37142986100..e4f5ba3a006 100644 --- a/mobile/rpc-foundation/goldens/tk-item-checks-files.json +++ b/mobile/rpc-foundation/goldens/tk-item-checks-files.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-github.json b/mobile/rpc-foundation/goldens/tk-item-comment-github.json index 63e879d4776..1a3196812ee 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json index 237b9074831..e71ceb122ad 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json index 7a4eeef225c..04b99bf80ab 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json index f3f6789103e..0edc6709092 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github.json b/mobile/rpc-foundation/goldens/tk-item-detail-github.json index b4a8dab1025..5923ba4bf1f 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json index ba2eafc1141..2979d93acfb 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json index 63343def5a0..cc3ff056765 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json index 95ab430abc1..15e6aba81bd 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json index 6107483b462..2a499e70032 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json index 3cf732738b8..6fd0875b846 100644 --- a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json index 3436eaf8dd2..e8d38bbb06e 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json index 432b2a45d93..8dff1ae8d34 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json index 4612780b14b..db660a9866d 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json index 45da471c532..fae209129b1 100644 --- a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json +++ b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-review-github.json b/mobile/rpc-foundation/goldens/tk-item-review-github.json index 86a57193aa6..57fd9af7473 100644 --- a/mobile/rpc-foundation/goldens/tk-item-review-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-review-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json index 8c17643e3ac..b7cde92e644 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json index 6cc6c89d157..d922a9114b2 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-linear-connect.json b/mobile/rpc-foundation/goldens/tk-linear-connect.json index c4672b6ec87..6d2936bd9f1 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-connect.json +++ b/mobile/rpc-foundation/goldens/tk-linear-connect.json @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-linear-item.json b/mobile/rpc-foundation/goldens/tk-linear-item.json index 69996b58d71..5e9ec14fed4 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-item.json +++ b/mobile/rpc-foundation/goldens/tk-linear-item.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-linear-team-context.json b/mobile/rpc-foundation/goldens/tk-linear-team-context.json index 6d70a279884..e81f0c4226a 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-team-context.json +++ b/mobile/rpc-foundation/goldens/tk-linear-team-context.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json index 50f6c4f8a9e..b9c4d68ec4e 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json index 911f89be5c4..16a274f76c7 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-linear.json b/mobile/rpc-foundation/goldens/tk-list-linear.json index 2b2082832fc..5d0ff05f9e3 100644 --- a/mobile/rpc-foundation/goldens/tk-list-linear.json +++ b/mobile/rpc-foundation/goldens/tk-list-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-project-board-load.json b/mobile/rpc-foundation/goldens/tk-project-board-load.json index d71136bc311..ed74988dcdd 100644 --- a/mobile/rpc-foundation/goldens/tk-project-board-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-board-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json index b0f0caaa60a..45fae56b77c 100644 --- a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json +++ b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json index 6dae58f6c42..f7e9f7c3ee0 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json index 8c57208f6c2..690f9b33360 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-detail.json b/mobile/rpc-foundation/goldens/tk-project-row-detail.json index 91caf6cbbaf..49e5913b0f9 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-detail.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-detail.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-fields.json b/mobile/rpc-foundation/goldens/tk-project-row-fields.json index 00ee513d05e..a3bb0476de5 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-fields.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-fields.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json index 3fdf6e0d389..018c5975f31 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json index b816e75c56f..94b92d97f50 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json index 7600a0022bc..7c2627b8619 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-threads.json b/mobile/rpc-foundation/goldens/tk-project-row-threads.json index cd4304bd421..a6a234eea3b 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-threads.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-threads.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-provider-load.json b/mobile/rpc-foundation/goldens/tk-provider-load.json index d8f207d1e63..c60c07a063c 100644 --- a/mobile/rpc-foundation/goldens/tk-provider-load.json +++ b/mobile/rpc-foundation/goldens/tk-provider-load.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json index 41b96600cee..d18e4f70910 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json index e17b7da25d7..ab1afc2965d 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json index ccb11d7defa..6d31cb48ba9 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json index 9973098d83b..bf4ebf9a20b 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json index f6228aa6d39..3787c1d6c15 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json index 1098546f302..f6b7263c5b4 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json index 930dc2b649e..dac18e8dfee 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json index f82d2e20f3e..7cd051f2b0b 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json index 418a0c0fc3a..d46784c8394 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json index 569ff29db55..fe52b9266b1 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json index 9690faa2958..8198d6343ae 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json index 83567614693..980e2d18517 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json index b722a090a3e..0bb5e8c6291 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json index 478282271e1..cd2e6851aa8 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json index 87bbe7231d7..ec9f4ca6a09 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json @@ -3,7 +3,7 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json index b86c462a1d6..64329f03ba6 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json index 7d55c225237..f94b3aa50b7 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json index aed969a315d..cea4816faab 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-created.json b/mobile/rpc-foundation/goldens/tw-create-retry-created.json index 4c34a3bb8b5..2ad976ac03d 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-created.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-created.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json index 449100e75af..d56fb59f848 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json index 34ba2993608..bdf3a62334d 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json index f1421d9e34d..47d93d1fffa 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json index a947b6f2c01..c67998503f3 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json index f7f83c67e4e..28f32fd2b42 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json index ae83198ee1d..3cd6698ae60 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json index 0af00ef5423..a1ae250a957 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json index a3590aaea5f..bdbb6ef710f 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json index d2d74e6fcdd..2b52b990d95 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json index 24e83143bc4..acb19a7c0cf 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json index 04b03ae97f8..9e35e07e804 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json index 99094e1049d..7bdd56a1f96 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json index d2e7c7b2e6a..6d94a4e7b96 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json index 4141740d89b..779c8c60a1a 100644 --- a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json +++ b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json index f166b24b29f..d2cb946fd8d 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json index 916d9173fe7..6cbe4aaa513 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json index fc0946adf48..4d90a6fac9c 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json index cd0132408a7..1fcaf981e87 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json index 5ec47581ea0..10d5cf3612c 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json index d06a8840782..2013e441497 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json index b6549b4de43..41f0d873dd2 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json index 8fef89f1f38..5e33d63d533 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json index e802de2174a..01a477cc887 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json index e6308ed49ce..295ddfad359 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-home-catalog.json b/mobile/rpc-foundation/goldens/worktree-home-catalog.json index b76556bb76f..efeba0e1e19 100644 --- a/mobile/rpc-foundation/goldens/worktree-home-catalog.json +++ b/mobile/rpc-foundation/goldens/worktree-home-catalog.json @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-retired-names.json b/mobile/rpc-foundation/goldens/worktree-retired-names.json index 24730723968..562e03f3840 100644 --- a/mobile/rpc-foundation/goldens/worktree-retired-names.json +++ b/mobile/rpc-foundation/goldens/worktree-retired-names.json @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index c0fa900c8d7..db5a78e7eab 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baseline": "4a86b2dc565dea05491573afdbff17d6217806d5", + "baseline": "5741d48e592dad21660d995a3758319413a4baf0", "scenarios": [ { "id": "b1", @@ -23280,6 +23280,333 @@ "checkpoint": "served" } ] + }, + { + "id": "mobile-web-bundle-manifest-read", + "operation": "mobileWeb.bundle-manifest", + "version": 1, + "family": "mobileWeb.bundle-manifest", + "sites": ["mobile/src/transport/mobile-web-bundle-operations.ts"], + "schedules": [], + "steps": [ + { + "action": "read", + "id": "read" + }, + { + "complete": "mobileWeb.bundle.manifest#1", + "params": null, + "reply": { + "ok": true, + "result": { + "manifest": { + "schemaVersion": 1, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "entrypoint": "index.html", + "totalBytes": 37, + "assets": [ + { + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d", + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8" + }, + { + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e", + "byteLength": 26, + "contentType": "text/html; charset=utf-8" + } + ] + }, + "chunkBytes": 16 + } + } + }, + { + "checkpoint": "manifest-read" + } + ] + }, + { + "id": "mobile-web-bundle-fetch-paged", + "operation": "mobileWeb.bundle-fetch", + "version": 1, + "family": "mobileWeb.bundle-fetch", + "sites": [ + "mobile/src/transport/mobile-web-bundle-fetch.ts", + "mobile/src/diagnostics/use-mobile-web-bundle-probe.ts" + ], + "schedules": [], + "steps": [ + { + "action": "fetch", + "id": "fetch" + }, + { + "complete": "mobileWeb.bundle.manifest#1", + "params": null, + "reply": { + "ok": true, + "result": { + "manifest": { + "schemaVersion": 1, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "entrypoint": "index.html", + "totalBytes": 37, + "assets": [ + { + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d", + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8" + }, + { + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e", + "byteLength": 26, + "contentType": "text/html; charset=utf-8" + } + ] + }, + "chunkBytes": 16 + } + } + }, + { + "bind": "app-js", + "request": "mobileWeb.bundle.chunk#1", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "assets/app.js", + "offset": 0 + } + }, + { + "bind": "index-head", + "request": "mobileWeb.bundle.chunk#2", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "index.html", + "offset": 0 + } + }, + { + "complete": "app-js", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "assets/app.js", + "offset": 0 + }, + "reply": { + "ok": true, + "result": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "assets/app.js", + "offset": 0, + "assetByteLength": 11, + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d", + "dataBase64": "b3JjYS5ib290KCk=", + "eof": true + } + } + }, + { + "complete": "index-head", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "index.html", + "offset": 0 + }, + "reply": { + "ok": true, + "result": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "index.html", + "offset": 0, + "assetByteLength": 26, + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e", + "dataBase64": "PCFkb2N0eXBlIGh0bWw+PA==", + "eof": false + } + } + }, + { + "bind": "index-tail", + "request": "mobileWeb.bundle.chunk#3", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "index.html", + "offset": 16 + } + }, + { + "complete": "index-tail", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "index.html", + "offset": 16 + }, + "reply": { + "ok": true, + "result": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "index.html", + "offset": 16, + "assetByteLength": 26, + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e", + "dataBase64": "cD5vcmNhPC9wPg==", + "eof": true + } + } + }, + { + "checkpoint": "bundle-fetched" + } + ] + }, + { + "id": "mobile-web-bundle-unavailable", + "operation": "mobileWeb.bundle-fetch", + "version": 1, + "family": "mobileWeb.bundle-fetch", + "sites": [ + "mobile/src/transport/mobile-web-bundle-fetch.ts", + "mobile/src/diagnostics/use-mobile-web-bundle-probe.ts" + ], + "schedules": [], + "steps": [ + { + "action": "fetch", + "id": "fetch" + }, + { + "complete": "mobileWeb.bundle.manifest#1", + "params": null, + "reply": { + "ok": false, + "error": { + "code": "invalid_argument", + "message": "mobile_web_bundle_unavailable" + } + } + }, + { + "checkpoint": "bundle-unavailable" + } + ] + }, + { + "id": "mobile-web-bundle-build-changed", + "operation": "mobileWeb.bundle-fetch", + "version": 1, + "family": "mobileWeb.bundle-fetch", + "sites": [ + "mobile/src/transport/mobile-web-bundle-fetch.ts", + "mobile/src/diagnostics/use-mobile-web-bundle-probe.ts" + ], + "schedules": [], + "steps": [ + { + "action": "fetch", + "id": "fetch" + }, + { + "complete": "mobileWeb.bundle.manifest#1", + "params": null, + "reply": { + "ok": true, + "result": { + "manifest": { + "schemaVersion": 1, + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "desktopVersion": "1.4.201", + "minCompatibleRuntimeProtocolVersion": 2, + "runtimeProtocolVersion": 2, + "entrypoint": "index.html", + "totalBytes": 37, + "assets": [ + { + "path": "assets/app.js", + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d", + "byteLength": 11, + "contentType": "text/javascript; charset=utf-8" + }, + { + "path": "index.html", + "sha256": "483f915496f213c851665840f49b69e05b7a6bf70ec9d6939a831b15d298f31e", + "byteLength": 26, + "contentType": "text/html; charset=utf-8" + } + ] + }, + "chunkBytes": 16 + } + } + }, + { + "bind": "app-js", + "request": "mobileWeb.bundle.chunk#1", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "assets/app.js", + "offset": 0 + } + }, + { + "bind": "index-head", + "request": "mobileWeb.bundle.chunk#2", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "index.html", + "offset": 0 + } + }, + { + "complete": "app-js", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "assets/app.js", + "offset": 0 + }, + "reply": { + "ok": true, + "result": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "assets/app.js", + "offset": 0, + "assetByteLength": 11, + "sha256": "e99170780c392398db81fbb3dcaebc1a2c8264d4d8a9cd5932887e9c7206dc3d", + "dataBase64": "b3JjYS5ib290KCk=", + "eof": true + } + } + }, + { + "complete": "index-head", + "params": { + "buildId": "973c008f6baf56072d8d3d41f451e005257b59026401473543ce96e81c5aa177", + "path": "index.html", + "offset": 0 + }, + "reply": { + "ok": false, + "error": { + "code": "invalid_argument", + "message": "mobile_web_bundle_build_changed" + } + } + }, + { + "checkpoint": "bundle-build-changed" + } + ] } ] } diff --git a/mobile/src/diagnostics/mobile-web-bundle-probe-row.tsx b/mobile/src/diagnostics/mobile-web-bundle-probe-row.tsx new file mode 100644 index 00000000000..06a1db2efcb --- /dev/null +++ b/mobile/src/diagnostics/mobile-web-bundle-probe-row.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react' +import { View, Text, Pressable, ActivityIndicator } from 'react-native' +import { Package } from 'lucide-react-native' +import { loadHosts } from '../transport/host-store' +import { colors } from '../theme/mobile-theme' +import { troubleshootScreenStyles as styles } from './troubleshoot-screen-styles' +import { + useMobileWebBundleProbe, + type MobileWebBundleProbeState +} from './use-mobile-web-bundle-probe' +import type { HostProfile } from '../transport/types' + +/** Dialling the host is part of the tap, so the label says which half is still running. */ +function buttonLabel(state: MobileWebBundleProbeState, awaitingHost: boolean): string { + if (state.status !== 'running') { + return 'Fetch mobile web bundle' + } + return awaitingHost ? 'Connecting…' : 'Fetching bundle…' +} + +/** One `label — detail` line in the same row shape the diagnostic checks use. */ +function ProbeLine({ label, detail, failed }: { label: string; detail: string; failed?: boolean }) { + return ( + + {label} + {detail} + + ) +} + +function ProbeResult({ state, hostName }: { state: MobileWebBundleProbeState; hostName: string }) { + if (state.status === 'idle' || state.status === 'running') { + return null + } + if (state.status === 'failed') { + return ( + + + + ) + } + return ( + + + + + + + + + + + + ) +} + +/** + * Development-only: fetches the whole mobile web bundle from a paired desktop and reports what came + * back. Phase A ships no production path that renders a bundle, and this row is the only thing that + * exercises the operations end to end on a device. + * + * `app/troubleshoot.tsx` mounts it behind `__DEV__`, so a shipped build never runs the host lookup. + * + * The screen carries no host parameter and troubleshoots every paired host, one reachability check + * each, so there is no host it is "on". The probe takes the first paired host and names it in the + * result rather than implying it speaks for all of them. + */ +export function MobileWebBundleProbeRow() { + const [hosts, setHosts] = useState([]) + useEffect(() => { + let stale = false + void loadHosts().then((loaded) => { + if (!stale) { + setHosts(loaded) + } + }) + return () => { + stale = true + } + }, []) + const host = hosts[0] ?? null + const { state, run, awaitingHost } = useMobileWebBundleProbe(host?.id ?? null) + + return ( + + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed, + state.status === 'running' && styles.diagnosticButtonDisabled + ]} + testID="mobile-web-bundle-probe" + onPress={run} + disabled={state.status === 'running'} + > + {state.status === 'running' ? ( + + ) : ( + + )} + {buttonLabel(state, awaitingHost)} + + + + ) +} diff --git a/mobile/src/diagnostics/troubleshoot-view.tsx b/mobile/src/diagnostics/troubleshoot-view.tsx index 0fd86605f13..8655ddbce4c 100644 --- a/mobile/src/diagnostics/troubleshoot-view.tsx +++ b/mobile/src/diagnostics/troubleshoot-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useState, type ReactNode } from 'react' import { View, Text, Pressable, ScrollView, ActivityIndicator } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { @@ -39,7 +39,8 @@ export function TroubleshootView({ checks, runDiagnostics, onBack, - onConnectionLog + onConnectionLog, + developerRow }: { rootRef?: (node: View | null) => void diagnosticStatus: DiagnosticStatus @@ -47,6 +48,8 @@ export function TroubleshootView({ runDiagnostics: () => void onBack: () => void onConnectionLog: () => void + /** Slot the route fills only under `__DEV__`; null in every shipped build. */ + developerRow?: ReactNode }) { const insets = useSafeAreaInsets() const [expandedId, setExpandedId] = useState(null) @@ -108,6 +111,8 @@ export function TroubleshootView({ View network diagnostics + {developerRow} + {checks.length > 0 && ( {checks.map((check, i) => ( diff --git a/mobile/src/diagnostics/use-mobile-web-bundle-probe.test.tsx b/mobile/src/diagnostics/use-mobile-web-bundle-probe.test.tsx new file mode 100644 index 00000000000..e339d6125de --- /dev/null +++ b/mobile/src/diagnostics/use-mobile-web-bundle-probe.test.tsx @@ -0,0 +1,305 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileWebBundleFetchResult } from '../transport/mobile-web-bundle-fetch' + +const push = vi.hoisted(() => ({ attach: vi.fn(), detach: vi.fn() })) +vi.mock('../notifications/push-registration', () => ({ attachPushRegistration: push.attach })) + +const connectMock = vi.hoisted(() => vi.fn()) +const loadHostsMock = vi.hoisted(() => vi.fn()) +const fetchMock = vi.hoisted(() => vi.fn()) + +vi.mock('../transport/rpc-client', () => ({ + connect: (...args: unknown[]) => connectMock(...args) +})) +vi.mock('../transport/host-logical-client', () => ({ + openHostLogicalClient: (...args: unknown[]) => connectMock(...args) +})) +vi.mock('../transport/host-store', () => ({ loadHosts: () => loadHostsMock() })) +vi.mock('../transport/connection-revival-triggers', () => ({ + subscribeConnectionRevivalTriggers: () => () => {} +})) +vi.mock('../transport/mobile-web-bundle-fetch', () => ({ + fetchMobileWebBundle: (...args: unknown[]) => fetchMock(...args) +})) + +import { RpcClientProvider } from '../transport/client-context' +import { + useMobileWebBundleProbe, + type MobileWebBundleProbeState +} from './use-mobile-web-bundle-probe' + +const HOST = { + id: 'host-1', + name: 'Host 1', + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 0 +} + +function fakeClient(): RpcClient { + return { + sendRequest: vi.fn(), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: vi.fn(), + close: vi.fn() + } +} + +type ProbeHarness = { + readonly state: MobileWebBundleProbeState + readonly awaitingHost: boolean + run: () => Promise + unmount: () => Promise +} + +async function renderProbe(hostId: string | null): Promise { + let latest: ReturnType | null = null + // A box, not a `let`: assigning inside the callback leaves a `let` narrowed to `null`. + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + + function Probe(): null { + latest = useMobileWebBundleProbe(hostId) + return null + } + + await act(async () => { + rendered.tree = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + const read = () => { + if (!latest) { + throw new Error('probe did not render') + } + return latest + } + return { + get state() { + return read().state + }, + get awaitingHost() { + return read().awaitingHost + }, + run: async () => { + await act(async () => { + read().run() + }) + }, + unmount: async () => { + await act(async () => { + rendered.tree?.unmount() + }) + } + } +} + +function fetchedBundle(): MobileWebBundleFetchResult { + return { + manifest: { + schemaVersion: 1, + buildId: 'a'.repeat(64), + entrypoint: 'index.html', + totalBytes: 3, + assets: [ + { path: 'index.html', sha256: 'b'.repeat(64), byteLength: 3, contentType: 'text/html' } + ] + }, + assets: new Map([['index.html', new Uint8Array([1, 2, 3])]]), + totalBytes: 3, + elapsedMs: 12 + } +} + +type Deferred = { + readonly promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + const box: { resolve: (value: T) => void; reject: (error: unknown) => void } = { + resolve: () => {}, + reject: () => {} + } + const promise = new Promise((resolve, reject) => { + box.resolve = resolve + box.reject = reject + }) + return { promise, resolve: box.resolve, reject: box.reject } +} + +async function settle(): Promise { + await act(async () => { + await Promise.resolve() + }) +} + +beforeEach(() => { + push.attach.mockReset().mockReturnValue(push.detach) + push.detach.mockReset() + connectMock.mockReset().mockReturnValue(fakeClient()) + loadHostsMock.mockReset().mockResolvedValue([HOST]) + fetchMock.mockReset() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('useMobileWebBundleProbe', () => { + it('dials no host until the row is tapped', async () => { + const probe = await renderProbe(HOST.id) + + expect(connectMock).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(probe.state).toEqual({ status: 'idle' }) + + fetchMock.mockResolvedValue(fetchedBundle()) + await probe.run() + + expect(connectMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(probe.state).toEqual({ + status: 'done', + buildId: 'a'.repeat(64), + assetCount: 1, + totalBytes: 3, + elapsedMs: 12 + }) + expect(probe.awaitingHost).toBe(false) + }) + + it('reports the host code when the desktop refused', async () => { + fetchMock.mockRejectedValue(new Error('invalid_argument: mobile_web_bundle_unavailable')) + const probe = await renderProbe(HOST.id) + + await probe.run() + + expect(probe.state).toEqual({ status: 'failed', detail: 'mobile_web_bundle_unavailable' }) + }) + + it('reports a schema refusal, which carries no code, as its message', async () => { + fetchMock.mockRejectedValue( + new Error('invalid_argument: Invalid input: expected string, received number') + ) + const probe = await renderProbe(HOST.id) + + await probe.run() + + expect(probe.state).toEqual({ + status: 'failed', + detail: 'invalid_argument: Invalid input: expected string, received number' + }) + }) + + it('fails without dialling when no host is paired', async () => { + const probe = await renderProbe(null) + + await probe.run() + + expect(connectMock).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(probe.state).toEqual({ status: 'failed', detail: 'no paired host to fetch from' }) + }) + + it('gives up on a host whose client never arrives instead of waiting forever', async () => { + vi.useFakeTimers() + // The host is not in the store, so no client is ever acquired for it and `awaitingHost` would + // otherwise stay true with the row's button disabled for the life of the screen. + loadHostsMock.mockResolvedValue([]) + const probe = await renderProbe(HOST.id) + + await probe.run() + expect(probe.state).toEqual({ status: 'running' }) + expect(probe.awaitingHost).toBe(true) + + await act(async () => { + await vi.advanceTimersByTimeAsync(9_999) + }) + expect(probe.state).toEqual({ status: 'running' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(probe.state).toEqual({ status: 'failed', detail: 'no client for the host within 10s' }) + // The row re-enables its button off `running`, and nothing is left dialling the host. + expect(probe.awaitingHost).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('does not clear the deadline for a host that did arrive', async () => { + vi.useFakeTimers() + fetchMock.mockReturnValue(new Promise(() => {})) + const probe = await renderProbe(HOST.id) + + await probe.run() + expect(fetchMock).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + + // A slow fetch is not a host that never opened: the deadline covers acquiring the client only. + expect(probe.state).toEqual({ status: 'running' }) + }) + + it('ignores a result from a run the screen already moved on from', async () => { + const first = deferred() + const second = deferred() + fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const probe = await renderProbe(HOST.id) + + await probe.run() + await probe.run() + first.resolve({ ...fetchedBundle(), elapsedMs: 999 }) + await settle() + + expect(probe.state).toEqual({ status: 'running' }) + + second.resolve(fetchedBundle()) + await settle() + + expect(probe.state).toMatchObject({ status: 'done', elapsedMs: 12 }) + }) + + it('ignores a failure from a run the screen already moved on from', async () => { + const first = deferred() + const second = deferred() + fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const probe = await renderProbe(HOST.id) + + await probe.run() + await probe.run() + first.reject(new Error('invalid_argument: mobile_web_bundle_unavailable')) + await settle() + + expect(probe.state).toEqual({ status: 'running' }) + + second.resolve(fetchedBundle()) + await settle() + + expect(probe.state).toMatchObject({ status: 'done', elapsedMs: 12 }) + }) + + it('aborts the run it started when the screen goes away', async () => { + const captured: { signal: AbortSignal | null } = { signal: null } + fetchMock.mockImplementation((args: { signal?: AbortSignal }) => { + captured.signal = args.signal ?? null + return new Promise(() => {}) + }) + const probe = await renderProbe(HOST.id) + await probe.run() + + expect(captured.signal?.aborted).toBe(false) + await probe.unmount() + + expect(captured.signal?.aborted).toBe(true) + }) +}) diff --git a/mobile/src/diagnostics/use-mobile-web-bundle-probe.ts b/mobile/src/diagnostics/use-mobile-web-bundle-probe.ts new file mode 100644 index 00000000000..aeebb4573a5 --- /dev/null +++ b/mobile/src/diagnostics/use-mobile-web-bundle-probe.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useState } from 'react' +import { useHostClient } from '../transport/client-context' +import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch' +import { readMobileWebBundleErrorCode } from '../transport/mobile-web-bundle-operations' +import { startDiagnosticFetchTimeout } from './diagnostic-fetch-timeout' + +/** + * How long a tap waits for the host's client object before it gives up. + * + * Generous, because acquiring one can queue behind another screen's, but bounded, because none of + * that is a network round trip: the connect and request timeouts live below this, inside the fetch, + * and only apply once a client exists. Without a bound here a host that never opens leaves the row + * reading `Connecting…` with its button disabled for the life of the screen. + */ +const HOST_CLIENT_DIAL_DEADLINE_MS = 10_000 + +export type MobileWebBundleProbeState = + | { status: 'idle' } + | { status: 'running' } + | { + status: 'done' + buildId: string + assetCount: number + totalBytes: number + elapsedMs: number + } + | { status: 'failed'; detail: string } + +/** The host's own code when it refused, its message otherwise. A client-side integrity failure has + * no code, and so does a schema refusal the dispatcher raised before the handler ran. */ +function describeFailure(error: unknown): string { + const code = readMobileWebBundleErrorCode(error) + if (code !== null) { + return code + } + return error instanceof Error ? error.message : String(error) +} + +/** + * Drives one bundle fetch from the troubleshooting screen. Dev-only: nothing in a shipped build + * mounts this, and nothing here caches or renders what it downloads. + * + * The host is dialled on the first tap, not on mount: acquiring a client is what opens a connection, + * and opening Troubleshoot opened none before this row existed. Each request owns its + * `AbortController` so a re-run, an unmount, or StrictMode's second mount abandons the previous + * fetch instead of racing it — and, since the fetch checks that signal before every chunk, stops its + * reads rather than letting them hold the host's read slots. + */ +export function useMobileWebBundleProbe(hostId: string | null): { + state: MobileWebBundleProbeState + run: () => void + awaitingHost: boolean +} { + const [state, setState] = useState({ status: 'idle' }) + const [request, setRequest] = useState<{ id: number } | null>(null) + const { client } = useHostClient(request !== null && hostId !== null ? hostId : undefined) + + useEffect(() => { + if (request === null || client === null) { + return + } + let abandoned = false + const controller = new AbortController() + fetchMobileWebBundle({ client, signal: controller.signal }).then( + (fetched) => { + if (abandoned) { + return + } + setState({ + status: 'done', + buildId: fetched.manifest.buildId, + assetCount: fetched.assets.size, + totalBytes: fetched.totalBytes, + elapsedMs: fetched.elapsedMs + }) + }, + (error: unknown) => { + if (abandoned) { + return + } + setState({ status: 'failed', detail: describeFailure(error) }) + } + ) + return () => { + abandoned = true + controller.abort() + } + }, [client, request]) + + useEffect(() => { + if (request === null || client !== null) { + return + } + const deadline = startDiagnosticFetchTimeout(HOST_CLIENT_DIAL_DEADLINE_MS) + const giveUp = () => { + setState({ + status: 'failed', + detail: `no client for the host within ${HOST_CLIENT_DIAL_DEADLINE_MS / 1000}s` + }) + // Drops the acquisition too, so a host that never opens stops being dialled. + setRequest(null) + } + deadline.signal.addEventListener('abort', giveUp) + return () => { + // Removed first: `dispose` aborts a signal it has not already aborted. + deadline.signal.removeEventListener('abort', giveUp) + deadline.dispose() + } + }, [client, request]) + + const run = useCallback(() => { + if (hostId === null) { + setState({ status: 'failed', detail: 'no paired host to fetch from' }) + return + } + setState({ status: 'running' }) + setRequest((previous) => ({ id: (previous?.id ?? 0) + 1 })) + }, [hostId]) + + return { state, run, awaitingHost: request !== null && client === null } +} diff --git a/mobile/src/test-support/rpc-recording/README.md b/mobile/src/test-support/rpc-recording/README.md index f20ce18fb54..8f8bbc89fc6 100644 --- a/mobile/src/test-support/rpc-recording/README.md +++ b/mobile/src/test-support/rpc-recording/README.md @@ -183,7 +183,7 @@ off the context `client-context.tsx` keeps module-private, and each used to carr `exports.recorderHostClientContext = Ctx;`. That string names a local no type checker follows, so five spellings were five independent ways to reach a `ReferenceError` seconds into a recording. `hostClientContextExposure` is the one copy; the trade is that it sits inside `recorderSha256`, so -editing it re-records all 778 goldens rather than the five families. A rename of the local is still +editing it re-records all 787 goldens rather than the five families. A rename of the local is still invisible to `tsc` — nothing short of editing the product module makes a private local checkable — so `adapter-seam.test.ts` asserts the declaration it names exists exactly once, and refuses a sixth inline copy. @@ -378,8 +378,8 @@ families because no reference states are defined for them. ## What this oracle does and does not see -It replays 393 manifest scenarios against frozen goldens and fails on any divergence: 778 goldens -over 781 tests, all inside `pnpm --dir mobile test`. Counted with +It replays 397 manifest scenarios against frozen goldens and fails on any divergence: 787 goldens +over 790 tests, all inside `pnpm --dir mobile test`. Counted with `python3 -c "import json;print(len(json.load(open('mobile/rpc-foundation/pilot-scenarios.json'))['scenarios']))"`, `find mobile/rpc-foundation/goldens -type f | wc -l`, and the reported total of `vitest run src/test-support/rpc-recording/{pilot,family}-recordings.test.ts src/test-support/rpc-recording/derived-goldens.test.ts`. Counts quoted further down are measurements of @@ -613,7 +613,7 @@ the drop happened under, and records a non-empty report as a `reply-salvage` eff operation, the method, the decoded variant, the dropped paths and the count. Nothing in the product tree changes: the report was already being built and thrown away. -44 of the 778 goldens carry one, and every other checked read in the corpus decodes its reply +44 of the 787 goldens carry one, and every other checked read in the corpus decodes its reply whole (`grep -l reply-salvage mobile/rpc-foundation/goldens/*.json | wc -l`). The matrix varies the envelope a host sends rather than the shape of a row inside a result, so on most families this observation pins an absence rather than a recorded drop. What it buys is the next tightening: an element or member schema narrowed so a recorded row stops parsing moves the diff --git a/mobile/src/test-support/rpc-recording/adapters/mobile-web-bundle-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/mobile-web-bundle-mount-adapters.ts new file mode 100644 index 00000000000..af2f00b5e3e --- /dev/null +++ b/mobile/src/test-support/rpc-recording/adapters/mobile-web-bundle-mount-adapters.ts @@ -0,0 +1,101 @@ +import type { MountAdapter, MountContext } from '../recording-scenario' +import type { operationModuleLoader } from '../operation-module-loader' + +const OPERATIONS_MODULE = 'mobile/src/transport/mobile-web-bundle-operations.ts' +const FETCH_MODULE = 'mobile/src/transport/mobile-web-bundle-fetch.ts' +const RPC_OPERATION_MODULE = 'mobile/src/transport/rpc-operation.ts' + +type OperationsModule = typeof import('../../../transport/mobile-web-bundle-operations') +type FetchModule = typeof import('../../../transport/mobile-web-bundle-fetch') +type RpcOperationModule = typeof import('../../../transport/rpc-operation') + +/** The host's own code where it has one, so the projection observes the code reader too. */ +function describeFailure(operations: OperationsModule, error: unknown): string { + const code = operations.readMobileWebBundleErrorCode(error) + if (code !== null) { + return `refused: ${code}` + } + return `failed: ${error instanceof Error ? error.message : String(error)}` +} + +/** + * The two client reads that fetch the desktop-served mobile web bundle. + * + * `bundle-manifest` drives the manifest descriptor alone, so the loose reader's verdict on one reply + * is the whole observation. `bundle-fetch` drives the paging flow, and its state carries the decoded + * bytes of every asset rather than a count: a reassembly that misplaces a chunk still has the right + * length, and only the bytes say so. + */ +export function mobileWebBundleMountAdapters( + modules: ReturnType +): Record { + return { + 'mobileWeb.bundle-manifest': ({ client }: MountContext) => { + const operations = modules.load(OPERATIONS_MODULE) + const runRpcOperation = modules.load(RPC_OPERATION_MODULE).runRpcOperation + let outcome: unknown = 'unread' + return { + action() { + const started = runRpcOperation(client, operations.mobileWebBundleManifestRead, null) + started.then( + (reply) => { + outcome = { + chunkBytes: reply.chunkBytes, + buildId: reply.manifest.buildId, + entrypoint: reply.manifest.entrypoint, + paths: reply.manifest.assets.map((asset) => asset.path) + } + }, + (error: unknown) => { + outcome = describeFailure(operations, error) + } + ) + return started + }, + state: () => ({ outcome }), + dispose: () => {} + } + }, + 'mobileWeb.bundle-fetch': ({ client, effect }: MountContext) => { + const operations = modules.load(OPERATIONS_MODULE) + const fetchMobileWebBundle = modules.load(FETCH_MODULE).fetchMobileWebBundle + let outcome: unknown = 'unfetched' + let assets: unknown = null + return { + action() { + // Projected rather than returned whole: the result carries a Map of Uint8Arrays, and the + // observation refuses a non-plain object, which loses the settlement and files an + // unhandled rejection in its place. + return fetchMobileWebBundle({ + client, + onProgress: (progress) => { + effect('bundle-progress', { + completedAssets: progress.completedAssets, + totalAssets: progress.totalAssets, + receivedBytes: progress.receivedBytes + }) + } + }).then( + (fetched) => { + outcome = { + buildId: fetched.manifest.buildId, + assetCount: fetched.assets.size, + totalBytes: fetched.totalBytes + } + assets = Object.fromEntries( + [...fetched.assets].map(([path, bytes]) => [path, new TextDecoder().decode(bytes)]) + ) + return outcome + }, + (error: unknown) => { + outcome = describeFailure(operations, error) + throw error + } + ) + }, + state: () => ({ outcome, assets }), + dispose: () => {} + } + } + } +} diff --git a/mobile/src/test-support/rpc-recording/adapters/mounted-operation-modules.ts b/mobile/src/test-support/rpc-recording/adapters/mounted-operation-modules.ts index 615036e40c3..b706040a280 100644 --- a/mobile/src/test-support/rpc-recording/adapters/mounted-operation-modules.ts +++ b/mobile/src/test-support/rpc-recording/adapters/mounted-operation-modules.ts @@ -27,6 +27,7 @@ import { homeAccountsMountAdapters } from './home-accounts-mount-adapters' import { hostScreenMountAdapters } from './host-screen-mount-adapters' import { hostWorktreeActionMountAdapters } from './host-worktree-action-mount-adapters' import { hostedReviewMountAdapters } from './hosted-review-mount-adapters' +import { mobileWebBundleMountAdapters } from './mobile-web-bundle-mount-adapters' import { nativeChatPagingMountAdapters } from './native-chat-paging-mount-adapters' import { nativeChatWriteMountAdapters } from './native-chat-write-mount-adapters' import { newTabAgentMountAdapters } from './new-tab-agent-mount-adapters' @@ -129,6 +130,7 @@ export const MOUNTED_OPERATION_MODULES: readonly MountedOperationModule[] = [ mounts: hostWorktreeActionMountAdapters }, { source: 'hosted-review-mount-adapters.ts', mounts: hostedReviewMountAdapters }, + { source: 'mobile-web-bundle-mount-adapters.ts', mounts: mobileWebBundleMountAdapters }, { source: 'native-chat-paging-mount-adapters.ts', mounts: nativeChatPagingMountAdapters }, { source: 'native-chat-write-mount-adapters.ts', mounts: nativeChatWriteMountAdapters }, { source: 'new-tab-agent-mount-adapters.ts', mounts: newTabAgentMountAdapters }, diff --git a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts index 38867e23bb3..58b12257cdf 100644 --- a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts +++ b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts @@ -73,6 +73,14 @@ export const OPERATION_MUTATIONS = { before: 'const snapshot = decodeAccountsSnapshot(accounts.value)', after: 'const snapshot = decodeAccountsSnapshot(reply)' }, + // Writes every chunk of an asset at offset 0, so a multi-chunk asset reassembles as its last + // chunk over a zero-filled buffer. The length still matches the manifest; only the sha256 check + // and the decoded bytes in the projection say the bundle is wrong. + 'mobile-web-bundle-chunk-placement': { + file: 'mobile-web-bundle-fetch.ts', + before: 'whole.set(bytes, offset)', + after: 'whole.set(bytes, 0)' + }, // Puts the workspace catalog's reply back behind an unchecked reader, so a reply carrying neither // rows nor an `unchanged` token reaches `admitWorktreeCatalogResponse` as an invalid admission // instead of being named at the boundary — main's answer, and the one the host screen showed as diff --git a/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts b/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts index 8af7f8af5ce..bcdcbf46a9f 100644 --- a/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts +++ b/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts @@ -45,7 +45,8 @@ const mutants: Record = { 'terminal-worktree-connection-resolved': 'worktree-connection-first-repo', 'pr-sidebar-checks-refused': 'pr-sidebar-checks-failure-state', 'tk-item-detail-metadata': 'assignable-user-avatar-null-collapse', - 'worktree-catalog-snapshot-unreadable': 'worktree-catalog-unchecked-reader' + 'worktree-catalog-snapshot-unreadable': 'worktree-catalog-unchecked-reader', + 'mobile-web-bundle-fetch-paged': 'mobile-web-bundle-chunk-placement' } /** * The archived tree's visible state, pinned per seed: b1 serves the poisoned empty inventory, b2 diff --git a/mobile/src/transport/mobile-web-bundle-fetch.test.ts b/mobile/src/transport/mobile-web-bundle-fetch.test.ts new file mode 100644 index 00000000000..70856553407 --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-fetch.test.ts @@ -0,0 +1,530 @@ +import { sha256 } from '@noble/hashes/sha256' +import { describe, expect, it, vi } from 'vitest' +import { fetchMobileWebBundle } from './mobile-web-bundle-fetch' +import { readMobileWebBundleErrorCode } from './mobile-web-bundle-operations' +import type { RpcClient } from './rpc-client' +import type { RpcResponse } from './types' + +const BUILD_ID = 'a'.repeat(64) + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +function encodeBase64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) +} + +function bytesOf(text: string): Uint8Array { + return new TextEncoder().encode(text) +} + +type HostCall = { method: string; params: unknown } + +type HostOptions = { + chunkBytes?: number + buildId?: string + /** Replaces the reply the host would have sent for this request. */ + intercept?: (call: HostCall) => unknown + onInFlight?: (inFlight: number) => void +} + +/** Box first, so params that are not an object read as absent instead of throwing. */ +function paramField(params: unknown, key: string): unknown { + const boxed: Record = Object(params) + return boxed[key] +} + +/** A host that serves a fixed asset table by the same rules the real one does. */ +function bundleHost(files: Record, options: HostOptions = {}) { + const chunkBytes = options.chunkBytes ?? 4 + const buildId = options.buildId ?? BUILD_ID + const bytes = new Map(Object.entries(files).map(([path, text]) => [path, bytesOf(text)])) + const assets = [...bytes.entries()] + .map(([path, content]) => ({ + path, + sha256: toHex(sha256(content)), + byteLength: content.byteLength, + contentType: 'text/plain' + })) + .sort((left, right) => (left.path < right.path ? -1 : 1)) + const manifest = { + schemaVersion: 1, + buildId, + desktopVersion: '1.4.200', + minCompatibleRuntimeProtocolVersion: 2, + runtimeProtocolVersion: 2, + entrypoint: assets[0]!.path, + totalBytes: assets.reduce((total, entry) => total + entry.byteLength, 0), + assets + } + const calls: HostCall[] = [] + let inFlight = 0 + + const answer = (method: string, params: unknown): unknown => { + if (method === 'mobileWeb.bundle.manifest') { + return { manifest, chunkBytes } + } + const path = String(paramField(params, 'path')) + const offset = Number(paramField(params, 'offset')) + const content = bytes.get(path)! + const slice = content.subarray(offset, offset + chunkBytes) + return { + buildId, + path, + offset, + assetByteLength: content.byteLength, + sha256: toHex(sha256(content)), + dataBase64: encodeBase64(slice), + eof: offset + slice.byteLength >= content.byteLength + } + } + + const client: RpcClient = { + sendRequest: vi.fn(async (method: string, params?: unknown): Promise => { + const call: HostCall = { method, params: params ?? {} } + calls.push(call) + inFlight += 1 + options.onInFlight?.(inFlight) + try { + await new Promise((resolve) => setTimeout(resolve, 0)) + const replaced = options.intercept?.(call) + const result = replaced === undefined ? answer(method, call.params) : replaced + if (result instanceof Error) { + return { + id: 'rpc-1', + ok: false, + error: { code: 'invalid_argument', message: result.message }, + _meta: { runtimeId: 'runtime-1' } + } + } + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } + } finally { + inFlight -= 1 + } + }), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => 1, + onStateChange: () => () => {}, + notifyForeground: vi.fn(), + close: vi.fn() + } + return { client, calls, manifest } +} + +function chunkCallCount(calls: readonly HostCall[]): number { + return calls.filter((call) => call.method === 'mobileWeb.bundle.chunk').length +} + +/** Long enough for an unstopped worker pool to page three 40-byte assets one byte at a time. */ +async function drainPendingHostWork(): Promise { + for (let tick = 0; tick < 300; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe('fetchMobileWebBundle', () => { + it('pages every asset to eof and returns the verified bytes', async () => { + const host = bundleHost({ 'index.html': '

orca

', 'assets/app.js': 'x=1' }) + const progress: number[] = [] + + const fetched = await fetchMobileWebBundle({ + client: host.client, + onProgress: (update) => progress.push(update.receivedBytes) + }) + + expect([...fetched.assets.keys()].sort()).toEqual(['assets/app.js', 'index.html']) + expect(new TextDecoder().decode(fetched.assets.get('index.html'))).toBe('

orca

') + expect(new TextDecoder().decode(fetched.assets.get('assets/app.js'))).toBe('x=1') + expect(fetched.totalBytes).toBe(16) + expect(fetched.manifest.buildId).toBe(BUILD_ID) + expect(fetched.elapsedMs).toBeGreaterThanOrEqual(0) + expect(progress).toHaveLength(2) + expect(progress.at(-1)).toBe(16) + // 13 bytes at 4 per chunk is four requests, 3 bytes is one, plus the manifest. + expect(host.calls.filter((call) => call.method === 'mobileWeb.bundle.chunk')).toHaveLength(5) + expect(host.calls[0]!.method).toBe('mobileWeb.bundle.manifest') + expect(host.calls[0]!.params).toEqual({}) + }) + + it('asks for each chunk at the offset the previous reply ended on', async () => { + const host = bundleHost({ 'index.html': 'abcdefghij' }, { chunkBytes: 3 }) + + await fetchMobileWebBundle({ client: host.client }) + + expect( + host.calls + .filter((call) => call.method === 'mobileWeb.bundle.chunk') + .map((call) => call.params) + ).toEqual([ + { buildId: BUILD_ID, path: 'index.html', offset: 0 }, + { buildId: BUILD_ID, path: 'index.html', offset: 3 }, + { buildId: BUILD_ID, path: 'index.html', offset: 6 }, + { buildId: BUILD_ID, path: 'index.html', offset: 9 } + ]) + }) + + it('fails when a reassembled asset does not hash to the manifest entry', async () => { + const host = bundleHost( + { 'index.html': 'abcdef' }, + { + chunkBytes: 3, + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' && paramField(call.params, 'offset') === 3 + ? { + buildId: BUILD_ID, + path: 'index.html', + offset: 3, + assetByteLength: 6, + sha256: toHex(sha256(bytesOf('abcdef'))), + dataBase64: encodeBase64(bytesOf('XYZ')), + eof: true + } + : undefined + } + ) + + await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow( + /index\.html hashed [0-9a-f]{64}, not/ + ) + }) + + it('fails when the host serves a later chunk from a different build', async () => { + const host = bundleHost( + { 'index.html': 'abcdef' }, + { + chunkBytes: 3, + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' && paramField(call.params, 'offset') === 3 + ? { + buildId: 'c'.repeat(64), + path: 'index.html', + offset: 3, + assetByteLength: 6, + sha256: toHex(sha256(bytesOf('abcdef'))), + dataBase64: encodeBase64(bytesOf('def')), + eof: true + } + : undefined + } + ) + + await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow( + 'bundle build changed mid-fetch' + ) + }) + + it('refuses a chunk that answers a different path or offset', async () => { + const host = bundleHost( + { 'index.html': 'abc' }, + { + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' + ? { + buildId: BUILD_ID, + path: 'other.html', + offset: 0, + assetByteLength: 3, + sha256: toHex(sha256(bytesOf('abc'))), + dataBase64: encodeBase64(bytesOf('abc')), + eof: true + } + : undefined + } + ) + + await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow( + 'bundle chunk answered other.html at 0, not index.html at 0' + ) + }) + + it('refuses a chunk that answers the right path at the wrong offset', async () => { + // The path half of the echo check is already covered; this is the offset half on its own, so + // a host that re-serves chunk zero cannot have its bytes written at the offset we asked for. + const host = bundleHost( + { 'index.html': 'abcdef' }, + { + chunkBytes: 3, + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' && paramField(call.params, 'offset') === 3 + ? { + buildId: BUILD_ID, + path: 'index.html', + offset: 0, + assetByteLength: 6, + sha256: toHex(sha256(bytesOf('abcdef'))), + dataBase64: encodeBase64(bytesOf('abc')), + eof: false + } + : undefined + } + ) + + await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow( + 'bundle chunk answered index.html at 0, not index.html at 3' + ) + }) + + it('reads a zero-byte asset in one chunk and returns it empty', async () => { + // A real bundle carries these. The asset is whole the moment the host says eof, and nothing + // else in the loop can end it: a zero-length reply is otherwise how a host makes no progress. + const host = bundleHost({ 'assets/empty.css': '', 'index.html': 'abc' }) + + const fetched = await fetchMobileWebBundle({ client: host.client }) + + expect(fetched.assets.get('assets/empty.css')).toEqual(new Uint8Array(0)) + expect(fetched.totalBytes).toBe(3) + expect( + host.calls.filter( + (call) => + call.method === 'mobileWeb.bundle.chunk' && + paramField(call.params, 'path') === 'assets/empty.css' + ) + ).toHaveLength(1) + }) + + it('never puts a fifth chunk request on one connection', async () => { + const peaks: number[] = [] + const host = bundleHost( + Object.fromEntries( + Array.from({ length: 9 }, (_, index) => [`assets/${index}.js`, `body-${index}`]) + ), + { chunkBytes: 2, onInFlight: (inFlight) => peaks.push(inFlight) } + ) + + const fetched = await fetchMobileWebBundle({ client: host.client }) + + expect(fetched.assets.size).toBe(9) + expect(Math.max(...peaks)).toBe(4) + }) + + it('stops as soon as the caller aborts', async () => { + const controller = new AbortController() + const host = bundleHost({ 'index.html': 'abcdefgh' }, { chunkBytes: 2 }) + + const started = fetchMobileWebBundle({ client: host.client, signal: controller.signal }) + controller.abort() + + await expect(started).rejects.toThrow('mobile web bundle fetch aborted') + expect(host.calls.filter((call) => call.method === 'mobileWeb.bundle.chunk')).toHaveLength(0) + }) + + it('refuses a chunk larger than the size the host advertised', async () => { + const host = bundleHost( + { 'index.html': 'abcdef' }, + { + chunkBytes: 3, + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' + ? { + buildId: BUILD_ID, + path: 'index.html', + offset: 0, + assetByteLength: 6, + sha256: toHex(sha256(bytesOf('abcdef'))), + dataBase64: encodeBase64(bytesOf('abcdef')), + eof: true + } + : undefined + } + ) + + await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow( + "bundle chunk for index.html at 0 is 6 bytes, over the host's 3" + ) + }) + + it('refuses a chunk whose asset no longer matches the manifest entry', async () => { + const host = bundleHost( + { 'index.html': 'abc' }, + { + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' + ? { + buildId: BUILD_ID, + path: 'index.html', + offset: 0, + assetByteLength: 4, + sha256: toHex(sha256(bytesOf('abc'))), + dataBase64: encodeBase64(bytesOf('abc')), + eof: true + } + : undefined + } + ) + + await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow( + 'bundle asset index.html no longer matches the manifest entry' + ) + }) + + it('refuses an asset that ends short of the length the manifest declares', async () => { + const host = bundleHost( + { 'index.html': 'abcdef' }, + { + chunkBytes: 3, + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' + ? { + buildId: BUILD_ID, + path: 'index.html', + offset: 0, + assetByteLength: 6, + sha256: toHex(sha256(bytesOf('abcdef'))), + dataBase64: encodeBase64(bytesOf('abc')), + eof: true + } + : undefined + } + ) + + await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow( + 'bundle asset index.html ended at 3 of 6 declared bytes' + ) + }) + + it('stops a host that pages forever without sending a byte', async () => { + const host = bundleHost( + { 'index.html': 'abcdef' }, + { + chunkBytes: 3, + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' + ? { + buildId: BUILD_ID, + path: 'index.html', + offset: 0, + assetByteLength: 6, + sha256: toHex(sha256(bytesOf('abcdef'))), + dataBase64: '', + eof: false + } + : undefined + } + ) + + await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow( + 'bundle asset index.html made no progress at 0' + ) + }) + + it('stops the other workers mid-asset once one asset is refused', async () => { + const host = bundleHost( + { + 'a.js': 'x', + 'b.js': 'b'.repeat(40), + 'c.js': 'c'.repeat(40), + 'd.js': 'd'.repeat(40) + }, + { + chunkBytes: 1, + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' && paramField(call.params, 'path') === 'a.js' + ? new Error('mobile_web_bundle_asset_unknown') + : undefined + } + ) + + const error = await fetchMobileWebBundle({ client: host.client }).catch( + (thrown: unknown) => thrown + ) + const atRejection = chunkCallCount(host.calls) + await drainPendingHostWork() + + // The refusal is what the caller sees; the internal stop never surfaces. + expect(readMobileWebBundleErrorCode(error)).toBe('mobile_web_bundle_asset_unknown') + // 120 chunks would page the other three assets to the end. One more round of four is the most + // the abandoned workers can add, because each checks the stop before it asks for a chunk. + expect(chunkCallCount(host.calls)).toBeLessThanOrEqual(atRejection + 4) + expect(chunkCallCount(host.calls)).toBeLessThan(10) + }) + + it('asks for nothing at all when the caller arrives already aborted', async () => { + const host = bundleHost({ 'index.html': 'abc' }) + + await expect( + fetchMobileWebBundle({ client: host.client, signal: AbortSignal.abort() }) + ).rejects.toThrow('mobile web bundle fetch aborted') + expect(host.calls).toHaveLength(0) + }) + + it('refuses an asset the host over-pages with real bytes', async () => { + const host = bundleHost( + { 'index.html': 'abcdef' }, + { + chunkBytes: 3, + // Never says eof, so the third reply writes past the six bytes the manifest declares. + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' + ? { + buildId: BUILD_ID, + path: 'index.html', + offset: paramField(call.params, 'offset'), + assetByteLength: 6, + sha256: toHex(sha256(bytesOf('abcdef'))), + dataBase64: encodeBase64(bytesOf('abc')), + eof: false + } + : undefined + } + ) + + await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow( + 'bundle asset index.html is longer than the manifest declares' + ) + }) + + it('counts the bytes it received rather than the total the manifest claims', async () => { + const host = bundleHost({ 'index.html': 'abcdef' }, { chunkBytes: 3 }) + // The mobile reader is loose, so it does not carry the host schema's sum refinement: a manifest + // whose total disagrees with its assets must not decide what the fetch reports. + host.manifest.totalBytes = 999 + + const fetched = await fetchMobileWebBundle({ client: host.client }) + + expect(fetched.totalBytes).toBe(6) + }) + + it('reads a schema refusal whose message is prose as a generic failure', async () => { + // The dispatcher refuses params that fail the host schema before the bundle handler runs, so the + // message is zod prose rather than one of the six codes. + const host = bundleHost( + { 'index.html': 'abc' }, + { + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' + ? new Error('Invalid input: expected string, received number') + : undefined + } + ) + + const error = await fetchMobileWebBundle({ client: host.client }).catch( + (thrown: unknown) => thrown + ) + + expect(readMobileWebBundleErrorCode(error)).toBeNull() + expect(error).toBeInstanceOf(Error) + expect(String(error)).toContain('Invalid input: expected string, received number') + }) + + it('surfaces the host code when the bundle is not there to serve', async () => { + const host = bundleHost( + { 'index.html': 'abc' }, + { + intercept: (call) => + call.method === 'mobileWeb.bundle.manifest' + ? new Error('mobile_web_bundle_unavailable') + : undefined + } + ) + + const error = await fetchMobileWebBundle({ client: host.client }).catch( + (thrown: unknown) => thrown + ) + + expect(readMobileWebBundleErrorCode(error)).toBe('mobile_web_bundle_unavailable') + }) +}) diff --git a/mobile/src/transport/mobile-web-bundle-fetch.ts b/mobile/src/transport/mobile-web-bundle-fetch.ts new file mode 100644 index 00000000000..2823085437a --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-fetch.ts @@ -0,0 +1,192 @@ +import { sha256 } from '@noble/hashes/sha256' +import { + mobileWebBundleChunkRead, + mobileWebBundleManifestRead +} from './mobile-web-bundle-operations' +import type { + MobileWebBundleAssetRead, + MobileWebBundleManifestRead +} from './mobile-web-bundle-reply-schemas' +import type { RpcClient } from './rpc-client' +import { runRpcOperation } from './rpc-operation' + +/** The host refuses the fifth concurrent read on one connection with `mobile_web_bundle_read_limited`, + * so the client never offers a fifth. Paging inside one asset stays sequential: the next offset is + * only known to be wanted once the previous reply says it is not the last. */ +const MAX_CONCURRENT_ASSET_READS = 4 + +export type MobileWebBundleFetchProgress = { + readonly completedAssets: number + readonly totalAssets: number + readonly receivedBytes: number + readonly totalBytes: number +} + +export type MobileWebBundleFetchResult = { + readonly manifest: MobileWebBundleManifestRead + readonly assets: ReadonlyMap + readonly totalBytes: number + readonly elapsedMs: number +} + +/** + * Reads the manifest, pages every asset, and returns the verified bytes. + * + * Nothing is cached and nothing is rendered: this is the Phase A proof that the pipe carries a whole + * bundle intact. Every asset is checked against the manifest's own sha256 before it is returned, so + * a truncated or reordered reassembly fails here rather than in a webview much later. + */ +export async function fetchMobileWebBundle(args: { + client: RpcClient + signal?: AbortSignal + onProgress?: (progress: MobileWebBundleFetchProgress) => void +}): Promise { + const startedAt = Date.now() + const stopped = new AbortController() + throwIfStopped(args.signal, stopped.signal) + const opened = await runRpcOperation(args.client, mobileWebBundleManifestRead, null) + const manifest = opened.manifest + const pending = [...manifest.assets] + const assets = new Map() + let receivedBytes = 0 + + const worker = async (): Promise => { + try { + for (let asset = pending.shift(); asset !== undefined; asset = pending.shift()) { + const bytes = await readBundleAsset({ + client: args.client, + asset, + buildId: manifest.buildId, + chunkBytes: opened.chunkBytes, + signal: args.signal, + stopped: stopped.signal + }) + assets.set(asset.path, bytes) + receivedBytes += bytes.byteLength + args.onProgress?.({ + completedAssets: assets.size, + totalAssets: manifest.assets.length, + receivedBytes, + totalBytes: manifest.totalBytes + }) + } + } catch (error) { + // One failed asset stops the other three mid-asset, not just between assets: every chunk they + // would still ask for holds one of the host's four read slots against the caller's retry. + stopped.abort() + throw error + } + } + + const workers = Math.min(MAX_CONCURRENT_ASSET_READS, pending.length) + await Promise.all(Array.from({ length: workers }, () => worker())) + return { manifest, assets, totalBytes: receivedBytes, elapsedMs: Date.now() - startedAt } +} + +async function readBundleAsset(args: { + client: RpcClient + asset: MobileWebBundleAssetRead + buildId: string + chunkBytes: number + signal?: AbortSignal + stopped: AbortSignal +}): Promise { + // Before the buffer, not after: an asset can be a tenth of the total ceiling, and a worker that + // picked one up after a sibling failed would otherwise allocate it only to drop it. + throwIfStopped(args.signal, args.stopped) + const whole = new Uint8Array(args.asset.byteLength) + let offset = 0 + for (;;) { + throwIfStopped(args.signal, args.stopped) + const chunk = await runRpcOperation(args.client, mobileWebBundleChunkRead, { + buildId: args.buildId, + path: args.asset.path, + offset + }) + assertChunkDescribesAsset(chunk, args.asset, args.buildId, offset) + const bytes = decodeBase64(chunk.dataBase64) + if (bytes.byteLength > args.chunkBytes) { + throw new Error( + `bundle chunk for ${args.asset.path} at ${offset} is ${bytes.byteLength} bytes, over the host's ${args.chunkBytes}` + ) + } + if (offset + bytes.byteLength > whole.byteLength) { + throw new Error(`bundle asset ${args.asset.path} is longer than the manifest declares`) + } + whole.set(bytes, offset) + offset += bytes.byteLength + if (chunk.eof) { + break + } + // Without this a host that keeps answering an unchanged offset with no bytes pages forever. + if (bytes.byteLength === 0) { + throw new Error(`bundle asset ${args.asset.path} made no progress at ${offset}`) + } + } + if (offset !== whole.byteLength) { + throw new Error( + `bundle asset ${args.asset.path} ended at ${offset} of ${whole.byteLength} declared bytes` + ) + } + const digest = toHex(sha256(whole)) + if (digest !== args.asset.sha256) { + throw new Error(`bundle asset ${args.asset.path} hashed ${digest}, not ${args.asset.sha256}`) + } + return whole +} + +/** + * Every chunk reply restates the build, path and offset it answers, and the whole asset's length and + * hash. Checking all five is what makes a misrouted or stale reply a failure here instead of a + * corrupt reassembly: a desktop that auto-updates mid-download answers a later chunk from a + * different build, and nothing else in the reply would say so. + */ +function assertChunkDescribesAsset( + chunk: { + buildId: string + path: string + offset: number + assetByteLength: number + sha256: string + }, + asset: MobileWebBundleAssetRead, + buildId: string, + offset: number +): void { + if (chunk.buildId !== buildId) { + throw new Error(`bundle build changed mid-fetch: asked ${buildId}, served ${chunk.buildId}`) + } + if (chunk.path !== asset.path || chunk.offset !== offset) { + throw new Error( + `bundle chunk answered ${chunk.path} at ${chunk.offset}, not ${asset.path} at ${offset}` + ) + } + if (chunk.sha256 !== asset.sha256 || chunk.assetByteLength !== asset.byteLength) { + throw new Error(`bundle asset ${asset.path} no longer matches the manifest entry`) + } +} + +/** The caller's abort is what it asked for; the internal one never leaves this module, because the + * asset that failed rejects first and is what `Promise.all` reports. */ +function throwIfStopped(caller: AbortSignal | undefined, stopped: AbortSignal): void { + if (caller?.aborted === true) { + throw new Error('mobile web bundle fetch aborted') + } + if (stopped.aborted) { + throw new Error('mobile web bundle fetch stopped after an earlier asset failed') + } +} + +/** Metro ships no Buffer; `atob` is the decoder the pairing and E2EE paths already run on Hermes. */ +function decodeBase64(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} diff --git a/mobile/src/transport/mobile-web-bundle-operations.ts b/mobile/src/transport/mobile-web-bundle-operations.ts new file mode 100644 index 00000000000..accd27bdfe5 --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-operations.ts @@ -0,0 +1,77 @@ +import { + MobileWebBundleErrorCodeSchema, + MOBILE_WEB_BUNDLE_CHUNK_METHOD, + MOBILE_WEB_BUNDLE_MANIFEST_METHOD, + type MobileWebBundleErrorCode +} from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' +import { + MobileWebBundleChunkReplySchema, + MobileWebBundleManifestReplySchema +} from './mobile-web-bundle-reply-schemas' +import { defineRpcOperation } from './rpc-operation' +import { rpcResultVariant } from './rpc-operation-result-reader' + +// The two reads that hand a paired phone the desktop's mobile web bundle. Both are +// `require-result-or-throw`: there is no partial success here, and a salvage policy would produce a +// half-bundle that fails a hash check much later, far from the cause. Both settle at `on-settle`, +// because each reply is acted on before the next request is built — the manifest decides which +// assets to page, and a chunk decides the next offset. + +/** The whole manifest plus the chunk size the host will serve it at. */ +export const mobileWebBundleManifestRead = defineRpcOperation({ + name: 'mobileWeb.bundle-manifest', + method: MOBILE_WEB_BUNDLE_MANIFEST_METHOD, + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: rpcResultVariant('mobile-web-bundle-manifest', MobileWebBundleManifestReplySchema) +}) + +/** One page of one asset, self-describing so a misplaced reply cannot corrupt a reassembly. */ +export const mobileWebBundleChunkRead = defineRpcOperation({ + name: 'mobileWeb.bundle-chunk', + method: MOBILE_WEB_BUNDLE_CHUNK_METHOD, + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: rpcResultVariant('mobile-web-bundle-chunk', MobileWebBundleChunkReplySchema) +}) + +/** A code is a bare snake_case token, so only the two positions one can occupy are read. */ +const LEADING_CODE_TOKEN = /^[a-z][a-z0-9_]*/ + +/** + * The host's six codes, read back off a thrown refusal. + * + * The host raises these as `InvalidArgumentError`, which the dispatcher maps to envelope code + * `invalid_argument` with the machine code as the message + * (`src/main/runtime/rpc/dispatcher-error-response.ts`), and `require-result-or-throw` throws + * `` `${code}: ${message}` ``. So the token this branches on is either the whole message or what + * follows the envelope code, and nowhere else: scanning the prose for a code anywhere would let a + * host that merely quoted one back read as that failure. + * + * Returns null for every other error, including a transport rejection, which is not a verdict about + * the bundle at all. Membership is decided by the contract's own enum, so the arms cannot drift + * from `MOBILE_WEB_BUNDLE_ERROR_CODES`. + */ +export function readMobileWebBundleErrorCode(error: unknown): MobileWebBundleErrorCode | null { + if (!(error instanceof Error)) { + return null + } + const head = LEADING_CODE_TOKEN.exec(error.message)?.[0] + if (head === undefined) { + return null + } + const direct = MobileWebBundleErrorCodeSchema.safeParse(head) + if (direct.success) { + return direct.data + } + const prefix = `${head}: ` + if (!error.message.startsWith(prefix)) { + return null + } + const nested = LEADING_CODE_TOKEN.exec(error.message.slice(prefix.length))?.[0] + if (nested === undefined) { + return null + } + const parsed = MobileWebBundleErrorCodeSchema.safeParse(nested) + return parsed.success ? parsed.data : null +} diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts new file mode 100644 index 00000000000..3b80906d836 --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from 'vitest' +import { + MOBILE_WEB_BUNDLE_CHUNK_BYTES, + MOBILE_WEB_BUNDLE_ERROR_CODES +} from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' +import { + MOBILE_WEB_BUNDLE_MAX_ASSETS, + MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, + MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES +} from '../../../src/shared/mobile-web-bundle/manifest-contract' +import { + mobileWebBundleChunkRead, + mobileWebBundleManifestRead, + readMobileWebBundleErrorCode +} from './mobile-web-bundle-operations' +import type { RpcReadResult } from './rpc-operation-contract' + +const BUILD_ID = 'a'.repeat(64) +const ASSET_SHA = 'b'.repeat(64) +const MAX_DATA_BASE64_LENGTH = Math.ceil(MOBILE_WEB_BUNDLE_CHUNK_BYTES / 3) * 4 + 8 + +function asset(overrides: Record = {}) { + return { + path: 'index.html', + sha256: ASSET_SHA, + byteLength: 12, + contentType: 'text/html; charset=utf-8', + ...overrides + } +} + +function manifestReply(overrides: Record = {}) { + return { + manifest: { + schemaVersion: 1, + buildId: BUILD_ID, + desktopVersion: '1.4.200', + minCompatibleRuntimeProtocolVersion: 2, + runtimeProtocolVersion: 2, + entrypoint: 'index.html', + totalBytes: 12, + assets: [asset()], + ...overrides + }, + chunkBytes: MOBILE_WEB_BUNDLE_CHUNK_BYTES + } +} + +function chunkReply(overrides: Record = {}) { + return { + buildId: BUILD_ID, + path: 'index.html', + offset: 0, + assetByteLength: 12, + sha256: ASSET_SHA, + dataBase64: 'aGVsbG8=', + eof: true, + ...overrides + } +} + +function readManifest(raw: unknown): RpcReadResult { + return mobileWebBundleManifestRead.read(raw) +} + +function readChunk(raw: unknown): RpcReadResult { + return mobileWebBundleChunkRead.read(raw) +} + +describe('mobile web bundle manifest reply reader', () => { + it('reads a manifest and keeps every member an unknown key carries', () => { + const result = readManifest({ + ...manifestReply(), + manifest: { ...manifestReply().manifest, contentEncoding: 'br' }, + servedFrom: 'asar' + }) + + expect(result.compatible).toBe(true) + if (!result.compatible) { + return + } + expect(result.variant).toBe('mobile-web-bundle-manifest') + expect(result.salvage).toEqual({ droppedPaths: [], droppedCount: 0 }) + expect(result.value).toMatchObject({ + servedFrom: 'asar', + chunkBytes: MOBILE_WEB_BUNDLE_CHUNK_BYTES, + manifest: { contentEncoding: 'br', buildId: BUILD_ID } + }) + }) + + it('accepts a host that shrank chunkBytes and refuses one that grew it', () => { + expect(readManifest({ ...manifestReply(), chunkBytes: 1 }).compatible).toBe(true) + expect( + readManifest({ ...manifestReply(), chunkBytes: MOBILE_WEB_BUNDLE_CHUNK_BYTES }).compatible + ).toBe(true) + for (const chunkBytes of [0, -1, 1.5, MOBILE_WEB_BUNDLE_CHUNK_BYTES + 1]) { + expect(readManifest({ ...manifestReply(), chunkBytes }).compatible).toBe(false) + } + }) + + it('bounds what a manifest can make the fetch allocate, however it declares totalBytes', () => { + // The ceilings above bound each asset and the asset count, and `totalBytes` separately. None + // of them bounds the product, which is what the fetch allocates. + const oversized = Array.from({ length: MOBILE_WEB_BUNDLE_MAX_ASSETS }, (_, index) => + asset({ path: `assets/${index}.js`, byteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES }) + ) + expect(readManifest(manifestReply({ totalBytes: 0, assets: oversized })).compatible).toBe(false) + expect(readManifest(manifestReply({ totalBytes: 12, assets: oversized })).compatible).toBe( + false + ) + }) + + it('accepts a bundle that sums to the ceiling and refuses one byte more', () => { + // Four assets, because one quarter of the total ceiling is the largest share that still fits + // under the per-asset ceiling. `lastByteLength` moves only the final one. + const quarter = MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES / 4 + const spread = (lastByteLength: number) => + Array.from({ length: 4 }, (_, index) => + asset({ path: `assets/${index}.js`, byteLength: index === 3 ? lastByteLength : quarter }) + ) + expect( + readManifest( + manifestReply({ totalBytes: MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES, assets: spread(quarter) }) + ).compatible + ).toBe(true) + expect( + readManifest( + manifestReply({ + totalBytes: MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES, + assets: spread(quarter + 1) + }) + ).compatible + ).toBe(false) + }) + + it('refuses one asset over the per-asset ceiling and accepts one at it', () => { + expect( + readManifest( + manifestReply({ + totalBytes: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, + assets: [asset({ byteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES })] + }) + ).compatible + ).toBe(true) + expect( + readManifest( + manifestReply({ assets: [asset({ byteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES + 1 })] }) + ).compatible + ).toBe(false) + }) + + it('refuses a schemaVersion it does not know rather than guessing at the shape', () => { + expect(readManifest(manifestReply({ schemaVersion: 2 })).compatible).toBe(false) + expect(readManifest(manifestReply({ schemaVersion: undefined })).compatible).toBe(false) + }) + + it('bounds every manifest field the fetch reads', () => { + expect(readManifest(manifestReply({ buildId: 'A'.repeat(64) })).compatible).toBe(false) + expect(readManifest(manifestReply({ buildId: 'a'.repeat(63) })).compatible).toBe(false) + expect(readManifest(manifestReply({ assets: [] })).compatible).toBe(false) + expect( + readManifest( + manifestReply({ + assets: Array.from({ length: MOBILE_WEB_BUNDLE_MAX_ASSETS + 1 }, (_, index) => + asset({ path: `assets/${index}.js` }) + ) + }) + ).compatible + ).toBe(false) + expect( + readManifest(manifestReply({ totalBytes: MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES + 1 })).compatible + ).toBe(false) + expect(readManifest(manifestReply({ entrypoint: undefined })).compatible).toBe(false) + expect(readManifest(manifestReply({ entrypoint: '../escape.html' })).compatible).toBe(false) + }) + + it('bounds every asset field and rejects a path that could leave the bundle root', () => { + for (const path of ['../evil.js', '/abs.js', 'a\\b.js', 'nul.js', 'trailing.']) { + expect(readManifest(manifestReply({ assets: [asset({ path })] })).compatible).toBe(false) + } + expect(readManifest(manifestReply({ assets: [asset({ sha256: 'zz' })] })).compatible).toBe( + false + ) + expect( + readManifest( + manifestReply({ assets: [asset({ byteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES + 1 })] }) + ).compatible + ).toBe(false) + expect(readManifest(manifestReply({ assets: [asset({ byteLength: -1 })] })).compatible).toBe( + false + ) + expect(readManifest(manifestReply({ assets: [asset({ contentType: '' })] })).compatible).toBe( + false + ) + }) +}) + +describe('mobile web bundle chunk reply reader', () => { + it('reads a chunk and passes an unknown member through', () => { + const result = readChunk({ ...chunkReply(), contentEncoding: 'br' }) + + expect(result.compatible).toBe(true) + if (!result.compatible) { + return + } + expect(result.variant).toBe('mobile-web-bundle-chunk') + expect(result.value).toMatchObject({ contentEncoding: 'br', eof: true, offset: 0 }) + expect(result.salvage.droppedCount).toBe(0) + }) + + it('bounds dataBase64 at the chunk size the contract allows', () => { + expect( + readChunk(chunkReply({ dataBase64: 'A'.repeat(MAX_DATA_BASE64_LENGTH) })).compatible + ).toBe(true) + expect( + readChunk(chunkReply({ dataBase64: 'A'.repeat(MAX_DATA_BASE64_LENGTH + 1) })).compatible + ).toBe(false) + }) + + it('requires every member that makes a chunk self-describing', () => { + for (const overrides of [ + { buildId: 'nope' }, + { path: '../escape.js' }, + { offset: -1 }, + { offset: 1.5 }, + { assetByteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES + 1 }, + { sha256: 'b'.repeat(63) }, + { dataBase64: undefined }, + { eof: 'yes' }, + { eof: undefined } + ]) { + expect(readChunk(chunkReply(overrides)).compatible).toBe(false) + } + }) + + it('refuses a reply that is not an object at all', () => { + for (const raw of [null, undefined, 'chunk', 7, []]) { + expect(readChunk(raw).compatible).toBe(false) + } + }) +}) + +describe('mobile web bundle error codes', () => { + it('maps every code the host declares, from both positions one can occupy', () => { + expect(MOBILE_WEB_BUNDLE_ERROR_CODES).toHaveLength(6) + for (const code of MOBILE_WEB_BUNDLE_ERROR_CODES) { + expect(readMobileWebBundleErrorCode(new Error(`invalid_argument: ${code}`))).toBe(code) + expect(readMobileWebBundleErrorCode(new Error(code))).toBe(code) + expect(readMobileWebBundleErrorCode(new Error(`invalid_argument: ${code}: no bundle`))).toBe( + code + ) + } + }) + + it('answers null for anything that is not one of those six', () => { + expect(readMobileWebBundleErrorCode(new Error('invalid_argument: some_other_code'))).toBeNull() + expect(readMobileWebBundleErrorCode(new Error('internal_error: boom'))).toBeNull() + expect(readMobileWebBundleErrorCode(new Error(' '))).toBeNull() + expect(readMobileWebBundleErrorCode(new Error('Network request failed'))).toBeNull() + expect(readMobileWebBundleErrorCode('mobile_web_bundle_unavailable')).toBeNull() + expect(readMobileWebBundleErrorCode(null)).toBeNull() + }) + + it('does not read a code a host merely quoted somewhere in its prose', () => { + expect( + readMobileWebBundleErrorCode( + new Error('invalid_argument: the bundle is mobile_web_bundle_unavailable here') + ) + ).toBeNull() + // Unanchored, this one reads as a refusal; the code is neither the whole message nor what + // follows the envelope code. + expect( + readMobileWebBundleErrorCode(new Error('RPC mobile_web_bundle_unavailable failed')) + ).toBeNull() + // The second position is `: `, exactly. Slicing the leading token's length off + // any message would make this one read as the code that follows the bracket. + expect( + readMobileWebBundleErrorCode(new Error('rpc (mobile_web_bundle_unavailable)')) + ).toBeNull() + }) + + it('reads a dispatcher schema refusal, whose message is prose, as no code at all', () => { + for (const message of [ + 'invalid_argument: Invalid input: expected string, received number', + 'invalid_argument: too_small: expected string to have >=1 characters' + ]) { + expect(readMobileWebBundleErrorCode(new Error(message))).toBeNull() + } + }) +}) + +describe('mobile web bundle operation descriptors', () => { + it('accepts no reply but a result', () => { + // A salvage or bare-message policy would hand the fetch a half-bundle that fails a hash check + // much later, far from the cause. + for (const operation of [mobileWebBundleManifestRead, mobileWebBundleChunkRead]) { + expect(operation.acceptance).toBe('require-result-or-throw') + expect(operation.barrier).toBe('on-settle') + } + }) +}) diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts new file mode 100644 index 00000000000..aa1ba5ec662 --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts @@ -0,0 +1,82 @@ +import { z } from 'zod' +import { MOBILE_WEB_BUNDLE_CHUNK_BYTES } from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' +import { + MobileWebBundleAssetPathSchema, + MOBILE_WEB_BUNDLE_MAX_ASSETS, + MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, + MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES, + MOBILE_WEB_BUNDLE_SCHEMA_VERSION +} from '../../../src/shared/mobile-web-bundle/manifest-contract' + +// Hoisted, never built inside a reader: a schema constructed per parse cost 2275 ns against 156 ns +// for the same shape hoisted (#21311). +// +// Loose where the host contract is strict, and required only where this client reads. The host's +// own schemas describe what it produces and stay `.strict()`; a phone that rejected an unknown +// member would turn a later optional field into a released-client break instead of the Rule 1 +// addition `docs/reference/remote-wire-compatibility.md` allows. + +/** Lowercase hex digest. The shared contract keeps its copy private, so this is the one place the + * client states the shape it accepts. */ +const SHA256_PATTERN = /^[a-f0-9]{64}$/ + +/** Base64 of one chunk, bounded by the same arithmetic as `skill-upload-session-contract.ts`, so a + * host that overshoots is refused at the boundary instead of at reassembly. */ +const MAX_DATA_BASE64_LENGTH = Math.ceil(MOBILE_WEB_BUNDLE_CHUNK_BYTES / 3) * 4 + 8 + +const assetSchema = z.looseObject({ + path: MobileWebBundleAssetPathSchema, + sha256: z.string().regex(SHA256_PATTERN), + byteLength: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), + contentType: z.string().min(1) +}) + +/** Everything the fetch reads: the id it caches under, the assets it pages, and the entry it will + * later load. `desktopVersion` and the protocol window pass through untyped — Phase B's update + * wall reads them, this phase does not. + * + * `schemaVersion` stays a literal because the manifest is closed in both directions: a bump is the + * only change path, and an unrecognised one is an unusable bundle to re-fetch, never a crash. */ +const manifestSchema = z + .looseObject({ + schemaVersion: z.literal(MOBILE_WEB_BUNDLE_SCHEMA_VERSION), + buildId: z.string().regex(SHA256_PATTERN), + entrypoint: MobileWebBundleAssetPathSchema, + totalBytes: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES), + assets: z.array(assetSchema).min(1).max(MOBILE_WEB_BUNDLE_MAX_ASSETS) + }) + // The allocation bound, and the reason it is the sum rather than `totalBytes`: the fetch + // allocates one buffer per asset from `byteLength` and holds them all, so a manifest declaring + // `totalBytes` 0 alongside 256 assets of 10 MiB each would pass every ceiling above and still + // cost 2560 MiB. The host pins sum === totalBytes; this client never trusts `totalBytes` for + // anything, so it bounds what it will actually allocate instead. + .refine( + (manifest) => + manifest.assets.reduce((sum, asset) => sum + asset.byteLength, 0) <= + MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES, + 'assets sum to more than the contract total' + ) + +/** `chunkBytes` is read, never assumed: the host may shrink it without a client release. Capped at + * the constant because a larger value would overshoot `dataBase64` above. */ +export const MobileWebBundleManifestReplySchema = z.looseObject({ + manifest: manifestSchema, + chunkBytes: z.number().int().positive().max(MOBILE_WEB_BUNDLE_CHUNK_BYTES) +}) + +/** Self-describing on purpose: `buildId`, `path` and `offset` are echoed so a reassembler cannot + * misplace a reply, and `sha256`/`assetByteLength` describe the whole asset rather than this + * chunk, which is what lets the fetch verify without a second index. */ +export const MobileWebBundleChunkReplySchema = z.looseObject({ + buildId: z.string().regex(SHA256_PATTERN), + path: MobileWebBundleAssetPathSchema, + offset: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), + assetByteLength: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), + sha256: z.string().regex(SHA256_PATTERN), + dataBase64: z.string().max(MAX_DATA_BASE64_LENGTH), + eof: z.boolean() +}) + +export type MobileWebBundleManifestReply = z.output +export type MobileWebBundleManifestRead = MobileWebBundleManifestReply['manifest'] +export type MobileWebBundleAssetRead = MobileWebBundleManifestRead['assets'][number] diff --git a/mobile/src/transport/rpc-operation-compile-fence.ts b/mobile/src/transport/rpc-operation-compile-fence.ts index ac14b326fd2..c57c4950a01 100644 --- a/mobile/src/transport/rpc-operation-compile-fence.ts +++ b/mobile/src/transport/rpc-operation-compile-fence.ts @@ -9,6 +9,7 @@ import { } from './rpc-operation' import { rpcResultVariants } from './rpc-operation-result-reader' import { + pushTestWithoutParams, workspaceListAtBarrier, workspaceListOrNull, workspaceRowsReader, @@ -146,6 +147,17 @@ export async function fenceBarrierAndParams(): Promise { ) } +// A method the catalog declares params-less keeps every shape a shipped sender may use. An +// explicit `null` is the one that matters: `params: null` is not the frame that omits the key, +// so narrowing this to omission would rewrite bytes main already puts on the wire. +export async function fenceParamlessSend(): Promise { + await runRpcOperation(client, pushTestWithoutParams, null) + await runRpcOperation(client, pushTestWithoutParams, undefined) + await runRpcOperation(client, pushTestWithoutParams) + // @ts-expect-error a method that declares no params accepts none + await runRpcOperation(client, pushTestWithoutParams, { path: 'main.js' }) +} + export async function fenceVerdictTypes(): Promise { // @ts-expect-error the probe's policy yields a boolean, not the other family's rows const rows: WorkspaceRows = await runRpcOperation(client, worktreePsProbe, {}) diff --git a/mobile/src/transport/rpc-operation-test-families.ts b/mobile/src/transport/rpc-operation-test-families.ts index 73554b7a4f8..a93c0ba1abf 100644 --- a/mobile/src/transport/rpc-operation-test-families.ts +++ b/mobile/src/transport/rpc-operation-test-families.ts @@ -54,6 +54,14 @@ export const worktreePsProbe = defineRpcOperation({ barrier: 'on-settle' }) +/** A method the catalog declares with no params at all, so `RpcSendParams` reads `void`. */ +export const pushTestWithoutParams = defineRpcOperation({ + name: 'test.pushTestWithoutParams', + method: 'notifications.testPush', + acceptance: 'method-not-found-refusal', + barrier: 'on-settle' +}) + export const terminalStreamOpener = defineRpcOperation({ name: 'test.terminalStreamOpener', method: 'terminal.subscribe', diff --git a/mobile/src/transport/rpc-operation.ts b/mobile/src/transport/rpc-operation.ts index a836dc429fb..93424f35433 100644 --- a/mobile/src/transport/rpc-operation.ts +++ b/mobile/src/transport/rpc-operation.ts @@ -173,10 +173,13 @@ export async function runRpcOperation< >( client: UnvalidatedRpcRequestPort, operation: RpcOperation, - params: RpcSendParams, - options?: SendRequestOptions + // Shares the deferred sender's tuple so the two cannot disagree about what a params-less + // method may be called with: the catalog types those `void`, and an explicit `null` is the + // frame three of them go out with today (`notifications.testPush`, + // `notifications.unregisterPush`, `speech.models.list`), all through the deferred entry point. + ...args: RpcSendArguments ): Promise> { - const outcome = await request(client, operation, params, options) + const outcome = await request(client, operation, args[0], args[1]) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. return interpretRpcOutcome(operation, outcome) as RpcVerdict } From 69246e9b068b8282666a3b0af14cb4581a9e2562 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 17 Sep 2026 23:52:01 -0700 Subject: [PATCH 002/224] fix(terminal): retire explicitly closed pending split connections (#21001) * fix(terminal): retire explicitly closed pending split connections * test(memory): keep pending split proof compatible with formatted source * fix(terminal): confirm pending split retirement before stopping work * fix(terminal): restore the pending split-close gates CI checks Three CI gates were red on this branch and all three were this branch's own. The hook-order parity snapshot did not count the `confirmedCloseRef` this branch adds to `use-terminal-pane-close-actions.ts`. Dumping the flattened order against clean `main` shows exactly one added `useRef` at position 148 and no reordering, so the count moves 211 -> 212 and the digest with it. `pending-split-close-test-fixture.ts` is Vitest support code, but it sits outside the `*.test` / `*.spec` / `tests` globs that already switch `anti-slop/no-module-mocking` off, so the gate failed on all twelve of its `vi.mock` calls. It carries a file-scoped disable with the reason, matching `work-item-search-test-harness.ts`. `fix.patch` still described the pre-confirmation shape of the close hook, so `reproduce.mjs` aborted with `Source changed` and the cited ablation could not run at this head. Regenerated against the committed sources; the harness again reports 10 pass / 14 fail before and 24 pass / 0 fail after. Merges `main` rather than rebasing: #21005 is stacked on this branch. --------- Co-authored-by: m4air Co-authored-by: Neil --- docs/audits/pending-split-close/README.md | 42 ++++ .../pending-split-close/daemon-proof.test.ts | 132 ++++++++++ docs/audits/pending-split-close/fix.patch | 237 ++++++++++++++++++ docs/audits/pending-split-close/reproduce.mjs | 152 +++++++++++ docs/audits/pending-split-close/results.json | 65 +++++ .../terminal-pane/ipc-pty-connect.ts | 4 + .../pending-pane-close-confirmation.test.ts | 192 ++++++++++++++ .../pending-split-close-test-fixture.ts | 207 +++++++++++++++ .../terminal-pane/pending-split-close.test.ts | 193 ++++++++++++++ .../terminal-pane/pty-transport-types.ts | 5 +- .../components/terminal-pane/pty-transport.ts | 5 +- .../retire-unbound-ipc-terminal-pane.ts | 83 ++++++ .../terminal-pane-close-admission.ts | 68 +++++ .../terminal-pane-hook-order-parity.test.ts | 6 +- .../use-terminal-pane-close-actions.ts | 51 +++- ...terminal-pane-retirement-ownership.test.ts | 85 +++++++ .../store/slices/terminal-tab-retirement.ts | 24 ++ 17 files changed, 1537 insertions(+), 14 deletions(-) create mode 100644 docs/audits/pending-split-close/README.md create mode 100644 docs/audits/pending-split-close/daemon-proof.test.ts create mode 100644 docs/audits/pending-split-close/fix.patch create mode 100644 docs/audits/pending-split-close/reproduce.mjs create mode 100644 docs/audits/pending-split-close/results.json create mode 100644 src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts create mode 100644 src/renderer/src/components/terminal-pane/pending-split-close.test.ts create mode 100644 src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts create mode 100644 src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts diff --git a/docs/audits/pending-split-close/README.md b/docs/audits/pending-split-close/README.md new file mode 100644 index 00000000000..e1c4902f166 --- /dev/null +++ b/docs/audits/pending-split-close/README.md @@ -0,0 +1,42 @@ +# Pending split close can omit shell retirement + +A restored split pane can be explicitly closed while its IPC spawn/reattach reply is pending. Its transport has no bound PTY ID yet. The old close path deleted the durable leaf binding and destroyed the unbound transport without requesting retirement. The late-result cleanup deliberately preserved reattach and cold-restore replies for remounts, so an existing shell or newly cold-restored shell could remain live with no pane and no kill request. + +This is a concrete mechanism matching the **missing kill requests** in [#15210](https://github.com/stablyai/orca/issues/15210). The relevant close, late-result exclusion, and daemon cold-restore behavior exists in both `v1.4.184` and `v1.4.198`. The report does not establish that this sequence produced its 51 shells. It also does not establish a retained Electron-main heap slope or independently explain [#19831](https://github.com/stablyai/orca/issues/19831). + +## Ownership and fix + +The explicit split-close hook captures its durable requested PTY before removing the leaf binding. Existing tab-retirement planning resolves the local/direct-SSH execution owner. It requests retirement immediately and retains an explicit-close callback for the late same-ID result. The late callback rechecks current store ownership and the current transport map, including a replacement at the same leaf. It makes a second request only when no owner remains. + +An eager request alone is insufficient: Electron spawn preflight can still be waiting before the adapter/history lock exists. A kill can return `SessionNotFoundError`, then the pending spawn creates a new cold-restored shell. The late-result request closes that window. If spawn already owns the adapter's history lock, the ordinary known-ID shutdown waits behind it; the control case preserves that behavior. + +Generic detach/destroy keeps its existing behavior. Repeated destroy retains explicit intent, while a different returned reattach identity remains protected. The tab aggregate/row ID is not counted as a separate same-tab owner; it can be the closing leaf's own stale index. Other live tabs use all existing retirement ownership sources; remote alias matching reuses the existing normalized identity. + +Paired-runtime handles, runtime-owned native hints, and unresolved owners never fall through to local kill. They are outside this IPC fix. A provider failure is logged by the existing retirement helper; requesting retirement is not confirmation of process death. No host inventory sweep, wire change, global tombstone, or remount-driven shutdown is introduced. A shared owner already present at close keeps its established retirement responsibility; this does not change all pending-fresh/shared-owner races. + +## Close-confirmation review correction + +The original tests called `executeClosePane` after a close decision. A separate review found that the public `handleRequestClosePane` callback skipped the running-work check while the transport was still unbound. Retirement now obtains the pending local/direct-SSH identity from the existing retirement plan and runs the existing confirmation flow first. An unverified pending probe asks for confirmation; Cancel preserves the process. Before a delayed decision or confirmation acts, the captured tab generation, pane, transport, binding, and execution owner must still match. A split that became the final pane cannot turn an old confirmation into a whole-tab close. + +`pending-pane-close-confirmation.test.ts` exercises the public callbacks, including live and unverified work, Cancel, confirmed close, completed attachment, ownership replacement, and direct SSH. These are separate regression controls added after the original comparative proof below; they do not change its historical case counts. + +## Reproduce + +From the repository root with the existing dependencies installed: + +```sh +ORCA_BACKGROUND_LAUNCH=1 node docs/audits/pending-split-close/reproduce.mjs +``` + +The script runs the actual close hook, layout binding, pane-close handler, IPC transport, daemon server, and daemon adapter. React registration and unrelated presentation/status callbacks are mocked. Daemon cases use temporary sockets, synthetic history, and the existing fake subprocess fixture; no real shell or Orca window is launched. It cleans its temporary configuration and invokes Vitest through the repository's cross-platform `runProcess`. + +`fix.patch` is reversed only inside a temporary Vite source transform for the baseline. Working files remain unchanged. New helper/test sources remain present, but the baseline close hook cannot call the helper. Source hashes and exact cases are recorded in `results.json`: + +| Version | Passed | Failed | +| --- | ---: | ---: | +| Before fix | 10 | 14 | +| With fix | 24 | 0 | + +The 24 cases retain the original nine reproduction/control scenarios and add same-leaf/new-map/different-tab adoption, sibling ownership, repeated destroy, provider failure/retry, spawn rejection, returned-ID mismatch, direct SSH, unresolved/paired-runtime routing, and folder workspace coverage. Eight separate ownership-query tests cover legacy/scoped aliases and the existing tab ownership sources. The baseline failures include assertions about the new eager request; they are not 14 independent leaks. + +Historical entry points: `v1.4.184` `TerminalPane.tsx:1163`, `use-terminal-pane-lifecycle.ts:1368`, `pty-transport.ts:859`, and `src/main/daemon/daemon-pty-adapter.ts:750`. The current equivalents are `use-terminal-pane-close-actions.ts`, `terminal-pane-pane-closed.ts`, `ipc-pty-connect.ts`, and `daemon-pty-spawn-result.ts`. diff --git a/docs/audits/pending-split-close/daemon-proof.test.ts b/docs/audits/pending-split-close/daemon-proof.test.ts new file mode 100644 index 00000000000..3a722fab071 --- /dev/null +++ b/docs/audits/pending-split-close/daemon-proof.test.ts @@ -0,0 +1,132 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { preparePendingSplitClose } from '../../../src/renderer/src/components/terminal-pane/pending-split-close-test-fixture' +import { + startDaemonAdapterHarness, + createMockSubprocess +} from '../../../src/main/daemon/daemon-pty-adapter-test-harness' +import { DaemonPtyAdapter } from '../../../src/main/daemon/daemon-pty-adapter' +import { getHistorySessionDirName } from '../../../src/main/daemon/history-paths' +import { SessionNotFoundError } from '../../../src/main/daemon/types' + +async function coldRestoreHarness() { + const subprocess = createMockSubprocess() + const h = await startDaemonAdapterHarness(() => subprocess) + const historyPath = join(h.dir, 'history') + const sessionDir = join(historyPath, getHistorySessionDirName('pty-restored')) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: h.dir, + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'synthetic saved history\r\n') + const adapter = new DaemonPtyAdapter({ + socketPath: h.socketPath, + tokenPath: h.tokenPath, + historyPath + }) + const requests: Promise[] = [] + const absent: string[] = [] + const bridgeKill = (id: string): Promise => { + const request = adapter.shutdown(id, { immediate: true }).catch((error) => { + // The renderer IPC handler treats the provider's already-gone reply as success. + if (error instanceof SessionNotFoundError) { + absent.push(id) + return + } + throw error + }) + requests.push(request) + return request + } + return { + adapter, + subprocess, + requests, + absent, + bridgeKill, + async dispose() { + adapter.dispose() + h.adapter.dispose() + await h.server.shutdown() + rmSync(h.dir, { recursive: true, force: true }) + } + } +} + +it('explicit split close retires a real daemon cold restore whose reply is pending', async () => { + const h = await coldRestoreHarness() + try { + const p = await preparePendingSplitClose() + vi.mocked(window.api.pty.kill).mockImplementation(h.bridgeKill) + const result = await h.adapter.spawn({ cols: 80, rows: 24, sessionId: 'pty-restored' }) + expect(result.isReattach).not.toBe(true) + expect(result.coldRestore).toBeDefined() + expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(true) + p.actions.executeClosePane(1) + p.spawn.resolve(result) + await p.connecting + await Promise.all(h.requests) + expect(window.api.pty.kill).toHaveBeenCalledTimes(2) + expect(h.subprocess.forceKill).toHaveBeenCalledOnce() + expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(false) + } finally { + await h.dispose() + } +}) + +it('the explicit late reply retries a kill that completed before adapter admission', async () => { + const h = await coldRestoreHarness() + try { + const p = await preparePendingSplitClose() + vi.mocked(window.api.pty.kill).mockImplementation(h.bridgeKill) + p.actions.executeClosePane(1) + // Hold admission outside the adapter; there is no history lock or session yet. + await Promise.all(h.requests) + expect(h.absent).toEqual(['pty-restored']) + const result = await h.adapter.spawn({ cols: 80, rows: 24, sessionId: 'pty-restored' }) + expect(result.coldRestore).toBeDefined() + expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(true) + p.spawn.resolve(result) + await p.connecting + await Promise.all(h.requests) + expect(window.api.pty.kill).toHaveBeenCalledTimes(2) + expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(false) + } finally { + await h.dispose() + } +}) + +it('a known-ID shutdown already admitted to the adapter waits for its spawn history lock', async () => { + const h = await coldRestoreHarness() + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const finish = h.adapter['finishSpawn'].bind(h.adapter) + h.adapter['finishSpawn'] = async (context, result) => { + started.resolve() + await release.promise + return finish(context, result) + } + try { + const spawning = h.adapter.spawn({ cols: 80, rows: 24, sessionId: 'pty-restored' }) + await started.promise + const stopping = h.adapter.shutdown('pty-restored', { immediate: true }) + expect(h.subprocess.forceKill).not.toHaveBeenCalled() + release.resolve() + await spawning + await stopping + expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(false) + expect(h.subprocess.forceKill).toHaveBeenCalledOnce() + } finally { + release.resolve() + await h.dispose() + } +}) diff --git a/docs/audits/pending-split-close/fix.patch b/docs/audits/pending-split-close/fix.patch new file mode 100644 index 00000000000..65a7ad808b8 --- /dev/null +++ b/docs/audits/pending-split-close/fix.patch @@ -0,0 +1,237 @@ +diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +index 3023236de0e..52b983c51fd 100644 +--- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts ++++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +@@ -27,6 +27,7 @@ type IpcPtyConnectContext = { + /** True only for the one buffered exit consumed by this connect attempt. */ + isExpectedExitCurrent: () => boolean + ownsPtyId: (id: string) => boolean ++ handleExplicitlyClosedConnect?: (id: string) => boolean + bind: (id: string) => void + isCurrent: (id: string) => boolean + setCallbacks: (callbacks: PtyConnectOptions['callbacks']) => void +@@ -89,6 +90,9 @@ export async function connectIpcPty( + const priorIncarnationFence = currentPreHandlerPtySequence() + const spawnResult = await spawnIpcPty(transportOptions, options, admittedSessionId) + const retireFreshSpawn = async (): Promise => { ++ if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) { ++ return ++ } + // A newer generation may already own a recycled id; an id-only kill would retire its PTY. + if ( + !spawnResult.isReattach && +diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts +index 4d7eaf7358b..2f6ed734bbf 100644 +--- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts ++++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts +@@ -232,7 +232,10 @@ export type PtyTransport = { + * it also drops the transport's output processor from the pty side-effect memory census, + * so a reattached one would run untracked. Create a new transport instead. */ + detach?: (options?: { preserveExitObserver?: boolean }) => void +- destroy?: () => void | Promise ++ destroy?: (options?: { ++ /** Explicit close can retain retirement intent until an unbound connect settles. */ ++ onAbandonedConnect?: (ptyId: string) => boolean ++ }) => void | Promise + } + + export type IpcPtyTransportOptions = { +diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts +index f794b9a1e4d..7731e75eac5 100644 +--- a/src/renderer/src/components/terminal-pane/pty-transport.ts ++++ b/src/renderer/src/components/terminal-pane/pty-transport.ts +@@ -44,6 +44,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra + } = opts + let connected = false + let destroyed = false ++ let onAbandonedConnect: ((ptyId: string) => boolean) | undefined + let ptyId: string | null = null + let lifecycleGeneration = 0 + let lastExitGeneration: number | null = null +@@ -137,6 +138,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra + lastExitGeneration === lifecycleGeneration && + lifecycleGeneration === connectGeneration + 1, + ownsPtyId: (id) => !destroyed && connected && ptyId === id, ++ handleExplicitlyClosedConnect: (id) => destroyed && (onAbandonedConnect?.(id) ?? false), + bind, + isCurrent: (id) => lifecycleGeneration === connectGeneration && connected && ptyId === id, + setCallbacks, +@@ -268,7 +270,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra + : { ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(shellOverride ? { shellOverride } : {}) }, + resetCrossChunkParserState: outputProcessor.resetAgentStatusCarry, + +- destroy() { ++ destroy(options) { ++ onAbandonedConnect ??= options?.onAbandonedConnect + destroyed = true + try { + this.disconnect() +diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +index 330b42166cd..ea85e929e81 100644 +--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts ++++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +@@ -1,4 +1,4 @@ +-import { useCallback, useImperativeHandle } from 'react' ++import { useCallback, useImperativeHandle, useRef } from 'react' + import { useAppStore } from '../../store' + import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' + import { makePaneKey } from '../../../../shared/stable-pane-id' +@@ -13,8 +13,11 @@ import { + } from './terminal-pane-tab-detach' + import { clearPaneTerminalError } from './terminal-error-accumulation' + import type { TerminalPaneBindingController } from './use-terminal-pane-layout-bindings' ++import { retireUnboundIpcTerminalPane } from './retire-unbound-ipc-terminal-pane' ++import { capturePendingTerminalPaneClose } from './terminal-pane-close-admission' + + export function useTerminalPaneCloseActions(controller: TerminalPaneBindingController) { ++ const confirmedCloseRef = useRef<(() => void) | null>(null) + const { + clearSessionRestoredBannerForPane, + managerRef, +@@ -46,6 +49,13 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr + clearSessionRestoredBannerForPane(paneId) + const leafId = manager.getLeafId(paneId) + if (leafId) { ++ retireUnboundIpcTerminalPane({ ++ getState: useAppStore.getState, ++ tabId, ++ leafId, ++ transport: paneTransportsRef.current.get(paneId), ++ getTransports: () => paneTransportsRef.current ++ }) + useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null) + useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true }) + } +@@ -79,12 +89,18 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr + return + } + const transport = paneTransportsRef.current.get(paneId) +- const ptyId = transport?.getPtyId() ++ const pending = capturePendingTerminalPaneClose(controller, paneId, useAppStore.getState) ++ const ptyId = transport?.getPtyId() ?? pending?.ptyId + if (!ptyId) { + executeClosePane(paneId) + return + } + const settings = useAppStore.getState().settings ++ const close = (): void => { ++ if (!pending || pending.isCurrent()) { ++ executeClosePane(paneId) ++ } ++ } + let decided = false + const decide = (act: () => void): void => { + if (decided) { +@@ -93,12 +109,23 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr + decided = true + act() + } +- const confirmClose = (): void => ++ const confirmClose = (): void => { ++ if (pending && !pending.isCurrent()) { ++ return ++ } ++ confirmedCloseRef.current = close + setPendingCloseConfirmation({ + paneId, + copyKind: getCloseDialogCopyKind(paneId) + }) +- const probeTimeout = setTimeout(() => decide(confirmClose), RUNNING_CLOSE_PROBE_TIMEOUT_MS) ++ } ++ const probeTimeout = setTimeout( ++ () => ++ decide( ++ pending && settings?.skipCloseTerminalWithRunningProcessConfirm ? close : confirmClose ++ ), ++ RUNNING_CLOSE_PROBE_TIMEOUT_MS ++ ) + // Why the shared probe rather than a direct inspect: this is the same question the tab-close + // guard asks, and the two must not drift on what an unanswered host means. + void probePtyRunningWork(settings, [ptyId], { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS }) +@@ -106,10 +133,10 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr + clearTimeout(probeTimeout) + decide(() => { + if ( +- probes[0]?.verdict !== 'live' || ++ (pending ? probes[0]?.verdict === 'exited' : probes[0]?.verdict !== 'live') || + settings?.skipCloseTerminalWithRunningProcessConfirm + ) { +- executeClosePane(paneId) ++ close() + } else { + confirmClose() + } +@@ -117,7 +144,9 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr + }) + .catch(() => { + clearTimeout(probeTimeout) +- decide(() => executeClosePane(paneId)) ++ decide( ++ pending && !settings?.skipCloseTerminalWithRunningProcessConfirm ? confirmClose : close ++ ) + }) + }, + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. +@@ -143,22 +172,24 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr + }, []) + const handleConfirmClose = useCallback( + (dontAskAgain: boolean) => { +- if (pendingCloseConfirmation === null) { ++ if (pendingCloseConfirmation === null || confirmedCloseRef.current === null) { + return + } +- const paneId = pendingCloseConfirmation.paneId ++ const confirmedClose = confirmedCloseRef.current ++ confirmedCloseRef.current = null + setPendingCloseConfirmation(null) + if (dontAskAgain) { + void updateSettings({ + skipCloseTerminalWithRunningProcessConfirm: true + }) + } +- executeClosePane(paneId) ++ confirmedClose() + }, + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. + [executeClosePane, pendingCloseConfirmation, updateSettings] + ) + const handleCancelClose = useCallback(() => { ++ confirmedCloseRef.current = null + setPendingCloseConfirmation(null) + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. + }, []) +diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.ts b/src/renderer/src/store/slices/terminal-tab-retirement.ts +index 80e67824699..e2e034fa6af 100644 +--- a/src/renderer/src/store/slices/terminal-tab-retirement.ts ++++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts +@@ -135,6 +135,30 @@ export function isTerminalTabPresent( + return locateTerminalTab(state.tabsByWorktree, tabId) !== null + } + ++export function hasTerminalPtyOwnerOutsidePane( ++ state: TerminalTabRetirementState, ++ identity: string, ++ tabId: string, ++ excludedLeafId?: string ++): boolean { ++ for (const [ownerTabId, owner] of collectLiveTerminalTabs(state)) { ++ const ids = ++ ownerTabId === tabId ++ ? Object.entries(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}) ++ .filter(([leafId]) => leafId !== excludedLeafId) ++ .map(([, ptyId]) => ptyId) ++ : collectPtyIdsForTab(state, ownerTabId, owner.rowPtyId) ++ if ( ++ ids.some( ++ (ptyId) => getTerminalPtyOwnershipIdentity(state, ptyId, owner.worktreeId) === identity ++ ) ++ ) { ++ return true ++ } ++ } ++ return false ++} ++ + export function buildTerminalTabRetirementPlan( + state: TerminalTabRetirementState, + tabId: string diff --git a/docs/audits/pending-split-close/reproduce.mjs b/docs/audits/pending-split-close/reproduce.mjs new file mode 100644 index 00000000000..73b33c09e30 --- /dev/null +++ b/docs/audits/pending-split-close/reproduce.mjs @@ -0,0 +1,152 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { applyPatch, parsePatch, reversePatch } from 'diff' +import { build } from 'esbuild' + +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.') +} + +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8') +const beforeSources = {} +const sourceHashes = {} +for (const parsed of parsePatch(patch)) { + const path = parsed.newFileName.replace(/^b\//, '') + const absolute = resolve(root, path) + const current = await readFile(absolute, 'utf8') + const before = applyPatch(current, reversePatch(parsed)) + if (before === false) { + throw new Error(`Source changed; review the proof patch: ${path}`) + } + beforeSources[absolute.replaceAll('\\', '/')] = before + sourceHashes[path] = { + before: createHash('sha256').update(before).digest('hex'), + after: createHash('sha256').update(current).digest('hex') + } +} + +for (const path of [ + 'src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts', + 'src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts', + 'src/renderer/src/components/terminal-pane/pending-split-close.test.ts', + 'docs/audits/pending-split-close/daemon-proof.test.ts' +]) { + sourceHashes[path] = { + current: createHash('sha256') + .update(await readFile(resolve(root, path))) + .digest('hex') + } +} + +const scratch = await mkdtemp(join(tmpdir(), 'orca-pending-split-close-')) +const require = createRequire(import.meta.url) +let runnerModuleId +try { + const runnerPath = join(scratch, 'run-process.cjs') + await build({ + absWorkingDir: root, + entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')], + outfile: runnerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + runnerModuleId = require.resolve(runnerPath) + const { runProcess } = require(runnerModuleId) + const baselineConfig = join(scratch, 'before.config.mjs') + const fixedConfig = join(scratch, 'after.config.mjs') + const includes = [ + 'src/renderer/src/components/terminal-pane/pending-split-close.test.ts', + 'docs/audits/pending-split-close/daemon-proof.test.ts' + ] + const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href) + await writeFile( + baselineConfig, + `import base from ${configImport}; +const beforeSources = ${JSON.stringify(beforeSources)}; +export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{ + name: 'pending-split-close-before-fix', enforce: 'pre', + transform(_code, id) { + const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]]; + return before === undefined ? null : {code: before, map: null}; + } +}]};\n` + ) + + await writeFile( + fixedConfig, + `import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n` + ) + + async function run(label, config) { + const report = join(scratch, `${label}.json`) + const result = await runProcess({ + program: process.execPath, + args: [ + resolve(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--config', + config, + '--reporter=json', + `--outputFile=${report}` + ], + cwd: root, + env: process.env, + timeoutMs: 90_000, + maxOutputBytes: 4 * 1024 * 1024 + }) + let parsed + try { + parsed = JSON.parse(await readFile(report, 'utf8')) + } catch (error) { + throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error }) + } + return { + exitCode: result.code, + passed: parsed.numPassedTests, + failed: parsed.numFailedTests, + failedCases: parsed.testResults.flatMap((suite) => + suite.assertionResults + .filter((test) => test.status === 'failed') + .map((test) => test.fullName) + ) + } + } + + const before = await run('before', baselineConfig) + const after = await run('after', fixedConfig) + const passed = + before.failed === 14 && + before.passed === 10 && + before.passed + before.failed === 24 && + after.passed === 24 && + after.failed === 0 + console.log( + JSON.stringify( + { + comparison: + 'Actual split close/IPC transport and temporary daemon socket tests; before reverses only fix.patch in a temporary Vite transform', + sourceHashes, + before, + after, + passed + }, + null, + 2 + ) + ) + if (!passed) { + process.exitCode = 1 + } +} finally { + if (runnerModuleId) { + delete require.cache[runnerModuleId] + } + await rm(scratch, { recursive: true, force: true }) +} diff --git a/docs/audits/pending-split-close/results.json b/docs/audits/pending-split-close/results.json new file mode 100644 index 00000000000..7e694e953b2 --- /dev/null +++ b/docs/audits/pending-split-close/results.json @@ -0,0 +1,65 @@ +{ + "comparison": "Actual split close/IPC transport and temporary daemon socket tests; before reverses only fix.patch in a temporary Vite transform", + "sourceHashes": { + "src/renderer/src/components/terminal-pane/ipc-pty-connect.ts": { + "before": "7fe38b17d9a1c105bd93088d70086feb0c08f7f80f4513ffd975c5b4bc2167b0", + "after": "29985c0b538f701341fb8eca383386ad20f9326090dff452d4b2703b49fc4ea3" + }, + "src/renderer/src/components/terminal-pane/pty-transport-types.ts": { + "before": "ab404437789ca3f7da2e8d0757778a1098bbe43dc4e04c5b0f112e7a4456c11c", + "after": "15dcd4cb02cceffca4c3b880f6b2688fbd14e6ea1421984d5873f100fa39b5fb" + }, + "src/renderer/src/components/terminal-pane/pty-transport.ts": { + "before": "0455dba126457eb50a6075a8b265b29016aa5ed8c0730948e23d4f864bd8d5b3", + "after": "b5ac687bbb5c21e94a433d4a66f9b8fca46b3004f331f128c44bfa21b32a3838" + }, + "src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts": { + "before": "978003ccd82d0af5dce5501edaa7c20c87170e590a5f3f06e55b28f9e4f0323f", + "after": "bbfeb2385fd120a7a1407d043011fb689b5c2b652c31150de44061f1edb76a7b" + }, + "src/renderer/src/store/slices/terminal-tab-retirement.ts": { + "before": "e622afc63524826732f1779a6180d17b51ac5a2c705654062cc0907ea735db4f", + "after": "21a5f350885851398e316201686e073e3560c107b4b9d1b2e08eea6e07ec980f" + }, + "src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts": { + "current": "3194229bdd3c992e8459cdad727a3931653953b204854486b8aaf4d129152d24" + }, + "src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts": { + "current": "5fddc75178546161448f8a84b43f0e7da41c7dbb986682a09f8f7ec4e44617bf" + }, + "src/renderer/src/components/terminal-pane/pending-split-close.test.ts": { + "current": "106abb33917dd403606af413558bfe95730a064199d4f7085c4970d38cfde0cc" + }, + "docs/audits/pending-split-close/daemon-proof.test.ts": { + "current": "0a29132942a9f8aa60ceada3498fb4fb344c1c0e14a8c9b4cbc8ed4cfa9543d9" + } + }, + "before": { + "exitCode": 1, + "passed": 10, + "failed": 14, + "failedCases": [ + "explicit split close retires a real daemon cold restore whose reply is pending", + "the explicit late reply retries a kill that completed before adapter admission", + "explicit split close retires a pending reattach before and after its reply", + "explicit split close retires a pending cold-restore-new before and after its reply", + "explicit split close retires a pending ordinary-fresh before and after its reply", + "protects a same-leaf replacement that adopts after explicit close", + "protects a different-tab replacement that adopts after explicit close", + "protects a new-transport-map replacement that adopts after explicit close", + "retains explicit close intent across repeated generic destroy calls", + "preserves a different returned reattach identity", + "retries an eager provider failure when the same-ID reply arrives", + "does not invent a late session after a rejected spawn", + "routes direct SSH retirement through the existing IPC identity", + "retires a local folder-workspace split without a git worktree row" + ] + }, + "after": { + "exitCode": 0, + "passed": 24, + "failed": 0, + "failedCases": [] + }, + "passed": true +} diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 3023236de0e..52b983c51fd 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -27,6 +27,7 @@ type IpcPtyConnectContext = { /** True only for the one buffered exit consumed by this connect attempt. */ isExpectedExitCurrent: () => boolean ownsPtyId: (id: string) => boolean + handleExplicitlyClosedConnect?: (id: string) => boolean bind: (id: string) => void isCurrent: (id: string) => boolean setCallbacks: (callbacks: PtyConnectOptions['callbacks']) => void @@ -89,6 +90,9 @@ export async function connectIpcPty( const priorIncarnationFence = currentPreHandlerPtySequence() const spawnResult = await spawnIpcPty(transportOptions, options, admittedSessionId) const retireFreshSpawn = async (): Promise => { + if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) { + return + } // A newer generation may already own a recycled id; an id-only kill would retire its PTY. if ( !spawnResult.isReattach && diff --git a/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts b/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts new file mode 100644 index 00000000000..03f8887564d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { preparePendingSplitClose } from './pending-split-close-test-fixture' +import { flushPtySideEffects } from './pty-transport-test-harness' +import type { PtyRunningWorkProbe } from '../terminal/pty-running-work-probe' + +beforeEach(() => vi.clearAllMocks()) +afterEach(() => vi.useRealTimers()) + +async function prepare(remote = false, requestedPtyId?: string) { + const p = await preparePendingSplitClose(requestedPtyId) + Object.assign(p.state, { settings: { skipCloseTerminalWithRunningProcessConfirm: false } }) + const { probePtyRunningWork } = await import('../terminal/pty-running-work-probe') + const { useTerminalPaneCloseActions } = await import('./use-terminal-pane-close-actions') + // Supply the dialog state that the UI renders after setPendingCloseConfirmation. + // oxlint-disable-next-line react-hooks/rules-of-hooks -- React registration is mocked; exercise public close callbacks without mounting UI. + const actions = useTerminalPaneCloseActions({ + ...p.controller, + pendingCloseConfirmation: { paneId: 1, copyKind: 'command' } + }) + const reply = Promise.withResolvers() + vi.mocked(probePtyRunningWork).mockReturnValueOnce(reply.promise) + const verdict = (value: PtyRunningWorkProbe['verdict']) => + reply.resolve([{ ptyId: 'captured', verdict: value, timedOut: false, remote }]) + const closed = () => vi.mocked(window.api.pty.kill).mock.calls.length > 0 + const settle = async () => { + p.spawn.resolve({ id: requestedPtyId ?? 'pty-restored', isReattach: true }) + await p.connecting + await flushPtySideEffects() + } + return { ...p, actions, probePtyRunningWork, verdict, reply, closed, settle } +} + +it.each([false])('requires confirmation for pending live work, paired=%s', async (remote) => { + const p = await prepare(remote) + p.actions.handleRequestClosePane(1) + expect(p.probePtyRunningWork).toHaveBeenCalledWith( + expect.any(Object), + [remote ? 'remote:env-1@@term_original' : 'pty-restored'], + expect.any(Object) + ) + expect(p.closed()).toBe(false) + p.verdict('live') + await flushPtySideEffects() + expect(p.controller.setPendingCloseConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ paneId: 1 }) + ) + expect(p.closed()).toBe(false) + p.actions.handleConfirmClose(false) + await p.settle() + expect(p.closed()).toBe(true) +}) + +it.each([false])('Cancel preserves pending work, paired=%s', async (remote) => { + const p = await prepare(remote) + p.actions.handleRequestClosePane(1) + p.verdict('live') + await flushPtySideEffects() + p.actions.handleCancelClose() + p.actions.handleConfirmClose(false) + await p.settle() + expect(p.closed()).toBe(false) +}) + +it.each(['unverifiable', 'rejected', 'timeout'] as const)( + 'requires confirmation when a pending owner probe is %s', + async (mode) => { + vi.useFakeTimers() + const p = await prepare() + p.actions.handleRequestClosePane(1) + if (mode === 'rejected') { + p.reply.reject(new Error('owner unavailable')) + } else if (mode === 'unverifiable') { + p.verdict(mode) + } else { + await vi.advanceTimersByTimeAsync(101) + } + await Promise.resolve() + await Promise.resolve() + expect(p.controller.setPendingCloseConfirmation).toHaveBeenCalled() + expect(p.closed()).toBe(false) + p.actions.handleCancelClose() + p.verdict('exited') + vi.useRealTimers() + await p.settle() + expect(p.closed()).toBe(false) + } +) + +it('uses the direct SSH identity in the running-work probe', async () => { + const id = 'ssh:ssh-1@@pty-restored' + const p = await prepare(false, id) + p.state.worktreesByRepo = { repo: [{ id: 'workspace', repoId: 'repo', hostId: 'ssh:ssh-1' }] } + p.actions.handleRequestClosePane(1) + p.verdict('live') + await flushPtySideEffects() + expect(p.probePtyRunningWork).toHaveBeenCalledWith(expect.any(Object), [id], expect.any(Object)) + expect(p.closed()).toBe(false) + p.actions.handleConfirmClose(false) + await p.settle() + expect(window.api.pty.kill).toHaveBeenCalledWith(id) +}) + +it.each(['rejected', 'timeout'] as const)( + 'honors the skip-confirmation setting when a pending probe is %s', + async (mode) => { + vi.useFakeTimers() + const p = await prepare() + Object.assign(p.state, { settings: { skipCloseTerminalWithRunningProcessConfirm: true } }) + p.actions.handleRequestClosePane(1) + if (mode === 'rejected') { + p.reply.reject(new Error('owner unavailable')) + } else { + await vi.advanceTimersByTimeAsync(101) + } + await Promise.resolve() + await Promise.resolve() + expect(p.controller.setPendingCloseConfirmation).not.toHaveBeenCalled() + expect(p.closed()).toBe(true) + p.verdict('exited') + vi.useRealTimers() + await p.settle() + } +) + +it.each(['exited', 'skip-setting'] as const)('keeps allowed pending close for %s', async (mode) => { + const p = await prepare() + if (mode === 'skip-setting') { + Object.assign(p.state, { settings: { skipCloseTerminalWithRunningProcessConfirm: true } }) + } + p.actions.handleRequestClosePane(1) + p.verdict(mode === 'exited' ? 'exited' : 'live') + await flushPtySideEffects() + expect(p.controller.setPendingCloseConfirmation).not.toHaveBeenCalled() + await p.settle() + expect(p.closed()).toBe(true) +}) + +it.each(['probe', 'dialog'] as const)( + 'accepts the same pending attach finishing during %s', + async (phase) => { + const p = await prepare() + p.actions.handleRequestClosePane(1) + if (phase === 'dialog') { + p.verdict('live') + await flushPtySideEffects() + } + p.spawn.resolve({ id: 'pty-restored', isReattach: true }) + await p.connecting + p.verdict('live') + await flushPtySideEffects() + expect(p.closed()).toBe(false) + p.actions.handleConfirmClose(false) + await flushPtySideEffects() + expect(p.closed()).toBe(true) + } +) + +it.each(['tab', 'generation', 'leaf', 'transport', 'manager', 'whole-tab', 'binding'] as const)( + 'does not apply a pending confirmation to a replacement %s', + async (replacement) => { + const p = await prepare() + p.actions.handleRequestClosePane(1) + p.verdict('live') + await flushPtySideEffects() + if (replacement === 'tab') { + p.state.tabsByWorktree.workspace[0].createdAt += 1 + } + if (replacement === 'generation') { + p.state.tabsByWorktree.workspace[0].generation = 1 + } + if (replacement === 'leaf') { + p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId = {} + } + if (replacement === 'binding') { + p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId = { [p.leafId]: 'successor' } + } + if (replacement === 'transport') { + p.transports.delete(1) + } + if (replacement === 'manager') { + p.controller.managerRef.current = null + } + if (replacement === 'whole-tab') { + vi.spyOn(p.controller.managerRef.current!, 'getPanes').mockReturnValue([]) + } + p.actions.handleConfirmClose(false) + await p.settle() + expect(p.closed()).toBe(false) + expect(p.controller.onCloseTab).not.toHaveBeenCalled() + p.transport.detach?.({ preserveExitObserver: false }) + } +) diff --git a/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts new file mode 100644 index 00000000000..66bfa3f9307 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts @@ -0,0 +1,207 @@ +/* oxlint-disable anti-slop/no-module-mocking -- Vitest support module for the sibling + pending-split-close specs, not shipped code, and it falls outside the *.test / *.spec / tests + glob set this rule is already switched off for. The calls have to live in one shared module: + `vi.mock` is registered per importing spec, so the alternative is copying all twelve into every + spec, where they would drift apart. */ +import { afterEach, beforeEach, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { TerminalTabRetirementState } from '@/store/slices/terminal-tab-retirement' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyConnectResult, PtyTransport } from './pty-transport-types' +import type { TerminalPaneBindingController } from './use-terminal-pane-layout-bindings' +import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' + +const store = vi.hoisted((): { current: AppState | null } => ({ current: null })) +vi.mock('../../store', () => ({ useAppStore: { getState: () => store.current } })) +vi.mock('react', () => ({ + useCallback: (fn: unknown) => fn, + useImperativeHandle: () => {}, + useRef: (current: unknown) => ({ current }) +})) +vi.mock('../../runtime/web-runtime-session', () => ({ closeWebRuntimeTerminal: vi.fn() })) +vi.mock('../../runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +vi.mock('../terminal/terminal-close-copy-kind', () => ({ resolveLeafCloseCopyKind: vi.fn() })) +vi.mock('../terminal/running-terminal-close-guard', () => ({ RUNNING_CLOSE_PROBE_TIMEOUT_MS: 100 })) +vi.mock('../terminal/pty-running-work-probe', () => ({ probePtyRunningWork: vi.fn() })) +vi.mock('./terminal-pane-tab-detach', () => ({ + detachTerminalPaneToTab: vi.fn(), + isTerminalTabStripDropTarget: vi.fn(), + resolveTerminalTabStripDropTarget: vi.fn() +})) +vi.mock('./terminal-pane-close-identity', () => ({ + resolveTabTitleAfterPaneClose: vi.fn(), + shouldClearLaunchAgentForClosedPane: () => false +})) +vi.mock('./terminal-pane-lifecycle-primitives', () => ({ reportActiveRendererPtyForPane: vi.fn() })) +vi.mock('./deferred-split-pane-handoff', () => ({ + clearDeferredSplitPaneHandoff: vi.fn(), + discardDeferredSplitPaneHandoffForKey: vi.fn() +})) +vi.mock('./expand-collapse', () => ({ useExpandCollapseActions: () => ({}) })) + +const originalWindow = globalThis.window +beforeEach(() => { + vi.resetModules() + installIpcPtyWindow(originalWindow, {}) +}) +afterEach(() => { + restorePtySpecWindow(originalWindow) + vi.restoreAllMocks() +}) + +export function makeCloseTestTab( + id: string, + ptyId: string | null, + worktreeId = 'workspace' +): TerminalTab { + return { + id, + worktreeId, + ptyId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +export async function preparePendingSplitClose( + requestedPtyId = 'pty-restored', + worktreeId = 'workspace' +) { + const { createIpcPtyTransport } = await import('./pty-transport') + const { useTerminalPaneLayoutBindings } = await import('./use-terminal-pane-layout-bindings') + const { useTerminalPaneCloseActions } = await import('./use-terminal-pane-close-actions') + const { createTerminalPaneClosedHandler } = await import('./terminal-pane-pane-closed') + const { useTerminalPaneLifecycleRefs } = await import('./use-terminal-pane-lifecycle-refs') + const tabId = 'tab-parent' + const leafId = '11111111-1111-4111-8111-111111111111' + const siblingLeafId = '22222222-2222-4222-8222-222222222222' + const state: TerminalTabRetirementState = { + worktreesByRepo: { repo: [{ id: worktreeId, repoId: 'repo', hostId: 'local' }] }, + tabsByWorktree: { [worktreeId]: [makeCloseTestTab(tabId, requestedPtyId, worktreeId)] }, + unifiedTabsByWorktree: {}, + ptyIdsByTabId: { [tabId]: [requestedPtyId] }, + lastKnownRelayPtyIdByTabId: {}, + deferredSshSessionIdsByTabId: {}, + pendingReconnectPtyIdByTabId: {}, + terminalLayoutsByTabId: { + [tabId]: { + root: { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { type: 'leaf', leafId }, + second: { type: 'leaf', leafId: siblingLeafId } + }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: requestedPtyId, [siblingLeafId]: 'pty-sibling' } + } + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The real close paths read only this retirement state and the supplied actions. + store.current = Object.assign(state, { + setCacheTimerStartedAt: vi.fn(), + dropAgentStatus: vi.fn(), + retireAgentPaneAuthority: vi.fn(), + suppressPtyExit: vi.fn() + }) as unknown as AppState + const spawn = Promise.withResolvers() + vi.mocked(window.api.pty.spawn).mockReturnValueOnce(spawn.promise) + const transport = createIpcPtyTransport({}) + const connecting = transport.connect({ url: '', sessionId: requestedPtyId, callbacks: {} }) + let onClosed: ReturnType = () => {} + let panes = [ + { id: 1, leafId }, + { id: 2, leafId: siblingLeafId } + ] + const managerFixture = { + getPanes: () => panes, + getLeafId: () => leafId, + getActivePane: () => null, + closePane(id: number) { + panes = panes.filter((pane) => pane.id !== id) + onClosed(id, { leafId, reason: 'close' }) + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These real close methods use only the four manager operations above; no DOM is mounted. + const manager = managerFixture as unknown as PaneManager + const transports = new Map([[1, transport]]) + const partial: Partial = { + tabId, + worktreeId, + managerRef: { current: manager }, + paneTransportsRef: { current: transports }, + panePtyBindingsRef: { current: new Map() }, + paneMode2031Ref: { current: new Map() }, + paneKittyKeyboardModesRef: { current: new Map() }, + paneLastThemeModeRef: { current: new Map() }, + paneCwdRef: { current: new Map() }, + paneFontSizesRef: { current: new Map() }, + replayingPanesRef: { current: new Map() }, + paneTitlesRef: { current: {} }, + expandedPaneIdRef: { current: null }, + expandedStyleSnapshotRef: { current: new Map() }, + containerRef: { current: null }, + pendingPaneSizeRefreshFrameIdsRef: { current: [] }, + ref: { current: null }, + clearSessionRestoredBannerForPane: vi.fn(), + persistLayoutSnapshot: vi.fn(), + setPendingCloseConfirmation: vi.fn(), + setTerminalErrorsByPaneId: vi.fn(), + updateSettings: vi.fn(), + setExpandedPaneId: vi.fn(), + setTabPaneExpanded: vi.fn(), + onCloseTab: vi.fn(), + clearTabPtyId: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + setPaneTitles: vi.fn(), + setRenamingPaneId: vi.fn(), + setPaneCount: vi.fn(), + updateTabTitle: vi.fn(), + setTabLayout: (_tabId, layout) => { + if (layout) { + state.terminalLayoutsByTabId[tabId] = layout + } else { + delete state.terminalLayoutsByTabId[tabId] + } + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Every controller field read by the three exercised hooks is provided above or assigned from the real binding hook below. + const controller = partial as TerminalPaneBindingController + // oxlint-disable-next-line react-hooks/rules-of-hooks -- React registration is mocked; exercise the real binding callbacks without mounting UI. + Object.assign(controller, useTerminalPaneLayoutBindings(controller)) + const closeContext = { + deps: { + ...controller, + effectiveMacOptionAsAltRef: { current: 'false' as const }, + consumeSuppressedPtyExit: () => false, + isPtyShutdownPending: () => false, + onShowSessionRestoredBanner: vi.fn() + }, + // oxlint-disable-next-line react-hooks/rules-of-hooks -- The mocked useRef allocates the real per-pane ownership registries. + refs: useTerminalPaneLifecycleRefs(), + deferredSplitHandoffs: new Map() + } + onClosed = createTerminalPaneClosedHandler( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The real close handler reads only deps, refs, and deferredSplitHandoffs from its broader mount context. + closeContext as unknown as Parameters[0] + ) + // oxlint-disable-next-line react-hooks/rules-of-hooks -- React registration is mocked; exercise the real close callbacks without mounting UI. + const actions = useTerminalPaneCloseActions(controller) + return { + transport, + transports, + connecting, + spawn, + controller, + actions, + state, + tabId, + leafId, + siblingLeafId + } +} diff --git a/src/renderer/src/components/terminal-pane/pending-split-close.test.ts b/src/renderer/src/components/terminal-pane/pending-split-close.test.ts new file mode 100644 index 00000000000..5a022ef9c99 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pending-split-close.test.ts @@ -0,0 +1,193 @@ +import { expect, it, vi } from 'vitest' +import { makeCloseTestTab, preparePendingSplitClose } from './pending-split-close-test-fixture' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { flushPtySideEffects } from './pty-transport-test-harness' + +const replyFor = (kind: 'reattach' | 'cold-restore-new' | 'ordinary-fresh') => ({ + id: 'pty-restored', + ...(kind === 'reattach' ? { isReattach: true } : {}), + ...(kind === 'cold-restore-new' ? { coldRestore: { scrollback: 'saved', cwd: '/tmp' } } : {}) +}) + +it.each(['reattach', 'cold-restore-new', 'ordinary-fresh'] as const)( + 'explicit split close retires a pending %s before and after its reply', + async (kind) => { + const p = await preparePendingSplitClose() + expect(p.transport.getPtyId()).toBeNull() + p.actions.executeClosePane(1) + expect(p.transports.has(1)).toBe(false) + expect(p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId?.[p.leafId]).toBeUndefined() + expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith('pty-restored') + p.spawn.resolve(replyFor(kind)) + await p.connecting + expect(window.api.pty.kill).toHaveBeenCalledTimes(2) + expect(p.transport.getPtyId()).toBeNull() + } +) + +it.each(['reattach', 'cold-restore-new'] as const)( + 'ordinary remount preserves pending %s for adoption', + async (kind) => { + const p = await preparePendingSplitClose() + p.transport.detach?.({ preserveExitObserver: false }) + p.spawn.resolve(replyFor(kind)) + await p.connecting + expect(window.api.pty.kill).not.toHaveBeenCalled() + expect(p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId?.[p.leafId]).toBe('pty-restored') + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockResolvedValueOnce({ id: 'pty-restored', isReattach: true }) + const replacement = createIpcPtyTransport({}) + await replacement.connect({ url: '', sessionId: 'pty-restored', callbacks: {} }) + expect(replacement.getPtyId()).toBe('pty-restored') + replacement.detach?.({ preserveExitObserver: false }) + } +) + +it.each(['layout', 'transport'] as const)( + 'protects a sibling owner recorded only in %s', + async (owner) => { + const p = await preparePendingSplitClose() + const { createIpcPtyTransport } = await import('./pty-transport') + const survivor = createIpcPtyTransport({}) + if (owner === 'transport') { + survivor.attach({ existingPtyId: 'pty-restored', callbacks: {} }) + p.transports.set(2, survivor) + } else { + p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId = { + [p.leafId]: 'pty-restored', + [p.siblingLeafId]: 'pty-restored' + } + } + p.actions.executeClosePane(1) + p.spawn.resolve(replyFor('reattach')) + await p.connecting + expect(window.api.pty.kill).not.toHaveBeenCalled() + survivor.detach?.({ preserveExitObserver: false }) + } +) + +it('protects a different tab owner present before explicit close', async () => { + const p = await preparePendingSplitClose() + p.state.tabsByWorktree.workspace.push(makeCloseTestTab('survivor', 'pty-restored')) + p.actions.executeClosePane(1) + p.spawn.resolve(replyFor('cold-restore-new')) + await p.connecting + expect(window.api.pty.kill).not.toHaveBeenCalled() +}) + +it.each(['same-leaf', 'different-tab', 'new-transport-map'] as const)( + 'protects a %s replacement that adopts after explicit close', + async (owner) => { + const p = await preparePendingSplitClose() + p.actions.executeClosePane(1) + const { createIpcPtyTransport } = await import('./pty-transport') + const replacement = createIpcPtyTransport({}) + if (owner === 'same-leaf') { + p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId = { [p.leafId]: 'pty-restored' } + } else if (owner === 'different-tab') { + p.state.tabsByWorktree.workspace.push(makeCloseTestTab('new-owner', 'pty-restored')) + } else { + replacement.attach({ existingPtyId: 'pty-restored', callbacks: {} }) + p.controller.paneTransportsRef.current = new Map([[1, replacement]]) + } + p.spawn.resolve(replyFor('reattach')) + await p.connecting + expect(window.api.pty.kill).toHaveBeenCalledTimes(1) + replacement.detach?.({ preserveExitObserver: false }) + } +) + +it('retains explicit close intent across repeated generic destroy calls', async () => { + const p = await preparePendingSplitClose() + p.actions.executeClosePane(1) + p.transport.destroy?.() + p.transport.destroy?.() + p.spawn.resolve(replyFor('cold-restore-new')) + await p.connecting + expect(window.api.pty.kill).toHaveBeenCalledTimes(2) +}) + +it('preserves a different returned reattach identity', async () => { + const p = await preparePendingSplitClose() + p.actions.executeClosePane(1) + p.spawn.resolve({ id: 'different-existing-session', isReattach: true }) + await p.connecting + expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith('pty-restored') +}) + +it('retries an eager provider failure when the same-ID reply arrives', async () => { + const p = await preparePendingSplitClose() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(window.api.pty.kill).mockRejectedValueOnce( + new Error('provider temporarily unavailable') + ) + p.actions.executeClosePane(1) + await flushPtySideEffects() + expect(warn).toHaveBeenCalledWith( + '[terminal-retirement] provider teardown failed', + expect.any(Object) + ) + p.spawn.resolve(replyFor('reattach')) + await p.connecting + expect(window.api.pty.kill).toHaveBeenCalledTimes(2) +}) + +it('does not invent a late session after a rejected spawn', async () => { + const p = await preparePendingSplitClose() + vi.spyOn(console, 'error').mockImplementation(() => {}) + p.actions.executeClosePane(1) + p.spawn.reject(new Error('spawn rejected')) + await p.connecting + expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith('pty-restored') +}) + +it('routes direct SSH retirement through the existing IPC identity', async () => { + const id = 'ssh:ssh-1@@pty-restored' + const p = await preparePendingSplitClose(id) + p.state.worktreesByRepo = { repo: [{ id: 'workspace', repoId: 'repo', hostId: 'ssh:ssh-1' }] } + p.actions.executeClosePane(1) + p.spawn.resolve({ id, isReattach: true }) + await p.connecting + expect(window.api.pty.kill).toHaveBeenNthCalledWith(1, id) + expect(window.api.pty.kill).toHaveBeenNthCalledWith(2, id) +}) + +it.each(['runtime-handle', 'runtime-legacy', 'unresolved-owner', 'runtime-native-hint'] as const)( + 'never falls through to local kill for %s', + async (kind) => { + const id = + kind === 'runtime-handle' + ? 'remote:env-1@@pty-restored' + : kind === 'runtime-legacy' + ? 'remote:pty-restored' + : 'pty-restored' + const p = await preparePendingSplitClose(id) + if (kind === 'unresolved-owner') { + p.state.worktreesByRepo = {} + } else if (kind === 'runtime-native-hint') { + p.state.worktreesByRepo = { + repo: [ + { + id: 'workspace', + repoId: 'repo', + hostId: 'ssh:ssh-1', + runtimeOwnerEnvironmentId: 'env-1' + } + ] + } + } + p.actions.executeClosePane(1) + p.spawn.resolve({ id, isReattach: true }) + await p.connecting + expect(window.api.pty.kill).not.toHaveBeenCalled() + } +) + +it('retires a local folder-workspace split without a git worktree row', async () => { + const p = await preparePendingSplitClose('pty-restored', folderWorkspaceKey('folder-1')) + p.state.worktreesByRepo = {} + p.actions.executeClosePane(1) + p.spawn.resolve(replyFor('reattach')) + await p.connecting + expect(window.api.pty.kill).toHaveBeenCalledTimes(2) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 4d7eaf7358b..2f6ed734bbf 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -232,7 +232,10 @@ export type PtyTransport = { * it also drops the transport's output processor from the pty side-effect memory census, * so a reattached one would run untracked. Create a new transport instead. */ detach?: (options?: { preserveExitObserver?: boolean }) => void - destroy?: () => void | Promise + destroy?: (options?: { + /** Explicit close can retain retirement intent until an unbound connect settles. */ + onAbandonedConnect?: (ptyId: string) => boolean + }) => void | Promise } export type IpcPtyTransportOptions = { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f794b9a1e4d..7731e75eac5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -44,6 +44,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } = opts let connected = false let destroyed = false + let onAbandonedConnect: ((ptyId: string) => boolean) | undefined let ptyId: string | null = null let lifecycleGeneration = 0 let lastExitGeneration: number | null = null @@ -137,6 +138,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra lastExitGeneration === lifecycleGeneration && lifecycleGeneration === connectGeneration + 1, ownsPtyId: (id) => !destroyed && connected && ptyId === id, + handleExplicitlyClosedConnect: (id) => destroyed && (onAbandonedConnect?.(id) ?? false), bind, isCurrent: (id) => lifecycleGeneration === connectGeneration && connected && ptyId === id, setCallbacks, @@ -268,7 +270,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra : { ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(shellOverride ? { shellOverride } : {}) }, resetCrossChunkParserState: outputProcessor.resetAgentStatusCarry, - destroy() { + destroy(options) { + onAbandonedConnect ??= options?.onAbandonedConnect destroyed = true try { this.disconnect() diff --git a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts new file mode 100644 index 00000000000..081a33fc895 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts @@ -0,0 +1,83 @@ +import type { AppState } from '@/store/types' +import { + buildTerminalTabRetirementPlan, + getTerminalPtyOwnershipIdentity, + hasTerminalPtyOwnerOutsidePane +} from '@/store/slices/terminal-tab-retirement' +import { startTerminalTabProviderRetirement } from '@/store/terminals/terminal-tab-close-providers' +import type { PtyTransport } from './pty-transport-types' + +/** Capture explicit split-close intent before the durable leaf binding is removed. */ +export function retireUnboundIpcTerminalPane(args: { + getState: () => AppState + tabId: string + leafId: string + transport: PtyTransport | undefined + getTransports: () => ReadonlyMap +}): void { + const { getState, tabId, leafId, transport, getTransports } = args + if (!transport || transport.getPtyId()) { + return + } + const state = getState() + const requestedPtyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + if (!requestedPtyId) { + return + } + const plan = buildTerminalTabRetirementPlan(state, tabId) + const identity = getTerminalPtyOwnershipIdentity(state, requestedPtyId, plan.worktreeId) + const ptyId = plan.localOrSshPtyIds.find( + (candidate) => getTerminalPtyOwnershipIdentity(state, candidate, plan.worktreeId) === identity + ) + // Paired-runtime handles and unresolved routes cannot authorize an IPC kill. + if (!ptyId) { + return + } + const hasOtherOwner = (excludedLeafId?: string): boolean => { + const current = getState() + return ( + hasTerminalPtyOwnerOutsidePane(current, identity, tabId, excludedLeafId) || + [...getTransports().values()].some((candidate) => { + const boundId = candidate.getPtyId() + return ( + boundId !== null && + getTerminalPtyOwnershipIdentity(current, boundId, plan.worktreeId) === identity + ) + }) + ) + } + if (hasOtherOwner(leafId)) { + return + } + const retirementPlan = { + ...plan, + ptyIds: [ptyId], + localOrSshPtyIds: [ptyId], + runtimeTerminals: [], + cleanupOnlyPtyIds: [], + sharedPtyIds: [], + unroutablePtyIds: [] + } + const requestRetirement = (): void => { + startTerminalTabProviderRetirement({ + localPtyTeardownOwnedExternally: false, + remoteCloseOwnedByHost: false, + retirementPlan, + state: getState(), + tabId + }) + } + transport.destroy?.({ + onAbandonedConnect: (returnedPtyId) => { + if (returnedPtyId !== requestedPtyId) { + return false + } + // A replacement can own even this same leaf by the time the reply arrives. + if (!hasOtherOwner()) { + requestRetirement() + } + return true + } + }) + requestRetirement() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts new file mode 100644 index 00000000000..19ee58355c3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts @@ -0,0 +1,68 @@ +import type { AppState } from '@/store/types' +import { + buildTerminalTabRetirementPlan, + getTerminalPtyOwnershipIdentity +} from '@/store/slices/terminal-tab-retirement' +import { locateTerminalTab } from '@/store/terminals/terminal-tab-location' +import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import type { TerminalPaneBindingController } from './use-terminal-pane-layout-bindings' + +export function capturePendingTerminalPaneClose( + controller: Pick, + paneId: number, + getState: () => AppState +): { ptyId: string; isCurrent: () => boolean } | undefined { + const { managerRef, paneTransportsRef, tabId } = controller + const manager = managerRef.current + const transport = paneTransportsRef.current.get(paneId) + const leafId = manager?.getLeafId(paneId) + const state = getState() + const ptyId = leafId && state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + if (!manager || !transport || transport.getPtyId() || !leafId || !ptyId) { + return undefined + } + const plan = buildTerminalTabRetirementPlan(state, tabId) + const remote = parseRemoteRuntimePtyId(ptyId) + const environmentId = remote?.environmentId?.trim() + const owner = resolveTerminalHostOwnership(state, plan.worktreeId, 'teardown') + const identity = getTerminalPtyOwnershipIdentity(state, ptyId, plan.worktreeId) + const isIdentity = (id: string): boolean => + getTerminalPtyOwnershipIdentity(state, id, plan.worktreeId) === identity + if (!plan.localOrSshPtyIds.some(isIdentity)) { + return undefined + } + const originalTab = locateTerminalTab(state.tabsByWorktree, tabId)?.tab + const createdAt = originalTab?.createdAt + const generation = originalTab?.generation ?? 0 + const pairingRevision = environmentId ? getRuntimeEnvironmentRevision(environmentId) : undefined + return { + ptyId, + isCurrent: () => { + const current = getState() + const currentTab = locateTerminalTab(current.tabsByWorktree, tabId) + const currentOwner = resolveTerminalHostOwnership(current, plan.worktreeId, 'teardown') + const currentPtyId = current.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + const boundId = transport.getPtyId() + // A split confirmation cannot authorize a replacement pane or a later whole-tab close. + return ( + managerRef.current === manager && + manager.getPanes().length > 1 && + manager.getPanes().some((pane) => pane.id === paneId && pane.leafId === leafId) && + manager.getLeafId(paneId) === leafId && + paneTransportsRef.current.get(paneId) === transport && + currentTab?.worktreeId === plan.worktreeId && + currentTab?.tab.createdAt === createdAt && + (currentTab?.tab.generation ?? 0) === generation && + currentOwner.kind === owner.kind && + currentOwner.runtimeEnvironmentId === owner.runtimeEnvironmentId && + (!environmentId || getRuntimeEnvironmentRevision(environmentId) === pairingRevision) && + !!currentPtyId && + getTerminalPtyOwnershipIdentity(current, currentPtyId, plan.worktreeId) === identity && + (boundId === null || + getTerminalPtyOwnershipIdentity(current, boundId, plan.worktreeId) === identity) + ) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts index d53306b031e..0757276aa2c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts @@ -20,8 +20,10 @@ const TERMINAL_PANE_HOOK_SOURCE_PATTERN = // (last host layout leaf set, retired leaf set) (209 hooks, still 8 useMemo). // Then search match count + Cmd+F focus parity (#9035) added a `useRef` and a `useCallback` in // foundation (search input ref, focus-search-input) (211 hooks, still 8 useMemo). +// Then the pending split-close admission added one `useRef` in close-actions +// (the confirmed-close continuation) (212 hooks, still 8 useMemo). const PRE_REFACTOR_HOOK_ORDER_SHA256 = - 'ed41829b57f0723c155af1b0339513f5191e4485cd2e7b3fa2c062039afdc4e3' + 'a3ec9b9faac724605fbf8ebe6647f65b185ed0e2b2e763282159b289f0d199c6' const sourceFiles = readdirSync(__dirname) .filter((name) => TERMINAL_PANE_HOOK_SOURCE_PATTERN.test(name)) @@ -86,7 +88,7 @@ function readFlattenedHookOrder(): string[] { describe('TerminalPane refactor hook parity', () => { it('preserves the recursively flattened render hook order', () => { const hooks = readFlattenedHookOrder() - expect(hooks).toHaveLength(211) + expect(hooks).toHaveLength(212) expect(hooks.filter((hook) => hook === 'useMemo')).toHaveLength(8) expect(createHash('sha256').update(hooks.join('\n')).digest('hex')).toBe( PRE_REFACTOR_HOOK_ORDER_SHA256 diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index 330b42166cd..ea85e929e81 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -1,4 +1,4 @@ -import { useCallback, useImperativeHandle } from 'react' +import { useCallback, useImperativeHandle, useRef } from 'react' import { useAppStore } from '../../store' import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' import { makePaneKey } from '../../../../shared/stable-pane-id' @@ -13,8 +13,11 @@ import { } from './terminal-pane-tab-detach' import { clearPaneTerminalError } from './terminal-error-accumulation' import type { TerminalPaneBindingController } from './use-terminal-pane-layout-bindings' +import { retireUnboundIpcTerminalPane } from './retire-unbound-ipc-terminal-pane' +import { capturePendingTerminalPaneClose } from './terminal-pane-close-admission' export function useTerminalPaneCloseActions(controller: TerminalPaneBindingController) { + const confirmedCloseRef = useRef<(() => void) | null>(null) const { clearSessionRestoredBannerForPane, managerRef, @@ -46,6 +49,13 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr clearSessionRestoredBannerForPane(paneId) const leafId = manager.getLeafId(paneId) if (leafId) { + retireUnboundIpcTerminalPane({ + getState: useAppStore.getState, + tabId, + leafId, + transport: paneTransportsRef.current.get(paneId), + getTransports: () => paneTransportsRef.current + }) useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null) useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true }) } @@ -79,12 +89,18 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr return } const transport = paneTransportsRef.current.get(paneId) - const ptyId = transport?.getPtyId() + const pending = capturePendingTerminalPaneClose(controller, paneId, useAppStore.getState) + const ptyId = transport?.getPtyId() ?? pending?.ptyId if (!ptyId) { executeClosePane(paneId) return } const settings = useAppStore.getState().settings + const close = (): void => { + if (!pending || pending.isCurrent()) { + executeClosePane(paneId) + } + } let decided = false const decide = (act: () => void): void => { if (decided) { @@ -93,12 +109,23 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr decided = true act() } - const confirmClose = (): void => + const confirmClose = (): void => { + if (pending && !pending.isCurrent()) { + return + } + confirmedCloseRef.current = close setPendingCloseConfirmation({ paneId, copyKind: getCloseDialogCopyKind(paneId) }) - const probeTimeout = setTimeout(() => decide(confirmClose), RUNNING_CLOSE_PROBE_TIMEOUT_MS) + } + const probeTimeout = setTimeout( + () => + decide( + pending && settings?.skipCloseTerminalWithRunningProcessConfirm ? close : confirmClose + ), + RUNNING_CLOSE_PROBE_TIMEOUT_MS + ) // Why the shared probe rather than a direct inspect: this is the same question the tab-close // guard asks, and the two must not drift on what an unanswered host means. void probePtyRunningWork(settings, [ptyId], { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS }) @@ -106,10 +133,10 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr clearTimeout(probeTimeout) decide(() => { if ( - probes[0]?.verdict !== 'live' || + (pending ? probes[0]?.verdict === 'exited' : probes[0]?.verdict !== 'live') || settings?.skipCloseTerminalWithRunningProcessConfirm ) { - executeClosePane(paneId) + close() } else { confirmClose() } @@ -117,7 +144,9 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr }) .catch(() => { clearTimeout(probeTimeout) - decide(() => executeClosePane(paneId)) + decide( + pending && !settings?.skipCloseTerminalWithRunningProcessConfirm ? confirmClose : close + ) }) }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. @@ -143,22 +172,24 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr }, []) const handleConfirmClose = useCallback( (dontAskAgain: boolean) => { - if (pendingCloseConfirmation === null) { + if (pendingCloseConfirmation === null || confirmedCloseRef.current === null) { return } - const paneId = pendingCloseConfirmation.paneId + const confirmedClose = confirmedCloseRef.current + confirmedCloseRef.current = null setPendingCloseConfirmation(null) if (dontAskAgain) { void updateSettings({ skipCloseTerminalWithRunningProcessConfirm: true }) } - executeClosePane(paneId) + confirmedClose() }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. [executeClosePane, pendingCloseConfirmation, updateSettings] ) const handleCancelClose = useCallback(() => { + confirmedCloseRef.current = null setPendingCloseConfirmation(null) // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. }, []) diff --git a/src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts b/src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts new file mode 100644 index 00000000000..ee21460d8c7 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts @@ -0,0 +1,85 @@ +import { expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { + getTerminalPtyOwnershipIdentity, + hasTerminalPtyOwnerOutsidePane, + type TerminalTabRetirementState +} from './terminal-tab-retirement' + +function tab(id: string, ptyId: string | null = null): TerminalTab { + return { + id, + worktreeId: 'wt', + ptyId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} +function state(): TerminalTabRetirementState { + return { + worktreesByRepo: { repo: [{ id: 'wt', repoId: 'repo', runtimeOwnerEnvironmentId: 'env-1' }] }, + tabsByWorktree: { wt: [tab('closing', 'remote:env-1@@session')] }, + unifiedTabsByWorktree: {}, + ptyIdsByTabId: { closing: ['remote:env-1@@session'] }, + terminalLayoutsByTabId: { + closing: { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { original: 'remote:env-1@@session' } + } + }, + lastKnownRelayPtyIdByTabId: {}, + deferredSshSessionIdsByTabId: {}, + pendingReconnectPtyIdByTabId: {} + } +} + +it('ignores the closing leaf and its aggregate row, but protects a sibling legacy alias', () => { + const current = state() + const identity = getTerminalPtyOwnershipIdentity(current, 'remote:env-1@@session', 'wt') + expect(hasTerminalPtyOwnerOutsidePane(current, identity, 'closing', 'original')).toBe(false) + current.terminalLayoutsByTabId.closing.ptyIdsByLeafId = { + original: 'remote:env-1@@session', + sibling: 'remote:session' + } + expect(hasTerminalPtyOwnerOutsidePane(current, identity, 'closing', 'original')).toBe(true) +}) + +it('the late check protects replacement ownership at the same durable leaf', () => { + const current = state() + const identity = getTerminalPtyOwnershipIdentity(current, 'remote:session', 'wt') + expect(hasTerminalPtyOwnerOutsidePane(current, identity, 'closing')).toBe(true) +}) + +it.each(['row', 'layout', 'aggregate', 'relay', 'deferred', 'reconnect'] as const)( + 'protects another live tab with only %s ownership', + (source) => { + const current = state() + const id = 'remote:session' + current.tabsByWorktree.wt.push(tab('survivor', source === 'row' ? id : null)) + if (source === 'layout') { + current.terminalLayoutsByTabId.survivor = { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { leaf: id } + } + } else if (source === 'aggregate') { + current.ptyIdsByTabId.survivor = [id] + } else if (source === 'relay') { + current.lastKnownRelayPtyIdByTabId.survivor = id + } else if (source === 'deferred') { + current.deferredSshSessionIdsByTabId.survivor = id + } else if (source === 'reconnect') { + current.pendingReconnectPtyIdByTabId.survivor = id + } + const identity = getTerminalPtyOwnershipIdentity(current, 'remote:env-1@@session', 'wt') + expect(hasTerminalPtyOwnerOutsidePane(current, identity, 'closing', 'original')).toBe(true) + current.tabsByWorktree.wt.pop() + expect(hasTerminalPtyOwnerOutsidePane(current, identity, 'closing', 'original')).toBe(false) + } +) diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.ts b/src/renderer/src/store/slices/terminal-tab-retirement.ts index 80e67824699..e2e034fa6af 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts @@ -135,6 +135,30 @@ export function isTerminalTabPresent( return locateTerminalTab(state.tabsByWorktree, tabId) !== null } +export function hasTerminalPtyOwnerOutsidePane( + state: TerminalTabRetirementState, + identity: string, + tabId: string, + excludedLeafId?: string +): boolean { + for (const [ownerTabId, owner] of collectLiveTerminalTabs(state)) { + const ids = + ownerTabId === tabId + ? Object.entries(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}) + .filter(([leafId]) => leafId !== excludedLeafId) + .map(([, ptyId]) => ptyId) + : collectPtyIdsForTab(state, ownerTabId, owner.rowPtyId) + if ( + ids.some( + (ptyId) => getTerminalPtyOwnershipIdentity(state, ptyId, owner.worktreeId) === identity + ) + ) { + return true + } + } + return false +} + export function buildTerminalTabRetirementPlan( state: TerminalTabRetirementState, tabId: string From 0d7381d1f25db1ebe93746feeb2d1053e3f77d7a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:54:26 -0700 Subject: [PATCH 003/224] fix(terminal): keep an unverifiable park-reveal snapshot apart from an empty pane (#21396) On a park-reveal of a remote-runtime pty the host snapshot probe is the only structural paint (the reattach carries no relay tail). Every non-snapshot answer collapsed to null with no retry, so a host that stayed silent past the request timeout, or answered 'no-serializable-buffer' ("not proof the pane is empty"), painted the same blank pane as a host with nothing. That reads unverifiable as exited (docs/reference/ssh-execution-boundary.md). Classify the probe three ways: a host image paints; permanently-unavailable / unavailable paints nothing and asks nothing; everything that proves nothing (timeout, host declined for now, local lane gate, imageless success) paints nothing and hands off to the hidden-output restore loop, which already budgets retry-worthy answers (7 host declines / 30 local gates / 5 re-arm cycles), repaints from the host on success, and ends in the explicit loss banner. The reveal's own probe is charged to that same budget, so the bound is shared, not doubled. No structural clear is issued on the unverifiable path, so whatever the layout replay painted from the client's own copy stays visible. --- .../hidden-output-restore-limits.ts | 5 + .../hidden-output-restore-request.ts | 14 +- .../hidden-output-snapshot-serialize.ts | 13 + .../park-reveal-snapshot-verdict.test.ts | 183 ++++++++++ .../park-reveal-snapshot-verdict.ts | 120 +++++++ ...result-handler-park-reveal-verdict.test.ts | 335 ++++++++++++++++++ .../pty-connection/reattach-result-handler.ts | 36 +- .../pty-connection/run-deferred-connect.ts | 2 + ...ote-park-reveal-unverifiable-retry.test.ts | 279 +++++++++++++++ .../terminal-snapshot-unavailability.ts | 9 +- 10 files changed, 982 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-park-reveal-unverifiable-retry.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts index 80ae6078f20..85336952549 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts @@ -35,3 +35,8 @@ export const HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS = 30 // terminal state is unavailable, so the user has an explicit loss signal. export const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = '\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n' +// Why distinct from the warning above: that one closes a bounded retry the host kept declining +// (transient); this one reports a host that answered and can never produce the buffer for this +// request, so a blank or stale pane is not mistaken for an empty terminal. +export const PARK_REVEAL_NO_HOST_IMAGE_WARNING = + "\r\n[Orca could not restore this terminal's history from its host.]\r\n" diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts index 08946421c5d..e89c14f432a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts @@ -17,10 +17,12 @@ import { } from './hidden-output-restore-limits' import { shouldWritePtyOutputForeground } from './foreground-output-scan' import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' -import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import type { ConnectPanePtySession } from './connect-pane-pty-session' -import type { HiddenOutputSnapshotResult } from './hidden-output-snapshot-serialize' +import { + classifyHiddenOutputSnapshotReject, + type HiddenOutputSnapshotResult +} from './hidden-output-snapshot-serialize' export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession): void { session.requestHiddenOutputRestoreIfNeeded = function (opts?: { @@ -138,13 +140,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession): ) }) } catch { - snapshotResult = - !isRemoteRuntimePtyId(currentPtyId) || - session.hiddenOutputRestoreLegacyPtyId === currentPtyId || - typeof session.transport.serializeBufferOutcome !== 'function' - ? { kind: 'unavailable' } - : // Why 'host': the only reject here is the request timeout — the frame went out and the host stayed silent. - { kind: 'retry-worthy', source: 'host' } + snapshotResult = classifyHiddenOutputSnapshotReject(session, currentPtyId) } if (session.disposed) { return diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts index 8629163d09e..e44c7a1661b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts @@ -3,6 +3,7 @@ import { onTerminalScrollIntentFollowOutput } from '@/lib/pane-manager/terminal- import { shouldWritePtyOutputForeground } from './foreground-output-scan' import { readE2eHiddenSnapshotOverride } from './e2e-terminal-pty-harness' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import type { PtyBufferSnapshot } from '../pty-transport' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -14,6 +15,18 @@ export type HiddenOutputSnapshotResult = | { kind: 'unknown-legacy-host' } | { kind: 'unavailable' } +/** Why 'host': on a modern remote transport the only reject is the request timeout — the frame went out and the host stayed silent. Elsewhere local main answered nothing. */ +export function classifyHiddenOutputSnapshotReject( + session: ConnectPanePtySession, + ptyId: string +): HiddenOutputSnapshotResult { + return !isRemoteRuntimePtyId(ptyId) || + session.hiddenOutputRestoreLegacyPtyId === ptyId || + typeof session.transport.serializeBufferOutcome !== 'function' + ? { kind: 'unavailable' } + : { kind: 'retry-worthy', source: 'host' } +} + export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession): void { session.serializeHiddenOutputSnapshot = async function ( ptyId: string, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.test.ts new file mode 100644 index 00000000000..6b3c263c814 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + bindParkRevealSnapshotVerdictActions, + classifyParkRevealSnapshot +} from './park-reveal-snapshot-verdict' +import { PARK_REVEAL_NO_HOST_IMAGE_WARNING } from './hidden-output-restore-limits' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +const writeTerminalOutput = vi.hoisted(() => vi.fn()) + +vi.mock('../terminal-freeze-breadcrumbs', () => ({ recordTerminalFreezeBreadcrumb: vi.fn() })) +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ writeTerminalOutput })) + +const IMAGE = { data: 'PROMPT $ ', cols: 80, rows: 24, seq: 7, source: 'headless' as const } +const REMOTE_PTY_ID = 'remote:env-1@@pty-1' +const LOCAL_PTY_ID = 'wt-1@@local-pty-1' + +describe('classifyParkRevealSnapshot', () => { + it('reads a non-empty host image as positive evidence', () => { + expect( + classifyParkRevealSnapshot({ kind: 'snapshot', snapshot: IMAGE }, REMOTE_PTY_ID) + ).toEqual({ + kind: 'host-snapshot', + snapshot: IMAGE + }) + }) + + it('reads an alt-screen image as positive evidence even when its data is empty', () => { + const snapshot = { ...IMAGE, data: '', alternateScreen: true } + expect(classifyParkRevealSnapshot({ kind: 'snapshot', snapshot }, REMOTE_PTY_ID)).toEqual({ + kind: 'host-snapshot', + snapshot + }) + }) + + // A remote host's fallback serializer answers `data: ''` with no `unavailable` reason before + // its pane has hydrated. applyMainBufferSnapshot already refuses to paint that; so must the reveal. + it('reads a remote imageless success as unverifiable, charged to the host budget', () => { + expect( + classifyParkRevealSnapshot( + { kind: 'snapshot', snapshot: { ...IMAGE, data: '' } }, + REMOTE_PTY_ID + ) + ).toEqual({ kind: 'unverifiable', ledger: 'host' }) + }) + + it('reads an empty local-main model as positive evidence: local main is the execution host', () => { + const snapshot = { ...IMAGE, data: '' } + expect(classifyParkRevealSnapshot({ kind: 'snapshot', snapshot }, LOCAL_PTY_ID)).toEqual({ + kind: 'host-snapshot', + snapshot + }) + }) + + it.each([ + ['host silent past the request timeout', 'host'], + ['request lane gated before any frame left the client', 'local'] + ] as const)('reads retry-worthy (%s) as unverifiable on the %s ledger', (_label, source) => { + expect(classifyParkRevealSnapshot({ kind: 'retry-worthy', source }, REMOTE_PTY_ID)).toEqual({ + kind: 'unverifiable', + ledger: source + }) + }) + + it('reads a legacy host empty reply as unverifiable with no ledger charge', () => { + expect(classifyParkRevealSnapshot({ kind: 'unknown-legacy-host' }, REMOTE_PTY_ID)).toEqual({ + kind: 'unverifiable', + ledger: null + }) + }) + + // Retention is never inferred from image content: this arm is reachable only from an + // explicit host answer, and it carries which one so a caller need not re-derive it. + it.each(['permanently-unavailable', 'unavailable'] as const)( + 'reads %s as no host image, carrying the answer that closed the door', + (kind) => { + expect(classifyParkRevealSnapshot({ kind }, REMOTE_PTY_ID)).toEqual({ + kind: 'no-host-image', + reason: kind + }) + } + ) +}) + +function buildSession(overrides: Record = {}): ConnectPanePtySession { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a deliberately partial session bag; only the members the verdict actions touch exist, so an unexpected access fails the test. + const session = { + disposed: false, + transport: { getPtyId: () => REMOTE_PTY_ID }, + pane: { terminal: { id: 'xterm-1' } }, + beforeTerminalOutputWrite: vi.fn(), + canUseHiddenOutputSnapshot: () => true, + hiddenOutputRestoreRemoteOutcomeAttempts: 0, + hiddenOutputRestoreLocalGateAttempts: 0, + markHiddenOutputRestoreNeeded: vi.fn(), + ...overrides + } as unknown as ConnectPanePtySession + bindParkRevealSnapshotVerdictActions(session) + return session +} + +describe('warnParkRevealNoHostImage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes the no-host-image banner into a remote pane, pen reset, no CAN byte', () => { + const session = buildSession() + + expect(session.warnParkRevealNoHostImage(REMOTE_PTY_ID, 'unavailable')).toBe(true) + + expect(writeTerminalOutput).toHaveBeenCalledExactlyOnceWith( + session.pane.terminal, + `\x1b[0m${PARK_REVEAL_NO_HOST_IMAGE_WARNING}`, + { foreground: true, beforeWrite: session.beforeTerminalOutputWrite } + ) + expect(writeTerminalOutput.mock.calls[0]?.[1]).not.toContain('\x18') + }) + + it('stays silent for a local pane, whose layout copy is never released', () => { + const session = buildSession({ transport: { getPtyId: () => LOCAL_PTY_ID } }) + + expect(session.warnParkRevealNoHostImage(LOCAL_PTY_ID, 'unavailable')).toBe(false) + + expect(writeTerminalOutput).not.toHaveBeenCalled() + }) + + it('does not banner a pane whose transport has moved on', () => { + const session = buildSession({ transport: { getPtyId: () => 'remote:env-1@@pty-2' } }) + + expect(session.warnParkRevealNoHostImage(REMOTE_PTY_ID, 'permanently-unavailable')).toBe(false) + + expect(writeTerminalOutput).not.toHaveBeenCalled() + }) +}) + +describe('retryUnverifiableParkRevealSnapshot', () => { + it('charges the reveal probe to the host budget and hands off to the restore loop', () => { + const session = buildSession() + + expect(session.retryUnverifiableParkRevealSnapshot('remote:env-1@@pty-1', 'host')).toBe(true) + + expect(session.hiddenOutputRestoreRemoteOutcomeAttempts).toBe(1) + expect(session.hiddenOutputRestoreLocalGateAttempts).toBe(0) + expect(session.markHiddenOutputRestoreNeeded).toHaveBeenCalledOnce() + }) + + it('charges a local gate to the local budget only', () => { + const session = buildSession() + + session.retryUnverifiableParkRevealSnapshot('remote:env-1@@pty-1', 'local') + + expect(session.hiddenOutputRestoreRemoteOutcomeAttempts).toBe(0) + expect(session.hiddenOutputRestoreLocalGateAttempts).toBe(1) + expect(session.markHiddenOutputRestoreNeeded).toHaveBeenCalledOnce() + }) + + it('charges nothing for a legacy host but still hands off', () => { + const session = buildSession() + + session.retryUnverifiableParkRevealSnapshot('remote:env-1@@pty-1', null) + + expect(session.hiddenOutputRestoreRemoteOutcomeAttempts).toBe(0) + expect(session.hiddenOutputRestoreLocalGateAttempts).toBe(0) + expect(session.markHiddenOutputRestoreNeeded).toHaveBeenCalledOnce() + }) + + it.each([ + [ + 'the transport moved to another PTY', + { transport: { getPtyId: () => 'remote:env-1@@pty-2' } } + ], + ['the session is disposed', { disposed: true }], + ['no hidden snapshot source exists', { canUseHiddenOutputSnapshot: () => false }] + ])('does nothing when %s', (_label, overrides) => { + const session = buildSession(overrides) + + expect(session.retryUnverifiableParkRevealSnapshot('remote:env-1@@pty-1', 'host')).toBe(false) + + expect(session.hiddenOutputRestoreRemoteOutcomeAttempts).toBe(0) + expect(session.markHiddenOutputRestoreNeeded).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.ts b/src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.ts new file mode 100644 index 00000000000..5bfa786d3bf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.ts @@ -0,0 +1,120 @@ +import { RESET_GRAPHIC_RENDITION } from '../../../../../shared/terminal-mode-reset-profiles' +import { redactPtyIdForDiagnostics } from '../../../../../shared/pty-delivery-diagnostics' +import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs' +import type { PtyBufferSnapshot } from '../pty-transport' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { PARK_REVEAL_NO_HOST_IMAGE_WARNING } from './hidden-output-restore-limits' +import type { HiddenOutputSnapshotResult } from './hidden-output-snapshot-serialize' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +/** Which restore budget an unverifiable probe is charged to; null when the loop keeps its own count (legacy hosts). */ +export type ParkRevealRetryLedger = 'host' | 'local' | null + +/** The explicit answer that closed the door: only these two ever reach `no-host-image`. */ +export type ParkRevealNoHostImageReason = 'permanently-unavailable' | 'unavailable' + +/** + * What a park-reveal's host snapshot probe proved. Three verdicts, no synonyms + * (docs/reference/ssh-execution-boundary.md): only `host-snapshot` is positive + * evidence of the pane's contents, and a probe that proves nothing must never + * be read as "the pane is empty". Retention is never inferred from image + * content: `no-host-image` is reachable only from an explicit host answer. + */ +export type ParkRevealSnapshotVerdict = + | { kind: 'host-snapshot'; snapshot: PtyBufferSnapshot } + /** Timeout, host declined for now, local request-lane gate, or an imageless success. */ + | { kind: 'unverifiable'; ledger: ParkRevealRetryLedger } + /** The host answered, and repeating this request can never yield the buffer. */ + | { kind: 'no-host-image'; reason: ParkRevealNoHostImageReason } + +export function classifyParkRevealSnapshot( + result: HiddenOutputSnapshotResult, + ptyId: string +): ParkRevealSnapshotVerdict { + switch (result.kind) { + case 'snapshot': { + const { snapshot } = result + // Why remote-only: a remote host's fallback serializer can answer `data: ''` before its + // pane has hydrated, so an imageless success proves nothing there (applyMainBufferSnapshot + // refuses it too) and painting it would clear the client's own copy. Local main is the + // execution host for a local pty; its empty model is the positive answer. + const carriesNoImage = + isRemoteRuntimePtyId(ptyId) && + snapshot.alternateScreen !== true && + snapshot.data === '' && + !snapshot.scrollbackAnsi + return carriesNoImage + ? { kind: 'unverifiable', ledger: 'host' } + : { kind: 'host-snapshot', snapshot } + } + case 'retry-worthy': + return { kind: 'unverifiable', ledger: result.source } + case 'unknown-legacy-host': + return { kind: 'unverifiable', ledger: null } + case 'permanently-unavailable': + case 'unavailable': + return { kind: 'no-host-image', reason: result.kind } + } +} + +export function bindParkRevealSnapshotVerdictActions(session: ConnectPanePtySession): void { + // Why a hand-off, not a loop: the hidden-output restore loop already budgets + // retry-worthy answers (7 host declines / 30 local gates / 5 re-arm cycles), + // repaints from the host on success, and ends in an explicit loss banner. + // A second loop here would double every bound. + session.retryUnverifiableParkRevealSnapshot = function ( + ptyId: string, + ledger: ParkRevealRetryLedger + ): boolean { + if ( + session.disposed || + session.transport.getPtyId() !== ptyId || + !session.canUseHiddenOutputSnapshot(ptyId) + ) { + return false + } + // Charge the reveal's own probe so the shared budget counts it: 1 + 6 = 7 host requests. + if (ledger === 'host') { + session.hiddenOutputRestoreRemoteOutcomeAttempts += 1 + } else if (ledger === 'local') { + session.hiddenOutputRestoreLocalGateAttempts += 1 + } + recordTerminalFreezeBreadcrumb('park-reveal-unverifiable', { + id: redactPtyIdForDiagnostics(ptyId), + ledger: ledger ?? 'none' + }) + session.markHiddenOutputRestoreNeeded() + return true + } + + // Unavoidable loss must be visible: a pane the host could not image must not + // look like an empty terminal. Remote-only because local main is the + // execution host and a local pane's layout copy is never released, so the + // pane already shows everything there is. + session.warnParkRevealNoHostImage = function ( + ptyId: string, + reason: ParkRevealNoHostImageReason + ): boolean { + recordTerminalFreezeBreadcrumb('park-reveal-no-host-image', { + id: redactPtyIdForDiagnostics(ptyId), + reason + }) + if ( + !isRemoteRuntimePtyId(ptyId) || + session.disposed || + session.transport.getPtyId() !== ptyId + ) { + return false + } + // Why no CAN byte here: the subscribe-time push snapshot may have left a pending escape + // tail for the next live chunk to complete; only the pen is reset. + writeTerminalOutput( + session.pane.terminal, + `${RESET_GRAPHIC_RENDITION}${PARK_REVEAL_NO_HOST_IMAGE_WARNING}`, + { foreground: true, beforeWrite: session.beforeTerminalOutputWrite } + ) + return true + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts new file mode 100644 index 00000000000..86237ac8f21 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts @@ -0,0 +1,335 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { bindHandleReattachResult } from './reattach-result-handler' +import { bindSerializeHiddenOutputSnapshot } from './hidden-output-snapshot-serialize' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { HiddenOutputSnapshotResult } from './hidden-output-snapshot-serialize' +import type { ReattachPayloadContext } from './reattach-payload-context' + +/** + * A park-reveal of a remote-runtime pty arrives with `replay: ''`, so the host + * snapshot probe is the ONLY structural paint. The probe has three answers and + * the handler must keep them apart (docs/reference/ssh-execution-boundary.md): + * + * host image → paint it (the structural clear + repaint) + * unverifiable → paint nothing AND re-ask the host, bounded + * no host image → paint nothing, ask nothing + * + * These tests assert on the decision the handler hands to the payload and the + * retry it arms, not on what the pane looks like: a live host also pushes a + * retained tail at subscribe time, so the rendered pane can look right whether + * or not the decision was. + */ +const REMOTE_PTY_ID = 'remote:env-1@@pty-1' +const HOST_IMAGE = { data: 'PROMPT $ ', cols: 80, rows: 24, seq: 7, source: 'headless' as const } + +const mocks = vi.hoisted(() => { + const state: Record = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + const capturedContexts: ReattachPayloadContext[] = [] + const callOrder: string[] = [] + return { state, capturedContexts, callOrder } +}) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mocks.state } +})) +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep: vi.fn() })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +vi.mock('../terminal-freeze-breadcrumbs', () => ({ recordTerminalFreezeBreadcrumb: vi.fn() })) +// Why: the payload handlers need a mounted xterm; what matters here is the +// context the handler decided on, so capture it and report the payload applied. +vi.mock('./apply-reattach-payload', () => ({ + createReattachPayloadHandlers: (_session: unknown, ctx: ReattachPayloadContext) => { + mocks.capturedContexts.push(ctx) + return { + applyReattachPayload: async () => { + mocks.callOrder.push('applyReattachPayload') + ctx.reattachPayloadApplied = true + }, + fitAfterReattachRestore: async () => { + mocks.callOrder.push('fitAfterReattachRestore') + } + } + } +})) + +type Bag = { + session: ConnectPanePtySession + transport: Record + /** The pane-transport registry, keyed by pane id; the bag is deliberately untyped. */ + paneTransports: Map + retryUnverifiableParkRevealSnapshot: ReturnType + warnParkRevealNoHostImage: ReturnType + structuralRun: ReturnType +} + +function buildParkRevealSession(overrides: Record = {}): Bag { + const transport: Record = { + getPtyId: () => REMOTE_PTY_ID, + disconnect: vi.fn(), + serializeBuffer: vi.fn() + } + const paneTransports = new Map([['pane-1', transport]]) + const warnParkRevealNoHostImage = vi.fn(() => { + mocks.callOrder.push('warnParkRevealNoHostImage') + return true + }) + const retryUnverifiableParkRevealSnapshot = vi.fn(() => { + mocks.callOrder.push('retryUnverifiableParkRevealSnapshot') + return true + }) + const structuralRun = vi.fn( + async ( + task: () => Promise, + opts?: { afterRestore?: () => Promise } + ): Promise => { + await task() + await opts?.afterRestore?.() + } + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a deliberately partial session bag; only the members the park-reveal decision touches exist, so an unexpected access fails the test. + const session = { + transport, + disposed: false, + transportStreamGeneration: 0, + authoritativeReattachGeneration: 0, + // The reveal remount of a parked pane; consume-once in the handler. + mountFollowsTerminalPark: true, + followsDirectSshReconnect: false, + connectionId: null, + cacheKey: 'cache-1', + directSshRetryAttempt: undefined, + capturedDirectSshRetryPtyAccepted: false, + pane: { id: 'pane-1', leafId: 'leaf-1', terminal: { options: { scrollback: 1000 } } }, + deps: { + tabId: 'tab-1', + worktreeId: 'wt-1', + paneTransportsRef: { current: paneTransports }, + isVisibleRef: { current: true }, + clearTabPtyId: vi.fn(), + updateTabPtyId: vi.fn(), + restoredLeafId: null + }, + agentCompletionCoordinator: { startProcessTracking: vi.fn() }, + structuralReplayCoordinator: { run: structuralRun }, + getSshMainModelSnapshotProbe: () => async () => null, + serializeHiddenOutputSnapshot: vi.fn(), + retryUnverifiableParkRevealSnapshot, + warnParkRevealNoHostImage, + rejectObsoleteDirectSshReattach: () => false, + registerEffectiveLaunchConfig: vi.fn(), + clearExitedPanePtyLayoutBinding: vi.fn(), + syncPanePtyLayoutBinding: vi.fn(), + startFreshColdRestoreAgentResume: vi.fn(), + setPanePtyFitBinding: vi.fn(), + reportPanePtyVisibility: vi.fn(), + registerSideEffectFactConsumerForPty: vi.fn(), + syncHiddenRendererPtyDelivery: vi.fn(), + registerPaneSerializerFor: vi.fn(), + sampleVisiblePaneForegroundAgent: vi.fn(), + scheduleReattachIdleAgentCursorReset: vi.fn(), + settlePaneAttachAttempt: vi.fn(), + ...overrides + } as unknown as ConnectPanePtySession + bindHandleReattachResult(session) + return { + session, + transport, + paneTransports, + retryUnverifiableParkRevealSnapshot, + warnParkRevealNoHostImage, + structuralRun + } +} + +/** The remote transport's park-reveal result: a bare reattach with no relay tail. */ +const PARK_REVEAL_RESULT = { id: REMOTE_PTY_ID, replay: '', isReattach: true } + +function probeAnswers(result: HiddenOutputSnapshotResult): ReturnType { + return vi.fn(async () => result) +} + +function decidedContext(): ReattachPayloadContext { + expect(mocks.capturedContexts).toHaveLength(1) + return mocks.capturedContexts[0]! +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + mocks.capturedContexts = [] + mocks.callOrder = [] +}) + +describe('handleReattachResult park-reveal snapshot verdict', () => { + it('paints a host image and arms no retry', async () => { + const bag = buildParkRevealSession({ + serializeHiddenOutputSnapshot: probeAnswers({ kind: 'snapshot', snapshot: HOST_IMAGE }) + }) + + await expect(bag.session.handleReattachResult(PARK_REVEAL_RESULT)).resolves.toBe(true) + + const ctx = decidedContext() + expect(ctx.prefetchedParkModelSnapshot).toBe(HOST_IMAGE) + expect(ctx.shouldApplyStructuralPayload).toBe(true) + expect(bag.structuralRun).toHaveBeenCalledOnce() + expect(bag.retryUnverifiableParkRevealSnapshot).not.toHaveBeenCalled() + }) + + // The defect: a host that stayed silent past the request timeout used to + // collapse to the same null as "nothing to paint" and was never asked again. + it('does not paint blank when the host stayed silent, and re-asks it', async () => { + const bag = buildParkRevealSession({ + serializeHiddenOutputSnapshot: probeAnswers({ kind: 'retry-worthy', source: 'host' }) + }) + + await expect(bag.session.handleReattachResult(PARK_REVEAL_RESULT)).resolves.toBe(true) + + const ctx = decidedContext() + expect(ctx.prefetchedParkModelSnapshot).toBeNull() + // No structural transaction means no `\x1b[2J\x1b[3J` clear over the client's own copy. + expect(ctx.shouldApplyStructuralPayload).toBe(false) + expect(bag.structuralRun).not.toHaveBeenCalled() + expect(bag.retryUnverifiableParkRevealSnapshot).toHaveBeenCalledExactlyOnceWith( + REMOTE_PTY_ID, + 'host' + ) + // The retry's own repaint must queue behind this attempt, never nest inside it. + expect(mocks.callOrder).toEqual([ + 'applyReattachPayload', + 'fitAfterReattachRestore', + 'retryUnverifiableParkRevealSnapshot' + ]) + }) + + it('treats a rejected probe on a modern remote transport as the host staying silent', async () => { + const bag = buildParkRevealSession({ + hiddenOutputRestoreLegacyPtyId: null, + serializeHiddenOutputSnapshot: vi.fn(async () => { + throw new Error('Remote terminal snapshot timed out.') + }) + }) + bag.transport.serializeBufferOutcome = vi.fn() + + await expect(bag.session.handleReattachResult(PARK_REVEAL_RESULT)).resolves.toBe(true) + + expect(decidedContext().prefetchedParkModelSnapshot).toBeNull() + expect(bag.retryUnverifiableParkRevealSnapshot).toHaveBeenCalledExactlyOnceWith( + REMOTE_PTY_ID, + 'host' + ) + }) + + // Through the real serializer: the host answered `unavailable: 'no-serializable-buffer'`, + // whose own host-side comment reads "not proof the pane is empty". + it("re-asks a host that answered 'no-serializable-buffer' instead of painting blank", async () => { + const bag = buildParkRevealSession({ + canUseMainBufferSnapshot: () => false, + hiddenOutputRestoreLegacyPtyId: null + }) + bag.transport.serializeBufferOutcome = vi.fn(async () => ({ + availability: { kind: 'retry-worthy', cause: 'host-no-serializable-buffer' }, + snapshot: null + })) + bindSerializeHiddenOutputSnapshot(bag.session) + + await expect(bag.session.handleReattachResult(PARK_REVEAL_RESULT)).resolves.toBe(true) + + expect(bag.transport.serializeBufferOutcome).toHaveBeenCalledOnce() + const ctx = decidedContext() + expect(ctx.prefetchedParkModelSnapshot).toBeNull() + expect(ctx.shouldApplyStructuralPayload).toBe(false) + expect(bag.retryUnverifiableParkRevealSnapshot).toHaveBeenCalledExactlyOnceWith( + REMOTE_PTY_ID, + 'host' + ) + }) + + it('does not paint an imageless success over the client copy, and re-asks', async () => { + const bag = buildParkRevealSession({ + serializeHiddenOutputSnapshot: probeAnswers({ + kind: 'snapshot', + snapshot: { ...HOST_IMAGE, data: '' } + }) + }) + + await expect(bag.session.handleReattachResult(PARK_REVEAL_RESULT)).resolves.toBe(true) + + expect(decidedContext().prefetchedParkModelSnapshot).toBeNull() + expect(bag.structuralRun).not.toHaveBeenCalled() + expect(bag.retryUnverifiableParkRevealSnapshot).toHaveBeenCalledExactlyOnceWith( + REMOTE_PTY_ID, + 'host' + ) + }) + + it('charges a local request-lane gate to the local budget', async () => { + const bag = buildParkRevealSession({ + serializeHiddenOutputSnapshot: probeAnswers({ kind: 'retry-worthy', source: 'local' }) + }) + + await bag.session.handleReattachResult(PARK_REVEAL_RESULT) + + expect(bag.retryUnverifiableParkRevealSnapshot).toHaveBeenCalledExactlyOnceWith( + REMOTE_PTY_ID, + 'local' + ) + }) + + // Unavoidable loss, made visible: the host answered and cannot produce the buffer, so the + // pane must say so rather than pass for an empty terminal — and never ask again. + it.each(['permanently-unavailable', 'unavailable'] as const)( + 'paints nothing, asks nothing, and banners the loss when the host answered %s', + async (kind) => { + const bag = buildParkRevealSession({ serializeHiddenOutputSnapshot: probeAnswers({ kind }) }) + + await expect(bag.session.handleReattachResult(PARK_REVEAL_RESULT)).resolves.toBe(true) + + const ctx = decidedContext() + expect(ctx.prefetchedParkModelSnapshot).toBeNull() + expect(ctx.shouldApplyStructuralPayload).toBe(false) + expect(bag.retryUnverifiableParkRevealSnapshot).not.toHaveBeenCalled() + expect(bag.warnParkRevealNoHostImage).toHaveBeenCalledExactlyOnceWith(REMOTE_PTY_ID, kind) + expect(mocks.callOrder).toEqual([ + 'applyReattachPayload', + 'fitAfterReattachRestore', + 'warnParkRevealNoHostImage' + ]) + } + ) + + it.each([ + ['a host image', { kind: 'snapshot', snapshot: HOST_IMAGE }], + ['an unverifiable answer', { kind: 'retry-worthy', source: 'host' }] + ] as const)('does not banner the loss on %s', async (_label, result) => { + const bag = buildParkRevealSession({ serializeHiddenOutputSnapshot: probeAnswers(result) }) + + await bag.session.handleReattachResult(PARK_REVEAL_RESULT) + + expect(bag.warnParkRevealNoHostImage).not.toHaveBeenCalled() + }) + + it('arms no retry for an attempt a remount superseded while the probe was in flight', async () => { + const bag = buildParkRevealSession() + bag.session.serializeHiddenOutputSnapshot = vi.fn(async () => { + // A successor mount registered its own transport before the probe settled. + bag.paneTransports.set('pane-1', { getPtyId: () => REMOTE_PTY_ID }) + return { kind: 'retry-worthy', source: 'host' } + }) + + await expect(bag.session.handleReattachResult(PARK_REVEAL_RESULT)).resolves.toBe(false) + + expect(mocks.capturedContexts).toHaveLength(0) + expect(bag.retryUnverifiableParkRevealSnapshot).not.toHaveBeenCalled() + }) + + it('probes only the first reattach of a reveal remount', async () => { + const probe = probeAnswers({ kind: 'retry-worthy', source: 'host' }) + const bag = buildParkRevealSession({ serializeHiddenOutputSnapshot: probe }) + + await bag.session.handleReattachResult(PARK_REVEAL_RESULT) + await bag.session.handleReattachResult(PARK_REVEAL_RESULT) + + expect(probe).toHaveBeenCalledOnce() + expect(bag.retryUnverifiableParkRevealSnapshot).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index d485e526631..cbef66a0cfb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -19,6 +19,15 @@ import type { ReattachPayloadContext } from './reattach-payload-context' import { createReattachPayloadHandlers } from './apply-reattach-payload' import type { ReattachPayloadSession } from './reattach-payload-session' import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery' +import { + classifyHiddenOutputSnapshotReject, + type HiddenOutputSnapshotResult +} from './hidden-output-snapshot-serialize' +import { + classifyParkRevealSnapshot, + type ParkRevealNoHostImageReason, + type ParkRevealRetryLedger +} from './park-reveal-snapshot-verdict' type ReattachResultSession = ReattachPayloadSession & Pick< @@ -42,7 +51,9 @@ type ReattachResultSession = ReattachPayloadSession & | 'registerSideEffectFactConsumerForPty' | 'rejectObsoleteDirectSshReattach' | 'reportPanePtyVisibility' + | 'retryUnverifiableParkRevealSnapshot' | 'sampleVisiblePaneForegroundAgent' + | 'warnParkRevealNoHostImage' | 'scheduleReattachIdleAgentCursorReset' | 'serializeHiddenOutputSnapshot' | 'settlePaneAttachAttempt' @@ -280,19 +291,32 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi // host snapshot before releasing queued live bytes; null falls back to // the subscribe screen without keeping the old xterm mounted. let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null + // Why kept apart from null: null means "paint nothing", never "the pane is + // empty". A probe that proved nothing (timeout, host declined for now) must + // also re-ask the host, bounded, once the payload has settled. + let unverifiableParkRevealLedger: ParkRevealRetryLedger | undefined + let noHostImageReason: ParkRevealNoHostImageReason | undefined if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) { if (parseAppSshPtyId(ptyId)) { prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot() } else { + let result: HiddenOutputSnapshotResult try { - const result = await session.serializeHiddenOutputSnapshot(ptyId, { + result = await session.serializeHiddenOutputSnapshot(ptyId, { scrollbackRows: resolveHiddenRestoreScrollbackRows( session.pane.terminal.options.scrollback ) }) - prefetchedParkModelSnapshot = result.kind === 'snapshot' ? result.snapshot : null } catch { - prefetchedParkModelSnapshot = null + result = classifyHiddenOutputSnapshotReject(sessionBag, ptyId) + } + const verdict = classifyParkRevealSnapshot(result, ptyId) + if (verdict.kind === 'host-snapshot') { + prefetchedParkModelSnapshot = verdict.snapshot + } else if (verdict.kind === 'unverifiable') { + unverifiableParkRevealLedger = verdict.ledger + } else { + noHostImageReason = verdict.reason } } if (!isCurrentReattachPayload()) { @@ -332,6 +356,12 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi if (!isCurrentReattachPayload() || !reattachPayload.reattachPayloadApplied) { return false } + if (unverifiableParkRevealLedger !== undefined) { + // After the payload, so the retry's own structural repaint queues behind this attempt instead of nesting in it. + session.retryUnverifiableParkRevealSnapshot(ptyId, unverifiableParkRevealLedger) + } else if (noHostImageReason !== undefined) { + session.warnParkRevealNoHostImage(ptyId, noHostImageReason) + } session.scheduleReattachIdleAgentCursorReset() scheduleRuntimeGraphSync() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts index 3dc1c3aaef5..b02b9dfcef1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -24,6 +24,7 @@ import { bindSettlePaneSerializer } from './pane-serializer-settle' import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot' import { bindHiddenOutputSeqAndSkip } from './hidden-output-seq-and-skip' import { bindHiddenRestoreStateAndSshProbe } from './hidden-restore-state-and-ssh-probe' +import { bindParkRevealSnapshotVerdictActions } from './park-reveal-snapshot-verdict' export function installRunDeferredConnect(session: ConnectPanePtySession): void { const cwdPromise = session.deps.cwdPromise @@ -193,6 +194,7 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void bindHiddenRestoreStateAndSshProbe(session) bindPrepaintParkedSshSnapshot(session) + bindParkRevealSnapshotVerdictActions(session) bindHandleReattachResult(session) runDeferredSessionAttach(session) } diff --git a/src/renderer/src/components/terminal-pane/remote-park-reveal-unverifiable-retry.test.ts b/src/renderer/src/components/terminal-pane/remote-park-reveal-unverifiable-retry.test.ts new file mode 100644 index 00000000000..60e7079ec94 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-park-reveal-unverifiable-retry.test.ts @@ -0,0 +1,279 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { createDeferred, flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager, + type MockPane, + type MockPaneManager, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps, type PaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +/** + * End to end through connectPanePty: a parked remote-runtime pane is revealed, + * the host answers its snapshot probe with `unavailable: 'no-serializable-buffer'` + * ("not proof the pane is empty", per the host's own comment), and the pane must + * keep asking on the hidden-output restore loop's budget rather than paint blank + * once and go quiet. The request count and the loss banner are the oracle: with + * the reveal collapsing the answer to null, the count stays at one forever and + * the banner never appears. + */ +const REMOTE_PTY_ID = 'remote:env-1@@pty-1' +const HOST_IMAGE_MARKER = 'HOST-IMAGE-AFTER-PARK' +const BANNER_FRAGMENT = 'main recovery was unavailable' +const STRUCTURAL_CLEAR = '\x1b[2J\x1b[3J' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { + info: toastInfo + } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +function nextQueuedTransport(): MockTransport { + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport +} + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn(() => nextQueuedTransport()) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn(() => nextQueuedTransport()) +})) + +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) +} + +/** The host advertised paired parking while it was reachable, so the reveal reattaches its pty. */ +function pairedParkingHostStatus(): StoreState['runtimeStatusByEnvironmentId'] { + return new Map([ + [ + 'env-1', + { checkedAt: 1, status: { capabilities: [TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY] } } + ] + ]) +} + +type ConnectMockPane = ( + pane: MockPane, + manager: MockPaneManager, + deps: PaneConnectionDeps +) => { dispose: () => void } + +async function revealParkedRemotePane( + serializeBufferOutcome: ReturnType +): Promise<{ transport: MockTransport; writes: string[]; dispose: () => void }> { + const { connectPanePty } = await import('./pty-connection') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared mock pane, manager, and deps stand in for the real xterm-backed objects, exactly as every other connectPanePty suite drives them. + const connectMockPane = connectPanePty as unknown as ConnectMockPane + const transport = createMockTransport() + // The remote transport's park-reveal result: a bare reattach with no relay tail. + transport.connect.mockImplementation(async () => { + transport.getPtyId.mockReturnValue(REMOTE_PTY_ID) + return { id: REMOTE_PTY_ID, replay: '', isReattach: true } + }) + transport.serializeBuffer = vi.fn() + transport.serializeBufferOutcome = serializeBufferOutcome + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: REMOTE_PTY_ID }] }, + ptyIdsByTabId: { 'tab-1': [REMOTE_PTY_ID] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: REMOTE_PTY_ID } + } + }, + runtimeStatusByEnvironmentId: pairedParkingHostStatus() + } + const pane = createPane(1) + const { writes } = captureCallbackTerminalWrites(pane) + // Why the active pane: an inactive split defers its restore to the frame scheduler. + const binding = connectMockPane( + pane, + createManager(1, 1), + createDeps({ + mountFollowsTerminalPark: true, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: REMOTE_PTY_ID } + }) + ) + await flushAsyncTicks(30) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: REMOTE_PTY_ID }) + ) + return { transport, writes, dispose: () => binding.dispose() } +} + +function bannerCount(writes: string[]): number { + return writes.filter((data) => data.includes(BANNER_FRAGMENT)).length +} + +describe('parked remote pane reveal with an unverifiable host snapshot', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + vi.useRealTimers() + await restoreTerminalTestGlobals() + }) + + it("keeps asking a host that answered 'no-serializable-buffer' and banners once the budget is spent", async () => { + const declined = { + availability: { kind: 'retry-worthy', cause: 'host-no-serializable-buffer' }, + snapshot: null + } + // Why the deferred second answer: the loop arms its 2s re-ask timer when an + // answer lands, and that timer must be created under fake timers to advance. + const secondAnswer = createDeferred() + const serializeBufferOutcome = vi + .fn() + .mockResolvedValueOnce(declined) + .mockReturnValueOnce(secondAnswer.promise) + .mockResolvedValue(declined) + const reveal = await revealParkedRemotePane(serializeBufferOutcome) + + // The reveal's own probe, plus the immediate re-ask it hands to the restore loop. + expect(serializeBufferOutcome).toHaveBeenCalledTimes(2) + expect(reveal.writes.join('')).not.toContain(STRUCTURAL_CLEAR) + expect(bannerCount(reveal.writes)).toBe(0) + + vi.useFakeTimers() + secondAnswer.resolve(declined) + await flushAsyncTicks(20) + expect(serializeBufferOutcome).toHaveBeenCalledTimes(2) + // One shared budget: the reveal probe counts, so five more 2s cycles reach the seventh. + for (let expectedRequests = 3; expectedRequests <= 7; expectedRequests += 1) { + await vi.advanceTimersByTimeAsync(2_000) + await flushAsyncTicks(20) + expect(serializeBufferOutcome).toHaveBeenCalledTimes(expectedRequests) + expect(bannerCount(reveal.writes)).toBe(expectedRequests === 7 ? 1 : 0) + } + await vi.advanceTimersByTimeAsync(60_000) + await flushAsyncTicks(20) + expect(serializeBufferOutcome).toHaveBeenCalledTimes(7) + expect(bannerCount(reveal.writes)).toBe(1) + // At no point did the pane claim to be empty. + expect(reveal.writes.join('')).not.toContain(STRUCTURAL_CLEAR) + reveal.dispose() + }) + + it('paints the host image when the host answers with one, and asks nothing more', async () => { + const serializeBufferOutcome = vi.fn().mockResolvedValue({ + availability: { kind: 'snapshot' }, + snapshot: { data: `${HOST_IMAGE_MARKER}\r\n`, cols: 80, rows: 24, seq: 3, source: 'headless' } + }) + const reveal = await revealParkedRemotePane(serializeBufferOutcome) + + expect(serializeBufferOutcome).toHaveBeenCalledTimes(1) + const painted = reveal.writes.join('') + expect(painted).toContain(STRUCTURAL_CLEAR) + expect(painted).toContain(HOST_IMAGE_MARKER) + + vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(60_000) + await flushAsyncTicks(20) + expect(serializeBufferOutcome).toHaveBeenCalledTimes(1) + expect(bannerCount(reveal.writes)).toBe(0) + reveal.dispose() + }) +}) diff --git a/src/shared/terminal-snapshot-unavailability.ts b/src/shared/terminal-snapshot-unavailability.ts index 6d3ac26774f..3f5f73962ff 100644 --- a/src/shared/terminal-snapshot-unavailability.ts +++ b/src/shared/terminal-snapshot-unavailability.ts @@ -4,8 +4,13 @@ * Sent as the additive `unavailable` field on the SnapshotStart frame. Hosts that predate * this field omit it, so an absent value means "the host did not say" — never "nothing exists". * Both current reasons are transient: the host could not answer *now*, not that the pane has - * no retained output. A pane that genuinely has nothing still comes back as a real snapshot - * whose `data` is empty, because the host successfully serialized and found it empty. + * no retained output. + * + * A real snapshot with empty `data` and no reason is NOT proof the pane is empty either: + * `serializeTerminalBufferFromAvailableState` returns the renderer serializer's un-hydrated + * shell (`data: ''`) when a parked desktop pane registered its serializer before its xterm + * mounted and no provider history exists. Nothing on this wire positively says "the host + * retains nothing"; readers must treat an imageless success as unverifiable, never as empty. */ export const TERMINAL_SNAPSHOT_UNAVAILABLE_REASONS = [ // The pending-output buffer overflowed twice while serializing, so the reply was truncated to nothing. From 06a8ca5f69c79ca064d080eea5e30af8908ca666 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:56:11 -0700 Subject: [PATCH 004/224] fix(runtime): keep a client-dirty mirrored file dirty across a host republish (#21393) The host publishes only its own store's isDirty and never learns about client edits, so rebuilding a mirrored OpenFile from the snapshot cleared the client's flag while editorDrafts still held the draft. The tab strip then closed the tab with no unsaved-changes prompt and closeFile deleted the draft; the external-change reload guards would reload over it too. Keep the client's flag when the client's file is dirty and it holds a draft; with no draft the host's flag still wins so a host-side save does not strand the tab as dirty. A host-side save never clears a client draft. Fixes #21392 --- .../web-session-tabs-sync-editor-tabs.test.ts | 105 ++++++++++++++ ...ion-tabs-sync-mirrored-draft-close.test.ts | 133 ++++++++++++++++++ .../apply-preparation-browser.ts | 3 +- .../runtime/web-session-tabs-sync/state.ts | 3 + .../web-session-tabs-sync/tab-builders.ts | 11 +- 5 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts diff --git a/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts index c978ac47e1b..0820a71164a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts @@ -410,4 +410,109 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.activeTabType).toBeUndefined() expect(patch.activeTabTypeByWorktree).toBeUndefined() }) + + describe('client dirtiness across a host republish (#21392)', () => { + const notesPath = '/repo/NOTES.md' + const mirroredNotes = (isDirty: boolean): OpenFile => ({ + id: notesPath, + filePath: notesPath, + relativePath: 'NOTES.md', + worktreeId: WT, + language: 'markdown', + isDirty, + runtimeEnvironmentId: ENV, + mode: 'edit', + mirroredFromRuntimeSession: true + }) + const notesUnifiedTab: Tab = { + id: 'host-notes-unified', + entityId: notesPath, + groupId: 'host-group-1', + worktreeId: WT, + contentType: 'editor', + label: 'NOTES.md', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW - 10, + isPreview: false, + isPinned: false + } + // The host republishes the same tab with its own store's flag: not dirty. + const hostCleanSnapshot = () => + makeSnapshot( + [ + { + type: 'markdown', + id: 'host-notes-unified', + title: 'NOTES.md', + filePath: notesPath, + relativePath: 'NOTES.md', + language: 'markdown', + mode: 'edit', + isDirty: false, + isActive: true, + sourceFileId: notesPath, + sourceFilePath: notesPath, + sourceRelativePath: 'NOTES.md', + documentVersion: `file:${notesPath}`, + color: null, + isPinned: false + } + ], + { activeTabId: 'host-notes-unified', activeTabType: 'markdown' } + ) + + it('keeps a client-dirty mirrored file dirty when the host republishes isDirty: false', () => { + // Why: the host never learns about client edits, so its flag would otherwise erase the + // client's, and the tab strip would close the tab with no prompt while the draft lives. + const patch = applyWebSessionTabsSnapshot( + makeState({ + openFiles: [mirroredNotes(true)], + editorDrafts: { [notesPath]: '# unsaved client edits' }, + unifiedTabsByWorktree: { [WT]: [notesUnifiedTab] } + }), + hostCleanSnapshot(), + ENV, + NOW + ) + + // No open-file change means the dirty flag survived exactly as it was. + expect(patch.openFiles).toBeUndefined() + }) + + it('follows a host-side save when the client holds no draft', () => { + // Why: a dirty flag with no client draft came from an earlier host snapshot; keeping it + // would strand the tab as dirty after the host saved. + const patch = applyWebSessionTabsSnapshot( + makeState({ + openFiles: [mirroredNotes(true)], + editorDrafts: {}, + unifiedTabsByWorktree: { [WT]: [notesUnifiedTab] } + }), + hostCleanSnapshot(), + ENV, + NOW + ) + + expect(patch.openFiles).toMatchObject([{ id: notesPath, isDirty: false }]) + }) + + it('does not invent dirtiness from a draft the client already reverted', () => { + // Why: a lingering draft with isDirty false means the user typed and undid; the tab is + // clean and must not start prompting on close. + const patch = applyWebSessionTabsSnapshot( + makeState({ + openFiles: [mirroredNotes(false)], + editorDrafts: { [notesPath]: 'same as disk' }, + unifiedTabsByWorktree: { [WT]: [notesUnifiedTab] } + }), + hostCleanSnapshot(), + ENV, + NOW + ) + + expect(patch.openFiles).toBeUndefined() + }) + }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts new file mode 100644 index 00000000000..3091d9370cc --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/tab-types' +import type { OpenFile } from '../store/slices/editor' + +const closeWebRuntimeSessionTabMock = vi.fn(async (_args: unknown) => 'applied' as const) + +vi.mock('./web-runtime-session', () => ({ + closeWebRuntimeSessionTab: (args: unknown) => closeWebRuntimeSessionTabMock(args) +})) + +import { useAppStore } from '../store' +import { createWorkspaceTabCloseCommands } from '@/components/tab-group/workspace-tab-close-commands' +import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT } from '@/components/editor/editor-autosave' +import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync' +import { + ENV, + NOW, + WT, + makeSnapshot, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +const notesPath = '/repo/NOTES.md' +const clientDraft = '# unsaved client edits' + +const notesUnifiedTab: Tab = { + id: 'host-notes-unified', + entityId: notesPath, + groupId: 'host-group-1', + worktreeId: WT, + contentType: 'editor', + label: 'NOTES.md', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW - 10, + isPreview: false, + isPinned: false +} + +// A host-mirrored tab the user has edited on this client: dirty, with a draft recorded. +const clientDirtyMirroredNotes: OpenFile = { + id: notesPath, + filePath: notesPath, + relativePath: 'NOTES.md', + worktreeId: WT, + language: 'markdown', + isDirty: true, + runtimeEnvironmentId: ENV, + mode: 'edit', + mirroredFromRuntimeSession: true +} + +// The host republishes the same tab; its own store has no unsaved edits. +function hostCleanRepublish() { + return makeSnapshot( + [ + { + type: 'markdown', + id: notesUnifiedTab.id, + title: 'NOTES.md', + filePath: notesPath, + relativePath: 'NOTES.md', + language: 'markdown', + mode: 'edit', + isDirty: false, + isActive: true, + sourceFileId: notesPath, + sourceFilePath: notesPath, + sourceRelativePath: 'NOTES.md', + documentVersion: `file:${notesPath}`, + color: null, + isPinned: false + } + ], + { activeTabId: notesUnifiedTab.id, activeTabType: 'markdown' } + ) +} + +describe('tab-strip close of a client-dirty mirrored file after a host republish (#21392)', () => { + const initialState = useAppStore.getState() + const closeRequests: string[] = [] + const onCloseRequest = (event: Event): void => { + if (event instanceof CustomEvent) { + closeRequests.push(String(event.detail?.fileId)) + } + } + + beforeEach(() => { + resetWebSessionTabsSyncTestState() + closeWebRuntimeSessionTabMock.mockClear() + closeRequests.length = 0 + window.addEventListener(ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, onCloseRequest) + useAppStore.setState({ + ...initialState, + activeWorktreeId: WT, + openFiles: [clientDirtyMirroredNotes], + editorDrafts: { [notesPath]: clientDraft }, + unifiedTabsByWorktree: { [WT]: [notesUnifiedTab] } + }) + }) + + afterEach(() => { + window.removeEventListener(ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, onCloseRequest) + useAppStore.setState(initialState, true) + }) + + it('routes the close to the unsaved-changes prompt instead of discarding the draft', () => { + // Why: this is the user-visible property. #21363 lost a draft on a transient error; this + // path loses one on an ordinary Cmd+W / tab X unless the client's dirty flag survives the + // host's republish, because the tab strip gates its prompt on that flag alone. + const patch = applyWebSessionTabsSnapshot( + useAppStore.getState(), + hostCleanRepublish(), + ENV, + NOW + ) + useAppStore.setState(patch) + + createWorkspaceTabCloseCommands({ worktreeId: WT, groupTabs: [notesUnifiedTab] }).closeItem( + notesUnifiedTab.id + ) + + // Prompted, not closed: the request went to the save/discard queue and nothing was lost. + expect(closeRequests).toEqual([notesPath]) + const state = useAppStore.getState() + expect(state.openFiles.some((file) => file.id === notesPath)).toBe(true) + expect(state.editorDrafts[notesPath]).toBe(clientDraft) + expect(closeWebRuntimeSessionTabMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index 6752cdcd8e1..4dcfd7ae44d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -117,7 +117,8 @@ export function prepareWebSessionTabsSnapshotBrowser( hostGroupIdByTabId, targetGroupId, mirroredTerminalTabEntries.length + mirroredBrowserTabs.length, - now + now, + (fileId) => state.editorDrafts?.[fileId] !== undefined ) const mirroredAgentTabs = buildMirroredAgentTabs( snapshot, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index f44d4a3d18c..8db00f5a238 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -202,6 +202,9 @@ export type WebSessionTabsSyncState = Pick< | 'activityClearedAtByPaneKey' | 'agentLaunchConfigByPaneKey' | 'automaticAgentResumeClaimsByTabId' + // Why: a client draft is the evidence that a mirrored file's dirty flag is the client's + // own and must survive a host republish (#21392); absent here, the host flag wins. + | 'editorDrafts' | 'migrationUnsupportedByPtyId' | 'manuallyUnreadTurnsByPaneKey' | 'paneForegroundAgentByPaneKey' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts b/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts index 94234af1276..bc1dc6d83b1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts @@ -103,7 +103,8 @@ export function buildMirroredEditorTabs( hostGroupIdByTabId: ReadonlyMap, fallbackGroupId: string, sortOffset: number, - now: number + now: number, + hasLocalDraft: (fileId: string) => boolean ): MirroredEditorTab[] { return snapshot.tabs.filter(isReadyEditorTab).map((tab, index) => { const fileId = localEditorFileId(tab) @@ -111,6 +112,12 @@ export function buildMirroredEditorTabs( const existingUnifiedTab = existingTabIndex.getEditorUnifiedTab(fileId, tab.id) const sourceFileId = editorSourceFileId(tab) const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId + // Why: the host publishes only its own store's flag and never learns of client edits, so + // taking it verbatim would clear a client-dirty tab and the tab strip would then close it + // with no unsaved-changes prompt while the draft still exists (#21392). A local draft is + // the evidence the flag is the client's own; a dirty flag with no draft came from an + // earlier snapshot and must keep following the host, e.g. after a host-side save. + const keepsClientDirty = existingFile?.isDirty === true && hasLocalDraft(fileId) const file: OpenFile = { ...existingFile, id: fileId, @@ -118,7 +125,7 @@ export function buildMirroredEditorTabs( relativePath: tab.relativePath, worktreeId: snapshot.worktree, language: tab.language, - isDirty: tab.isDirty, + isDirty: tab.isDirty || keepsClientDirty, runtimeEnvironmentId: environmentId, mode: tab.type === 'markdown' ? tab.mode : 'edit', markdownPreviewSourceFileId: sourceFileId, From 2bdf281433f4092cb008172ebe344db486e77dd3 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 17 Sep 2026 23:59:42 -0700 Subject: [PATCH 005/224] fix: avoid retaining foreign SSH file frames before metadata (#21167) * fix: avoid retaining foreign SSH file frames before metadata * test(ssh): exercise empty metadata through the streaming mux fixture * fix(ssh): fail the file read when beforeResolve never runs Moving the metadata install from .then() to beforeResolve moved it from a mandatory callback to an optional one, and handleResponse clears the request timer before beforeResolve runs. That left "response fulfilled, metadata never installed" with no deadline: the read never settled, holding its notification and dispose closures until mux disposal. Before this PR the same state failed after the 60s inactivity deadline. Unreachable with the concrete mux, which calls resolve on the line after beforeResolve, but the hook is optional in the type and nothing enforces the pairing. The guard is a no-op on every real path: empty, missing streamId, cap-exceeded and alloc-failure all settle first, and the success path sets metadataReady. Found during review of #21167; raised at https://github.com/stablyai/orca/pull/21167#issuecomment-5726058832 --------- Co-authored-by: m4air Co-authored-by: Claude --- .../ssh-file-metadata-retention/README.md | 79 ++++++ .../before.config.mjs | 20 ++ .../ssh-file-metadata-retention/fix.patch | 138 +++++++++ .../main-before-electron-results.json | 140 +++++++++ .../main-before-node-results.json | 140 +++++++++ .../main-context.patch | 18 ++ .../main-fixed-electron-results.json | 140 +++++++++ .../main-fixed-node-results.json | 140 +++++++++ .../relay-fixture.mjs | 226 +++++++++++++++ .../scenario.test.mjs | 267 ++++++++++++++++++ .../source-versions.json | 225 +++++++++++++++ .../ssh-file-metadata-retention/sources.cjs | 84 ++++++ .../validation.json | 264 +++++++++++++++++ .../vitest.config.mjs | 31 ++ .../worktree-before-electron-results.json | 140 +++++++++ .../worktree-before-node-results.json | 140 +++++++++ .../worktree-fixed-electron-results.json | 140 +++++++++ .../worktree-fixed-node-results.json | 140 +++++++++ .../ssh-filesystem-provider-stream.test.ts | 63 +++-- .../providers/ssh-filesystem-provider.test.ts | 8 - src/main/ssh/ssh-filesystem-stream-reader.ts | 125 ++++---- .../ssh-filesystem-stream-retention.test.ts | 221 +++++++++++++++ 22 files changed, 2793 insertions(+), 96 deletions(-) create mode 100644 docs/audits/ssh-file-metadata-retention/README.md create mode 100644 docs/audits/ssh-file-metadata-retention/before.config.mjs create mode 100644 docs/audits/ssh-file-metadata-retention/fix.patch create mode 100644 docs/audits/ssh-file-metadata-retention/main-before-electron-results.json create mode 100644 docs/audits/ssh-file-metadata-retention/main-before-node-results.json create mode 100644 docs/audits/ssh-file-metadata-retention/main-context.patch create mode 100644 docs/audits/ssh-file-metadata-retention/main-fixed-electron-results.json create mode 100644 docs/audits/ssh-file-metadata-retention/main-fixed-node-results.json create mode 100644 docs/audits/ssh-file-metadata-retention/relay-fixture.mjs create mode 100644 docs/audits/ssh-file-metadata-retention/scenario.test.mjs create mode 100644 docs/audits/ssh-file-metadata-retention/source-versions.json create mode 100644 docs/audits/ssh-file-metadata-retention/sources.cjs create mode 100644 docs/audits/ssh-file-metadata-retention/validation.json create mode 100644 docs/audits/ssh-file-metadata-retention/vitest.config.mjs create mode 100644 docs/audits/ssh-file-metadata-retention/worktree-before-electron-results.json create mode 100644 docs/audits/ssh-file-metadata-retention/worktree-before-node-results.json create mode 100644 docs/audits/ssh-file-metadata-retention/worktree-fixed-electron-results.json create mode 100644 docs/audits/ssh-file-metadata-retention/worktree-fixed-node-results.json create mode 100644 src/main/ssh/ssh-filesystem-stream-retention.test.ts diff --git a/docs/audits/ssh-file-metadata-retention/README.md b/docs/audits/ssh-file-metadata-retention/README.md new file mode 100644 index 00000000000..592e069e66a --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/README.md @@ -0,0 +1,79 @@ +# SSH file readers retain unrelated streams before metadata + +The file reader queued every file-stream notification while awaiting its own metadata. A delayed read therefore retained payloads from other reads that had already completed. The fix installs metadata through the mux's existing synchronous `beforeResolve` callback and ignores notifications until the read has a stream identity. Listeners still register before the request, and own frames adjacent to the response are processed correctly. + +This is conditional transient retention during a pending metadata request. The proof establishes a source mechanism and its correction; it does not identify an affected host, measure a natural native I/O stall, or attribute #19831 to SSH. + +## Actual producer and ownership chain + +1. Desktop `filesystem-read-handlers.ts` calls the selected `SshFilesystemProvider.readFile` for `fs:readFile`; this route does not serialize reads. Runtime previews also use the provider with caller-specific caps. An AI-vault scan has an eight-operation gate, which still permits repeated completions in other slots while one operation waits. +2. `readFileViaStream` subscribes to chunk/end/error notifications before sending `fs.readFileStream`. Previously it appended all such notifications until the metadata promise's `.then` callback ran, even when they belonged to other streams. +3. Relay `FilesystemHandler` forwards the path and request context to `readRelayFileStreamMetadata`. The producer awaits `stat` before acquiring a stream slot. For unknown MIME types, its prefix probe also precedes registration. After opening/registering the file, it schedules its pump with `setImmediate` and returns metadata. +4. `RelayDispatcher` publishes the small metadata response in its control lane; the writer prioritizes control before bulk. The saturated-writer control verifies metadata precedes chunks after drain. +5. The mux runs `beforeResolve` synchronously during response dispatch, before resolving the request promise. Its decoder can dispatch adjacent notifications before any `.then` callback runs. The fix installs the stream ID and buffer at that synchronous boundary, eliminating the need to save foreign frames. + +The producer, mux, dispatcher, decoder, writer, file I/O, and stream registry are actual source in the portable proof. The fixture connects both ends through an in-memory duplex transport, uses real temporary files, and supplies the filesystem handler's small path/client/pacing adapter. It does not launch an SSH process, Electron window, native PTY, or network server. + +## Bounds and payload sharing + +- The relay allows **16 concurrent registered streams**, with a **four-chunk ACK window** per paced stream. Chunks are 256 KiB. A metadata operation waiting before registration occupies no stream slot. Other transfers can complete and reuse slots repeatedly. +- Reader size caps are **10 MiB text / 50 MiB binary**, optionally tightened by the caller. They apply after that reader's metadata and do not charge foreign history accumulated before it. +- The metadata request has a **30,000 ms deadline**. After metadata, the reader uses a **60,000 ms inactivity deadline**, reset by its own chunks and integrated with suspend/resume. Connection disposal also releases subscriptions. These timers and transport throughput bound ordinary retention duration; suspension/event-loop stalls can delay timers. No indefinite native stall was established. +- The decoder limits each turn to 64 frames / 4 ms and bounds retained framing bytes. Those limits do not bound arrays owned by subscribers after frames are parsed. +- The mux passes the **same parsed params object** to all subscribers. Four waiting readers add four wrappers per frame, but share its payload. The result is not four copied payloads or quadratic payload-byte growth. + +## Comparative results + +All **80 portable cases pass**: ten controls × baseline/fixed × audited-worktree/named-main graph × Node/Electron. Node is 26.6; Electron 43.7 uses Node 24.21. Reports record exact versions, all 59 selected source hashes, the observed reader hash, and proof artifact hashes. + +| Observation | Before | Fixed | +| ----------------------------------------------------------------------- | --------------------------------: | -------: | +| Four waiting readers; 16 completed 2 MiB transfers | 576 wrappers | 0 | +| Unique shared params objects retained | 144 | 0 | +| Logical base64 bytes, counted once per unique params object | 44,739,584 | 0 | +| Peak registered streams in that workload | 1 | 1 | +| ACKs processed | 128 | 128 | +| Reader history after metadata completion, handled disposal, or deadline | released | released | +| Actual pump with ACK delivery withheld | stops after 4 chunks | same | +| Sixteen active streams, then a seventeenth request | refused; later admission succeeds | same | +| Saturated writer, then drain | metadata before own chunks | same | +| Response plus own chunk/end in one decoder turn | correct result | same | +| Unpaced producer / ordinary completion | correct result | same | +| Canonical LF vs synthetic CRLF source/patch reads | 66 reads agree | same | + +The primary portable workload deliberately gates four request handlers **immediately before invoking the actual relay file producer**. The request remains pending while other real transfers complete. This controlled adapter delay is distinct from a native `stat` already in progress; production source establishes that awaiting `stat` occurs at the same pre-registration phase. It does not measure how often or how long native metadata I/O delays occur on a user's machine. + +The baseline observation adds only `WeakRef(pending)` to expose the closed-over array. It does not add a strong owner. Shared params identity is checked across all waiting readers. Heap deltas support the object/byte accounting but are neither exact object sizes nor RSS. After disposal, the test consumes lazy `Error.stack` and retains only error codes: externally retained unmaterialized V8 error stacks can themselves retain callback context, so the release claim is after normal error handling. + +Every successful transfer checks payload length and SHA-256. Stream capacity, pacing, cancellation, and output assertions run identically for both variants. The controlled producer ignores ACK pacing in one case; this tests existing unpaced behavior, not every historical relay binary. + +## Source graphs and publication + +`source-versions.json` records the full 59-module import graph and five additional actual caller hashes. Both graphs select the same file-reader source variant. The audited worktree and named main `291b4ddd6f1c1af480169885e0fda7f9c78ff053` otherwise differ only in the previously published SSH writer consumed-prefix correction. + +`main-context.patch` reconstructs that single context difference in memory. The loader accepts either of its two exact recorded checkout hashes and reconstructs the selected graph. This lets the same artifact run on this worktree or the independent main publication without depending on another memory PR. `fix.patch` is the separate, single-product-file change under review. Every other graph/caller source is hash-fenced; unknown production imports fail. Dedicated portable tests omit unrelated global Vitest setup files. + +The current reader baseline and relay file producer are also byte-identical to `v1.4.198` (`e0826956fcfc532f5a1e55b5e081f2e57e553c43`). That named version has synchronous `beforeResolve` and control-first writer scheduling. Its comparison is limited to the recorded paths; the proof does not execute a whole historical application. + +No wire field, opcode, host execution verdict, stream cap, timeout, fallback, or native process lifetime changes. Existing MethodNotFound fallback and malformed-metadata / tighter-cap / empty-image / adjacent-error handling are covered through the actual mux by the permanent regression suite. + +## Reproduce + +Choose either graph (`worktree` or `main`) and variant (`before` or `fixed`): + +```sh +ORCA_BACKGROUND_LAUNCH=1 ORCA_SSH_READER_GRAPH=main ORCA_SSH_READER_VARIANT=fixed pnpm exec vitest run --config docs/audits/ssh-file-metadata-retention/vitest.config.mjs +``` + +For Electron, invoke the installed Electron binary with `ELECTRON_RUN_AS_NODE=1` and `ORCA_BACKGROUND_LAUNCH=1`, passing `node_modules/vitest/vitest.mjs` and the same arguments. Reports are separate for every graph/variant/runtime. Set `ORCA_SSH_READER_OUTPUT` to an alternative file path to preserve captured reports. + +Permanent tests and the intentional baseline failure: + +```sh +ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/relay/fs-handler-stream.test.ts +ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/ssh-file-metadata-retention/before.config.mjs src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts +``` + +The baseline keeps all 64 observed foreign frame objects while metadata remains pending, causing exactly the new lifetime assertion to fail; the other 22 tests pass. The initial four-suite run passed 70 tests. Detailed quality/typecheck results are in `validation.json`. Full-file casting diagnostics are the same 15 inherited assertions in the original reader and provider test, verified by exact diagnostic/source-span comparison; the changed-code gate reports no new findings. No lint rule was suppressed and no unrelated wire validation behavior was changed to satisfy that baseline cleanup. + +The expanded five-suite run passes **124 tests**, including the general provider suite. The empty-file control uses the existing streaming fixture, which invokes the mux's `beforeResolve` callback before resolving metadata, and verifies all stream listeners are released. The older generic fixture omitted that callback and reproduced the CI timeout; actual-mux empty metadata controls already passed. This correction changes test setup only. diff --git a/docs/audits/ssh-file-metadata-retention/before.config.mjs b/docs/audits/ssh-file-metadata-retention/before.config.mjs new file mode 100644 index 00000000000..0f4b29b2e14 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/before.config.mjs @@ -0,0 +1,20 @@ +import { resolve } from 'node:path' +import { createRequire } from 'node:module' +import base from '../../../config/vitest.config.ts' +const { loadSources, versions } = createRequire(import.meta.url)('./sources.cjs') +const loaded = loadSources({ variant: 'before' }) +const target = resolve(loaded.root, versions.sourcePath) +export default { + ...base, + plugins: [ + { + name: 'ssh-file-metadata-baseline', + enforce: 'pre', + transform(_source, id) { + return resolve(id.split('?')[0]) === target + ? { code: loaded.sources.get(target), map: null } + : null + } + } + ] +} diff --git a/docs/audits/ssh-file-metadata-retention/fix.patch b/docs/audits/ssh-file-metadata-retention/fix.patch new file mode 100644 index 00000000000..7ffe13335fe --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/fix.patch @@ -0,0 +1,138 @@ +--- a/src/main/ssh/ssh-filesystem-stream-reader.ts ++++ b/src/main/ssh/ssh-filesystem-stream-reader.ts +@@ -77,7 +77 @@ +- // Why: chunk/end/error frames may arrive in the same dispatch tick as the +- // metadata response. Queue them until streamIdRef is set, then drain. +- type PendingFrame = +- | { kind: 'chunk'; params: Record } +- | { kind: 'end'; params: Record } +- | { kind: 'error'; params: Record } +- const pending: PendingFrame[] = [] ++ // Install metadata during response dispatch, before adjacent stream frames. +@@ -232,13 +225,0 @@ +- const drainPending = (): void => { +- while (!settled && pending.length > 0) { +- const frame = pending.shift()! +- if (frame.kind === 'chunk') { +- handleChunk(frame.params) +- } else if (frame.kind === 'end') { +- handleEnd(frame.params) +- } else { +- handleStreamError(frame.params) +- } +- } +- } +- +@@ -248 +228,0 @@ +- pending.push({ kind: 'chunk', params }) +@@ -257 +236,0 @@ +- pending.push({ kind: 'end', params }) +@@ -266 +244,0 @@ +- pending.push({ kind: 'error', params }) +@@ -287,51 +265,55 @@ +- .request('fs.readFileStream', { filePath, flowControl: 'ack' }) +- .then((rawMetadata) => { +- if (settled) { +- return +- } +- const metadata = rawMetadata as StreamMetadataResponse +- isBinary = metadata.isBinary +- isImage = metadata.isImage +- mimeType = metadata.mimeType +- resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64 +- +- if (metadata.empty) { +- succeed({ +- content: '', +- isBinary: metadata.isBinary, +- ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), +- ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}) +- }) +- return +- } +- +- if (typeof metadata.streamId !== 'number') { +- fail(new StreamProtocolError('Metadata missing streamId for non-empty stream')) +- return +- } +- +- const cap = sshFileStreamReadCap(metadata.isBinary, limits) +- if (metadata.totalSize < 0 || metadata.totalSize > cap) { +- streamIdRef.current = metadata.streamId +- fail( +- new FileReadCapExceededError( +- `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` +- ) +- ) +- return +- } +- +- totalSize = metadata.totalSize +- totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE) +- try { +- buffer = Buffer.alloc(totalSize) +- } catch (err) { +- streamIdRef.current = metadata.streamId +- fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`)) +- return +- } +- streamIdRef.current = metadata.streamId +- metadataReady = true +- inactivity.reset() +- drainPending() +- }) ++ .request( ++ 'fs.readFileStream', ++ { filePath, flowControl: 'ack' }, ++ { ++ beforeResolve: (rawMetadata) => { ++ if (settled) { ++ return ++ } ++ const metadata = rawMetadata as StreamMetadataResponse ++ isBinary = metadata.isBinary ++ isImage = metadata.isImage ++ mimeType = metadata.mimeType ++ resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64 ++ ++ if (metadata.empty) { ++ succeed({ ++ content: '', ++ isBinary: metadata.isBinary, ++ ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), ++ ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}) ++ }) ++ return ++ } ++ ++ if (typeof metadata.streamId !== 'number') { ++ fail(new StreamProtocolError('Metadata missing streamId for non-empty stream')) ++ return ++ } ++ ++ const cap = sshFileStreamReadCap(metadata.isBinary, limits) ++ if (metadata.totalSize < 0 || metadata.totalSize > cap) { ++ streamIdRef.current = metadata.streamId ++ fail( ++ new FileReadCapExceededError( ++ `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` ++ ) ++ ) ++ return ++ } ++ ++ totalSize = metadata.totalSize ++ totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE) ++ try { ++ buffer = Buffer.alloc(totalSize) ++ } catch (err) { ++ streamIdRef.current = metadata.streamId ++ fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`)) ++ return ++ } ++ streamIdRef.current = metadata.streamId ++ metadataReady = true ++ inactivity.reset() ++ } ++ } ++ ) diff --git a/docs/audits/ssh-file-metadata-retention/main-before-electron-results.json b/docs/audits/ssh-file-metadata-retention/main-before-electron-results.json new file mode 100644 index 00000000000..3bdfbee7446 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/main-before-electron-results.json @@ -0,0 +1,140 @@ +{ + "variant": "before", + "graph": "main", + "runtime": { + "node": "24.21.0", + "electron": "43.7.0" + }, + "sources": { + "src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "observedReaderSha256": "7a5c9c14faf63197765fc5a2900e1d3488f94aaab6757425b7ef87597479e963", + "controls": [ + { + "name": "held-metadata-foreign-history", + "readers": 4, + "entries": [144, 144, 144, 144], + "wrappers": 576, + "uniqueParams": 144, + "logicalBase64BytesByUniqueParams": 44739584, + "sharedAcrossReaders": true, + "decodedTransferBytes": 33554432, + "peakRegisteredStreams": 1, + "maxConcurrentStreams": 16, + "ackWindow": 4, + "ackCount": 128, + "observedHeapDelta": 45298640, + "released": true + }, + { + "name": "ordinary-completion", + "passed": true + }, + { + "name": "transport-disposal", + "passed": true + }, + { + "name": "metadata-request-deadline", + "milliseconds": 30000, + "relayContextAborted": true, + "released": true + }, + { + "name": "unpaced-relay", + "passed": true + }, + { + "name": "real-pump-credit-window", + "chunksBeforeAck": 4, + "totalChunks": 6 + }, + { + "name": "actual-stream-capacity", + "slots": 16, + "rejectedSeventeenth": true, + "admittedAfterCompletion": true + }, + { + "name": "saturated-writer-metadata-order", + "wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"] + }, + { + "name": "same-turn-response-and-own-frames", + "passed": true + }, + { + "name": "canonical-crlf-source-control", + "reads": 66, + "passed": true + } + ], + "artifactHashes": { + "sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f", + "relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079", + "scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af", + "vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849", + "before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962", + "fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6", + "main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9", + "source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d" + } +} diff --git a/docs/audits/ssh-file-metadata-retention/main-before-node-results.json b/docs/audits/ssh-file-metadata-retention/main-before-node-results.json new file mode 100644 index 00000000000..cdf0128daef --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/main-before-node-results.json @@ -0,0 +1,140 @@ +{ + "variant": "before", + "graph": "main", + "runtime": { + "node": "26.6.0", + "electron": null + }, + "sources": { + "src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "observedReaderSha256": "7a5c9c14faf63197765fc5a2900e1d3488f94aaab6757425b7ef87597479e963", + "controls": [ + { + "name": "held-metadata-foreign-history", + "readers": 4, + "entries": [144, 144, 144, 144], + "wrappers": 576, + "uniqueParams": 144, + "logicalBase64BytesByUniqueParams": 44739584, + "sharedAcrossReaders": true, + "decodedTransferBytes": 33554432, + "peakRegisteredStreams": 1, + "maxConcurrentStreams": 16, + "ackWindow": 4, + "ackCount": 128, + "observedHeapDelta": 45477336, + "released": true + }, + { + "name": "ordinary-completion", + "passed": true + }, + { + "name": "transport-disposal", + "passed": true + }, + { + "name": "metadata-request-deadline", + "milliseconds": 30000, + "relayContextAborted": true, + "released": true + }, + { + "name": "unpaced-relay", + "passed": true + }, + { + "name": "real-pump-credit-window", + "chunksBeforeAck": 4, + "totalChunks": 6 + }, + { + "name": "actual-stream-capacity", + "slots": 16, + "rejectedSeventeenth": true, + "admittedAfterCompletion": true + }, + { + "name": "saturated-writer-metadata-order", + "wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"] + }, + { + "name": "same-turn-response-and-own-frames", + "passed": true + }, + { + "name": "canonical-crlf-source-control", + "reads": 66, + "passed": true + } + ], + "artifactHashes": { + "sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f", + "relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079", + "scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af", + "vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849", + "before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962", + "fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6", + "main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9", + "source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d" + } +} diff --git a/docs/audits/ssh-file-metadata-retention/main-context.patch b/docs/audits/ssh-file-metadata-retention/main-context.patch new file mode 100644 index 00000000000..e1093f07364 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/main-context.patch @@ -0,0 +1,18 @@ +--- a/src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts ++++ b/src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts +@@ -6 +6 @@ +- entries: (T | undefined)[] ++ entries: T[] +@@ -23 +22,0 @@ +- queue.entries[queue.head] = undefined +@@ -25,5 +24,2 @@ +- if ( +- queue.head === queue.entries.length || +- (queue.head >= 1024 && queue.head * 2 >= queue.entries.length) +- ) { +- queue.entries = queue.entries.slice(queue.head) ++ if (queue.head === queue.entries.length) { ++ queue.entries.length = 0 +@@ -36 +32 @@ +- const entries = queue.entries.slice(queue.head).filter((entry): entry is T => entry !== undefined) ++ const entries = queue.entries.slice(queue.head) diff --git a/docs/audits/ssh-file-metadata-retention/main-fixed-electron-results.json b/docs/audits/ssh-file-metadata-retention/main-fixed-electron-results.json new file mode 100644 index 00000000000..1c9c0933590 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/main-fixed-electron-results.json @@ -0,0 +1,140 @@ +{ + "variant": "fixed", + "graph": "main", + "runtime": { + "node": "24.21.0", + "electron": "43.7.0" + }, + "sources": { + "src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "observedReaderSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "controls": [ + { + "name": "held-metadata-foreign-history", + "readers": 4, + "entries": [0, 0, 0, 0], + "wrappers": 0, + "uniqueParams": 0, + "logicalBase64BytesByUniqueParams": 0, + "sharedAcrossReaders": false, + "decodedTransferBytes": 33554432, + "peakRegisteredStreams": 1, + "maxConcurrentStreams": 16, + "ackWindow": 4, + "ackCount": 128, + "observedHeapDelta": 513692, + "released": true + }, + { + "name": "ordinary-completion", + "passed": true + }, + { + "name": "transport-disposal", + "passed": true + }, + { + "name": "metadata-request-deadline", + "milliseconds": 30000, + "relayContextAborted": true, + "released": true + }, + { + "name": "unpaced-relay", + "passed": true + }, + { + "name": "real-pump-credit-window", + "chunksBeforeAck": 4, + "totalChunks": 6 + }, + { + "name": "actual-stream-capacity", + "slots": 16, + "rejectedSeventeenth": true, + "admittedAfterCompletion": true + }, + { + "name": "saturated-writer-metadata-order", + "wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"] + }, + { + "name": "same-turn-response-and-own-frames", + "passed": true + }, + { + "name": "canonical-crlf-source-control", + "reads": 66, + "passed": true + } + ], + "artifactHashes": { + "sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f", + "relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079", + "scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af", + "vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849", + "before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962", + "fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6", + "main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9", + "source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d" + } +} diff --git a/docs/audits/ssh-file-metadata-retention/main-fixed-node-results.json b/docs/audits/ssh-file-metadata-retention/main-fixed-node-results.json new file mode 100644 index 00000000000..6e0629f9a29 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/main-fixed-node-results.json @@ -0,0 +1,140 @@ +{ + "variant": "fixed", + "graph": "main", + "runtime": { + "node": "26.6.0", + "electron": null + }, + "sources": { + "src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "observedReaderSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "controls": [ + { + "name": "held-metadata-foreign-history", + "readers": 4, + "entries": [0, 0, 0, 0], + "wrappers": 0, + "uniqueParams": 0, + "logicalBase64BytesByUniqueParams": 0, + "sharedAcrossReaders": false, + "decodedTransferBytes": 33554432, + "peakRegisteredStreams": 1, + "maxConcurrentStreams": 16, + "ackWindow": 4, + "ackCount": 128, + "observedHeapDelta": -669752, + "released": true + }, + { + "name": "ordinary-completion", + "passed": true + }, + { + "name": "transport-disposal", + "passed": true + }, + { + "name": "metadata-request-deadline", + "milliseconds": 30000, + "relayContextAborted": true, + "released": true + }, + { + "name": "unpaced-relay", + "passed": true + }, + { + "name": "real-pump-credit-window", + "chunksBeforeAck": 4, + "totalChunks": 6 + }, + { + "name": "actual-stream-capacity", + "slots": 16, + "rejectedSeventeenth": true, + "admittedAfterCompletion": true + }, + { + "name": "saturated-writer-metadata-order", + "wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"] + }, + { + "name": "same-turn-response-and-own-frames", + "passed": true + }, + { + "name": "canonical-crlf-source-control", + "reads": 66, + "passed": true + } + ], + "artifactHashes": { + "sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f", + "relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079", + "scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af", + "vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849", + "before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962", + "fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6", + "main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9", + "source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d" + } +} diff --git a/docs/audits/ssh-file-metadata-retention/relay-fixture.mjs b/docs/audits/ssh-file-metadata-retention/relay-fixture.mjs new file mode 100644 index 00000000000..2c545e5df89 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/relay-fixture.mjs @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, expect, vi } from 'vitest' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash, randomBytes } from 'node:crypto' +import { writeFileSync, readFileSync } from 'node:fs' +import { SshChannelMultiplexer } from '../../../src/main/ssh/ssh-channel-multiplexer' +import { readFileViaStream } from '../../../src/main/ssh/ssh-filesystem-stream-reader' +import { RelayDispatcher } from '../../../src/relay/dispatcher' +import { RelayStreamRegistry } from '../../../src/relay/fs-stream-registry' +import { readRelayFileStreamMetadata } from '../../../src/relay/fs-handler-file-read' + +import { createRequire } from 'node:module' +const { loadSources } = createRequire(import.meta.url)('./sources.cjs') +const sourceInfo = loadSources() +const gates = new Map() +const candidate = process.env.ORCA_SSH_READER_VARIANT !== 'before' +const graph = process.env.ORCA_SSH_READER_GRAPH ?? 'worktree' +const artifactNames = [ + 'sources.cjs', + 'relay-fixture.mjs', + 'scenario.test.mjs', + 'vitest.config.mjs', + 'before.config.mjs', + 'fix.patch', + 'main-context.patch', + 'source-versions.json' +] +const report = { + variant: candidate ? 'fixed' : 'before', + graph, + runtime: { node: process.versions.node, electron: process.versions.electron ?? null }, + sources: sourceInfo.hashes, + observedReaderSha256: sourceInfo.observedReaderSha256, + controls: [] +} +const nextTurn = () => new Promise((resolve) => setImmediate(resolve)) +let directory +let fixtures +let heldPaths +function gate(path) { + let release + const promise = new Promise((resolve) => { + release = resolve + }) + gates.set(path, { promise, release }) +} +async function heldFiles(count) { + const paths = [] + for (let i = 0; i < count; i++) { + const path = join(directory, `held-${i}.png`) + await writeFile(path, '') + gate(path) + paths.push(path) + heldPaths.push(path) + } + return paths +} +function connect({ pacing = true, passAcks = true, blockFirstWrite = false } = {}) { + let receive + let drain + let blocked = blockFirstWrite + const registry = new RelayStreamRegistry() + const stats = { peakStreams: 0, chunks: 0, ends: 0, acks: 0, contexts: [], wireOrder: [] } + const dispatcher = new RelayDispatcher( + (data) => { + if (data[0] === 1) { + const message = JSON.parse(data.subarray(13).toString()) + stats.wireOrder.push(message.method ?? 'response') + } + receive(data) + if (blocked) { + blocked = false + return false + } + }, + { + waitWriteDrain(callback) { + drain = callback + return () => {} + } + } + ) + const mux = new SshChannelMultiplexer({ + write(data) { + dispatcher.feed(data) + }, + onData(callback) { + receive = callback + }, + onClose() {} + }) + dispatcher.onRequest('fs.readFileStream', async (params, context) => { + stats.contexts.push(context) + await gates.get(params.filePath)?.promise + const result = await readRelayFileStreamMetadata( + params.filePath, + dispatcher, + registry, + context, + { clientId: context.clientId, paceWithAcks: pacing && params.flowControl === 'ack' } + ) + stats.peakStreams = Math.max(stats.peakStreams, registry.size()) + return result + }) + dispatcher.onNotification('fs.streamAck', (params) => { + stats.acks++ + if (passAcks) { + registry.recordAck(params.streamId, params.seq) + } + }) + dispatcher.onNotification('fs.cancelStream', (params) => registry.abort(params.streamId)) + mux.onNotificationByMethod('fs.streamChunk', () => { + stats.chunks++ + }) + mux.onNotificationByMethod('fs.streamEnd', () => { + stats.ends++ + }) + const fixture = { + mux, + dispatcher, + registry, + stats, + drain() { + drain?.() + } + } + fixtures.push(fixture) + return fixture +} +function snapshot(paths) { + const rows = paths.map((path) => globalThis.__sshPendingReaders.get(path)?.deref() ?? []) + const unique = new Set(rows.flatMap((row) => row.map((frame) => frame.params))) + const bytes = [...unique].reduce( + (sum, params) => sum + (typeof params.data === 'string' ? params.data.length : 0), + 0 + ) + return { + readers: paths.length, + entries: rows.map((row) => row.length), + wrappers: rows.reduce((sum, row) => sum + row.length, 0), + uniqueParams: unique.size, + logicalBase64BytesByUniqueParams: bytes, + sharedAcrossReaders: + rows.length > 1 && + rows[0].length > 0 && + rows.every((row) => row.every((frame, index) => frame.params === rows[0][index]?.params)) + } +} +async function collect() { + for (let i = 0; i < 5; i++) { + await nextTurn() + global.gc() + } + await nextTurn() +} +async function assertReleased(paths) { + await collect() + for (const path of paths) { + expect(globalThis.__sshPendingReaders.get(path)?.deref()).toBeUndefined() + } +} +async function makePayload(size) { + const path = join(directory, 'payload.png') + const bytes = randomBytes(size) + await writeFile(path, bytes) + return { path, hash: createHash('sha256').update(bytes).digest('hex'), size } +} +async function successfulRead(mux, payload) { + const result = await readFileViaStream(mux, payload.path) + expect(result.isImage).toBe(true) + const bytes = Buffer.from(result.content, 'base64') + expect(bytes.length).toBe(payload.size) + expect(createHash('sha256').update(bytes).digest('hex')).toBe(payload.hash) +} +beforeEach(async () => { + expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + directory = await mkdtemp(join(tmpdir(), 'orca-ssh-reader-')) + fixtures = [] + heldPaths = [] + globalThis.__sshPendingReaders = new Map() +}) +afterEach(async () => { + vi.useRealTimers() + for (const entry of gates.values()) { + entry.release() + } + gates.clear() + for (const fixture of fixtures) { + fixture.mux.dispose() + fixture.dispatcher.dispose() + await fixture.registry.disposeAll() + } + await nextTurn() + await rm(directory, { recursive: true, force: true }) + report.artifactHashes = Object.fromEntries( + artifactNames.map((name) => [ + name, + createHash('sha256') + .update(readFileSync(new URL(name, import.meta.url))) + .digest('hex') + ]) + ) + writeFileSync( + process.env.ORCA_SSH_READER_OUTPUT ?? + new URL( + `./${graph}-${report.variant}-${process.versions.electron ? 'electron' : 'node'}-results.json`, + import.meta.url + ), + `${JSON.stringify(report, null, 2)}\n` + ) +}) + +export { + candidate, + report, + nextTurn, + gates, + heldFiles, + connect, + snapshot, + collect, + assertReleased, + makePayload, + successfulRead +} diff --git a/docs/audits/ssh-file-metadata-retention/scenario.test.mjs b/docs/audits/ssh-file-metadata-retention/scenario.test.mjs new file mode 100644 index 00000000000..5e5c9455200 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/scenario.test.mjs @@ -0,0 +1,267 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { describe, expect, it, vi } from 'vitest' +import { SshChannelMultiplexer } from '../../../src/main/ssh/ssh-channel-multiplexer' +import { readFileViaStream } from '../../../src/main/ssh/ssh-filesystem-stream-reader' +import { + encodeJsonRpcFrame, + MAX_CONCURRENT_STREAMS, + STREAM_ACK_WINDOW_CHUNKS, + STREAM_CHUNK_SIZE, + RelayErrorCode +} from '../../../src/relay/protocol' +import { + candidate, + report, + nextTurn, + gates, + heldFiles, + connect, + snapshot, + collect, + assertReleased, + makePayload, + successfulRead +} from './relay-fixture.mjs' +describe('actual SSH mux, relay dispatcher and file producer ownership', () => { + it('retains shared foreign frames while four metadata request handlers are deliberately held', async () => { + const paths = await heldFiles(4) + const { mux, stats } = connect() + const pending = paths.map((path) => readFileViaStream(mux, path)) + const payload = await makePayload(2 * 1024 * 1024) + await collect() + const startHeap = process.memoryUsage().heapUsed + for (let i = 0; i < 16; i++) { + await successfulRead(mux, payload) + } + await collect() + const retained = snapshot(paths) + expect(stats.chunks).toBe(128) + expect(stats.ends).toBe(16) + expect(stats.acks).toBe(128) + expect(stats.peakStreams).toBe(1) + expect(retained.entries).toEqual(Array(4).fill(candidate ? 0 : 144)) + expect(retained.uniqueParams).toBe(candidate ? 0 : 144) + expect(retained.logicalBase64BytesByUniqueParams).toBe( + candidate ? 0 : 128 * Math.ceil(STREAM_CHUNK_SIZE / 3) * 4 + ) + expect(retained.sharedAcrossReaders).toBe(!candidate) + const heapDelta = process.memoryUsage().heapUsed - startHeap + for (const path of paths) { + gates.get(path).release() + } + expect(await Promise.all(pending)).toEqual( + Array.from({ length: 4 }, () => ({ + content: '', + isBinary: true, + isImage: true, + mimeType: 'image/png' + })) + ) + await assertReleased(paths) + report.controls.push({ + name: 'held-metadata-foreign-history', + ...retained, + decodedTransferBytes: 16 * payload.size, + peakRegisteredStreams: stats.peakStreams, + maxConcurrentStreams: MAX_CONCURRENT_STREAMS, + ackWindow: STREAM_ACK_WINDOW_CHUNKS, + ackCount: stats.acks, + observedHeapDelta: heapDelta, + released: true + }) + }) + it('finishes normally without a held metadata request and releases reader state', async () => { + const { mux } = connect() + const payload = await makePayload(STREAM_CHUNK_SIZE + 17) + for (let i = 0; i < 3; i++) { + await successfulRead(mux, payload) + } + await assertReleased([payload.path]) + report.controls.push({ name: 'ordinary-completion', passed: true }) + }) + it('cleans up all pending metadata listeners when transport is disposed', async () => { + const paths = await heldFiles(2) + const { mux } = connect() + const pending = paths.map((path) => + readFileViaStream(mux, path).catch((error) => { + void error.stack + return error.code + }) + ) + await successfulRead(mux, await makePayload(STREAM_CHUNK_SIZE + 1)) + expect(snapshot(paths).wrappers).toBe(candidate ? 0 : 6) + mux.dispose('connection_lost') + const results = await Promise.all(pending) + expect(results).toEqual(['CONNECTION_LOST', 'CONNECTION_LOST']) + await assertReleased(paths) + report.controls.push({ name: 'transport-disposal', passed: true }) + }) + it('retains no reader history after the 30 second request deadline while relay work remains pending', async () => { + const paths = await heldFiles(1) + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] + }) + const { mux, stats } = connect() + const pending = readFileViaStream(mux, paths[0]).catch((error) => error) + await successfulRead(mux, await makePayload(STREAM_CHUNK_SIZE)) + expect(snapshot(paths).wrappers).toBe(candidate ? 0 : 2) + // Keep the actual mux/relay health timers active on the fake clock as well. + for (let i = 0; i < 6; i++) { + await vi.advanceTimersByTimeAsync(5000) + } + expect((await pending).code).toBe('SSH_MUX_REQUEST_TIMEOUT') + expect(stats.contexts[0].signal.aborted).toBe(true) + vi.useRealTimers() + await assertReleased(paths) + report.controls.push({ + name: 'metadata-request-deadline', + milliseconds: 30000, + relayContextAborted: true, + released: true + }) + }) + it('supports a relay that ignores optional chunk pacing', async () => { + const paths = await heldFiles(1) + const { mux, stats } = connect({ pacing: false }) + const pending = readFileViaStream(mux, paths[0]) + await successfulRead(mux, await makePayload(2 * STREAM_CHUNK_SIZE + 7)) + expect(stats.chunks).toBe(3) + expect(snapshot(paths).wrappers).toBe(candidate ? 0 : 4) + gates.get(paths[0]).release() + await pending + await assertReleased(paths) + report.controls.push({ name: 'unpaced-relay', passed: true }) + }) + it('actually stops the pump after four chunks until acknowledgements resume', async () => { + const { mux, registry, stats } = connect({ passAcks: false }) + const payload = await makePayload(6 * STREAM_CHUNK_SIZE) + const pending = successfulRead(mux, payload) + for (let i = 0; i < 200 && stats.chunks < 4; i++) { + await new Promise((resolve) => setTimeout(resolve, 2)) + } + expect(stats.chunks).toBe(4) + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(stats.chunks).toBe(4) + registry.recordAck(1, 3) + await pending + expect(stats.chunks).toBe(6) + report.controls.push({ name: 'real-pump-credit-window', chunksBeforeAck: 4, totalChunks: 6 }) + }) + it('enforces the real 16 slot limit and admits another file after completion', async () => { + const { mux, registry, stats } = connect({ passAcks: false }) + const payload = await makePayload(5 * STREAM_CHUNK_SIZE) + const pending = Array.from({ length: 16 }, () => successfulRead(mux, payload)) + for (let i = 0; i < 500 && stats.chunks < 64; i++) { + await new Promise((resolve) => setTimeout(resolve, 2)) + } + expect(registry.size()).toBe(16) + expect(stats.chunks).toBe(64) + const error = await readFileViaStream(mux, payload.path).catch((error) => error) + expect(error.code).toBe(RelayErrorCode.TooManyStreams) + for (let id = 1; id <= 16; id++) { + registry.recordAck(id, 3) + } + await Promise.all(pending) + expect(registry.size()).toBe(0) + const small = await makePayload(1) + await successfulRead(mux, small) + report.controls.push({ + name: 'actual-stream-capacity', + slots: 16, + rejectedSeventeenth: true, + admittedAfterCompletion: true + }) + }) + it('writes metadata before own chunks when the relay writer resumes from saturation', async () => { + const fixture = connect({ blockFirstWrite: true }) + fixture.dispatcher.notifyClient(1, 'probe.prime') + const payload = await makePayload(STREAM_CHUNK_SIZE + 1) + const pending = successfulRead(fixture.mux, payload) + for (let i = 0; i < 100 && fixture.stats.peakStreams < 1; i++) { + await new Promise((resolve) => setTimeout(resolve, 2)) + } + await nextTurn() + expect(fixture.stats.peakStreams).toBe(1) + expect(fixture.stats.wireOrder).toEqual(['probe.prime']) + fixture.drain() + await pending + expect(fixture.stats.wireOrder).toEqual([ + 'probe.prime', + 'response', + 'fs.streamChunk', + 'fs.streamChunk', + 'fs.streamEnd' + ]) + report.controls.push({ + name: 'saturated-writer-metadata-order', + wireOrder: fixture.stats.wireOrder + }) + }) + it('handles response and own chunk/end in one decoder dispatch turn', async () => { + let receive + let requestId + const mux = new SshChannelMultiplexer({ + write(data) { + if (data[0] === 1) { + const message = JSON.parse(data.subarray(13).toString()) + if (message.method === 'fs.readFileStream') { + requestId = message.id + } + } + }, + onData(callback) { + receive = callback + }, + onClose() {} + }) + const pending = readFileViaStream(mux, 'coalesced.png') + const data = Buffer.from('adjacent\0frame') + receive( + Buffer.concat([ + encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id: requestId, + result: { streamId: 7, totalSize: data.length, isBinary: true } + }, + 1, + 0 + ), + encodeJsonRpcFrame( + { + jsonrpc: '2.0', + method: 'fs.streamChunk', + params: { streamId: 7, seq: 0, data: data.toString('base64') } + }, + 2, + 0 + ), + encodeJsonRpcFrame( + { jsonrpc: '2.0', method: 'fs.streamEnd', params: { streamId: 7 } }, + 3, + 0 + ) + ]) + ) + expect(await pending).toEqual({ content: data.toString('base64'), isBinary: true }) + mux.dispose() + await assertReleased(['coalesced.png']) + report.controls.push({ name: 'same-turn-response-and-own-frames', passed: true }) + }) +}) + +it('reconstructs both sources identically from synthetic CRLF checkout and patch reads', () => { + const { loadSources } = createRequire(import.meta.url)('./sources.cjs') + let reads = 0 + const observed = loadSources({ + read(filename) { + reads += 1 + return readFileSync(filename, 'utf8').replace(/\r?\n/g, '\r\n') + } + }) + const ordinary = loadSources() + expect(observed.hashes).toEqual(ordinary.hashes) + expect([...observed.sources]).toEqual([...ordinary.sources]) + report.controls.push({ name: 'canonical-crlf-source-control', reads, passed: true }) +}) diff --git a/docs/audits/ssh-file-metadata-retention/source-versions.json b/docs/audits/ssh-file-metadata-retention/source-versions.json new file mode 100644 index 00000000000..66f6a28a3d4 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/source-versions.json @@ -0,0 +1,225 @@ +{ + "sourcePath": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "baselineSha256": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef", + "fixedSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "contextPath": "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts", + "worktreeGraph": { + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "mainGraph": { + "src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "callerHashes": { + "src/main/providers/ssh-filesystem-provider.ts": "de6051f43ea272fe0a15b7ef5c6df5c8caedfa7adaada205386c4f19be7f04f2", + "src/main/ipc/filesystem/filesystem-read-handlers.ts": "cf012e6a7049f803936c75a32619cf1f209053d4b8951f67f7e4e39218448a6a", + "src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts": "27c4b20397471fe036f8fdfe0f76f089bf6bbf9e6d4ae9f23b41bc49359bc8bc", + "src/main/ai-vault/remote-session-scan-concurrency.ts": "1b7147a2b5d793d7f78059c2ae67c41f531ebfa37a9bd292091401d21143e36a", + "src/relay/fs-handler.ts": "bc8c57bdf91e5d34b2fb42d8fd00260873224c7b04d69e5aafae149a377c4235" + }, + "mainRef": "291b4ddd6f1c1af480169885e0fda7f9c78ff053", + "mainOriginalSourceHashes": { + "src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "reportedVersionComparison": { + "commit": "e0826956fcfc532f5a1e55b5e081f2e57e553c43", + "sourceHashes": { + "src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef", + "src/main/ssh/ssh-channel-multiplexer.ts": "480c722b27dd1ffb8c70bfca8fb3568294ff2777b7b02607548df93bb280f6ae", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "4a73e2194f15ec0604802fe6810742f34930fc55e542458b6d8ab7344ee841a2", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/providers/ssh-filesystem-provider.ts": "03d53c8be02737024f5c5f4f08d614a276a8d03f0e599c331be9d0eaa85c2d80", + "src/main/ipc/filesystem/filesystem-read-handlers.ts": "2491d39f0576a961a249ebbf94e563f2eefe7a0cf899bbb6bc77b7a9e7a2aa30", + "src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts": "27c4b20397471fe036f8fdfe0f76f089bf6bbf9e6d4ae9f23b41bc49359bc8bc", + "src/main/ai-vault/remote-session-scan-concurrency.ts": "175944c836a683a41c7d6446d45794eb9e0dd021414926370c64bc5fd574ad34", + "src/relay/fs-handler.ts": "b4f4121c3081b8c98749c4d1cb45fd3355f0c053c9cc078f539cb71210976533", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/relay/protocol.ts": "678f9d6dac998385ca10e94da9864fe3451c82fcc88b9c28490dfb42e99606a4", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34" + } + } +} diff --git a/docs/audits/ssh-file-metadata-retention/sources.cjs b/docs/audits/ssh-file-metadata-retention/sources.cjs new file mode 100644 index 00000000000..08a2f0424ed --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/sources.cjs @@ -0,0 +1,84 @@ +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { createHash } = require('node:crypto') +const path = require('node:path') +const { applyPatch, parsePatch, reversePatch } = require('diff') +const root = path.resolve(__dirname, '../../..') +const canonicalLf = (text) => text.replaceAll('\r\n', '\n') +const sha256 = (text) => createHash('sha256').update(text).digest('hex') +const readText = (filename) => canonicalLf(readFileSync(filename, 'utf8')) +const versions = JSON.parse(readText(path.join(__dirname, 'source-versions.json'))) + +function checkedPatch(name, expectedPath, read) { + const patches = parsePatch(canonicalLf(read(path.join(__dirname, name)))) + assert.equal(patches.length, 1) + assert.equal(patches[0].newFileName, `b/${expectedPath}`) + assert.equal(patches[0].oldFileName, `a/${expectedPath}`) + return patches[0] +} + +function observePending(source) { + return source.replace( + ' const pending: PendingFrame[] = []', + ' const pending: PendingFrame[] = []; globalThis.__sshPendingReaders.set(filePath, new WeakRef(pending))' + ) +} + +function loadSources({ + graph = process.env.ORCA_SSH_READER_GRAPH ?? 'worktree', + variant = process.env.ORCA_SSH_READER_VARIANT ?? 'fixed', + read = readText +} = {}) { + assert.ok(graph === 'worktree' || graph === 'main') + assert.ok(variant === 'before' || variant === 'fixed') + const targetPatch = checkedPatch('fix.patch', versions.sourcePath, read) + const contextPatch = checkedPatch('main-context.patch', versions.contextPath, read) + const sources = new Map() + const hashes = {} + const selected = graph === 'main' ? versions.mainGraph : versions.worktreeGraph + for (const [relative, expected] of Object.entries(selected)) { + const filename = path.join(root, relative) + let text = canonicalLf(read(filename)) + if (relative === versions.sourcePath) { + assert.equal(sha256(text), versions.fixedSha256, 'Fixed reader drift') + if (variant === 'before') { + text = applyPatch(text, reversePatch(targetPatch)) + assert.notEqual(text, false, 'Reader patch no longer reverses') + assert.equal(sha256(text), versions.baselineSha256) + } + } else if (relative === versions.contextPath) { + const actual = sha256(text) + assert.ok( + actual === versions.worktreeGraph[relative] || actual === versions.mainGraph[relative], + 'Unaudited writer context' + ) + if (actual !== expected) { + text = applyPatch(text, graph === 'main' ? contextPatch : reversePatch(contextPatch)) + assert.notEqual(text, false, 'Writer context no longer reconstructs') + } + assert.equal(sha256(text), expected) + } else { + assert.equal(sha256(text), expected, `Graph source drift: ${relative}`) + } + hashes[relative] = sha256(text) + sources.set(filename, text) + } + for (const [relative, expected] of Object.entries(versions.callerHashes)) { + assert.equal( + sha256(canonicalLf(read(path.join(root, relative)))), + expected, + `Caller drift: ${relative}` + ) + } + const reader = sources.get(path.join(root, versions.sourcePath)) + return { + root, + sources, + hashes, + observedReaderSha256: sha256(observePending(reader)), + graph, + variant + } +} + +module.exports = { loadSources, observePending, readText, versions } diff --git a/docs/audits/ssh-file-metadata-retention/validation.json b/docs/audits/ssh-file-metadata-retention/validation.json new file mode 100644 index 00000000000..862ccc5d497 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/validation.json @@ -0,0 +1,264 @@ +{ + "productTests": { + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/relay/fs-handler-stream.test.ts", + "passed": 124, + "files": 5, + "newTests": 9, + "exitCode": 0 + }, + "baselineOverlay": { + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/ssh-file-metadata-retention/before.config.mjs src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts", + "passed": 22, + "expectedFailed": 1, + "failure": "64 foreign frame params objects remain reachable before this reader receives metadata.", + "exitCode": 1 + }, + "transportIntegration": { + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/fs-stream-pty-echo-backpressure.integration.test.ts", + "passed": 3, + "exitCode": 0 + }, + "typecheck": { + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm tc:node", + "exitCode": 0 + }, + "portableProof": { + "cases": 80, + "reports": [ + { + "file": "worktree-before-node-results.json", + "controls": 10, + "runtime": { + "node": "26.6.0", + "electron": null + }, + "heapDelta": 45478552 + }, + { + "file": "worktree-before-electron-results.json", + "controls": 10, + "runtime": { + "node": "24.21.0", + "electron": "43.7.0" + }, + "heapDelta": 45298880 + }, + { + "file": "worktree-fixed-node-results.json", + "controls": 10, + "runtime": { + "node": "26.6.0", + "electron": null + }, + "heapDelta": -663256 + }, + { + "file": "worktree-fixed-electron-results.json", + "controls": 10, + "runtime": { + "node": "24.21.0", + "electron": "43.7.0" + }, + "heapDelta": 520252 + }, + { + "file": "main-before-node-results.json", + "controls": 10, + "runtime": { + "node": "26.6.0", + "electron": null + }, + "heapDelta": 45477336 + }, + { + "file": "main-before-electron-results.json", + "controls": 10, + "runtime": { + "node": "24.21.0", + "electron": "43.7.0" + }, + "heapDelta": 45298640 + }, + { + "file": "main-fixed-node-results.json", + "controls": 10, + "runtime": { + "node": "26.6.0", + "electron": null + }, + "heapDelta": -669752 + }, + { + "file": "main-fixed-electron-results.json", + "controls": 10, + "runtime": { + "node": "24.21.0", + "electron": "43.7.0" + }, + "heapDelta": 513692 + } + ], + "sourceGraphModules": 59, + "additionalCallerSources": 5, + "canonicalCrLfReads": 66 + }, + "quality": [ + { + "label": "ordinary lint", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs", + "exitCode": 0 + }, + { + "label": "casting", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings --config config/oxlint-code-quality-casting.json src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs", + "exitCode": 1 + }, + { + "label": "type-aware", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings --type-aware --config config/oxlint-code-quality-type-aware.json src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs", + "exitCode": 0 + }, + { + "label": "native code quality", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings --config config/oxlint-code-quality-native-plugins.json src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs", + "exitCode": 0 + }, + { + "label": "anti-slop", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings --config config/oxlint-anti-slop.json src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs", + "exitCode": 0 + } + ], + "inheritedCasting": { + "baselineFindings": 15, + "currentFindings": 15, + "sameRuleAndExactAssertionSpans": true, + "findings": [ + { + "path": "src/main/providers/ssh-filesystem-provider-stream.test.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["mux as never"] + }, + { + "path": "src/main/providers/ssh-filesystem-provider-stream.test.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["new Error('Method not found') as Error & { code: number }"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["err as Error"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["err as Error"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["err as { code?: unknown }"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["new Error(message) as Error & { code: string }"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["new Error(message) as Error & { code: string }"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["params.code as string | undefined"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["params.data as string"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["params.message as string | undefined"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["params.seq as number"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["params.streamId as number | undefined"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["params.streamId as number | undefined"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["params.streamId as number | undefined"] + }, + { + "path": "src/main/ssh/ssh-filesystem-stream-reader.ts", + "rule": "typescript(consistent-type-assertions)", + "spans": ["rawMetadata as StreamMetadataResponse"] + } + ], + "baselineSourceHashes": { + "src/main/providers/ssh-filesystem-provider-stream.test.ts": "422bcaa7293925217f9c61197599f26e9f0f0993cb562ec004a48a1b27d43fa3", + "src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef" + } + }, + "changedCodeGate": { + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm run check:code-quality:changed", + "exitCode": 0, + "newFindings": 0, + "observedGlobalChangedFiles": 392, + "baseline": "2fccacadbe23", + "scope": "Primary worktree including concurrent global evidence changes" + }, + "whitespace": { + "fullContentsChecked": true, + "zeroContextPatches": true, + "diagnostics": 0 + }, + "formatting": { + "idempotent": true + }, + "emptyFileFixtureCiCorrection": { + "failedHead": "458e11e9f3f5981a85fc1006083c19738a8b26e3", + "jobUrl": "https://github.com/stablyai/orca/actions/runs/35186075669/job/105088389138", + "cause": "General provider test double resolved empty metadata without invoking the synchronous beforeResolve callback. Real-mux empty metadata cases already passed.", + "correction": "Move the existing empty-file control to the existing streaming fixture, which models beforeResolve; also assert all stream/disposal listeners are released. No product change.", + "localCounterexample": { + "failed": 1, + "skipped": 53, + "timeoutMs": 1000, + "logSha256": "a53af8ea05b06fabf918ea12f5c81f635dbfec97709688ce1139f9efc619a438" + }, + "fixedFiveSuites": { + "passed": 124, + "exitCode": 0, + "logSha256": "bab1beb5ed50ff0611b67eda8a96b1176a86442baa50abc07516402e987e31db" + }, + "baselineOverlay": { + "expectedFailed": 1, + "passed": 22, + "exitCode": 1, + "logSha256": "044aaae5fd3943263616d32eafafbfbb0fc4fb742f754feb233a8e6b49160b6b" + }, + "otherCiFailure": { + "jobUrl": "https://github.com/stablyai/orca/actions/runs/35186075669/job/105088388326", + "path": "src/main/windows/windows-pty-job.win32.test.ts", + "failure": "ConPTY job ownership: grandchild never reported its pid", + "mainAndPublishedBlob": "c5f408f73115a11821b06fafe04ca28eaa15acb7", + "scope": "Untouched Windows native test; missing PID cause not established. No retry or timing-threshold change." + } + } +} diff --git a/docs/audits/ssh-file-metadata-retention/vitest.config.mjs b/docs/audits/ssh-file-metadata-retention/vitest.config.mjs new file mode 100644 index 00000000000..b1f6975e0df --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/vitest.config.mjs @@ -0,0 +1,31 @@ +import { resolve, sep } from 'node:path' +import { createRequire } from 'node:module' +import base from '../../../config/vitest.config.ts' +const { loadSources, observePending } = createRequire(import.meta.url)('./sources.cjs') +const loaded = loadSources() +export default { + ...base, + test: { + ...base.test, + setupFiles: [], + include: ['docs/audits/ssh-file-metadata-retention/scenario.test.mjs'], + maxWorkers: 1 + }, + plugins: [ + { + name: 'ssh-file-metadata-source-graph', + enforce: 'pre', + transform(_source, id) { + const absolute = resolve(id.split('?')[0]) + const source = loaded.sources.get(absolute) + if (source !== undefined) { + return { code: observePending(source), map: null } + } + if (absolute.startsWith(resolve(loaded.root, 'src') + sep) && absolute.endsWith('.ts')) { + throw new Error(`Unreviewed source import: ${absolute}`) + } + return null + } + } + ] +} diff --git a/docs/audits/ssh-file-metadata-retention/worktree-before-electron-results.json b/docs/audits/ssh-file-metadata-retention/worktree-before-electron-results.json new file mode 100644 index 00000000000..1bec84156d8 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/worktree-before-electron-results.json @@ -0,0 +1,140 @@ +{ + "variant": "before", + "graph": "worktree", + "runtime": { + "node": "24.21.0", + "electron": "43.7.0" + }, + "sources": { + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "observedReaderSha256": "7a5c9c14faf63197765fc5a2900e1d3488f94aaab6757425b7ef87597479e963", + "controls": [ + { + "name": "held-metadata-foreign-history", + "readers": 4, + "entries": [144, 144, 144, 144], + "wrappers": 576, + "uniqueParams": 144, + "logicalBase64BytesByUniqueParams": 44739584, + "sharedAcrossReaders": true, + "decodedTransferBytes": 33554432, + "peakRegisteredStreams": 1, + "maxConcurrentStreams": 16, + "ackWindow": 4, + "ackCount": 128, + "observedHeapDelta": 45298880, + "released": true + }, + { + "name": "ordinary-completion", + "passed": true + }, + { + "name": "transport-disposal", + "passed": true + }, + { + "name": "metadata-request-deadline", + "milliseconds": 30000, + "relayContextAborted": true, + "released": true + }, + { + "name": "unpaced-relay", + "passed": true + }, + { + "name": "real-pump-credit-window", + "chunksBeforeAck": 4, + "totalChunks": 6 + }, + { + "name": "actual-stream-capacity", + "slots": 16, + "rejectedSeventeenth": true, + "admittedAfterCompletion": true + }, + { + "name": "saturated-writer-metadata-order", + "wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"] + }, + { + "name": "same-turn-response-and-own-frames", + "passed": true + }, + { + "name": "canonical-crlf-source-control", + "reads": 66, + "passed": true + } + ], + "artifactHashes": { + "sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f", + "relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079", + "scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af", + "vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849", + "before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962", + "fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6", + "main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9", + "source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d" + } +} diff --git a/docs/audits/ssh-file-metadata-retention/worktree-before-node-results.json b/docs/audits/ssh-file-metadata-retention/worktree-before-node-results.json new file mode 100644 index 00000000000..b16ca49d1c6 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/worktree-before-node-results.json @@ -0,0 +1,140 @@ +{ + "variant": "before", + "graph": "worktree", + "runtime": { + "node": "26.6.0", + "electron": null + }, + "sources": { + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "observedReaderSha256": "7a5c9c14faf63197765fc5a2900e1d3488f94aaab6757425b7ef87597479e963", + "controls": [ + { + "name": "held-metadata-foreign-history", + "readers": 4, + "entries": [144, 144, 144, 144], + "wrappers": 576, + "uniqueParams": 144, + "logicalBase64BytesByUniqueParams": 44739584, + "sharedAcrossReaders": true, + "decodedTransferBytes": 33554432, + "peakRegisteredStreams": 1, + "maxConcurrentStreams": 16, + "ackWindow": 4, + "ackCount": 128, + "observedHeapDelta": 45478552, + "released": true + }, + { + "name": "ordinary-completion", + "passed": true + }, + { + "name": "transport-disposal", + "passed": true + }, + { + "name": "metadata-request-deadline", + "milliseconds": 30000, + "relayContextAborted": true, + "released": true + }, + { + "name": "unpaced-relay", + "passed": true + }, + { + "name": "real-pump-credit-window", + "chunksBeforeAck": 4, + "totalChunks": 6 + }, + { + "name": "actual-stream-capacity", + "slots": 16, + "rejectedSeventeenth": true, + "admittedAfterCompletion": true + }, + { + "name": "saturated-writer-metadata-order", + "wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"] + }, + { + "name": "same-turn-response-and-own-frames", + "passed": true + }, + { + "name": "canonical-crlf-source-control", + "reads": 66, + "passed": true + } + ], + "artifactHashes": { + "sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f", + "relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079", + "scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af", + "vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849", + "before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962", + "fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6", + "main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9", + "source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d" + } +} diff --git a/docs/audits/ssh-file-metadata-retention/worktree-fixed-electron-results.json b/docs/audits/ssh-file-metadata-retention/worktree-fixed-electron-results.json new file mode 100644 index 00000000000..c40cf2fe652 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/worktree-fixed-electron-results.json @@ -0,0 +1,140 @@ +{ + "variant": "fixed", + "graph": "worktree", + "runtime": { + "node": "24.21.0", + "electron": "43.7.0" + }, + "sources": { + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "observedReaderSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "controls": [ + { + "name": "held-metadata-foreign-history", + "readers": 4, + "entries": [0, 0, 0, 0], + "wrappers": 0, + "uniqueParams": 0, + "logicalBase64BytesByUniqueParams": 0, + "sharedAcrossReaders": false, + "decodedTransferBytes": 33554432, + "peakRegisteredStreams": 1, + "maxConcurrentStreams": 16, + "ackWindow": 4, + "ackCount": 128, + "observedHeapDelta": 520252, + "released": true + }, + { + "name": "ordinary-completion", + "passed": true + }, + { + "name": "transport-disposal", + "passed": true + }, + { + "name": "metadata-request-deadline", + "milliseconds": 30000, + "relayContextAborted": true, + "released": true + }, + { + "name": "unpaced-relay", + "passed": true + }, + { + "name": "real-pump-credit-window", + "chunksBeforeAck": 4, + "totalChunks": 6 + }, + { + "name": "actual-stream-capacity", + "slots": 16, + "rejectedSeventeenth": true, + "admittedAfterCompletion": true + }, + { + "name": "saturated-writer-metadata-order", + "wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"] + }, + { + "name": "same-turn-response-and-own-frames", + "passed": true + }, + { + "name": "canonical-crlf-source-control", + "reads": 66, + "passed": true + } + ], + "artifactHashes": { + "sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f", + "relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079", + "scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af", + "vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849", + "before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962", + "fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6", + "main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9", + "source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d" + } +} diff --git a/docs/audits/ssh-file-metadata-retention/worktree-fixed-node-results.json b/docs/audits/ssh-file-metadata-retention/worktree-fixed-node-results.json new file mode 100644 index 00000000000..e3a75af6824 --- /dev/null +++ b/docs/audits/ssh-file-metadata-retention/worktree-fixed-node-results.json @@ -0,0 +1,140 @@ +{ + "variant": "fixed", + "graph": "worktree", + "runtime": { + "node": "26.6.0", + "electron": null + }, + "sources": { + "src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18", + "src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108", + "src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2", + "src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060", + "src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd", + "src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546", + "src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a", + "src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e", + "src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a", + "src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1", + "src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34", + "src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071", + "src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8", + "src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851", + "src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11", + "src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081", + "src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194", + "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc", + "src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6", + "src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3", + "src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189", + "src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185", + "src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2", + "src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536", + "src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b", + "src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5", + "src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be", + "src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98", + "src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81", + "src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d", + "src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b", + "src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4", + "src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b", + "src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9", + "src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3", + "src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69", + "src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62", + "src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22", + "src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a", + "src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38", + "src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3", + "src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf", + "src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6", + "src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9", + "src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f", + "src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb", + "src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91", + "src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405", + "src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e", + "src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416", + "src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797", + "src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a", + "src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63", + "src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f", + "src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b", + "src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb", + "src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba", + "src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c" + }, + "observedReaderSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a", + "controls": [ + { + "name": "held-metadata-foreign-history", + "readers": 4, + "entries": [0, 0, 0, 0], + "wrappers": 0, + "uniqueParams": 0, + "logicalBase64BytesByUniqueParams": 0, + "sharedAcrossReaders": false, + "decodedTransferBytes": 33554432, + "peakRegisteredStreams": 1, + "maxConcurrentStreams": 16, + "ackWindow": 4, + "ackCount": 128, + "observedHeapDelta": -663256, + "released": true + }, + { + "name": "ordinary-completion", + "passed": true + }, + { + "name": "transport-disposal", + "passed": true + }, + { + "name": "metadata-request-deadline", + "milliseconds": 30000, + "relayContextAborted": true, + "released": true + }, + { + "name": "unpaced-relay", + "passed": true + }, + { + "name": "real-pump-credit-window", + "chunksBeforeAck": 4, + "totalChunks": 6 + }, + { + "name": "actual-stream-capacity", + "slots": 16, + "rejectedSeventeenth": true, + "admittedAfterCompletion": true + }, + { + "name": "saturated-writer-metadata-order", + "wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"] + }, + { + "name": "same-turn-response-and-own-frames", + "passed": true + }, + { + "name": "canonical-crlf-source-control", + "reads": 66, + "passed": true + } + ], + "artifactHashes": { + "sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f", + "relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079", + "scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af", + "vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849", + "before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962", + "fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6", + "main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9", + "source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d" + } +} diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index dd3e004b362..82defb9d0cc 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshMultiplexerRequestOptions } from '../ssh/ssh-channel-multiplexer' import { SshFilesystemProvider } from './ssh-filesystem-provider' import { SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS } from '../ssh/ssh-file-stream-inactivity-deadline' import { publishSystemResume, publishSystemSuspend } from '../system-power-lifecycle' type MockMultiplexer = { request: ReturnType + _response: ReturnType notify: ReturnType onNotification: ReturnType onNotificationByMethod: ReturnType @@ -16,10 +18,22 @@ type MockMultiplexer = { } function createMockMux(): MockMultiplexer { + const response = vi.fn().mockResolvedValue(undefined) const methodHandlers = new Map) => void>>() const disposeHandlers = new Set<(reason: 'shutdown' | 'connection_lost') => void>() return { - request: vi.fn().mockResolvedValue(undefined), + _response: response, + request: vi.fn( + async ( + method: string, + params?: Record, + options?: SshMultiplexerRequestOptions + ) => { + const result: unknown = await response(method, params) + options?.beforeResolve?.(result) + return result + } + ), notify: vi.fn(), onNotification: vi.fn(), onNotificationByMethod: vi.fn( @@ -69,15 +83,21 @@ describe('SshFilesystemProvider readFile streaming', () => { vi.useRealTimers() }) + it('returns empty metadata and releases its stream listeners', async () => { + mux._response.mockResolvedValue({ totalSize: 0, isBinary: false, empty: true }) + const result = await provider.readFile('/home/user/empty.txt') + expect(result).toEqual({ content: '', isBinary: false }) + expect(mux._listenerCount()).toBe(0) + }) + it('streams via fs.readFileStream and reassembles utf-8 text', async () => { const text = 'hello world' const totalSize = Buffer.byteLength(text, 'utf-8') - mux.request.mockImplementation(async (method: string) => { + mux._response.mockImplementation(async (method: string) => { if (method !== 'fs.readFileStream') { throw new Error(`unexpected method ${method}`) } - // Why: setImmediate fires after the metadata-resolution .then has set - // streamIdRef, ensuring subscribed handlers see a matching streamId. + // The relay schedules chunks after publishing metadata. setImmediate(() => { mux._emitMethod('fs.streamChunk', { streamId: 1, @@ -96,16 +116,19 @@ describe('SshFilesystemProvider readFile streaming', () => { }) const result = await provider.readFile('/home/user/file.txt') - expect(mux.request).toHaveBeenCalledWith('fs.readFileStream', { - filePath: '/home/user/file.txt', - flowControl: 'ack' - }) + expect(mux.request.mock.calls[0]?.slice(0, 2)).toEqual([ + 'fs.readFileStream', + { + filePath: '/home/user/file.txt', + flowControl: 'ack' + } + ]) expect(result).toEqual({ content: text, isBinary: false }) }) it('falls back to legacy fs.readFile on -32601 method-not-found', async () => { const legacyResult = { content: 'legacy', isBinary: false } - mux.request.mockImplementation(async (method: string) => { + mux._response.mockImplementation(async (method: string) => { if (method === 'fs.readFileStream') { const err = new Error('Method not found') as Error & { code: number } err.code = -32601 @@ -124,7 +147,7 @@ describe('SshFilesystemProvider readFile streaming', () => { it('rejects when chunk arrives out of order', async () => { const totalSize = 256 * 1024 * 2 - mux.request.mockImplementation(async () => { + mux._response.mockImplementation(async () => { setImmediate(() => { mux._emitMethod('fs.streamChunk', { streamId: 1, @@ -144,7 +167,7 @@ describe('SshFilesystemProvider readFile streaming', () => { }) it('rejects when totalSize exceeds client cap without allocating', async () => { - mux.request.mockResolvedValue({ + mux._response.mockResolvedValue({ streamId: 1, totalSize: 51 * 1024 * 1024, isBinary: true, @@ -156,7 +179,7 @@ describe('SshFilesystemProvider readFile streaming', () => { }) it('applies a caller binary cap before allocating the stream buffer', async () => { - mux.request.mockResolvedValue({ + mux._response.mockResolvedValue({ streamId: 2, totalSize: 2, isBinary: true, @@ -172,7 +195,7 @@ describe('SshFilesystemProvider readFile streaming', () => { it('rejects on fs.streamError notification', async () => { const totalSize = 1024 - mux.request.mockImplementation(async () => { + mux._response.mockImplementation(async () => { setImmediate(() => { mux._emitMethod('fs.streamError', { streamId: 7, @@ -193,7 +216,7 @@ describe('SshFilesystemProvider readFile streaming', () => { it('cancels and cleans up a stream that stalls after metadata', async () => { vi.useFakeTimers() - mux.request.mockResolvedValue({ + mux._response.mockResolvedValue({ streamId: 9, totalSize: 1, isBinary: false, @@ -213,7 +236,7 @@ describe('SshFilesystemProvider readFile streaming', () => { it('keeps a long stream alive while chunks continue arriving', async () => { vi.useFakeTimers() const chunkSize = 256 * 1024 - mux.request.mockResolvedValue({ + mux._response.mockResolvedValue({ streamId: 10, totalSize: chunkSize + 1, isBinary: true, @@ -246,7 +269,7 @@ describe('SshFilesystemProvider readFile streaming', () => { it('grants an active stream a fresh inactivity window after system resume', async () => { vi.useFakeTimers() - mux.request.mockResolvedValue({ + mux._response.mockResolvedValue({ streamId: 11, totalSize: 1, isBinary: false, @@ -276,7 +299,7 @@ describe('SshFilesystemProvider readFile streaming', () => { it('keeps metadata received during suspend paused until resume', async () => { vi.useFakeTimers() - mux.request.mockResolvedValue({ + mux._response.mockResolvedValue({ streamId: 12, totalSize: 1, isBinary: false, @@ -310,7 +333,7 @@ describe('SshFilesystemProvider readFile streaming', () => { it('rejects on chunk count mismatch at streamEnd', async () => { const totalSize = 256 * 1024 * 3 - mux.request.mockImplementation(async () => { + mux._response.mockImplementation(async () => { setImmediate(() => { mux._emitMethod('fs.streamChunk', { streamId: 1, @@ -335,7 +358,7 @@ describe('SshFilesystemProvider readFile streaming', () => { // count matches (2), so the old code resolved with a zero-filled tail. The // exact-length check must reject this. const totalSize = 256 * 1024 * 2 - mux.request.mockImplementation(async () => { + mux._response.mockImplementation(async () => { setImmediate(() => { mux._emitMethod('fs.streamChunk', { streamId: 1, @@ -362,7 +385,7 @@ describe('SshFilesystemProvider readFile streaming', () => { it('rejects a short non-final chunk before later chunks arrive', async () => { const totalSize = 256 * 1024 * 2 - mux.request.mockImplementation(async () => { + mux._response.mockImplementation(async () => { setImmediate(() => { mux._emitMethod('fs.streamChunk', { streamId: 1, diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index b9cb21c3354..a20dfb98d9b 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -77,14 +77,6 @@ describe('SshFilesystemProvider', () => { }) }) - describe('readFile', () => { - it('short-circuits on empty:true metadata without subscribing to chunks', async () => { - mux.request.mockResolvedValue({ totalSize: 0, isBinary: false, empty: true }) - const result = await provider.readFile('/home/user/empty.txt') - expect(result).toEqual({ content: '', isBinary: false }) - }) - }) - describe('readTerminalArtifact', () => { it('sends fs.readTerminalArtifact request with verification metadata', async () => { mux.request.mockResolvedValue({ content: '{}', isBinary: false }) diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index 568ab195dc8..8c5b8632fa7 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -74,13 +74,7 @@ export async function readFileViaStream( let bytesReceived = 0 let settled = false - // Why: chunk/end/error frames may arrive in the same dispatch tick as the - // metadata response. Queue them until streamIdRef is set, then drain. - type PendingFrame = - | { kind: 'chunk'; params: Record } - | { kind: 'end'; params: Record } - | { kind: 'error'; params: Record } - const pending: PendingFrame[] = [] + // Install metadata during response dispatch, before adjacent stream frames. let metadataReady = false const inactivity = createSshFileStreamInactivityDeadline(() => { @@ -229,23 +223,9 @@ export async function readFileViaStream( fail(err) } - const drainPending = (): void => { - while (!settled && pending.length > 0) { - const frame = pending.shift()! - if (frame.kind === 'chunk') { - handleChunk(frame.params) - } else if (frame.kind === 'end') { - handleEnd(frame.params) - } else { - handleStreamError(frame.params) - } - } - } - unsubscribers.push( mux.onNotificationByMethod('fs.streamChunk', (params) => { if (!metadataReady) { - pending.push({ kind: 'chunk', params }) return } handleChunk(params) @@ -254,7 +234,6 @@ export async function readFileViaStream( unsubscribers.push( mux.onNotificationByMethod('fs.streamEnd', (params) => { if (!metadataReady) { - pending.push({ kind: 'end', params }) return } handleEnd(params) @@ -263,7 +242,6 @@ export async function readFileViaStream( unsubscribers.push( mux.onNotificationByMethod('fs.streamError', (params) => { if (!metadataReady) { - pending.push({ kind: 'error', params }) return } handleStreamError(params) @@ -284,56 +262,67 @@ export async function readFileViaStream( void mux // Why: flowControl declares this client acks each chunk, letting a new // relay pace the pump. Old relays ignore the extra param and flood. - .request('fs.readFileStream', { filePath, flowControl: 'ack' }) - .then((rawMetadata) => { - if (settled) { - return - } - const metadata = rawMetadata as StreamMetadataResponse - isBinary = metadata.isBinary - isImage = metadata.isImage - mimeType = metadata.mimeType - resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64 + .request( + 'fs.readFileStream', + { filePath, flowControl: 'ack' }, + { + beforeResolve: (rawMetadata) => { + if (settled) { + return + } + const metadata = rawMetadata as StreamMetadataResponse + isBinary = metadata.isBinary + isImage = metadata.isImage + mimeType = metadata.mimeType + resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64 - if (metadata.empty) { - succeed({ - content: '', - isBinary: metadata.isBinary, - ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), - ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}) - }) - return - } + if (metadata.empty) { + succeed({ + content: '', + isBinary: metadata.isBinary, + ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), + ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}) + }) + return + } - if (typeof metadata.streamId !== 'number') { - fail(new StreamProtocolError('Metadata missing streamId for non-empty stream')) - return - } + if (typeof metadata.streamId !== 'number') { + fail(new StreamProtocolError('Metadata missing streamId for non-empty stream')) + return + } - const cap = sshFileStreamReadCap(metadata.isBinary, limits) - if (metadata.totalSize < 0 || metadata.totalSize > cap) { - streamIdRef.current = metadata.streamId - fail( - new FileReadCapExceededError( - `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` - ) - ) - return - } + const cap = sshFileStreamReadCap(metadata.isBinary, limits) + if (metadata.totalSize < 0 || metadata.totalSize > cap) { + streamIdRef.current = metadata.streamId + fail( + new FileReadCapExceededError( + `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` + ) + ) + return + } - totalSize = metadata.totalSize - totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE) - try { - buffer = Buffer.alloc(totalSize) - } catch (err) { - streamIdRef.current = metadata.streamId - fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`)) - return + totalSize = metadata.totalSize + totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE) + try { + buffer = Buffer.alloc(totalSize) + } catch (err) { + streamIdRef.current = metadata.streamId + fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`)) + return + } + streamIdRef.current = metadata.streamId + metadataReady = true + inactivity.reset() + } + } + ) + // Why: beforeResolve is an optional hook; if a mux ever resolves without running + // it, metadata never installs and no deadline is armed. Fail instead of hanging. + .then(() => { + if (!settled && !metadataReady) { + fail(new StreamProtocolError('Metadata response resolved without stream identity')) } - streamIdRef.current = metadata.streamId - metadataReady = true - inactivity.reset() - drainPending() }) .catch((err) => { fail(err as Error) diff --git a/src/main/ssh/ssh-filesystem-stream-retention.test.ts b/src/main/ssh/ssh-filesystem-stream-retention.test.ts new file mode 100644 index 00000000000..15400212f78 --- /dev/null +++ b/src/main/ssh/ssh-filesystem-stream-retention.test.ts @@ -0,0 +1,221 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { + FileReadCapExceededError, + readFileViaStream, + StreamProtocolError +} from './ssh-filesystem-stream-reader' +import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' +import { + encodeJsonRpcFrame, + MessageType, + parseJsonRpcMessage, + type JsonRpcMessage +} from './relay-protocol' + +const muxes: SshChannelMultiplexer[] = [] +afterEach(() => { + for (const mux of muxes.splice(0)) { + mux.dispose() + } +}) + +function createConnection() { + let receive = (_data: Buffer): void => {} + let sequence = 1 + const sent: JsonRpcMessage[] = [] + const mux = new SshChannelMultiplexer({ + write(data) { + if (data[0] === MessageType.Regular) { + sent.push(parseJsonRpcMessage(data.subarray(13))) + } + }, + onData(callback) { + receive = callback + }, + onClose() {} + }) + muxes.push(mux) + return { + mux, + sent, + feed(...messages: JsonRpcMessage[]) { + receive(Buffer.concat(messages.map((message) => encodeJsonRpcFrame(message, sequence++, 0)))) + } + } +} + +async function collect(): Promise { + if (!global.gc) { + throw new Error('Retention test requires --expose-gc') + } + for (let turn = 0; turn < 8; turn += 1) { + await new Promise((resolve) => setImmediate(resolve)) + global.gc() + } +} + +function sendForeignFrames(connection: ReturnType): WeakRef[] { + const refs: WeakRef[] = [] + const stop = connection.mux.onNotificationByMethod('fs.streamChunk', (params) => { + refs.push(new WeakRef(params)) + }) + for (let index = 0; index < 64; index += 1) { + connection.feed({ + jsonrpc: '2.0', + method: 'fs.streamChunk', + params: { streamId: index + 10, seq: 0, data: 'eA==' } + }) + } + stop() + return refs +} + +it('releases foreign stream frames while its own metadata is still pending', async () => { + const connection = createConnection() + const pending = readFileViaStream(connection.mux, '/held.txt') + const refs = sendForeignFrames(connection) + try { + expect(refs).toHaveLength(64) + await collect() + expect(refs.filter((ref) => ref.deref())).toHaveLength(0) + expect(connection.sent).toHaveLength(1) + } finally { + connection.feed({ + jsonrpc: '2.0', + id: 1, + result: { empty: true, totalSize: 0, isBinary: false } + }) + await pending + } +}) + +it('installs metadata before its own chunk and end in the same decoder turn', async () => { + const connection = createConnection() + const pending = readFileViaStream(connection.mux, '/own.txt') + const text = 'same turn 漢\u0000' + const data = Buffer.from(text) + connection.feed( + { + jsonrpc: '2.0', + id: 1, + result: { streamId: 3, totalSize: data.length, isBinary: false, resultEncoding: 'utf-8' } + }, + { + jsonrpc: '2.0', + method: 'fs.streamChunk', + params: { streamId: 3, seq: 0, data: data.toString('base64') } + }, + { jsonrpc: '2.0', method: 'fs.streamEnd', params: { streamId: 3 } } + ) + await expect(pending).resolves.toEqual({ content: text, isBinary: false }) + expect(connection.sent).toContainEqual({ + jsonrpc: '2.0', + method: 'fs.streamAck', + params: { streamId: 3, seq: 0 } + }) +}) + +it('preserves empty image metadata while ignoring earlier unknown stream identifiers', async () => { + const connection = createConnection() + const pending = readFileViaStream(connection.mux, '/empty.png') + connection.feed( + { jsonrpc: '2.0', method: 'fs.streamChunk', params: { streamId: -1, seq: 0, data: 'eA==' } }, + { + jsonrpc: '2.0', + id: 1, + result: { empty: true, totalSize: 0, isBinary: true, isImage: true, mimeType: 'image/png' } + } + ) + await expect(pending).resolves.toEqual({ + content: '', + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + expect(connection.sent).toHaveLength(1) +}) + +it('rejects metadata missing its stream identifier', async () => { + const connection = createConnection() + const pending = readFileViaStream(connection.mux, '/invalid.txt') + connection.feed({ jsonrpc: '2.0', id: 1, result: { totalSize: 1, isBinary: false } }) + await expect(pending).rejects.toBeInstanceOf(StreamProtocolError) + expect(connection.sent).toHaveLength(1) +}) + +it.each([-1, 51 * 1024 * 1024])( + 'rejects invalid or oversized totalSize %d and cancels the identified stream', + async (totalSize) => { + const connection = createConnection() + const pending = readFileViaStream(connection.mux, '/invalid.png') + connection.feed({ jsonrpc: '2.0', id: 1, result: { streamId: 3, totalSize, isBinary: true } }) + await expect(pending).rejects.toBeInstanceOf(FileReadCapExceededError) + expect(connection.sent).toContainEqual({ + jsonrpc: '2.0', + method: 'fs.cancelStream', + params: { streamId: 3 } + }) + } +) + +it('preserves a tighter caller cap before accepting adjacent data', async () => { + const connection = createConnection() + const pending = readFileViaStream(connection.mux, '/small.txt', { maxTextBytes: 1 }) + connection.feed( + { jsonrpc: '2.0', id: 1, result: { streamId: 3, totalSize: 2, isBinary: false } }, + { jsonrpc: '2.0', method: 'fs.streamChunk', params: { streamId: 3, seq: 0, data: 'eHg=' } } + ) + await expect(pending).rejects.toBeInstanceOf(FileReadCapExceededError) + expect(connection.sent).toHaveLength(2) +}) + +it('preserves an adjacent own-stream error after metadata', async () => { + const connection = createConnection() + const pending = readFileViaStream(connection.mux, '/removed.txt') + connection.feed( + { jsonrpc: '2.0', id: 1, result: { streamId: 3, totalSize: 1, isBinary: false } }, + { + jsonrpc: '2.0', + method: 'fs.streamError', + params: { streamId: 3, code: 'ENOENT', message: 'gone' } + } + ) + await expect(pending).rejects.toMatchObject({ code: 'ENOENT', message: 'gone' }) +}) + +it('preserves the provider fallback when an older relay has no streaming method', async () => { + const connection = createConnection() + const provider = new SshFilesystemProvider('test', connection.mux) + const pending = provider.readFile('/legacy.txt') + try { + connection.feed({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } }) + await new Promise((resolve) => setImmediate(resolve)) + expect(connection.sent).toContainEqual({ + jsonrpc: '2.0', + id: 2, + method: 'fs.readFile', + params: { filePath: '/legacy.txt' } + }) + connection.feed({ jsonrpc: '2.0', id: 2, result: { content: 'legacy', isBinary: false } }) + await expect(pending).resolves.toEqual({ content: 'legacy', isBinary: false }) + } finally { + provider.dispose() + } +}) + +// Why: the metadata install moved from the mandatory resolve path to the optional +// beforeResolve hook, and the request timer is cleared before that hook runs. A mux +// that ignores the hook must fail the read, not leave it pending with no deadline. +it('fails the read when a multiplexer resolves without running beforeResolve', async () => { + const connection = createConnection() + vi.spyOn(connection.mux, 'request').mockResolvedValue({ + totalSize: 10, + isBinary: false, + streamId: 7 + }) + + await expect(readFileViaStream(connection.mux, '/no-hook.txt')).rejects.toBeInstanceOf( + StreamProtocolError + ) +}) From 1e3795de9968056199b71bfef25526520d57b6d5 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 18 Sep 2026 00:02:03 -0700 Subject: [PATCH 006/224] fix(log-tail): retire watches with their renderer lifetime (#21009) * fix(log-tail): retire watches with their renderer lifetime * fix(ci): clean up renderer tests --------- Co-authored-by: m4air Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- docs/audits/local-log-tail-lifetime/README.md | 28 ++ docs/audits/local-log-tail-lifetime/fix.patch | 210 +++++++++++++++ .../local-log-tail-lifetime/reproduce.mjs | 146 ++++++++++ .../local-log-tail-lifetime/results.json | 39 +++ src/main/ipc/local-log-tail-lifetime.test.ts | 249 ++++++++++++++++++ src/main/ipc/local-log-tail.test.ts | 30 ++- src/main/ipc/local-log-tail.ts | 156 ++++++++--- .../AiVaultSessionSubagents.test.tsx | 4 +- 8 files changed, 808 insertions(+), 54 deletions(-) create mode 100644 docs/audits/local-log-tail-lifetime/README.md create mode 100644 docs/audits/local-log-tail-lifetime/fix.patch create mode 100644 docs/audits/local-log-tail-lifetime/reproduce.mjs create mode 100644 docs/audits/local-log-tail-lifetime/results.json create mode 100644 src/main/ipc/local-log-tail-lifetime.test.ts diff --git a/docs/audits/local-log-tail-lifetime/README.md b/docs/audits/local-log-tail-lifetime/README.md new file mode 100644 index 00000000000..ac2f6a09c55 --- /dev/null +++ b/docs/audits/local-log-tail-lifetime/README.md @@ -0,0 +1,28 @@ +# Local log-tail watchers outliving their renderer + +Main can receive a log-tail subscription, await path authorization, and finish installing its native watcher after the requesting renderer has gone away. Previously, the destroyed listener was registered only after authorization. Installed watchers also survived a renderer crash or a new document loaded into the same WebContents. The map retained each watcher and its sender callback; callbacks suppressed notifications to destroyed senders without releasing resources. This handler is present in `v1.4.198`. + +The fix gives each sender one owner using the existing `abortWhenRendererGone` policy: destruction, renderer process loss, or committed document navigation closes its live watches and invalidates pending authorization. Same-document and canceled navigation preserve the owner. For a reused subscription ID, the latest pending request wins. Each pending subscription has an identity token; old completions and old watcher errors cannot replace or close newer subscriptions. Failed authorization preserves an existing installed watch. The last pending/live release removes all owner listeners. + +This is a reproduced native-handle and small metadata leak. Watchers do not retain file-content chunks. It does not establish the input frequency or memory scale in [#19768](https://github.com/stablyai/orca/issues/19768) or [#19831](https://github.com/stablyai/orca/issues/19831). + +## Reproduce + +From the repository root with existing dependencies: + +```sh +ORCA_BACKGROUND_LAUNCH=1 node docs/audits/local-log-tail-lifetime/reproduce.mjs +``` + +The script runs the actual IPC handlers against temporary files, real `fs.watch` handles, controlled authorization promises, and EventEmitter senders. The existing IPC tests use watcher doubles to deliver an error from a retired watcher. No Electron window, real user log, process inventory, or network request is used. Test cleanup releases all watchers. + +The baseline reverses only `fix.patch` in a temporary Vite transform. Working sources remain unchanged; source hashes and exact failed cases are recorded in `results.json`. Child test runners use the shared cross-platform process runner. + +| Version | Passed | Failed | +| ------------------- | -----: | -----: | +| Before lifetime fix | 9 | 10 | +| With lifetime fix | 19 | 0 | + +The twenty-owner case retained twenty native watcher owners before the fix and zero afterward. The broader cases cover destruction during authorization, active-plus-pending replacement, process loss/navigation, failed replacement, explicit stop, idle listener disposal, superseded success/error, and failed native installation. Ordinary tab cancellation already waited for start before stop; that behavior remains covered by the renderer hook tests. + +Additional validation: Node typecheck, direct lint, and the existing renderer-lifetime and local-log-tail hook suites. This endpoint only watches renderer-authorized local logs. SSH/paired-runtime execution ownership and wire schemas do not change; the local editor eligibility check already excludes runtime-environment files. Folder workspaces follow the existing path authorization policy. diff --git a/docs/audits/local-log-tail-lifetime/fix.patch b/docs/audits/local-log-tail-lifetime/fix.patch new file mode 100644 index 00000000000..398f2c3f820 --- /dev/null +++ b/docs/audits/local-log-tail-lifetime/fix.patch @@ -0,0 +1,210 @@ +diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts +index 430882b4e0..0892665ad5 100644 +--- a/src/main/ipc/local-log-tail.ts ++++ b/src/main/ipc/local-log-tail.ts +@@ -9,35 +9,83 @@ import type { + } from '../../shared/local-log-tail-types' + import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' + import { resolveAuthorizedPath } from './filesystem-auth' ++import { abortWhenRendererGone } from './renderer-lifetime-abort' + +-type TailWatch = { ++type TailSenderOwner = { + senderId: number ++ pending: Map ++ watchKeys: Set ++ signal: AbortSignal ++ dispose: () => void ++} ++ ++type TailWatch = { ++ owner: TailSenderOwner + watcher: FSWatcher + } + + const tailWatches = new Map() +-const senderCleanupRegistered = new Set() ++const senderOwners = new Map() + + function watchKey(senderId: number, subscriptionId: string): string { + return `${senderId}:${subscriptionId}` + } + +-function closeWatch(key: string): void { ++function releaseIdleOwner(owner: TailSenderOwner): void { ++ if (owner.pending.size > 0 || owner.watchKeys.size > 0) { ++ return ++ } ++ if (senderOwners.get(owner.senderId) === owner) { ++ senderOwners.delete(owner.senderId) ++ } ++ owner.dispose() ++} ++ ++function closeWatch(key: string, expected?: TailWatch): void { + const subscription = tailWatches.get(key) +- if (!subscription) { ++ if (!subscription || (expected && subscription !== expected)) { + return + } + tailWatches.delete(key) +- subscription.watcher.close() ++ subscription.owner.watchKeys.delete(key) ++ try { ++ subscription.watcher.close() ++ } finally { ++ releaseIdleOwner(subscription.owner) ++ } + } + +-function closeSenderWatches(senderId: number): void { +- senderCleanupRegistered.delete(senderId) +- for (const [key, subscription] of tailWatches) { +- if (subscription.senderId === senderId) { +- closeWatch(key) ++function closeSenderWatches(owner: TailSenderOwner): void { ++ owner.pending.clear() ++ for (const key of owner.watchKeys) { ++ const subscription = tailWatches.get(key) ++ if (subscription?.owner === owner) { ++ closeWatch(key, subscription) ++ } ++ } ++ releaseIdleOwner(owner) ++} ++ ++function getSenderOwner(sender: WebContents): TailSenderOwner { ++ const existing = senderOwners.get(sender.id) ++ if (existing) { ++ return existing ++ } ++ const lifetime = abortWhenRendererGone(sender) ++ const onAbort = (): void => closeSenderWatches(owner) ++ const owner: TailSenderOwner = { ++ senderId: sender.id, ++ pending: new Map(), ++ watchKeys: new Set(), ++ signal: lifetime.signal, ++ dispose: () => { ++ lifetime.signal.removeEventListener('abort', onAbort) ++ lifetime.dispose() + } + } ++ senderOwners.set(sender.id, owner) ++ lifetime.signal.addEventListener('abort', onAbort, { once: true }) ++ return owner + } + + function validateSubscriptionId(value: unknown): string { +@@ -47,12 +95,52 @@ function validateSubscriptionId(value: unknown): string { + return value + } + +-function registerSenderCleanup(sender: WebContents): void { +- if (senderCleanupRegistered.has(sender.id)) { ++async function startWatch( ++ sender: WebContents, ++ args: LocalLogTailWatchArgs, ++ store: Store ++): Promise { ++ const subscriptionId = validateSubscriptionId(args.subscriptionId) ++ if (sender.isDestroyed()) { + return + } +- senderCleanupRegistered.add(sender.id) +- sender.once('destroyed', () => closeSenderWatches(sender.id)) ++ const key = watchKey(sender.id, subscriptionId) ++ const owner = getSenderOwner(sender) ++ const pending = Symbol(subscriptionId) ++ owner.pending.set(key, pending) ++ try { ++ const filePath = await resolveAuthorizedPath(args.filePath, store) ++ if ( ++ sender.isDestroyed() || ++ owner.signal.aborted || ++ senderOwners.get(sender.id) !== owner || ++ owner.pending.get(key) !== pending ++ ) { ++ return ++ } ++ closeWatch(key) ++ const sendChange = (eventType: 'change' | 'rename'): void => { ++ if (tailWatches.get(key) !== subscription || sender.isDestroyed()) { ++ return ++ } ++ const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } ++ sender.send('fs:localLogTailChanged', payload) ++ } ++ const watcher = watch(filePath, (eventType) => sendChange(eventType)) ++ const subscription: TailWatch = { owner, watcher } ++ watcher.on('error', () => { ++ // Rotation needs one final drain before releasing this exact watcher. ++ sendChange('rename') ++ closeWatch(key, subscription) ++ }) ++ tailWatches.set(key, subscription) ++ owner.watchKeys.add(key) ++ } finally { ++ if (owner.pending.get(key) === pending) { ++ owner.pending.delete(key) ++ } ++ releaseIdleOwner(owner) ++ } + } + + export function registerLocalLogTailHandlers(store: Store): void { +@@ -64,43 +152,25 @@ export function registerLocalLogTailHandlers(store: Store): void { + } + ) + +- ipcMain.handle( +- 'fs:startLocalLogTail', +- async (event, args: LocalLogTailWatchArgs): Promise => { +- const subscriptionId = validateSubscriptionId(args.subscriptionId) +- const filePath = await resolveAuthorizedPath(args.filePath, store) +- const key = watchKey(event.sender.id, subscriptionId) +- closeWatch(key) +- +- const sendChange = (eventType: 'change' | 'rename'): void => { +- if (!tailWatches.has(key) || event.sender.isDestroyed()) { +- return +- } +- const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } +- event.sender.send('fs:localLogTailChanged', payload) +- } +- const watcher = watch(filePath, (eventType) => sendChange(eventType)) +- watcher.on('error', () => { +- // Why: an error commonly accompanies rotation. Signal one final drain so +- // the renderer can detect identity change, then release the dead handle. +- sendChange('rename') +- closeWatch(key) +- }) +- tailWatches.set(key, { senderId: event.sender.id, watcher }) +- registerSenderCleanup(event.sender) +- } ++ ipcMain.handle('fs:startLocalLogTail', (event, args: LocalLogTailWatchArgs): Promise => ++ startWatch(event.sender, args, store) + ) + + ipcMain.handle('fs:stopLocalLogTail', (event, args: { subscriptionId: string }): void => { +- closeWatch(watchKey(event.sender.id, validateSubscriptionId(args.subscriptionId))) ++ const key = watchKey(event.sender.id, validateSubscriptionId(args.subscriptionId)) ++ const owner = senderOwners.get(event.sender.id) ++ owner?.pending.delete(key) ++ closeWatch(key) ++ if (owner) { ++ releaseIdleOwner(owner) ++ } + }) + } + + export function closeAllLocalLogTailWatchers(): void { +- for (const key of Array.from(tailWatches.keys())) { +- closeWatch(key) ++ for (const owner of senderOwners.values()) { ++ closeSenderWatches(owner) + } +- senderCleanupRegistered.clear() + } + + /** Test-only: verifies tab/window teardown does not retain native watchers. */ diff --git a/docs/audits/local-log-tail-lifetime/reproduce.mjs b/docs/audits/local-log-tail-lifetime/reproduce.mjs new file mode 100644 index 00000000000..4d683bb09e8 --- /dev/null +++ b/docs/audits/local-log-tail-lifetime/reproduce.mjs @@ -0,0 +1,146 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { applyPatch, parsePatch, reversePatch } from 'diff' +import { build } from 'esbuild' + +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.') +} + +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8') +const beforeSources = {} +const sourceHashes = {} +for (const parsed of parsePatch(patch)) { + const path = parsed.newFileName.replace(/^b\//, '') + const absolute = resolve(root, path) + const current = await readFile(absolute, 'utf8') + const before = applyPatch(current, reversePatch(parsed)) + if (before === false) { + throw new Error(`Source changed; review the proof patch: ${path}`) + } + beforeSources[absolute.replaceAll('\\', '/')] = before + sourceHashes[path] = { + before: createHash('sha256').update(before).digest('hex'), + after: createHash('sha256').update(current).digest('hex') + } +} + +for (const path of [ + 'src/main/ipc/local-log-tail-lifetime.test.ts', + 'src/main/ipc/local-log-tail.test.ts' +]) { + sourceHashes[path] = { + current: createHash('sha256') + .update(await readFile(resolve(root, path))) + .digest('hex') + } +} + +const scratch = await mkdtemp(join(tmpdir(), 'orca-local-log-tail-lifetime-')) +const require = createRequire(import.meta.url) +let runnerModuleId +try { + const runnerPath = join(scratch, 'run-process.cjs') + await build({ + absWorkingDir: root, + entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')], + outfile: runnerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + runnerModuleId = require.resolve(runnerPath) + const { runProcess } = require(runnerModuleId) + const baselineConfig = join(scratch, 'before.config.mjs') + const fixedConfig = join(scratch, 'after.config.mjs') + const includes = [ + 'src/main/ipc/local-log-tail-lifetime.test.ts', + 'src/main/ipc/local-log-tail.test.ts' + ] + const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href) + await writeFile( + baselineConfig, + `import base from ${configImport}; +const beforeSources = ${JSON.stringify(beforeSources)}; +export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{ + name: 'local-log-lifetime-before-fix', enforce: 'pre', + transform(_code, id) { + const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]]; + return before === undefined ? null : {code: before, map: null}; + } +}]};\n` + ) + + await writeFile( + fixedConfig, + `import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n` + ) + + async function run(label, config) { + const report = join(scratch, `${label}.json`) + const result = await runProcess({ + program: process.execPath, + args: [ + resolve(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--config', + config, + '--reporter=json', + `--outputFile=${report}` + ], + cwd: root, + env: process.env, + timeoutMs: 90_000, + maxOutputBytes: 4 * 1024 * 1024 + }) + let parsed + try { + parsed = JSON.parse(await readFile(report, 'utf8')) + } catch (error) { + throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error }) + } + return { + exitCode: result.code, + passed: parsed.numPassedTests, + failed: parsed.numFailedTests, + failedCases: parsed.testResults.flatMap((suite) => + suite.assertionResults + .filter((test) => test.status === 'failed') + .map((test) => test.fullName) + ) + } + } + + const before = await run('before', baselineConfig) + const after = await run('after', fixedConfig) + const passed = + before.failed === 10 && before.passed === 9 && after.passed === 19 && after.failed === 0 + console.log( + JSON.stringify( + { + comparison: + 'Actual local log IPC lifecycle tests with real temporary-file watchers and controlled sender events; before reverses only fix.patch in a temporary Vite transform', + sourceHashes, + before, + after, + passed + }, + null, + 2 + ) + ) + if (!passed) { + process.exitCode = 1 + } +} finally { + if (runnerModuleId) { + delete require.cache[runnerModuleId] + } + await rm(scratch, { recursive: true, force: true }) +} diff --git a/docs/audits/local-log-tail-lifetime/results.json b/docs/audits/local-log-tail-lifetime/results.json new file mode 100644 index 00000000000..c4e77d95759 --- /dev/null +++ b/docs/audits/local-log-tail-lifetime/results.json @@ -0,0 +1,39 @@ +{ + "comparison": "Actual local log IPC lifecycle tests with real temporary-file watchers and controlled sender events; before reverses only fix.patch in a temporary Vite transform", + "sourceHashes": { + "src/main/ipc/local-log-tail.ts": { + "before": "6c7b9912fdab5be8b219eacc5000f2e11097219832212ec2f532879222292c02", + "after": "e5db5f0256dd1c2d8d6b42039f2ad5f2cf46faa9e2edeb6f522a962fa58fbf81" + }, + "src/main/ipc/local-log-tail-lifetime.test.ts": { + "current": "2ed1a7f9a1a0ddaf724b429aaa2ec1f9c531dda1c6e82c9884e8d85f25877ef6" + }, + "src/main/ipc/local-log-tail.test.ts": { + "current": "eafa0ccdf60d7adbc14ed9b14ca04c27e775a55c77996e5b2894f448a126637c" + } + }, + "before": { + "exitCode": 1, + "passed": 9, + "failed": 10, + "failedCases": [ + "does not install a watcher after its sender is destroyed during authorization", + "does not revive an existing subscription while a replacement is authorizing at destruction", + "rejects both overlapping same-ID admissions after renderer destruction", + "releases installed and pending watches on render-process-gone and permits a new document owner", + "releases installed and pending watches on did-navigate and permits a new document owner", + "shares lifecycle listeners and releases them when the last watch stops", + "explicit stop invalidates pending authorization without retaining idle listeners", + "late success from an older same-ID request preserves the newer installed watch", + "does not accumulate watchers across twenty destroyed renderer owners", + "local log tail IPC ignores errors from a retired watcher after a same-ID replacement" + ] + }, + "after": { + "exitCode": 0, + "passed": 19, + "failed": 0, + "failedCases": [] + }, + "passed": true +} diff --git a/src/main/ipc/local-log-tail-lifetime.test.ts b/src/main/ipc/local-log-tail-lifetime.test.ts new file mode 100644 index 00000000000..4abf283b9ec --- /dev/null +++ b/src/main/ipc/local-log-tail-lifetime.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const { handlers, authorize } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + authorize: vi.fn() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler) + } +})) +vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: authorize })) +import { + closeAllLocalLogTailWatchers, + getActiveLocalLogTailWatcherCount, + registerLocalLogTailHandlers +} from './local-log-tail' + +class Sender extends EventEmitter { + dead = false + send = vi.fn() + constructor(readonly id: number) { + super() + } + isDestroyed() { + return this.dead + } + destroy() { + this.dead = true + this.emit('destroyed') + } +} +let directory = '' +let filePath = '' +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-log-admission-test-')) + filePath = join(directory, 'fixture.log') + await writeFile(filePath, 'test\n') + authorize.mockReset().mockResolvedValue(filePath) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization is mocked; the handler never reads Store in this isolated fixture. + registerLocalLogTailHandlers({} as never) +}) +afterEach(async () => { + closeAllLocalLogTailWatchers() + await rm(directory, { force: true, recursive: true }) +}) +function start(sender: Sender, subscriptionId = 'tail') { + return handlers.get('fs:startLocalLogTail')!({ sender }, { filePath, subscriptionId }) +} +function deferAuthorization() { + let resolve!: (path: string) => void + let reject!: (error: Error) => void + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve + reject = onReject + }) + authorize.mockReturnValueOnce(promise) + return { resolve: (path = filePath) => resolve(path), reject: () => reject(new Error('denied')) } +} + +it('does not install a watcher after its sender is destroyed during authorization', async () => { + const sender = new Sender(1) + const admission = deferAuthorization() + const pending = start(sender) + sender.destroy() + admission.resolve() + await pending + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + expect(sender.listenerCount('destroyed')).toBe(0) +}) +it('does not revive an existing subscription while a replacement is authorizing at destruction', async () => { + const sender = new Sender(2) + await start(sender) + const admission = deferAuthorization() + const pending = start(sender) + sender.destroy() + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + admission.resolve() + await pending + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + expect(sender.listenerCount('destroyed')).toBe(0) +}) +it('rejects both overlapping same-ID admissions after renderer destruction', async () => { + const sender = new Sender(3) + const first = deferAuthorization() + const pendingFirst = start(sender) + const second = deferAuthorization() + const pendingSecond = start(sender) + sender.destroy() + second.resolve() + await pendingSecond + first.resolve() + await pendingFirst + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + expect(sender.listenerCount('destroyed')).toBe(0) +}) +it('replaces a live same-ID subscription and keeps one sender cleanup listener', async () => { + const sender = new Sender(4) + await start(sender) + await start(sender) + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + expect(sender.listenerCount('destroyed')).toBe(1) + sender.destroy() + expect(getActiveLocalLogTailWatcherCount()).toBe(0) +}) +it('preserves a live subscription when replacement authorization fails', async () => { + const sender = new Sender(5) + await start(sender) + const admission = deferAuthorization() + const pending = Promise.resolve(start(sender)) + const rejection = expect(pending).rejects.toThrow('denied') + admission.reject() + await rejection + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + sender.destroy() + expect(getActiveLocalLogTailWatcherCount()).toBe(0) +}) +it('a failed older admission cannot remove a newer successful same-ID watch', async () => { + const sender = new Sender(6) + const admission = deferAuthorization() + const pending = Promise.resolve(start(sender)) + const rejection = expect(pending).rejects.toThrow('denied') + await start(sender) + admission.reject() + await rejection + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + sender.destroy() + expect(getActiveLocalLogTailWatcherCount()).toBe(0) +}) + +it.each(['render-process-gone', 'did-navigate'])( + 'releases installed and pending watches on %s and permits a new document owner', + async (event) => { + const sender = new Sender(7) + await start(sender, 'installed') + const admission = deferAuthorization() + const pending = start(sender, 'pending') + sender.emit(event) + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + expect(sender.listenerCount('destroyed')).toBe(0) + await start(sender, 'pending') + admission.resolve() + await pending + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + expect(sender.listenerCount('destroyed')).toBe(1) + sender.destroy() + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + } +) + +it('keeps live watches for same-document and canceled navigation', async () => { + const sender = new Sender(8) + await start(sender) + sender.emit('did-start-navigation', {}, 'https://blocked.example', false, true) + sender.emit('did-navigate-in-page', {}, 'app://index.html#route', true) + expect(getActiveLocalLogTailWatcherCount()).toBe(1) +}) + +it('shares lifecycle listeners and releases them when the last watch stops', async () => { + const sender = new Sender(9) + await Promise.all(Array.from({ length: 20 }, (_, index) => start(sender, `tail-${index}`))) + for (const event of ['destroyed', 'render-process-gone', 'did-navigate']) { + expect(sender.listenerCount(event)).toBe(1) + } + for (let index = 0; index < 20; index++) { + handlers.get('fs:stopLocalLogTail')!({ sender }, { subscriptionId: `tail-${index}` }) + } + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + for (const event of ['destroyed', 'render-process-gone', 'did-navigate']) { + expect(sender.listenerCount(event)).toBe(0) + } +}) + +it('explicit stop invalidates pending authorization without retaining idle listeners', async () => { + const sender = new Sender(10) + const admission = deferAuthorization() + const pending = start(sender) + handlers.get('fs:stopLocalLogTail')!({ sender }, { subscriptionId: 'tail' }) + expect(sender.listenerCount('destroyed')).toBe(0) + admission.resolve() + await pending + expect(getActiveLocalLogTailWatcherCount()).toBe(0) +}) + +it('late success from an older same-ID request preserves the newer installed watch', async () => { + const sender = new Sender(11) + const admission = deferAuthorization() + const pending = start(sender) + await start(sender) + const listeners = sender.rawListeners('destroyed') + admission.resolve(join(directory, 'retired-file-no-longer-exists.log')) + await pending + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + expect(sender.rawListeners('destroyed')).toEqual(listeners) +}) + +it('a failed initial authorization releases all lifecycle listeners', async () => { + const sender = new Sender(12) + authorize.mockRejectedValueOnce(new Error('denied')) + await expect(start(sender)).rejects.toThrow('denied') + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + for (const event of ['destroyed', 'render-process-gone', 'did-navigate']) { + expect(sender.listenerCount(event)).toBe(0) + } +}) + +it('close-all invalidates pending admission and leaves replacement ownership intact', async () => { + const sender = new Sender(13) + const admission = deferAuthorization() + const pending = start(sender) + closeAllLocalLogTailWatchers() + await start(sender) + admission.resolve() + await pending + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + expect(sender.listenerCount('destroyed')).toBe(1) +}) + +it('releases a failed native watcher installation after authorization', async () => { + const sender = new Sender(14) + authorize.mockResolvedValueOnce(join(directory, 'missing.log')) + await expect(start(sender)).rejects.toThrow() + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + for (const event of ['destroyed', 'render-process-gone', 'did-navigate']) { + expect(sender.listenerCount(event)).toBe(0) + } +}) + +it('does not accumulate watchers across twenty destroyed renderer owners', async () => { + let authorizeNow!: (path: string) => void + authorize.mockReturnValue( + new Promise((resolve) => { + authorizeNow = resolve + }) + ) + const senders = Array.from({ length: 20 }, (_, index) => new Sender(index + 20)) + const pending = senders.map((sender) => start(sender)) + for (const sender of senders) { + sender.destroy() + } + authorizeNow(filePath) + await Promise.all(pending) + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + expect(senders.every((sender) => sender.listenerCount('destroyed') === 0)).toBe(true) +}) diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts index beac464f4a4..5f7f46ab0f5 100644 --- a/src/main/ipc/local-log-tail.test.ts +++ b/src/main/ipc/local-log-tail.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' const { handlers, watchMock, resolveAuthorizedPathMock, readRangeMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), @@ -49,18 +50,13 @@ function makeWatcher(): FakeWatcher { } function makeSender(id: number) { - let destroyedListener: (() => void) | undefined - return { + const sender = new EventEmitter() + return Object.assign(sender, { id, send: vi.fn(), isDestroyed: vi.fn(() => false), - once: vi.fn((event: string, listener: () => void) => { - if (event === 'destroyed') { - destroyedListener = listener - } - }), - destroy: () => destroyedListener?.() - } + destroy: () => sender.emit('destroyed') + }) } beforeEach(() => { @@ -124,4 +120,20 @@ describe('local log tail IPC', () => { expect(second.close).toHaveBeenCalledTimes(1) expect(getActiveLocalLogTailWatcherCount()).toBe(0) }) + it('ignores errors from a retired watcher after a same-ID replacement', async () => { + const first = makeWatcher() + const second = makeWatcher() + watchMock.mockReturnValueOnce(first).mockReturnValueOnce(second) + const sender = makeSender(10) + const args = { filePath: '/logs/session.jsonl', subscriptionId: 'tail' } + await handlers.get('fs:startLocalLogTail')?.({ sender }, args) + await handlers.get('fs:startLocalLogTail')?.({ sender }, args) + + first.emitError() + + expect(first.close).toHaveBeenCalledTimes(1) + expect(second.close).not.toHaveBeenCalled() + expect(sender.send).not.toHaveBeenCalled() + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + }) }) diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts index 430882b4e07..0892665ad50 100644 --- a/src/main/ipc/local-log-tail.ts +++ b/src/main/ipc/local-log-tail.ts @@ -9,35 +9,83 @@ import type { } from '../../shared/local-log-tail-types' import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' import { resolveAuthorizedPath } from './filesystem-auth' +import { abortWhenRendererGone } from './renderer-lifetime-abort' + +type TailSenderOwner = { + senderId: number + pending: Map + watchKeys: Set + signal: AbortSignal + dispose: () => void +} type TailWatch = { - senderId: number + owner: TailSenderOwner watcher: FSWatcher } const tailWatches = new Map() -const senderCleanupRegistered = new Set() +const senderOwners = new Map() function watchKey(senderId: number, subscriptionId: string): string { return `${senderId}:${subscriptionId}` } -function closeWatch(key: string): void { +function releaseIdleOwner(owner: TailSenderOwner): void { + if (owner.pending.size > 0 || owner.watchKeys.size > 0) { + return + } + if (senderOwners.get(owner.senderId) === owner) { + senderOwners.delete(owner.senderId) + } + owner.dispose() +} + +function closeWatch(key: string, expected?: TailWatch): void { const subscription = tailWatches.get(key) - if (!subscription) { + if (!subscription || (expected && subscription !== expected)) { return } tailWatches.delete(key) - subscription.watcher.close() + subscription.owner.watchKeys.delete(key) + try { + subscription.watcher.close() + } finally { + releaseIdleOwner(subscription.owner) + } } -function closeSenderWatches(senderId: number): void { - senderCleanupRegistered.delete(senderId) - for (const [key, subscription] of tailWatches) { - if (subscription.senderId === senderId) { - closeWatch(key) +function closeSenderWatches(owner: TailSenderOwner): void { + owner.pending.clear() + for (const key of owner.watchKeys) { + const subscription = tailWatches.get(key) + if (subscription?.owner === owner) { + closeWatch(key, subscription) } } + releaseIdleOwner(owner) +} + +function getSenderOwner(sender: WebContents): TailSenderOwner { + const existing = senderOwners.get(sender.id) + if (existing) { + return existing + } + const lifetime = abortWhenRendererGone(sender) + const onAbort = (): void => closeSenderWatches(owner) + const owner: TailSenderOwner = { + senderId: sender.id, + pending: new Map(), + watchKeys: new Set(), + signal: lifetime.signal, + dispose: () => { + lifetime.signal.removeEventListener('abort', onAbort) + lifetime.dispose() + } + } + senderOwners.set(sender.id, owner) + lifetime.signal.addEventListener('abort', onAbort, { once: true }) + return owner } function validateSubscriptionId(value: unknown): string { @@ -47,12 +95,52 @@ function validateSubscriptionId(value: unknown): string { return value } -function registerSenderCleanup(sender: WebContents): void { - if (senderCleanupRegistered.has(sender.id)) { +async function startWatch( + sender: WebContents, + args: LocalLogTailWatchArgs, + store: Store +): Promise { + const subscriptionId = validateSubscriptionId(args.subscriptionId) + if (sender.isDestroyed()) { return } - senderCleanupRegistered.add(sender.id) - sender.once('destroyed', () => closeSenderWatches(sender.id)) + const key = watchKey(sender.id, subscriptionId) + const owner = getSenderOwner(sender) + const pending = Symbol(subscriptionId) + owner.pending.set(key, pending) + try { + const filePath = await resolveAuthorizedPath(args.filePath, store) + if ( + sender.isDestroyed() || + owner.signal.aborted || + senderOwners.get(sender.id) !== owner || + owner.pending.get(key) !== pending + ) { + return + } + closeWatch(key) + const sendChange = (eventType: 'change' | 'rename'): void => { + if (tailWatches.get(key) !== subscription || sender.isDestroyed()) { + return + } + const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } + sender.send('fs:localLogTailChanged', payload) + } + const watcher = watch(filePath, (eventType) => sendChange(eventType)) + const subscription: TailWatch = { owner, watcher } + watcher.on('error', () => { + // Rotation needs one final drain before releasing this exact watcher. + sendChange('rename') + closeWatch(key, subscription) + }) + tailWatches.set(key, subscription) + owner.watchKeys.add(key) + } finally { + if (owner.pending.get(key) === pending) { + owner.pending.delete(key) + } + releaseIdleOwner(owner) + } } export function registerLocalLogTailHandlers(store: Store): void { @@ -64,43 +152,25 @@ export function registerLocalLogTailHandlers(store: Store): void { } ) - ipcMain.handle( - 'fs:startLocalLogTail', - async (event, args: LocalLogTailWatchArgs): Promise => { - const subscriptionId = validateSubscriptionId(args.subscriptionId) - const filePath = await resolveAuthorizedPath(args.filePath, store) - const key = watchKey(event.sender.id, subscriptionId) - closeWatch(key) - - const sendChange = (eventType: 'change' | 'rename'): void => { - if (!tailWatches.has(key) || event.sender.isDestroyed()) { - return - } - const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } - event.sender.send('fs:localLogTailChanged', payload) - } - const watcher = watch(filePath, (eventType) => sendChange(eventType)) - watcher.on('error', () => { - // Why: an error commonly accompanies rotation. Signal one final drain so - // the renderer can detect identity change, then release the dead handle. - sendChange('rename') - closeWatch(key) - }) - tailWatches.set(key, { senderId: event.sender.id, watcher }) - registerSenderCleanup(event.sender) - } + ipcMain.handle('fs:startLocalLogTail', (event, args: LocalLogTailWatchArgs): Promise => + startWatch(event.sender, args, store) ) ipcMain.handle('fs:stopLocalLogTail', (event, args: { subscriptionId: string }): void => { - closeWatch(watchKey(event.sender.id, validateSubscriptionId(args.subscriptionId))) + const key = watchKey(event.sender.id, validateSubscriptionId(args.subscriptionId)) + const owner = senderOwners.get(event.sender.id) + owner?.pending.delete(key) + closeWatch(key) + if (owner) { + releaseIdleOwner(owner) + } }) } export function closeAllLocalLogTailWatchers(): void { - for (const key of Array.from(tailWatches.keys())) { - closeWatch(key) + for (const owner of senderOwners.values()) { + closeSenderWatches(owner) } - senderCleanupRegistered.clear() } /** Test-only: verifies tab/window teardown does not retain native watchers. */ diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx index a39b732b28a..821636f8ce7 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import type { ComponentProps, JSX } from 'react' -import { act, fireEvent, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SubagentExpansionProvider } from './ai-vault-subagent-expansion' import { TooltipProvider } from '@/components/ui/tooltip' @@ -30,7 +30,7 @@ beforeEach(() => { }) afterEach(() => { - document.body.replaceChildren() + cleanup() }) function makeSession(overrides: Partial = {}): AiVaultSession { From f819ed96cae2f76a9481d300ec3e44d594a6174e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:12:46 -0400 Subject: [PATCH 007/224] fix(skills): keep the disposal verdict when staging cleanup fails, and retry release-cut installs (#21366) * fix(skills): keep the disposal verdict when staging cleanup fails `begin()` ended with `await this.removeOwnershipIfDisposed()` inside its `finally`, so when a caller raced `dispose()` the rejection it received was whatever that opportunistic `rmdir` threw -- not `skill-upload-service-disposed`. A caller could not tell "the service shut down" from "the filesystem broke", and the Windows release gate saw it as `EPERM: operation not permitted, rmdir`. Two causes, both fixed here: - The EPERM itself: an in-flight operation and disposal each call `ownership.remove()`, so two `rm -rf` run concurrently against the same owner directory. On POSIX the loser reads ENOENT and `force: true` swallows it; on Windows the loser reads a delete-pending directory and gets EPERM. `SkillUploadStagingOwnership.remove()` now joins one removal and forgets it on failure so a later caller still retries. - The masking: cleanup in a `finally` no longer replaces the outcome of the call it is cleaning up after. Disposal retries staging removal and reports its own failure, matching `removeUnpublished`/`retainFailedCleanup` in this class. Both regressions are pinned platform-independently: one injects a failing ownership removal and asserts the racing `begin` still rejects with `skill-upload-service-disposed` while `dispose()` reports the cleanup failure; the other models Windows delete-pending rmdir in the `node:fs/promises` mock, which turns a second removal into EPERM on every platform. * ci(release-cut): retry the installs that fetch node-gyp headers `golden e2e windows` installs with lifecycle scripts enabled, so pnpm runs node-gyp for the `native/windows-registry` workspace project, which downloads that Node version's headers from nodejs.org. A single `read ECONNRESET` on that fetch failed a blocking release gate, and the release build job one screen below already wraps its install in `nick-fields/retry@v4` for exactly this class of failure. Both remaining unretried installs in this workflow (the blocking platform golden and the non-blocking rendering-evidence lane) now use the same wrapper, and a contract test keeps every release-cut install retryable. --- .github/workflows/release-cut.yml | 19 ++++- .../ci-dependency-download-cache.test.mjs | 20 ++++- ...pload-session-admission-regression.test.ts | 77 ++++++++++++++++++- .../skills/skill-upload-session-service.ts | 3 +- .../skills/skill-upload-staging-ownership.ts | 13 +++- 5 files changed, 123 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index ac2e7f904e3..eb80d20af72 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -871,8 +871,17 @@ jobs: npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + # Why: this install runs lifecycle scripts, so node-gyp rebuilds + # native/windows-registry and fetches that Node version's headers from + # nodejs.org. One `read ECONNRESET` there failed this blocking gate and the + # whole cut. Retry like the release build's install below. - name: Install dependencies - run: pnpm install --frozen-lockfile + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile - name: Build Electron app for platform golden run: npx electron-vite build --mode e2e @@ -1088,8 +1097,14 @@ jobs: npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + # Same node-gyp header fetch as the blocking golden gate above. - name: Install dependencies - run: pnpm install --frozen-lockfile + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile - name: Build Electron app for terminal rendering evidence run: npx electron-vite build --mode e2e diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index d2111a230bb..0860b53ebca 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -32,25 +32,37 @@ describe('CI dependency download caches', () => { describe('release install targets', () => { const macCpuFlag = '--cpu=current,x64,arm64' // Both shapes: `run:` steps and steps wrapped in nick-fields/retry (`with.command`). + const installCommand = (step) => step.with?.command ?? step.run const installSteps = (name) => Object.values(workflow(name).jobs) .flatMap((job) => job.steps ?? []) - .map((step) => step.with?.command ?? step.run) - .filter((command) => typeof command === 'string' && command.includes('pnpm install ')) + .filter((step) => installCommand(step)?.includes('pnpm install ')) + const installCommands = (name) => installSteps(name).map(installCommand) it.each(['adhoc-mac-build', 'daily-mac-build', 'hourly-mac-build', 'release-mac-build'])( '%s installs both mac CPU variants for the x64+arm64 package config', (name) => { - const installs = installSteps(name) + const installs = installCommands(name) expect(installs.length).toBeGreaterThan(0) expect(installs.some((command) => command.includes(macCpuFlag))).toBe(true) } ) + // A transient `read ECONNRESET` fetching this Node version's headers for + // native/windows-registry's node-gyp rebuild failed a blocking golden gate and the cut. + it('retries every release-cut install so one transient download cannot fail a cut', () => { + const installs = installSteps('release-cut') + expect(installs.length).toBeGreaterThan(0) + for (const step of installs) { + expect(step.uses).toBe('nick-fields/retry@v4') + expect(step.with.max_attempts).toBeGreaterThan(1) + } + }) + it.each(['release-cut', 'dev-channel-win-build', 'windows-signing-rehearsal'])( '%s keeps installs scoped to the runner host', (name) => { - const installs = installSteps(name) + const installs = installCommands(name) expect(installs.length).toBeGreaterThan(0) for (const command of installs) { expect(command).not.toContain('--os=') diff --git a/src/main/skills/skill-upload-session-admission-regression.test.ts b/src/main/skills/skill-upload-session-admission-regression.test.ts index 9bbdb71ef68..3d13a587889 100644 --- a/src/main/skills/skill-upload-session-admission-regression.test.ts +++ b/src/main/skills/skill-upload-session-admission-regression.test.ts @@ -1,11 +1,12 @@ import { createHash } from 'node:crypto' -import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, rm } from 'node:fs/promises' import type * as NodeFsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SkillUploadRetainedPaths } from './skill-upload-retained-paths' import { SkillUploadSessionService } from './skill-upload-session-service' +import type { SkillUploadStagingOwnership } from './skill-upload-staging-ownership' const roots: string[] = [] @@ -14,6 +15,9 @@ const openGate = vi.hoisted(() => ({ started: null as (() => void) | null })) +// Models Windows delete-pending rmdir: the first removal wins and every later one gets EPERM. +const deletePendingGate = vi.hoisted((): { removed: Set | null } => ({ removed: null })) + vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { @@ -27,6 +31,16 @@ vi.mock('node:fs/promises', async (importOriginal) => { await release } return handle + }, + rm: async (path: string, options?: Parameters[1]) => { + const removed = deletePendingGate.removed + if (removed?.has(path)) { + throw Object.assign(new Error(`EPERM: operation not permitted, rmdir '${path}'`), { + code: 'EPERM' + }) + } + removed?.add(path) + await actual.rm(path, options) } } }) @@ -35,6 +49,7 @@ afterEach(async () => { vi.useRealTimers() openGate.release = null openGate.started = null + deletePendingGate.removed = null await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) @@ -52,6 +67,30 @@ function retainedPathCleanup(service: SkillUploadSessionService): SkillUploadRet return service['retainedPaths'] } +function stagingOwnership(service: SkillUploadSessionService): SkillUploadStagingOwnership { + return service['ownership'] +} + +function initializationGate(uploads: string) { + let releaseInitialization!: () => void + const initializationReleased = new Promise((resolve) => { + releaseInitialization = resolve + }) + let markInitializationStarted!: () => void + const initializationStarted = new Promise((resolve) => { + markInitializationStarted = resolve + }) + return { + initializationStarted, + releaseInitialization, + initializeRoot: async () => { + await mkdir(uploads, { recursive: true }) + markInitializationStarted() + await initializationReleased + } + } +} + async function stagedArchiveCount(uploads: string): Promise { const owners = await readdir(uploads, { withFileTypes: true }) const archives = await Promise.all( @@ -146,6 +185,42 @@ describe('SkillUploadSessionService admission regressions', () => { await service.dispose() }) + it('reports disposal, not the staging cleanup failure, to a begin racing disposal', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-upload-admission-')) + roots.push(root) + const uploads = join(root, 'uploads') + const gate = initializationGate(uploads) + const service = new SkillUploadSessionService(uploads, { initializeRoot: gate.initializeRoot }) + const cleanupFailure = new Error('injected-staging-rmdir-failure') + vi.spyOn(stagingOwnership(service), 'remove').mockRejectedValue(cleanupFailure) + + const begin = service.begin({ package: identity(Buffer.from('closing package')) }) + await gate.initializationStarted + const disposal = service.dispose() + gate.releaseInitialization() + + await expect(begin).rejects.toThrow('skill-upload-service-disposed') + await expect(disposal).rejects.toBe(cleanupFailure) + }) + + it('removes disposed staging once when a begin and disposal race the same directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-upload-admission-')) + roots.push(root) + const uploads = join(root, 'uploads') + const gate = initializationGate(uploads) + const service = new SkillUploadSessionService(uploads, { initializeRoot: gate.initializeRoot }) + deletePendingGate.removed = new Set() + + const begin = service.begin({ package: identity(Buffer.from('closing package')) }) + await gate.initializationStarted + const disposal = service.dispose() + gate.releaseInitialization() + + await expect(begin).rejects.toThrow('skill-upload-service-disposed') + await disposal + expect(await readdir(uploads)).toEqual([]) + }) + it('removes an unpublished archive when disposal starts during open', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skill-upload-admission-')) roots.push(root) diff --git a/src/main/skills/skill-upload-session-service.ts b/src/main/skills/skill-upload-session-service.ts index c06e628dcff..e2bba665aa2 100644 --- a/src/main/skills/skill-upload-session-service.ts +++ b/src/main/skills/skill-upload-session-service.ts @@ -77,7 +77,8 @@ export class SkillUploadSessionService { return skillUploadBeginResult(session) } finally { leaveOperation() - await this.removeOwnershipIfDisposed() + // Opportunistic cleanup: disposal retries it, so its failure must not replace this outcome. + await this.removeOwnershipIfDisposed().catch(() => undefined) } } diff --git a/src/main/skills/skill-upload-staging-ownership.ts b/src/main/skills/skill-upload-staging-ownership.ts index 1f9c01518f5..cc630b004e0 100644 --- a/src/main/skills/skill-upload-staging-ownership.ts +++ b/src/main/skills/skill-upload-staging-ownership.ts @@ -16,6 +16,7 @@ export type SkillUploadStagingOwnershipOptions = { export class SkillUploadStagingOwnership { readonly directory: string private readonly processIsAlive: (pid: number) => boolean + private removal: Promise | null = null constructor( private readonly root: string, @@ -35,8 +36,18 @@ export class SkillUploadStagingOwnership { await mkdir(this.directory, { recursive: true, mode: 0o700 }) } + // Callers race this (an in-flight operation and disposal), and a second rmdir of a + // delete-pending directory fails with EPERM on Windows, so join one removal instead. async remove(): Promise { - await rm(this.directory, { recursive: true, force: true }) + const removal = (this.removal ??= rm(this.directory, { recursive: true, force: true })) + try { + await removal + } catch (error) { + if (this.removal === removal) { + this.removal = null + } + throw error + } } private async cleanupAbandonedOwners(): Promise { From 1ff4fe677c6be8ea4a5064878941eb113018a752 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 18 Sep 2026 00:18:19 -0700 Subject: [PATCH 008/224] fix(main,preload): tear down renderer relay and preload listeners (#20909) * Clean up renderer relay listeners on teardown * fix(main): guard empty markdown relay results * test: document relay window test double safety * fix(relay): retain web contents through window destruction --------- Co-authored-by: m4air Co-authored-by: m4air Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../mobile-markdown-request-relay.test.ts | 34 +++++++++++ .../window/mobile-markdown-request-relay.ts | 55 ++++++++++++++--- .../terminal-tab-close-request-relay.test.ts | 32 ++++++++++ .../terminal-tab-close-request-relay.ts | 59 +++++++++++++++---- src/preload/preload-runtime-support.ts | 12 ++++ .../native-chat-composer-drop-scope.test.tsx | 2 + 6 files changed, 173 insertions(+), 21 deletions(-) diff --git a/src/main/window/mobile-markdown-request-relay.test.ts b/src/main/window/mobile-markdown-request-relay.test.ts index 3f3f610498e..db587f07344 100644 --- a/src/main/window/mobile-markdown-request-relay.test.ts +++ b/src/main/window/mobile-markdown-request-relay.test.ts @@ -67,4 +67,38 @@ describe('requestMobileMarkdownFromRenderer', () => { await expect(pending).resolves.toMatchObject({ content: '# ok' }) }) + + it('rejects and cleans up when the BrowserWindow closes and webContents becomes unavailable', async () => { + const { requestMobileMarkdownFromRenderer } = await import('./mobile-markdown-request-relay') + const webContents = Object.assign(new EventEmitter(), { + send: vi.fn() + }) + let windowClosed = false + const mainWindow = Object.assign(new EventEmitter(), { + isDestroyed: () => false + }) + Object.defineProperty(mainWindow, 'webContents', { + get: () => { + if (windowClosed) { + throw new Error('webContents unavailable after close') + } + return webContents + } + }) + + const pending = requestMobileMarkdownFromRenderer(mainWindow as never, { + operation: 'read', + worktreeId: 'wt-1', + tabId: 'tab-md' + }) + expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(1) + + windowClosed = true + mainWindow.emit('closed') + + await expect(pending).rejects.toThrow('renderer_unavailable') + expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(0) + expect(webContents.listenerCount('destroyed')).toBe(0) + expect(webContents.listenerCount('render-process-gone')).toBe(0) + }) }) diff --git a/src/main/window/mobile-markdown-request-relay.ts b/src/main/window/mobile-markdown-request-relay.ts index d9cd408bc54..d1c80a38920 100644 --- a/src/main/window/mobile-markdown-request-relay.ts +++ b/src/main/window/mobile-markdown-request-relay.ts @@ -24,31 +24,68 @@ export async function requestMobileMarkdownFromRenderer( if (mainWindow.isDestroyed()) { throw new Error('renderer_unavailable') } + const webContents = mainWindow.webContents const id = randomUUID() return await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { + let settled = false + const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable')) + const finish = ( + error?: Error, + result?: RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) ipcMain.removeListener('ui:mobileMarkdownResponse', onResponse) - reject(new Error('renderer_timeout')) - }, MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS) + if (typeof mainWindow.removeListener === 'function') { + mainWindow.removeListener('closed', onRendererUnavailable) + } + if (typeof webContents.removeListener === 'function') { + webContents.removeListener('destroyed', onRendererUnavailable) + webContents.removeListener('render-process-gone', onRendererUnavailable) + } + if (error) { + reject(error) + } else if (result) { + resolve(result) + } else { + reject(new Error('renderer_unavailable')) + } + } + const timeout = setTimeout( + () => finish(new Error('renderer_timeout')), + MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS + ) const onResponse = ( event: Electron.IpcMainEvent, response: RuntimeMobileMarkdownResponse ): void => { - if (event.sender !== mainWindow.webContents) { + if (event.sender !== webContents) { return } if (response.id !== id) { return } - clearTimeout(timeout) - ipcMain.removeListener('ui:mobileMarkdownResponse', onResponse) if (response.ok) { - resolve(response.result) + finish(undefined, response.result) } else { - reject(new Error(response.error)) + finish(new Error(response.error)) } } ipcMain.on('ui:mobileMarkdownResponse', onResponse) - mainWindow.webContents.send('ui:mobileMarkdownRequest', { id, ...request }) + if (typeof mainWindow.once === 'function') { + mainWindow.once('closed', onRendererUnavailable) + } + if (typeof webContents.once === 'function') { + webContents.once('destroyed', onRendererUnavailable) + webContents.once('render-process-gone', onRendererUnavailable) + } + try { + webContents.send('ui:mobileMarkdownRequest', { id, ...request }) + } catch { + finish(new Error('renderer_unavailable')) + } }) } diff --git a/src/main/window/terminal-tab-close-request-relay.test.ts b/src/main/window/terminal-tab-close-request-relay.test.ts index d578d581319..ab3e3c57c77 100644 --- a/src/main/window/terminal-tab-close-request-relay.test.ts +++ b/src/main/window/terminal-tab-close-request-relay.test.ts @@ -78,4 +78,36 @@ describe('requestTerminalTabCloseFromRenderer', () => { await expect(pending).rejects.toThrow('terminal_tab_pinned') }) + + it('rejects and cleans up when the BrowserWindow closes and webContents becomes unavailable', async () => { + const { requestTerminalTabCloseFromRenderer } = + await import('./terminal-tab-close-request-relay') + const webContents = Object.assign(new EventEmitter(), { + isDestroyed: () => false, + send: vi.fn() + }) + let windowClosed = false + const mainWindow = Object.assign(new EventEmitter(), { + isDestroyed: () => false + }) + Object.defineProperty(mainWindow, 'webContents', { + get: () => { + if (windowClosed) { + throw new Error('webContents unavailable after close') + } + return webContents + } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the EventEmitter test double implements the BrowserWindow events used by this test. + const pending = requestTerminalTabCloseFromRenderer(mainWindow as never, 'tab-closed') + expect(ipcEmitter.listenerCount('ui:terminalTabCloseResponse')).toBe(1) + + windowClosed = true + mainWindow.emit('closed') + + await expect(pending).rejects.toThrow('renderer_unavailable') + expect(ipcEmitter.listenerCount('ui:terminalTabCloseResponse')).toBe(0) + expect(webContents.listenerCount('destroyed')).toBe(0) + expect(webContents.listenerCount('render-process-gone')).toBe(0) + }) }) diff --git a/src/main/window/terminal-tab-close-request-relay.ts b/src/main/window/terminal-tab-close-request-relay.ts index f6bf67c7aca..d70f6d2525d 100644 --- a/src/main/window/terminal-tab-close-request-relay.ts +++ b/src/main/window/terminal-tab-close-request-relay.ts @@ -14,31 +14,66 @@ export async function requestTerminalTabCloseFromRenderer( tabId: string, options: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } = {} ): Promise { - if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { + if (mainWindow.isDestroyed()) { + throw new Error('renderer_unavailable') + } + const webContents = mainWindow.webContents + if (webContents.isDestroyed()) { throw new Error('renderer_unavailable') } const requestId = randomUUID() await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse) - reject(new Error('terminal_tab_close_timeout')) - }, TERMINAL_TAB_CLOSE_TIMEOUT_MS) - const onResponse = (event: Electron.IpcMainEvent, response: TerminalTabCloseResponse): void => { - // Why: request IDs are visible to renderer code; only the selected main - // window may commit or reject its lifecycle transaction. - if (event.sender !== mainWindow.webContents || response.requestId !== requestId) { + let settled = false + const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable')) + const finish = (error?: Error): void => { + if (settled) { return } + settled = true clearTimeout(timeout) ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse) - if (response.error) { - reject(new Error(response.error)) + if (typeof mainWindow.removeListener === 'function') { + mainWindow.removeListener('closed', onRendererUnavailable) + } + if (typeof webContents.removeListener === 'function') { + webContents.removeListener('destroyed', onRendererUnavailable) + webContents.removeListener('render-process-gone', onRendererUnavailable) + } + if (error) { + reject(error) } else { resolve() } } + const timeout = setTimeout( + () => finish(new Error('terminal_tab_close_timeout')), + TERMINAL_TAB_CLOSE_TIMEOUT_MS + ) + const onResponse = (event: Electron.IpcMainEvent, response: TerminalTabCloseResponse): void => { + // Why: request IDs are visible to renderer code; only the selected main + // window may commit or reject its lifecycle transaction. + if (event.sender !== webContents || response.requestId !== requestId) { + return + } + if (response.error) { + finish(new Error(response.error)) + } else { + finish() + } + } ipcMain.on('ui:terminalTabCloseResponse', onResponse) + if (typeof mainWindow.once === 'function') { + mainWindow.once('closed', onRendererUnavailable) + } + if (typeof webContents.once === 'function') { + webContents.once('destroyed', onRendererUnavailable) + webContents.once('render-process-gone', onRendererUnavailable) + } const request: TerminalTabCloseRequest = { requestId, tabId, ...options } - mainWindow.webContents.send('ui:terminalTabCloseRequest', request) + try { + webContents.send('ui:terminalTabCloseRequest', request) + } catch { + finish(new Error('renderer_unavailable')) + } }) } diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 6a9462473c1..9861583b34a 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -46,6 +46,7 @@ export function getLinuxDisplayServer(): 'wayland' | 'x11' | null { type NativeFileDropCallback = (data: NativeFileDropPayload) => void const nativeFileDropCallbacks: NativeFileDropCallback[] = [] let nativeFileDropListenerRegistered = false +let nativeFileDropHandlersInstalled = false const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { for (const callback of Array.from(nativeFileDropCallbacks)) { @@ -89,6 +90,11 @@ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { /** Installs the one preload-side listener that converts native File objects to paths. */ export function installNativeFileDropHandlers(): void { + // Preload entry points can be evaluated more than once in tests and during development reloads; + // duplicate document listeners retain every closure and process each drop repeatedly. + if (nativeFileDropHandlersInstalled) { + return + } document.addEventListener( 'dragover', (event) => { @@ -155,6 +161,7 @@ export function installNativeFileDropHandlers(): void { }, true ) + nativeFileDropHandlersInstalled = true } export const browserFindSubscriptions = createBrowserFindSubscriptions() @@ -162,12 +169,17 @@ export const browserClientPageRendererRequests = createBrowserClientPageRenderer ipc: ipcRenderer, isTopFrame: () => window.top === window }) +let browserFindListenerInstalled = false /** Registers browser find forwarding once for this preload context. */ export function installBrowserFindListener(): void { + if (browserFindListenerInstalled) { + return + } ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { browserFindSubscriptions.dispatch(source) }) + browserFindListenerInstalled = true } export const updaterQuitAbortRelay = createUpdaterQuitAbortRelay( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index c7c7dec050c..508a7c20e5e 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -146,6 +146,8 @@ describe('native chat composer drop scoping', () => { value: { ui: { onFileDrop: subscribeNativeFileDrop }, fs: intake } }) installNativeFileDropHandlers() + // Repeated preload setup must stay singleton or every OS drop is processed once per install. + installNativeFileDropHandlers() }) beforeEach(() => { From c263f5d09263473fae2e58e6e9201762d5a43a9a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:34:30 -0400 Subject: [PATCH 009/224] chore(mobile): repin the RPC recording baseline to main after #21374 (#21402) #21374 squashed to 60a774c30c, which main does not contain, so the pin guard's ancestry check is red on main; main has since moved past that commit and src/shared changed, so the pin is main's tip 1e3795de99 rather than the squash sha. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../rpc-foundation/goldens/aivault-history-scan-fulfilled.json | 2 +- .../goldens/aivault-history-scan-unsupported.json | 2 +- .../goldens/aivault-history-scan-worktrees-late.json | 2 +- .../rpc-foundation/goldens/aivault-history-screen-listed.json | 2 +- .../goldens/aivault-history-screen-worktrees.json | 2 +- .../goldens/aivault-resume-launch-create-refused.json | 2 +- .../goldens/aivault-resume-launch-invalid-tab.json | 2 +- mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json | 2 +- mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json | 2 +- .../rpc-foundation/goldens/aivault-resume-prepare-refused.json | 2 +- mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json | 2 +- .../rpc-foundation/goldens/aivault-resume-prepare-skipped.json | 2 +- .../goldens/aivault-resume-prepare-unavailable.json | 2 +- mobile/rpc-foundation/goldens/b1.json | 2 +- mobile/rpc-foundation/goldens/b2.json | 2 +- mobile/rpc-foundation/goldens/b3.json | 2 +- mobile/rpc-foundation/goldens/browser-dialog-accepted.json | 2 +- mobile/rpc-foundation/goldens/browser-dialog-dismissed.json | 2 +- mobile/rpc-foundation/goldens/browser-keyboard-input.json | 2 +- .../rpc-foundation/goldens/browser-pointer-click-accepted.json | 2 +- .../rpc-foundation/goldens/browser-pointer-click-fallback.json | 2 +- mobile/rpc-foundation/goldens/browser-wheel-scrolled.json | 2 +- .../goldens/clipboard-image-attachment-anonymous.json | 2 +- .../goldens/clipboard-image-attachment-blocked-before-send.json | 2 +- .../goldens/clipboard-image-attachment-cancelled.json | 2 +- .../goldens/clipboard-image-attachment-pasted.json | 2 +- .../goldens/clipboard-image-attachment-upload-refused.json | 2 +- .../goldens/clipboard-image-upload-aborts-on-chunk-failure.json | 2 +- .../rpc-foundation/goldens/clipboard-image-upload-chunked.json | 2 +- .../goldens/clipboard-image-upload-single-frame-fallback.json | 2 +- .../goldens/clipboard-image-upload-start-refused.json | 2 +- mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json | 2 +- mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json | 2 +- mobile/rpc-foundation/goldens/components-codex-capability.json | 2 +- mobile/rpc-foundation/goldens/components-setup-ask.json | 2 +- mobile/rpc-foundation/goldens/components-target-local.json | 2 +- mobile/rpc-foundation/goldens/components-target-ssh.json | 2 +- mobile/rpc-foundation/goldens/diff-review-branch-compare.json | 2 +- mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json | 2 +- .../goldens/diff-review-notes-refused-before-compare.json | 2 +- .../rpc-foundation/goldens/diff-review-refused-file-diff.json | 2 +- mobile/rpc-foundation/goldens/diff-review-snapshot.json | 2 +- .../rpc-foundation/goldens/diff-review-status-unavailable.json | 2 +- .../rpc-foundation/goldens/diff-review-worktree-file-diff.json | 2 +- mobile/rpc-foundation/goldens/file-tap-open-refused.json | 2 +- mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json | 2 +- .../goldens/file-tap-previews-absolute-artifact.json | 2 +- mobile/rpc-foundation/goldens/file-tap-resolve-miss.json | 2 +- mobile/rpc-foundation/goldens/file-tap-resolve-refused.json | 2 +- .../rpc-foundation/goldens/files-explorer-legacy-fallback.json | 2 +- mobile/rpc-foundation/goldens/files-explorer-readdir.json | 2 +- mobile/rpc-foundation/goldens/files-ownership-local.json | 2 +- mobile/rpc-foundation/goldens/files-ownership-ssh.json | 2 +- .../rpc-foundation/goldens/files-preview-artifact-direct.json | 2 +- .../goldens/files-preview-artifact-image-read.json | 2 +- mobile/rpc-foundation/goldens/files-preview-artifact-image.json | 2 +- mobile/rpc-foundation/goldens/files-preview-grant-refresh.json | 2 +- .../goldens/files-preview-worktree-image-read.json | 2 +- mobile/rpc-foundation/goldens/files-preview-worktree-image.json | 2 +- .../goldens/files-preview-worktree-text-read.json | 2 +- mobile/rpc-foundation/goldens/files-preview-worktree.json | 2 +- mobile/rpc-foundation/goldens/files-save-blind.json | 2 +- mobile/rpc-foundation/goldens/files-save-verified.json | 2 +- mobile/rpc-foundation/goldens/files-tab-doc-shapes.json | 2 +- mobile/rpc-foundation/goldens/home-host-accounts.json | 2 +- mobile/rpc-foundation/goldens/home-host-stats.json | 2 +- mobile/rpc-foundation/goldens/host-view-settings-sync.json | 2 +- .../goldens/host-worktree-actions-pin-open-delete.json | 2 +- mobile/rpc-foundation/goldens/host-worktree-delete-refused.json | 2 +- mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json | 2 +- .../goldens/interruptions-inventory-lifecycle.json | 2 +- .../goldens/interruptions-settings-bot-overrides-fulfilled.json | 2 +- mobile/rpc-foundation/goldens/inventory-lifecycle.json | 2 +- mobile/rpc-foundation/goldens/inventory-repeat-query.json | 2 +- mobile/rpc-foundation/goldens/lifecycle-b3.json | 2 +- .../rpc-foundation/goldens/lifecycle-inventory-lifecycle.json | 2 +- .../goldens/lifecycle-settings-bot-overrides-fulfilled.json | 2 +- .../goldens/lifecycle-settings-task-hydration-fulfilled.json | 2 +- .../goldens/lifecycle-settings-workspace-context-fulfilled.json | 2 +- mobile/rpc-foundation/goldens/linear-select-workspace.json | 2 +- mobile/rpc-foundation/goldens/live-worktree-name-stream.json | 2 +- ...ix-agentsession.structured-create-agentsession.create-1.json | 2 +- ...tsession.structured-create-agentsession.createsupport-1.json | 2 +- ...tsession.structured-launch-agentsession.createsupport-1.json | 2 +- .../goldens/matrix-aivault.history-aivault.listsessions-1.json | 2 +- .../goldens/matrix-aivault.history-screen-platform-status.json | 2 +- .../goldens/matrix-aivault.history-screen-status.get-2.json | 2 +- .../goldens/matrix-aivault.history-screen-worktree.ps-1.json | 2 +- .../goldens/matrix-aivault.history-status.get-1.json | 2 +- ...rix-aivault.resume-launch-session.tabs.createterminal-1.json | 2 +- .../goldens/matrix-aivault.resume-launch-terminal.send-1.json | 2 +- ...vault.resume-preparation-aivault.preparesessionresume-1.json | 2 +- .../goldens/matrix-browser.dialog-browser.dialogaccept-1.json | 2 +- .../matrix-browser.keyboard-browser.keyboardinserttext-1.json | 2 +- .../goldens/matrix-browser.keyboard-browser.keypress-1.json | 2 +- .../matrix-browser.pointer-click-browser.mouseclick-1.json | 2 +- .../matrix-browser.pointer-click-browser.mousedown-1.json | 2 +- .../matrix-browser.pointer-click-browser.mousemove-1.json | 2 +- .../goldens/matrix-browser.pointer-click-browser.mouseup-1.json | 2 +- .../goldens/matrix-browser.wheel-browser.mousemove-1.json | 2 +- .../goldens/matrix-browser.wheel-browser.mousewheel-1.json | 2 +- ...clipboard.image-attachment-clipboard.startimageupload-1.json | 2 +- ...-clipboard.image-upload-clipboard.saveimageastempfile-1.json | 2 +- ...rix-clipboard.image-upload-clipboard.startimageupload-1.json | 2 +- .../matrix-components.codex-reset-capability-status.get-1.json | 2 +- ...s.codex-reset-credit-accounts.consumecodexresetcredit-1.json | 2 +- ...ponents.execution-target-local-preflight.detectagents-1.json | 2 +- ...ponents.execution-target-preflight.detectremoteagents-1.json | 2 +- .../matrix-components.execution-target-ssh.connect-1.json | 2 +- .../matrix-components.execution-target-ssh.getstate-1.json | 2 +- ...atrix-components.new-workspace-repositories-repo.list-1.json | 2 +- .../goldens/matrix-components.setup-script-repo.hooks-1.json | 2 +- .../goldens/matrix-files.explorer-screen-files.list-1.json | 2 +- .../goldens/matrix-files.explorer-screen-files.readdir-1.json | 2 +- .../goldens/matrix-files.mutation-ownership-ssh.getstate-1.json | 2 +- .../goldens/matrix-files.mutation-ownership-status.get-1.json | 2 +- .../matrix-files.mutation-ownership-worktree.show-1.json | 2 +- ...view-artifact-image-files.readterminalartifactpreview-1.json | 2 +- .../matrix-files.preview-load-files.readterminalartifact-1.json | 2 +- .../matrix-files.preview-load-files.readterminalartifact-2.json | 2 +- .../matrix-files.preview-load-files.resolveterminalpath-1.json | 2 +- .../matrix-files.preview-save-files.readterminalartifact-1.json | 2 +- ...matrix-files.preview-save-files.writeterminalartifact-1.json | 2 +- ...matrix-files.preview-worktree-image-files.readpreview-1.json | 2 +- .../matrix-files.preview-worktree-text-files.read-1.json | 2 +- .../goldens/matrix-files.tab-doc-files.read-1.json | 2 +- .../goldens/matrix-files.tab-doc-files.readpreview-1.json | 2 +- .../rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json | 2 +- .../goldens/matrix-files.terminal-path-tap-files.open-1.json | 2 +- ...rix-files.terminal-path-tap-files.resolveterminalpath-1.json | 2 +- .../matrix-git.base-ref-chain-repo.baserefdefault-1.json | 2 +- .../goldens/matrix-git.base-ref-chain-repo.list-1.json | 2 +- .../goldens/matrix-git.base-ref-chain-worktree.show-1.json | 2 +- .../matrix-git.branch-diff-preview-git.branchdiff-1.json | 2 +- .../goldens/matrix-git.changes-load-git.branchcompare-1.json | 2 +- .../goldens/matrix-git.changes-load-git.status-1.json | 2 +- .../goldens/matrix-git.changes-load-repo.list-1.json | 2 +- .../goldens/matrix-git.changes-load-worktree.show-1.json | 2 +- ...atrix-git.commit-message-ai-git.generatecommitmessage-1.json | 2 +- .../matrix-git.history-commit-files-git.commitcompare-1.json | 2 +- .../goldens/matrix-git.history-commit-files-git.history-1.json | 2 +- .../goldens/matrix-git.history-read-git.history-1.json | 2 +- .../goldens/matrix-git.remote-prerequisite-git.push-1.json | 2 +- .../goldens/matrix-git.review-preparation-git.status-1.json | 2 +- ...rix-github.pr-comment-mutation-github.addissuecomment-1.json | 2 +- ...ub.pr-comment-mutation-github.addprreviewcommentreply-1.json | 2 +- ...ment-mutation-github.project.deleteissuecommentbyslug-1.json | 2 +- ...ment-mutation-github.project.updateissuecommentbyslug-1.json | 2 +- ...github.pr-comment-mutation-github.resolvereviewthread-1.json | 2 +- .../goldens/matrix-github.pr-mutation-github.mergepr-1.json | 2 +- .../matrix-github.pr-mutation-github.removeprreviewers-1.json | 2 +- .../matrix-github.pr-mutation-github.requestprreviewers-1.json | 2 +- .../matrix-github.pr-mutation-github.rerunprchecks-1.json | 2 +- .../matrix-github.pr-mutation-github.setprautomerge-1.json | 2 +- .../matrix-github.pr-mutation-github.updateprstate-1.json | 2 +- .../matrix-github.pr-read-github.listassignableusers-1.json | 2 +- .../goldens/matrix-github.pr-read-github.prcheckdetails-1.json | 2 +- .../goldens/matrix-github.pr-read-github.prchecks-1.json | 2 +- .../goldens/matrix-github.pr-read-github.prforbranch-1.json | 2 +- .../goldens/matrix-github.pr-read-github.reposlug-1.json | 2 +- .../goldens/matrix-github.pr-read-github.workitemdetails-1.json | 2 +- .../goldens/matrix-github.pr-read-hostedreview.forbranch-1.json | 2 +- .../matrix-github.pr-title-mutation-github.updateprtitle-1.json | 2 +- .../goldens/matrix-home.host-accounts-accounts.list-1.json | 2 +- .../goldens/matrix-home.host-stats-stats.summary-1.json | 2 +- ...ost-worktree-refresh-runtime.clientevents.subscribe-1-1.json | 2 +- ...ost-worktree-refresh-runtime.clientevents.subscribe-1-2.json | 2 +- ...ost-worktree-refresh-runtime.clientevents.subscribe-1-3.json | 2 +- ...ost-worktree-refresh-runtime.clientevents.subscribe-2-1.json | 2 +- .../goldens/matrix-host.view-settings-ui.get-1.json | 2 +- .../goldens/matrix-host.view-settings-ui.set-1.json | 2 +- .../matrix-host.worktree-actions-worktree.activate-1.json | 2 +- .../goldens/matrix-host.worktree-actions-worktree.rm-1.json | 2 +- .../goldens/matrix-host.worktree-actions-worktree.set-1.json | 2 +- .../goldens/matrix-hostedreview.create-chain-git.push-1.json | 2 +- .../matrix-hostedreview.create-chain-hostedreview.create-1.json | 2 +- .../matrix-hostedreview.create-chain-worktree.set-1.json | 2 +- .../matrix-hostedreview.create-intent-git.bulkstage-1.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.commit-1.json | 2 +- ...-hostedreview.create-intent-git.generatecommitmessage-1.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.push-1.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.status-1.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.status-2.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.status-3.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.status-4.json | 2 +- ...matrix-hostedreview.create-intent-hostedreview.create-1.json | 2 +- ...iew.create-intent-hostedreview.getcreationeligibility-1.json | 2 +- ...iew.create-intent-hostedreview.getcreationeligibility-2.json | 2 +- .../matrix-hostedreview.create-intent-worktree.set-1.json | 2 +- ...eview.eligibility-hostedreview.getcreationeligibility-1.json | 2 +- .../goldens/matrix-legacy-inventory-files.searchpaths-1.json | 2 +- .../goldens/matrix-legacy-inventory-files.searchpaths-2.json | 2 +- .../goldens/matrix-legacy-inventory-fresh-inventory.json | 2 +- .../goldens/matrix-legacy-inventory-old-inventory.json | 2 +- .../goldens/matrix-linear-detail-barrier-linear.getissue-1.json | 2 +- .../matrix-linear-detail-barrier-linear.issuecomments-1.json | 2 +- ...linear.select-workspace-picker-linear.selectworkspace-1.json | 2 +- ...x-live-worktree-name-runtime.clientevents.subscribe-1-1.json | 2 +- ...x-live-worktree-name-runtime.clientevents.subscribe-1-2.json | 2 +- ...x-live-worktree-name-runtime.clientevents.subscribe-2-1.json | 2 +- .../goldens/matrix-live-worktree-name-worktree.show-1.json | 2 +- .../goldens/matrix-live-worktree-name-worktree.show-2.json | 2 +- .../goldens/matrix-live-worktree-name-worktree.show-3.json | 2 +- .../goldens/matrix-mobileweb.bundle-fetch-app-js.json | 2 +- .../goldens/matrix-mobileweb.bundle-fetch-index-head.json | 2 +- .../goldens/matrix-mobileweb.bundle-fetch-index-tail.json | 2 +- ...trix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json | 2 +- ...x-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json | 2 +- .../goldens/matrix-nativechat.image-paste-terminal.send-1.json | 2 +- .../goldens/matrix-nativechat.image-paste-terminal.send-2.json | 2 +- ...ix-nativechat.image-upload-clipboard.startimageupload-1.json | 2 +- ...n-option-pick-settings.mutatenativechatsessionoptions-1.json | 2 +- ....terminal-write-orchestration.workerterminaluserinput-1.json | 2 +- .../matrix-nativechat.terminal-write-terminal.send-1.json | 2 +- ...fications.desktop-stream-notifications.getmissedsince-1.json | 2 +- ...otifications.desktop-stream-notifications.subscribe-1-1.json | 2 +- ...otifications.desktop-stream-notifications.subscribe-1-2.json | 2 +- ...otifications.desktop-stream-notifications.unsubscribe-1.json | 2 +- ...ifications.display-test-screen-notifications.testpush-1.json | 2 +- ...fications.push-dismissal-notifications.getmissedsince-1.json | 2 +- ...ications.push-registration-notifications.registerpush-1.json | 2 +- ...ations.push-registration-notifications.unregisterpush-1.json | 2 +- .../goldens/matrix-pairing.pre-profile-direct-status.json | 2 +- .../matrix-pairing.pre-profile-pairing.getendpoints-1.json | 2 +- .../matrix-pairing.pre-profile-pairing.provisionrelay-1.json | 2 +- .../goldens/matrix-pairing.pre-profile-relay-status.json | 2 +- ...oject-explicit-false-github.project.updateissuebyslug-1.json | 2 +- ...matrix-relay.credential-rotation-pairing.getendpoints-1.json | 2 +- ...matrix-relay.credential-rotation-pairing.getendpoints-2.json | 2 +- ...trix-relay.credential-rotation-pairing.provisionrelay-1.json | 2 +- .../matrix-relay.direct-upgrade-pairing.getendpoints-1.json | 2 +- .../matrix-relay.direct-upgrade-pairing.getendpoints-2.json | 2 +- .../matrix-relay.direct-upgrade-pairing.provisionrelay-1.json | 2 +- .../matrix-relay.pairing-recovery-pairing.getendpoints-1.json | 2 +- .../matrix-session.browser-tab-create-browser.tabcreate-1.json | 2 +- .../matrix-session.content-create-files.createfile-1.json | 2 +- .../goldens/matrix-session.content-create-files.open-1.json | 2 +- .../goldens/matrix-session.content-create-status.get-1.json | 2 +- .../goldens/matrix-session.content-create-worktree.show-1.json | 2 +- ...x-session.create-terminal-session.tabs.createterminal-1.json | 2 +- .../goldens/matrix-session.create-terminal-terminal.send-1.json | 2 +- .../goldens/matrix-session.diff-notes-worktree.show-1.json | 2 +- .../matrix-session.diff-review-actions-worktree.set-1.json | 2 +- .../goldens/matrix-session.diff-review-base-ref-show.json | 2 +- .../goldens/matrix-session.diff-review-git.branchcompare-1.json | 2 +- .../goldens/matrix-session.diff-review-git.status-1.json | 2 +- .../goldens/matrix-session.diff-review-repo.list-1.json | 2 +- .../goldens/matrix-session.diff-review-review-show.json | 2 +- .../matrix-session.markdown-disk-fallback-files.read-1.json | 2 +- ...atrix-session.markdown-disk-fallback-markdown.readtab-1.json | 2 +- .../matrix-session.markdown-save-markdown.savetab-1.json | 2 +- ...atrix-session.native-chat-page-nativechat.readsession-1.json | 2 +- ...atrix-session.native-chat-page-nativechat.subscribe-1-1.json | 2 +- ...atrix-session.native-chat-page-nativechat.subscribe-2-1.json | 2 +- .../matrix-session.native-chat-readability-repo.list-1.json | 2 +- ...ative-chat-stop-orchestration.workerterminaluserinput-1.json | 2 +- .../matrix-session.native-chat-stop-terminal.send-1.json | 2 +- .../matrix-session.native-chat-stop-terminal.send-2.json | 2 +- .../matrix-session.pr-branch-context-git.branchcompare-1.json | 2 +- .../goldens/matrix-session.pr-branch-context-git.status-1.json | 2 +- .../goldens/matrix-session.pr-branch-context-repo.list-1.json | 2 +- .../matrix-session.pr-branch-context-worktree.show-1.json | 2 +- .../goldens/matrix-session.pr-sidebar-github.prchecks-1.json | 2 +- .../goldens/matrix-session.pr-sidebar-github.prforbranch-1.json | 2 +- .../matrix-session.pr-sidebar-hostedreview.forbranch-1.json | 2 +- .../goldens/matrix-session.pr-sidebar-worktree.show-1.json | 2 +- .../matrix-session.pr-triage-session.tabs.createterminal-1.json | 2 +- .../goldens/matrix-session.pr-triage-terminal.send-1.json | 2 +- .../matrix-session.review-branch-diff-git.branchdiff-1.json | 2 +- .../goldens/matrix-session.review-file-diff-git.diff-1.json | 2 +- .../goldens/matrix-session.review-file-diff-git.diff-2.json | 2 +- .../goldens/matrix-session.review-file-diff-git.diff-3.json | 2 +- .../matrix-session.review-git-mutations-git.discard-1.json | 2 +- .../matrix-session.review-git-mutations-git.stage-1.json | 2 +- .../matrix-session.review-git-mutations-git.stage-2.json | 2 +- .../matrix-session.review-send-sheet-session.tabs.list-1.json | 2 +- .../goldens/matrix-session.startup-worktree.activate-1.json | 2 +- .../goldens/matrix-session.startup-worktree.activate-2.json | 2 +- .../matrix-session.tab-activation-session.tabs.activate-1.json | 2 +- .../goldens/matrix-session.tab-activation-terminal.focus-1.json | 2 +- .../matrix-session.tab-close-session-session.tabs.close-1.json | 2 +- .../goldens/matrix-session.tab-close-terminal.close-1.json | 2 +- .../matrix-session.tab-documents-markdown.readtab-1.json | 2 +- .../goldens/matrix-session.tab-rename-terminal.rename-1.json | 2 +- .../matrix-session.tab-reveal-session.tabs.activate-1.json | 2 +- .../goldens/matrix-session.tab-reveal-session.tabs.list-1.json | 2 +- .../matrix-session.tabs-stream-health-session.tabs.list-1.json | 2 +- ...session.terminal-display-mode-terminal.setdisplaymode-1.json | 2 +- ...l-gesture-input-orchestration.workerterminaluserinput-1.json | 2 +- ...x-session.terminal-gesture-input-terminal.clearbuffer-1.json | 2 +- .../matrix-session.terminal-gesture-input-terminal.send-1.json | 2 +- ...inal-input-send-orchestration.workerterminaluserinput-1.json | 2 +- .../matrix-session.terminal-input-send-terminal.send-1.json | 2 +- .../matrix-session.terminal-inventory-terminal.list-1.json | 2 +- ....terminal-paste-orchestration.workerterminaluserinput-1.json | 2 +- .../goldens/matrix-session.terminal-paste-settings.get-1.json | 2 +- .../goldens/matrix-session.terminal-paste-terminal.send-1.json | 2 +- .../goldens/matrix-session.worktree-connection-repo.list-1.json | 2 +- .../matrix-session.worktree-connection-settings.get-1.json | 2 +- ...trix-settings-agent-read-preflight.detectremoteagents-1.json | 2 +- .../goldens/matrix-settings-agent-read-repo.list-1.json | 2 +- .../goldens/matrix-settings-agent-read-settings.get-1.json | 2 +- .../goldens/matrix-settings-best-effort-settings.update-1.json | 2 +- .../goldens/matrix-settings.bot-overrides-settings.get-1.json | 2 +- .../goldens/matrix-settings.home-providers-linear.status-1.json | 2 +- .../matrix-settings.home-providers-preflight.check-1.json | 2 +- .../goldens/matrix-settings.home-providers-settings.get-1.json | 2 +- ...-settings.new-tab-local-agents-preflight.detectagents-1.json | 2 +- .../matrix-settings.new-tab-local-agents-repo.list-1.json | 2 +- .../matrix-settings.new-tab-local-agents-settings.get-1.json | 2 +- ...ings.quick-commands-settings.getterminalquickcommands-1.json | 2 +- ...s.quick-commands-settings.updateterminalquickcommands-1.json | 2 +- .../goldens/matrix-settings.repo-metadata-host.platform-1.json | 2 +- .../goldens/matrix-settings.repo-metadata-repo.list-1.json | 2 +- .../goldens/matrix-settings.repo-metadata-settings.get-1.json | 2 +- ...matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json | 2 +- .../matrix-settings.resume-metadata-folderworkspace.list-1.json | 2 +- .../matrix-settings.resume-metadata-projectgroup.list-1.json | 2 +- .../goldens/matrix-settings.resume-metadata-repo.list-1.json | 2 +- .../goldens/matrix-settings.resume-metadata-settings.get-1.json | 2 +- .../goldens/matrix-settings.resume-metadata-worktree.ps-1.json | 2 +- .../goldens/matrix-settings.task-hydration-linear.status-1.json | 2 +- .../matrix-settings.task-hydration-preflight.check-1.json | 2 +- .../goldens/matrix-settings.task-hydration-settings.get-1.json | 2 +- .../goldens/matrix-settings.task-hydration-status.get-1.json | 2 +- .../goldens/matrix-settings.task-hydration-ui.get-1.json | 2 +- .../matrix-settings.task-workspace-create-settings.get-1.json | 2 +- ...matrix-settings.task-workspace-create-worktree.create-1.json | 2 +- .../goldens/matrix-settings.task-workspace-settings.get-1.json | 2 +- .../matrix-settings.workspace-context-linear.status-1.json | 2 +- .../matrix-settings.workspace-context-preflight.check-1.json | 2 +- .../matrix-settings.workspace-context-settings.get-1.json | 2 +- .../goldens/matrix-settings.workspace-context-ui.get-1.json | 2 +- .../matrix-settings.workspace-submit-settings.get-1.json | 2 +- .../matrix-speech.dictation-chunk-speech.dictation.chunk-1.json | 2 +- ...trix-speech.dictation-session-speech.dictation.finish-1.json | 2 +- ...atrix-speech.dictation-session-speech.dictation.start-1.json | 2 +- ...matrix-speech.dictation-start-speech.dictation.cancel-1.json | 2 +- .../matrix-speech.dictation-start-speech.dictation.start-1.json | 2 +- .../matrix-speech.setup-sheet-speech.dictation.setup-1.json | 2 +- .../matrix-speech.setup-sheet-speech.models.delete-1.json | 2 +- .../matrix-speech.setup-sheet-speech.models.download-1.json | 2 +- .../goldens/matrix-speech.setup-sheet-speech.models.list-1.json | 2 +- ...rix-tasks.item-checks-files-github.addprreviewcomment-1.json | 2 +- .../matrix-tasks.item-checks-files-github.prfilecontents-1.json | 2 +- .../matrix-tasks.item-checks-files-github.rerunprchecks-1.json | 2 +- ...ix-tasks.item-checks-files-github.resolvereviewthread-1.json | 2 +- ...matrix-tasks.item-checks-files-github.setprfileviewed-1.json | 2 +- ...trix-tasks.item-comment-github-github.addissuecomment-1.json | 2 +- ...trix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json | 2 +- ...trix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json | 2 +- ...atrix-tasks.item-detail-github-github.workitemdetails-1.json | 2 +- ...atrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json | 2 +- .../matrix-tasks.item-detail-linear-linear.getissue-1.json | 2 +- .../matrix-tasks.item-detail-linear-linear.issuecomments-1.json | 2 +- ...tasks.item-detail-metadata-github.listassignableusers-1.json | 2 +- .../matrix-tasks.item-detail-metadata-github.listlabels-1.json | 2 +- .../matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json | 2 +- .../matrix-tasks.item-metadata-github-github.updatepr-1.json | 2 +- .../matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json | 2 +- .../matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json | 2 +- .../matrix-tasks.item-reply-merge-github.addissuecomment-1.json | 2 +- ...tasks.item-reply-merge-github.addprreviewcommentreply-1.json | 2 +- .../goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json | 2 +- .../matrix-tasks.item-reply-merge-linear.updateissue-1.json | 2 +- .../matrix-tasks.item-review-github-github.prchecks-1.json | 2 +- ...ix-tasks.item-review-github-github.requestprreviewers-1.json | 2 +- .../matrix-tasks.item-status-gitlab-github.updateissue-1.json | 2 +- .../matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json | 2 +- ...trix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json | 2 +- .../goldens/matrix-tasks.linear-connect-linear.connect-1.json | 2 +- .../matrix-tasks.linear-item-linear.addissuecomment-1.json | 2 +- .../goldens/matrix-tasks.linear-item-linear.createissue-1.json | 2 +- .../goldens/matrix-tasks.linear-item-linear.getissue-1.json | 2 +- .../matrix-tasks.linear-team-context-linear.listteams-1.json | 2 +- .../matrix-tasks.linear-team-context-linear.teamstates-1.json | 2 +- .../goldens/matrix-tasks.paste-lookup-github.reposlug-1.json | 2 +- .../goldens/matrix-tasks.paste-lookup-github.workitem-1.json | 2 +- .../matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json | 2 +- .../matrix-tasks.paste-lookup-gitlab.workitembypath-1.json | 2 +- ...asks.project-board-load-github.project.listaccessible-1.json | 2 +- ...rix-tasks.project-board-load-github.project.listviews-1.json | 2 +- ...rix-tasks.project-board-load-github.project.listviews-2.json | 2 +- ...ix-tasks.project-board-load-github.project.resolveref-1.json | 2 +- ...rix-tasks.project-board-load-github.project.viewtable-1.json | 2 +- .../matrix-tasks.project-repo-slugs-github.reposlug-1.json | 2 +- ...w-comments-issue-github.project.addissuecommentbyslug-1.json | 2 +- ...t-row-comments-issue-github.project.updateissuebyslug-1.json | 2 +- ...omments-issue-github.project.updateissuecommentbyslug-1.json | 2 +- ...ow-comments-pr-github.project.updatepullrequestbyslug-1.json | 2 +- ...oject-row-detail-github.project.workitemdetailsbyslug-1.json | 2 +- ...asks.project-row-fields-github.project.clearitemfield-1.json | 2 +- ...oject-row-fields-github.project.updateissuetypebyslug-1.json | 2 +- ...sks.project-row-fields-github.project.updateitemfield-1.json | 2 +- ...sks.project-row-files-merge-github.addprreviewcomment-1.json | 2 +- .../matrix-tasks.project-row-files-merge-github.mergepr-1.json | 2 +- ...x-tasks.project-row-files-merge-github.prfilecontents-1.json | 2 +- ...trix-tasks.project-row-files-merge-github.updateissue-1.json | 2 +- ...ix-tasks.project-row-files-merge-github.updateprstate-1.json | 2 +- ...etadata-load-github.project.listassignableusersbyslug-1.json | 2 +- ...row-metadata-load-github.project.listissuetypesbyslug-1.json | 2 +- ...ect-row-metadata-load-github.project.listlabelsbyslug-1.json | 2 +- ...atrix-tasks.project-row-review-checks-github.prchecks-1.json | 2 +- ...s.project-row-review-checks-github.requestprreviewers-1.json | 2 +- ...-tasks.project-row-review-checks-github.rerunprchecks-1.json | 2 +- ...asks.project-row-review-checks-github.setprfileviewed-1.json | 2 +- ...trix-tasks.project-row-threads-github.addissuecomment-1.json | 2 +- ...ks.project-row-threads-github.addprreviewcommentreply-1.json | 2 +- ...t-row-threads-github.project.deleteissuecommentbyslug-1.json | 2 +- ...-tasks.project-row-threads-github.resolvereviewthread-1.json | 2 +- .../matrix-tasks.provider-load-github.countworkitems-1.json | 2 +- .../matrix-tasks.provider-load-github.listworkitems-1.json | 2 +- .../goldens/matrix-tasks.provider-load-linear.listteams-1.json | 2 +- .../goldens/matrix-tasks.provider-load-linear.status-1.json | 2 +- .../goldens/matrix-tasks.provider-load-settings.update-1.json | 2 +- .../goldens/matrix-tasks.route-repo-list-repo.list-1.json | 2 +- ...matrix-tasks.smart-source-search-github.listworkitems-1.json | 2 +- ...matrix-tasks.smart-source-search-gitlab.listworkitems-1.json | 2 +- .../matrix-tasks.smart-source-search-linear.listissues-1.json | 2 +- .../matrix-tasks.smart-source-search-linear.searchissues-1.json | 2 +- .../matrix-tasks.smart-source-search-repo.searchrefs-1.json | 2 +- .../matrix-tasks.task-create-github-github.createissue-1.json | 2 +- .../goldens/matrix-tasks.task-create-github-repo.update-1.json | 2 +- .../matrix-tasks.task-create-gitlab-gitlab.createissue-1.json | 2 +- .../matrix-tasks.task-create-linear-linear.createissue-1.json | 2 +- ...rix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json | 2 +- .../matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json | 2 +- .../matrix-tasks.task-list-linear-linear.listissues-1.json | 2 +- .../matrix-tasks.task-list-linear-linear.searchissues-1.json | 2 +- .../matrix-tasks.workspace-source-repo.searchrefs-1.json | 2 +- .../matrix-tasks.workspace-source-repo.sparsepresets-1.json | 2 +- .../matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json | 2 +- .../goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json | 2 +- ...trix-tasks.workspace-ssh-local-preflight.detectagents-1.json | 2 +- ...trix-tasks.workspace-ssh-preflight.detectremoteagents-1.json | 2 +- .../goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json | 2 +- .../goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json | 2 +- .../goldens/matrix-terminal.query-reply-terminal.send-1.json | 2 +- ...minal.raw-input-orchestration.workerterminaluserinput-1.json | 2 +- .../goldens/matrix-terminal.raw-input-terminal.send-1.json | 2 +- ...takeover-report-orchestration.workerterminaluserinput-1.json | 2 +- ...takeover-report-orchestration.workerterminaluserinput-2.json | 2 +- ...atrix-terminal.viewport-refit-terminal.updateviewport-1.json | 2 +- .../goldens/matrix-transport.capability-probe-status.get-1.json | 2 +- .../matrix-transport.host-status-gates-status.get-1.json | 2 +- .../goldens/matrix-transport.pairing-race-direct-status.json | 2 +- .../goldens/matrix-transport.pairing-race-relay-status.json | 2 +- .../matrix-worktree.agent-launch-create-agent.launch-1.json | 2 +- .../goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json | 2 +- .../goldens/matrix-worktree.create-retry-worktree.create-1.json | 2 +- .../goldens/matrix-worktree.home-catalog-worktree.ps-1.json | 2 +- .../matrix-worktree.hosted-base-worktree.resolvemrbase-1.json | 2 +- .../matrix-worktree.hosted-base-worktree.resolveprbase-1.json | 2 +- ...trix-worktree.retired-names-worktree.listretirednames-1.json | 2 +- .../goldens/matrix-worktree.review-link-worktree.set-1.json | 2 +- .../matrix-worktree.runtime-capabilities-status.get-1.json | 2 +- .../goldens/matrix-worktree.setup-hook-trust-ui.set-1.json | 2 +- .../rpc-foundation/goldens/mobile-web-bundle-build-changed.json | 2 +- .../rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json | 2 +- .../rpc-foundation/goldens/mobile-web-bundle-manifest-read.json | 2 +- .../rpc-foundation/goldens/mobile-web-bundle-unavailable.json | 2 +- .../rpc-foundation/goldens/native-chat-image-paste-single.json | 2 +- .../goldens/native-chat-image-paste-stops-on-rejection.json | 2 +- .../goldens/native-chat-image-paste-trailing-image.json | 2 +- .../goldens/native-chat-image-paste-two-images.json | 2 +- .../goldens/native-chat-image-upload-cancelled.json | 2 +- .../goldens/native-chat-image-upload-second-fails.json | 2 +- .../rpc-foundation/goldens/native-chat-image-upload-single.json | 2 +- .../goldens/native-chat-image-upload-start-refused.json | 2 +- mobile/rpc-foundation/goldens/native-chat-image-upload-two.json | 2 +- mobile/rpc-foundation/goldens/native-chat-page-earlier.json | 2 +- .../goldens/native-chat-readability-local-repo.json | 2 +- .../rpc-foundation/goldens/native-chat-readability-refused.json | 2 +- .../goldens/native-chat-readability-remote-repo.json | 2 +- .../goldens/native-chat-session-option-pick-empty.json | 2 +- .../goldens/native-chat-session-option-pick-refused.json | 2 +- .../goldens/native-chat-session-option-pick-written.json | 2 +- mobile/rpc-foundation/goldens/native-chat-stop-accepted.json | 2 +- .../rpc-foundation/goldens/native-chat-stop-both-rejected.json | 2 +- .../goldens/native-chat-stop-delivery-unknown.json | 2 +- mobile/rpc-foundation/goldens/native-chat-write-accepted.json | 2 +- mobile/rpc-foundation/goldens/native-chat-write-clear-line.json | 2 +- .../goldens/native-chat-write-delivery-unknown.json | 2 +- mobile/rpc-foundation/goldens/native-chat-write-rejected.json | 2 +- .../rpc-foundation/goldens/native-chat-write-typed-command.json | 2 +- mobile/rpc-foundation/goldens/new-tab-local-agents.json | 2 +- .../goldens/new-workspace-repositories-fulfilled.json | 2 +- .../goldens/notifications-desktop-stream-closed.json | 2 +- .../goldens/notifications-desktop-stream-replayed.json | 2 +- mobile/rpc-foundation/goldens/notifications-desktop-stream.json | 2 +- .../goldens/notifications-display-test-accepted.json | 2 +- .../goldens/notifications-display-test-not-registered.json | 2 +- .../goldens/notifications-display-test-rate-limited.json | 2 +- .../goldens/notifications-display-test-unknown-reason.json | 2 +- .../goldens/notifications-push-gateway-rejected.json | 2 +- .../rpc-foundation/goldens/notifications-push-registered.json | 2 +- .../goldens/pairing-pre-profile-direct-wins-and-provisions.json | 2 +- ...ing-pre-profile-provision-unsupported-saves-direct-host.json | 2 +- .../rpc-foundation/goldens/pairing-pre-profile-times-out.json | 2 +- mobile/rpc-foundation/goldens/pr-branch-identity.json | 2 +- mobile/rpc-foundation/goldens/pr-branch-repo-context.json | 2 +- mobile/rpc-foundation/goldens/pr-comment-mutation.json | 2 +- .../rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json | 2 +- mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json | 2 +- mobile/rpc-foundation/goldens/pr-mutation-status.json | 2 +- mobile/rpc-foundation/goldens/pr-read-fork-routing.json | 2 +- mobile/rpc-foundation/goldens/pr-read-surface.json | 2 +- mobile/rpc-foundation/goldens/pr-read-upstream-error.json | 2 +- mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json | 2 +- mobile/rpc-foundation/goldens/pr-sidebar-load.json | 2 +- mobile/rpc-foundation/goldens/pr-title-mutation.json | 2 +- mobile/rpc-foundation/goldens/pr-title-unconfirmed.json | 2 +- mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json | 2 +- mobile/rpc-foundation/goldens/pr-triage-launch.json | 2 +- mobile/rpc-foundation/goldens/pr-triage-send-locked.json | 2 +- mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json | 2 +- .../goldens/probe-new-tab-null-sibling-refused.json | 2 +- .../goldens/probe-new-tab-refused-sibling-rejects.json | 2 +- .../goldens/probe-new-tab-rejects-sibling-refused.json | 2 +- .../rpc-foundation/goldens/push-dismissal-tray-reconciled.json | 2 +- mobile/rpc-foundation/goldens/quick-commands-load-refused.json | 2 +- .../rpc-foundation/goldens/quick-commands-loaded-and-saved.json | 2 +- .../goldens/quick-commands-save-refused-rolls-back.json | 2 +- mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json | 2 +- .../goldens/relay-direct-upgrade-unsupported-host-declines.json | 2 +- .../goldens/relay-pairing-recovery-invite-authorizes.json | 2 +- .../goldens/relay-pairing-recovery-resume-committed.json | 2 +- .../goldens/relay-rotation-installs-and-commits.json | 2 +- .../goldens/relay-rotation-resumes-committed-pending.json | 2 +- mobile/rpc-foundation/goldens/review-branch-diff-shapes.json | 2 +- .../rpc-foundation/goldens/review-create-terminal-refused.json | 2 +- mobile/rpc-foundation/goldens/review-file-diff-shapes.json | 2 +- mobile/rpc-foundation/goldens/review-git-mutations-run.json | 2 +- .../rpc-foundation/goldens/review-mark-reviewed-persists.json | 2 +- .../rpc-foundation/goldens/review-mark-reviewed-rolls-back.json | 2 +- mobile/rpc-foundation/goldens/review-open-in-session.json | 2 +- .../goldens/review-send-notes-heals-stale-input.json | 2 +- .../goldens/review-send-sheet-lists-terminals.json | 2 +- mobile/rpc-foundation/goldens/review-stage-file.json | 2 +- mobile/rpc-foundation/goldens/review-stage-refused.json | 2 +- mobile/rpc-foundation/goldens/sc-base-ref-default.json | 2 +- mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json | 2 +- mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json | 2 +- mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json | 2 +- mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json | 2 +- mobile/rpc-foundation/goldens/sc-changes-loaded.json | 2 +- .../goldens/sc-commit-message-cancel-rejected.json | 2 +- mobile/rpc-foundation/goldens/sc-commit-message-canceled.json | 2 +- mobile/rpc-foundation/goldens/sc-commit-message-generated.json | 2 +- mobile/rpc-foundation/goldens/sc-create-existing-review.json | 2 +- .../goldens/sc-create-intent-stage-commit-push-create.json | 2 +- .../goldens/sc-create-intent-unlisted-provider.json | 2 +- .../goldens/sc-create-link-failure-is-non-fatal.json | 2 +- .../rpc-foundation/goldens/sc-create-pushes-then-creates.json | 2 +- .../rpc-foundation/goldens/sc-create-refused-empty-message.json | 2 +- .../goldens/sc-create-rejected-empty-message.json | 2 +- mobile/rpc-foundation/goldens/sc-eligibility-fetched.json | 2 +- mobile/rpc-foundation/goldens/sc-history-commit-files.json | 2 +- mobile/rpc-foundation/goldens/sc-history-loaded.json | 2 +- mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json | 2 +- mobile/rpc-foundation/goldens/sc-pr-link-read.json | 2 +- mobile/rpc-foundation/goldens/sc-pr-link-set.json | 2 +- .../goldens/sc-prefill-unavailable-on-refusal.json | 2 +- .../goldens/sc-prefill-unavailable-on-rejection.json | 2 +- .../goldens/sc-prerequisite-force-with-lease.json | 2 +- mobile/rpc-foundation/goldens/sc-prerequisite-publish.json | 2 +- mobile/rpc-foundation/goldens/sc-prerequisite-push.json | 2 +- mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json | 2 +- mobile/rpc-foundation/goldens/sc-reveal-first-poll.json | 2 +- mobile/rpc-foundation/goldens/sc-reveal-timeout.json | 2 +- .../rpc-foundation/goldens/sc-review-commit-inner-failure.json | 2 +- .../goldens/sc-review-commit-refused-empty-message.json | 2 +- mobile/rpc-foundation/goldens/sc-review-commit-rejected.json | 2 +- mobile/rpc-foundation/goldens/sc-review-commit.json | 2 +- .../goldens/sc-review-status-entries-not-array.json | 2 +- mobile/rpc-foundation/goldens/sc-review-status-normalized.json | 2 +- mobile/rpc-foundation/goldens/schedules-b3.json | 2 +- .../goldens/schedules-settings-home-providers-fulfilled.json | 2 +- .../rpc-foundation/goldens/schedules-settings-new-tab-ssh.json | 2 +- .../goldens/schedules-settings-repo-metadata-fulfilled.json | 2 +- .../goldens/schedules-settings-resume-metadata-fulfilled.json | 2 +- .../goldens/schedules-settings-task-hydration-fulfilled.json | 2 +- .../goldens/schedules-settings-workspace-context-fulfilled.json | 2 +- mobile/rpc-foundation/goldens/session-browser-tab-created.json | 2 +- .../rpc-foundation/goldens/session-create-browser-refused.json | 2 +- mobile/rpc-foundation/goldens/session-create-browser-tab.json | 2 +- .../goldens/session-create-markdown-name-collision.json | 2 +- mobile/rpc-foundation/goldens/session-create-markdown-note.json | 2 +- ...ssion-create-terminal-ignores-a-second-create-in-flight.json | 2 +- ...session-create-terminal-launches-an-agent-quick-command.json | 2 +- .../rpc-foundation/goldens/session-create-terminal-refused.json | 2 +- .../goldens/session-create-terminal-replaces-active.json | 2 +- .../goldens/session-create-terminal-runs-a-quick-command.json | 2 +- .../goldens/session-create-terminal-with-prompt.json | 2 +- .../goldens/session-create-terminal-without-active-tab.json | 2 +- .../goldens/session-create-terminal-without-handle.json | 2 +- .../rpc-foundation/goldens/session-diff-notes-load-refused.json | 2 +- mobile/rpc-foundation/goldens/session-diff-notes-loaded.json | 2 +- mobile/rpc-foundation/goldens/session-file-tab-read.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-disk-read.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-disk-served.json | 2 +- .../rpc-foundation/goldens/session-markdown-save-conflict.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-saved.json | 2 +- .../goldens/session-markdown-tab-disk-fallback.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-tab-read.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-tab-refused.json | 2 +- .../goldens/session-startup-both-activation-sites.json | 2 +- .../session-startup-floating-route-skips-activation.json | 2 +- .../session-startup-keeps-terminals-visible-on-reconnect.json | 2 +- .../session-startup-refused-tab-load-still-loads-terminals.json | 2 +- .../goldens/session-tab-activation-focus-and-activate.json | 2 +- .../rpc-foundation/goldens/session-tab-activation-refused.json | 2 +- .../goldens/session-tab-activation-transport-error.json | 2 +- .../goldens/session-tab-close-refused-keeps-tab.json | 2 +- .../rpc-foundation/goldens/session-tab-close-session-tab.json | 2 +- mobile/rpc-foundation/goldens/session-tab-close-terminal.json | 2 +- mobile/rpc-foundation/goldens/session-tab-closed.json | 2 +- mobile/rpc-foundation/goldens/session-tab-rename.json | 2 +- mobile/rpc-foundation/goldens/session-tab-renamed.json | 2 +- mobile/rpc-foundation/goldens/session-tabs-health-errored.json | 2 +- .../rpc-foundation/goldens/session-tabs-health-reconciled.json | 2 +- mobile/rpc-foundation/goldens/session-tabs-health-refused.json | 2 +- .../goldens/session-tabs-health-stale-application-revision.json | 2 +- .../goldens/session-terminal-display-mode-auto-take-floor.json | 2 +- ...session-terminal-display-mode-auto-without-device-token.json | 2 +- .../session-terminal-display-mode-auto-without-viewport.json | 2 +- .../session-terminal-display-mode-drops-second-toggle.json | 2 +- .../goldens/session-terminal-display-mode-to-desktop.json | 2 +- .../goldens/session-terminal-list-dedupes-handles.json | 2 +- .../goldens/session-terminal-list-empty-guarded.json | 2 +- mobile/rpc-foundation/goldens/session-terminal-list-merged.json | 2 +- .../rpc-foundation/goldens/session-terminal-list-refused.json | 2 +- .../goldens/settings-bot-overrides-fulfilled.json | 2 +- .../goldens/settings-bot-overrides-refresh-refused.json | 2 +- .../rpc-foundation/goldens/settings-bot-overrides-refused.json | 2 +- .../goldens/settings-bot-overrides-transport-error.json | 2 +- mobile/rpc-foundation/goldens/settings-home-coalesced.json | 2 +- .../goldens/settings-home-providers-fulfilled.json | 2 +- .../goldens/settings-home-providers-refuse-after-data.json | 2 +- .../rpc-foundation/goldens/settings-home-providers-refused.json | 2 +- .../goldens/settings-home-providers-transport-error.json | 2 +- mobile/rpc-foundation/goldens/settings-new-tab-refused.json | 2 +- mobile/rpc-foundation/goldens/settings-new-tab-ssh.json | 2 +- .../goldens/settings-new-tab-transport-error.json | 2 +- mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json | 2 +- .../goldens/settings-repo-metadata-fulfilled.json | 2 +- mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json | 2 +- .../goldens/settings-repo-metadata-refuse-after-data.json | 2 +- .../rpc-foundation/goldens/settings-repo-metadata-refused.json | 2 +- .../goldens/settings-repo-metadata-single-host.json | 2 +- .../goldens/settings-repo-metadata-transport-error.json | 2 +- .../goldens/settings-resume-metadata-fulfilled.json | 2 +- .../goldens/settings-resume-metadata-refuse-after-data.json | 2 +- .../goldens/settings-resume-metadata-refused.json | 2 +- .../goldens/settings-resume-metadata-transport-error.json | 2 +- .../goldens/settings-task-hydration-fulfilled.json | 2 +- .../goldens/settings-task-hydration-refuse-after-data.json | 2 +- .../rpc-foundation/goldens/settings-task-hydration-refused.json | 2 +- .../goldens/settings-task-hydration-transport-error.json | 2 +- .../goldens/settings-task-workspace-create-linear.json | 2 +- .../goldens/settings-task-workspace-create-pr-start-point.json | 2 +- .../goldens/settings-task-workspace-fulfilled.json | 2 +- .../rpc-foundation/goldens/settings-task-workspace-refused.json | 2 +- .../goldens/settings-task-workspace-transport-error.json | 2 +- mobile/rpc-foundation/goldens/settings-task-write.json | 2 +- .../goldens/settings-workspace-context-fulfilled.json | 2 +- .../goldens/settings-workspace-context-refuse-after-data.json | 2 +- .../goldens/settings-workspace-context-refused.json | 2 +- .../goldens/settings-workspace-context-transport-error.json | 2 +- .../goldens/settings-workspace-submit-fulfilled.json | 2 +- .../goldens/settings-workspace-submit-refused.json | 2 +- .../goldens/settings-workspace-submit-transport-error.json | 2 +- .../rpc-foundation/goldens/speech-audio-chunk-acknowledged.json | 2 +- .../rpc-foundation/goldens/speech-desktop-start-fulfilled.json | 2 +- .../goldens/speech-desktop-start-recording-failed.json | 2 +- .../rpc-foundation/goldens/speech-desktop-start-superseded.json | 2 +- .../goldens/speech-dictation-session-cancelled.json | 2 +- .../goldens/speech-dictation-session-transcript.json | 2 +- .../goldens/speech-setup-sheet-denied-to-mobile.json | 2 +- mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json | 2 +- .../goldens/speech-setup-sheet-legacy-desktop.json | 2 +- .../goldens/speech-setup-sheet-model-vocabulary.json | 2 +- .../goldens/structured-agent-session-created.json | 2 +- mobile/rpc-foundation/goldens/structured-launch-created.json | 2 +- .../goldens/structured-launch-definitive-refusal.json | 2 +- .../goldens/structured-launch-replays-dropped-create.json | 2 +- .../goldens/structured-launch-support-refused.json | 2 +- .../rpc-foundation/goldens/structured-launch-unsupported.json | 2 +- mobile/rpc-foundation/goldens/tasks-route-repo-list.json | 2 +- .../goldens/terminal-gesture-flush-and-clear.json | 2 +- mobile/rpc-foundation/goldens/terminal-input-send-accepted.json | 2 +- mobile/rpc-foundation/goldens/terminal-input-send-refused.json | 2 +- mobile/rpc-foundation/goldens/terminal-live-input-accepted.json | 2 +- mobile/rpc-foundation/goldens/terminal-paste-accepted.json | 2 +- mobile/rpc-foundation/goldens/terminal-paste-refused.json | 2 +- .../rpc-foundation/goldens/terminal-query-reply-accepted.json | 2 +- .../goldens/terminal-query-reply-unsubscribed.json | 2 +- mobile/rpc-foundation/goldens/terminal-raw-input-refused.json | 2 +- mobile/rpc-foundation/goldens/terminal-raw-input-reported.json | 2 +- .../goldens/terminal-takeover-report-accepted.json | 2 +- .../goldens/terminal-takeover-report-retried.json | 2 +- .../rpc-foundation/goldens/terminal-viewport-refit-applied.json | 2 +- .../goldens/terminal-viewport-refit-legacy-desktop.json | 2 +- .../goldens/terminal-worktree-connection-resolved.json | 2 +- mobile/rpc-foundation/goldens/tk-create-github.json | 2 +- mobile/rpc-foundation/goldens/tk-create-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-create-linear.json | 2 +- mobile/rpc-foundation/goldens/tk-item-checks-files.json | 2 +- mobile/rpc-foundation/goldens/tk-item-comment-github.json | 2 +- mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json | 2 +- mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json | 2 +- .../rpc-foundation/goldens/tk-item-detail-github-reactions.json | 2 +- mobile/rpc-foundation/goldens/tk-item-detail-github.json | 2 +- .../rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json | 2 +- mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-item-detail-linear.json | 2 +- mobile/rpc-foundation/goldens/tk-item-detail-metadata.json | 2 +- mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-item-metadata-github.json | 2 +- mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json | 2 +- mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-item-reply-merge.json | 2 +- mobile/rpc-foundation/goldens/tk-item-review-github.json | 2 +- mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json | 2 +- mobile/rpc-foundation/goldens/tk-item-status-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-linear-connect.json | 2 +- mobile/rpc-foundation/goldens/tk-linear-item.json | 2 +- mobile/rpc-foundation/goldens/tk-linear-team-context.json | 2 +- mobile/rpc-foundation/goldens/tk-list-gitlab-items.json | 2 +- mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json | 2 +- mobile/rpc-foundation/goldens/tk-list-linear.json | 2 +- mobile/rpc-foundation/goldens/tk-project-board-load.json | 2 +- mobile/rpc-foundation/goldens/tk-project-repo-slugs.json | 2 +- .../rpc-foundation/goldens/tk-project-row-comments-issue.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-detail.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-fields.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-files-merge.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-review-checks.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-threads.json | 2 +- mobile/rpc-foundation/goldens/tk-provider-load.json | 2 +- .../goldens/transport-capability-probe-cutover-reasks-fast.json | 2 +- ...transport-capability-probe-non-string-capabilities-drop.json | 2 +- .../goldens/transport-capability-probe-publishes.json | 2 +- .../goldens/transport-capability-probe-refused-backs-off.json | 2 +- .../transport-host-status-gates-drop-keeps-capabilities.json | 2 +- .../goldens/transport-host-status-gates-ready.json | 2 +- .../goldens/transport-host-status-gates-refused-degrades.json | 2 +- .../goldens/transport-pairing-race-both-refused.json | 2 +- .../goldens/transport-pairing-race-direct-completes-first.json | 2 +- .../goldens/transport-pairing-race-relay-completes-first.json | 2 +- .../transport-pairing-race-relay-wins-when-direct-refused.json | 2 +- mobile/rpc-foundation/goldens/tw-capabilities-advertised.json | 2 +- .../rpc-foundation/goldens/tw-capabilities-cutover-retried.json | 2 +- .../goldens/tw-capabilities-legacy-idempotency.json | 2 +- .../rpc-foundation/goldens/tw-create-retry-agent-launched.json | 2 +- .../goldens/tw-create-retry-ambiguous-after-drop.json | 2 +- .../goldens/tw-create-retry-ambiguous-while-connected.json | 2 +- .../goldens/tw-create-retry-ambiguous-without-idempotency.json | 2 +- mobile/rpc-foundation/goldens/tw-create-retry-created.json | 2 +- .../rpc-foundation/goldens/tw-create-retry-name-collision.json | 2 +- .../goldens/tw-create-retry-unretryable-refusal.json | 2 +- mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json | 2 +- mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json | 2 +- mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json | 2 +- mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json | 2 +- mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json | 2 +- .../goldens/tw-paste-lookup-slug-unsupported.json | 2 +- mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json | 2 +- mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json | 2 +- .../rpc-foundation/goldens/tw-smart-search-all-providers.json | 2 +- .../goldens/tw-smart-search-gitlab-provider-error.json | 2 +- .../rpc-foundation/goldens/tw-smart-search-linear-listed.json | 2 +- .../goldens/tw-task-preferences-resume-write.json | 2 +- .../goldens/tw-workspace-source-presets-refused.json | 2 +- mobile/rpc-foundation/goldens/tw-workspace-source-presets.json | 2 +- .../goldens/tw-workspace-sparse-missing-preset.json | 2 +- mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json | 2 +- .../goldens/tw-workspace-ssh-connect-refused.json | 2 +- mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json | 2 +- .../rpc-foundation/goldens/tw-workspace-ssh-local-agents.json | 2 +- mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json | 2 +- .../goldens/worktree-catalog-snapshot-unreadable.json | 2 +- mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json | 2 +- mobile/rpc-foundation/goldens/worktree-home-catalog.json | 2 +- mobile/rpc-foundation/goldens/worktree-retired-names.json | 2 +- mobile/rpc-foundation/pilot-scenarios.json | 2 +- 788 files changed, 788 insertions(+), 788 deletions(-) diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json index 9c087a374be..9764d774172 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json index 638829922ee..9a680528e3a 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json index 9978849b8a6..058640d1dfd 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json index eab9430cd59..7e0e21b73e8 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json index 3ad575d4db6..ead41a6fdb1 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json index e1486fd4f31..1d2cb2587d3 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json index 38bc92f8e6b..f7d84a9fe3b 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json index 1ccb03de2fa..3574d3010a3 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json index 8d6b4dc5bf3..dda9b370a6a 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json index a66ddfd8b42..1c5e2404742 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json index 28508997823..bd951c3ffc2 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json index ce575e1c76a..6f1133ed045 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json index cfdbc612399..af7f7979594 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/b1.json b/mobile/rpc-foundation/goldens/b1.json index 8c137e1148b..a60550af6ba 100644 --- a/mobile/rpc-foundation/goldens/b1.json +++ b/mobile/rpc-foundation/goldens/b1.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/b2.json b/mobile/rpc-foundation/goldens/b2.json index cee86516d62..ea5f30a2239 100644 --- a/mobile/rpc-foundation/goldens/b2.json +++ b/mobile/rpc-foundation/goldens/b2.json @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/b3.json b/mobile/rpc-foundation/goldens/b3.json index 0846bf836a6..b4c5b37c695 100644 --- a/mobile/rpc-foundation/goldens/b3.json +++ b/mobile/rpc-foundation/goldens/b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json index 2e32118b188..b5ac81395bf 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json index ef6b8c81d0f..bccfc5c2171 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-keyboard-input.json b/mobile/rpc-foundation/goldens/browser-keyboard-input.json index 860c57da1af..86aaace3c25 100644 --- a/mobile/rpc-foundation/goldens/browser-keyboard-input.json +++ b/mobile/rpc-foundation/goldens/browser-keyboard-input.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json index 10fd6455b47..5667814037a 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json index 13a8575e2d2..c9d28267b6c 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json index 7f03d11b14c..09e4ee5fc85 100644 --- a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json +++ b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json index 40499472cf9..b282d67dd32 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json index 6c87b9c24fc..5e95ab6fa53 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json index 1645ab66eee..dc5e6e6f41c 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json index 9e9ef92db17..132ed5910a9 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json index 52e53f73ee7..42b939ba697 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json index 4406c87bbba..b02905d0157 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json index 97803218f94..5d223d48ab5 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json index d70f943b27f..579cb1f30bb 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json index 7d54ad13020..f4a109c0b79 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json index 95683cb0275..8615b197a5b 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json index ade586d43d5..b38b6ee19a4 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/components-codex-capability.json b/mobile/rpc-foundation/goldens/components-codex-capability.json index 9fcba28c07e..2a37981af1c 100644 --- a/mobile/rpc-foundation/goldens/components-codex-capability.json +++ b/mobile/rpc-foundation/goldens/components-codex-capability.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-setup-ask.json b/mobile/rpc-foundation/goldens/components-setup-ask.json index 448bba08a94..fcdc19cd0dd 100644 --- a/mobile/rpc-foundation/goldens/components-setup-ask.json +++ b/mobile/rpc-foundation/goldens/components-setup-ask.json @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-local.json b/mobile/rpc-foundation/goldens/components-target-local.json index 584dff58131..7e36447b02d 100644 --- a/mobile/rpc-foundation/goldens/components-target-local.json +++ b/mobile/rpc-foundation/goldens/components-target-local.json @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-ssh.json b/mobile/rpc-foundation/goldens/components-target-ssh.json index 3041922356f..9cc2be30375 100644 --- a/mobile/rpc-foundation/goldens/components-target-ssh.json +++ b/mobile/rpc-foundation/goldens/components-target-ssh.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json index 62103ec119d..391a69557c7 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json index 82aca40040a..23809834373 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json index 52f956516b4..ab485242cc2 100644 --- a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json index 6cf14bfa6cf..80c18febb8f 100644 --- a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-snapshot.json b/mobile/rpc-foundation/goldens/diff-review-snapshot.json index 2396ef2c684..9684ad2dbba 100644 --- a/mobile/rpc-foundation/goldens/diff-review-snapshot.json +++ b/mobile/rpc-foundation/goldens/diff-review-snapshot.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json index 0a9c707944b..32d0a8ab49b 100644 --- a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json +++ b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json index 9803138546f..68a50493ee3 100644 --- a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/file-tap-open-refused.json b/mobile/rpc-foundation/goldens/file-tap-open-refused.json index 96a52c5a0ec..07f75c4effa 100644 --- a/mobile/rpc-foundation/goldens/file-tap-open-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-open-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json index 9460ecdad88..3557f6ac291 100644 --- a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json +++ b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json index 51af13c9b6a..54df619fc8d 100644 --- a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json +++ b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json index c57d44a47fd..72cf5e07c96 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json index e0782e72028..d26c034c377 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json index bc8949b6ddc..57f4f8949b0 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json +++ b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-explorer-readdir.json b/mobile/rpc-foundation/goldens/files-explorer-readdir.json index 3635407b85e..dc21814dd19 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-readdir.json +++ b/mobile/rpc-foundation/goldens/files-explorer-readdir.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-ownership-local.json b/mobile/rpc-foundation/goldens/files-ownership-local.json index b6fee34f12f..372565b6481 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-local.json +++ b/mobile/rpc-foundation/goldens/files-ownership-local.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-ownership-ssh.json b/mobile/rpc-foundation/goldens/files-ownership-ssh.json index 1c7281ba01b..6c6024d85b7 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-ssh.json +++ b/mobile/rpc-foundation/goldens/files-ownership-ssh.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json index f51dbbd96b3..7d3f0fd90db 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json index d656c291e6b..76525c3f3f4 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json index 8c11d914f1d..1bffc9c9b69 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json index a2643397928..3b8b6451cf7 100644 --- a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json +++ b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json index 3a934f629fe..f7ce524caa3 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json index 6205702638e..8a43ea8c453 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json index e89fb7239b5..e67456deec5 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree.json b/mobile/rpc-foundation/goldens/files-preview-worktree.json index 285dd5b2070..eba613fff51 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-blind.json b/mobile/rpc-foundation/goldens/files-save-blind.json index 5a3abcf56fd..fa785721142 100644 --- a/mobile/rpc-foundation/goldens/files-save-blind.json +++ b/mobile/rpc-foundation/goldens/files-save-blind.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-verified.json b/mobile/rpc-foundation/goldens/files-save-verified.json index 0421a3cba2b..d92c7e13072 100644 --- a/mobile/rpc-foundation/goldens/files-save-verified.json +++ b/mobile/rpc-foundation/goldens/files-save-verified.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json index a6a119288df..e0246b4abff 100644 --- a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json +++ b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/home-host-accounts.json b/mobile/rpc-foundation/goldens/home-host-accounts.json index 8206ebce25d..954b702b391 100644 --- a/mobile/rpc-foundation/goldens/home-host-accounts.json +++ b/mobile/rpc-foundation/goldens/home-host-accounts.json @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", diff --git a/mobile/rpc-foundation/goldens/home-host-stats.json b/mobile/rpc-foundation/goldens/home-host-stats.json index 1e17e46e1ab..b4084da3811 100644 --- a/mobile/rpc-foundation/goldens/home-host-stats.json +++ b/mobile/rpc-foundation/goldens/home-host-stats.json @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/host-view-settings-sync.json b/mobile/rpc-foundation/goldens/host-view-settings-sync.json index b63a50b9e0b..abf129404a9 100644 --- a/mobile/rpc-foundation/goldens/host-view-settings-sync.json +++ b/mobile/rpc-foundation/goldens/host-view-settings-sync.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json index 7bfa1f93583..3a65d658204 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json +++ b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json index 52fd008c50c..98ebd5f8394 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json +++ b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json index 9d28e5e2ebd..925a19bf76b 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json +++ b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json index 1634c69eff3..ecac77daa0e 100644 --- a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json index 0de13f9f0b6..c7cca71cc47 100644 --- a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/inventory-lifecycle.json b/mobile/rpc-foundation/goldens/inventory-lifecycle.json index 9c65d9e03f8..43824b3db3c 100644 --- a/mobile/rpc-foundation/goldens/inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/inventory-repeat-query.json b/mobile/rpc-foundation/goldens/inventory-repeat-query.json index 55e705fe802..f356612875a 100644 --- a/mobile/rpc-foundation/goldens/inventory-repeat-query.json +++ b/mobile/rpc-foundation/goldens/inventory-repeat-query.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/lifecycle-b3.json b/mobile/rpc-foundation/goldens/lifecycle-b3.json index 7faa0707243..3c1d405cc8d 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-b3.json +++ b/mobile/rpc-foundation/goldens/lifecycle-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index 792d8d69e95..01d9c9d86e7 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json index e6711bde5a0..b83fc972016 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json index d4b5bac6b88..bbac08894a7 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json index ca3b5b30a64..011b0559526 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/linear-select-workspace.json b/mobile/rpc-foundation/goldens/linear-select-workspace.json index a2bb58f3661..63042ed0739 100644 --- a/mobile/rpc-foundation/goldens/linear-select-workspace.json +++ b/mobile/rpc-foundation/goldens/linear-select-workspace.json @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", diff --git a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json index 9bff3a4e51b..2d7d5ebe16b 100644 --- a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json +++ b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json index 6f0f60122d4..b893359d5c5 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json index 37896453de4..b4fb45bfe62 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json index 85653cf50d7..a0e4970e62a 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json index 201a59ce8c7..0cd041c9201 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json index 55caa0ee5b1..427e515178f 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json index 68ca8a29cfd..5b28c288336 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json index b2abd8acd44..60ef2b2f356 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json index 8053b35fc98..0c39e2e5b78 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json index 421223a8d7f..c109d0afaf7 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json index 2868c463b3d..eba2be27856 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json index 6f715931a9e..11a0210151e 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json index 6f893b8f5da..f80d4d26b8c 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json index 2032bf4b1c4..36c559f668e 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json index 7e582828527..1ac2ef326c7 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json index 9253a936da4..19a8c182a5f 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json index b7984e55abf..8a7d495b605 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json index 9e6243891d2..a2afd16bc37 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json index fb4d631b251..a29854d450e 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json index f151f3b3933..8291ab94a55 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json index 93f0adb616f..fb4018d9e79 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json index b96753d8d24..563adffe53b 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json index 6e7ac708fe1..ff3dd022cef 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json index 6bf519616f7..b42e6700e11 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json index e1d74ee4acd..d7a96f52210 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json index 775bd039507..9da0d62cd74 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json index c4d2cb8b35b..75f021dba0e 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json index af2f982f16d..0359fe0382a 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json index d83cf728358..fd69d6899f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json index 582bcf99d25..47eb939c7ae 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json index d62336945f6..3811bb3f8c1 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", diff --git a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json index efb3484c637..55d3df96921 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json index 89801f19313..d849db7fd59 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json index f6922f9ddcc..1c95f330b90 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json index 026288fd2cd..d5088475514 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json index 723f06a7683..a1f8e277916 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json index 81bbf599592..f84f43380a2 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json index 4e192f50fe9..d238582baf3 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json index 24f83afdffb..a26835fb2da 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json index be9e5945688..0bdd2cb84bd 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json index 1b07ca8e439..69494b8c073 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json index 810068a90fe..c30d6510ad1 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json index 6d342cac764..ea605529d29 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json index abff758344f..afccf261b3e 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json index d395395e190..1c83adb9193 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json index 24700019285..01e4679fd0f 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json index a9522036b8f..8cdb705b01e 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json index 40a3d75279d..90b063dd137 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json index 2d307403d23..aca08f31139 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json index 58906eb7ce8..c899999a78f 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json index 2680929c395..567abfd834a 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json index 23eaf889f6a..f34513fe780 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json index f21fba36b6a..869662d4eac 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json index 811eeaad579..f042d3bada1 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json index 91ac30b8839..30297e66249 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json index 57d24564b55..ff5ce05acce 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json index f3f06b5023b..5d86546298a 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json index d520d2e5e56..10dd712666b 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json index 76cb7144be5..3dc60f7acf2 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json index 817329b6104..1b3df41916d 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json index cce23046c6f..d6d4bb59b2d 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json index e66e92c4f18..3a4a586f51a 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json index a5e3b14cb90..58a37169599 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json index 8ad4a5faf20..7abbd558bd0 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json index 1455515e450..2fa345de60e 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json index f43797fe303..bc9894c0487 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json index 53786d16204..fbec7940e4d 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json index b59a0b84edc..fd3b3fb5bdb 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json index 8d64298ac3f..d15e42ef09f 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json index 903bde1e5f0..41553f52001 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json index bcb49e675ca..213c1cdc024 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json index a0b3d9b73d4..c31e14216d2 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json index b0b358e1f6a..0b1b22e83b7 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json index 3aa180ea704..d615146b13d 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json index 64f54c38e8c..68462aa49ea 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json index af67fa26a13..75b40d11676 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json index 0a32cba06dd..e936d91d935 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json index 77af6982c86..c61c820741f 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json index 651579209c4..2896a72f8c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json index f9c63e81960..e50414f1c51 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json index 9fe0850b18e..fc50ae1cfaa 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json index 4d3e118c942..cfbd2623725 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json index 1c92dc53ce1..96de09a84ac 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json index e63b82c445b..2befba5d575 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json index 7f1b441ac75..31e31cca8df 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json index 5a0257c7801..87bf7805dd4 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json index 733e07b77e7..45698fe377a 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json index 68892d46f4f..f17859ccc63 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json index f18b0bdc542..79a48037013 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json index 59e8bfce095..e8292f7cec3 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json index ffd745529ec..fa1e01c5e92 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json index c94c38a4c3e..e73be851205 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json index 69225d9a8f7..b2007285f98 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json index 584b62fcd3e..9d68eff5ece 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json index c37161c6c30..15bbd094e78 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json index dab55c61515..3971925ff87 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json index 205224357f5..c7522b4a858 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json index 8a2fa23cd82..100eb186302 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json index 22df417024d..6e0f7077d46 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json index 7db76a5d39d..b00682f9c34 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json index 390618e1fc1..90c30663b55 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json index 0777ac8a6d9..7e6b9282547 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json index bb03a2497a8..93fcfb06df6 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json index adc516270d3..40b9fcfcdc2 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json index 3f140726e98..8f9924590a7 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json index ae23a6231d4..2b4a188f2cd 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json index dd74ad07fbd..1ef4b523c58 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json index a6082edeac0..42d52c49a44 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json index 51f082676e2..b9ddf737639 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json index daa10145bb7..6304aa46126 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json index 25406cd2d31..a8b9a7e8985 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json index 57c38e3b259..6d606acb5fa 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json index 869ddb226d0..c4876e119ac 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json index 78ac874bc0c..328e110696d 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json index 421ff6284b9..5b4594596a9 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json index 5b9a290d256..1b5b1952fa4 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json index ffe62097acf..ca0bd08439e 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json index 8941f661495..f6d095cf657 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json index d4224570656..37c7d34ad72 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json index 0b24fca0839..15d5d9961da 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json index d48f69b5a83..d29ffeb3672 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json index b5bbc67ce27..7da9484e8c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json index c3568a8fdc1..9771203ff9e 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json index 0f28580663d..1a25b56f197 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json index b92312906ea..64297264a71 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json index c323cf9fd41..02450981da9 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json index 4aa6817740e..62c1a6f052e 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json index 673c6bcde0c..b9fb58c44b8 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json index eb9af3347dd..79d301793e9 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json index 3d5af994e58..4cb256c6605 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json index 5a232e4778c..f38804b882b 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json index ad1b01dc79a..be53e048cc0 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json index 658c5fa15eb..a349054bf0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json index 1ee62b745f7..4ee82d32cbf 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json index 418fb01baae..77dc2a167f9 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json index 25df5c608b5..44aa830f084 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json index 2bfe76d3c6d..0106324fed2 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json index 9b35fe7aded..13f6f9643f2 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json index 4186fbf5cd4..810ca0f99b5 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json index e35222077a4..392fa9ff222 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json index 8a760ce0715..9b34e0405cc 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json index 0695c4c18f2..7844ed8231f 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json index e0b892d1427..8f7f4c2f183 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json index 82852f532c0..ef2169080fa 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json index d1429543e20..b4f95292995 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json index bcbee6b1e5b..b15ec010840 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json index fc1a0cd87be..a234db9cd9f 100644 --- a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json index 8068d4e21fb..77655d81252 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json index 5d23602cb01..a2db835aae9 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json index f18c7b9bee8..7fbd20b4527 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json index 7b9a106276d..39870d4781c 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json index d0a604a8cf8..8cd7fda26c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json index 0a5ff6b933d..c0be45956a3 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json index 6d1daff5e4c..7459541fcfe 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json index f1c04a310c8..ff018cdb033 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json index 44d92581593..5b33acaf06b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json index 784482f03fa..8e92429a423 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json index 937553048c3..fe99033c6f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json index 9ea677cce7c..43626e26d38 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json index 4f237e17ce4..4b3c829aa6e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json index a9e337d8364..5e333c68fa5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json index 623d827695a..90d3dfd8dd0 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json index 79cec039bdb..c18bf589272 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json index bdd1bf2cf58..e07d4c68360 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json index 0fed2535239..1ced16a28c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json index 1283e269962..8c3845af24d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json index f2c7d076bf0..de10acf9d42 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json index 1428f97fe04..b2bfa8324f6 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json index 572bfd06aa8..55be4d9104b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json index 889e6c9ebdf..300b6231980 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json index b22420593a7..7ad9468ef49 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json index d6a66ef187e..f75b7d0abf3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json index ec3c0aa305f..222abcc731c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json index 90b310931e3..72f7c00495f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json index 8426ae5057d..8094e659a4f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json index 10d0dcba8f2..737d8240fd2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json index a26fe07d343..2ad1acf0ee2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json index c28ba3987c8..7a3565307b5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json index c585a821bb3..997f0408692 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json index 6085103ee5e..979a5067d77 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json index f7fd1adb4ff..e575b677368 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json index e47fadcc518..bbe3ee2f359 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json index 48c2cd863ef..342a2911cbc 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json index 4eb0fc51ea0..8ab5d841322 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json index b8feccb506a..38d96339d8f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json index 57f913b1766..06d6df26c1a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json index cf3815b4ab0..42bb40c2aa6 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json index 6496fc23230..32c01d1d75c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json index 9cdcf1f43be..43562e7eacb 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json index e8e190b32f5..cdd59f5d08d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json index 78194b5fc50..c7eec85a2c5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json index cb57e9b87c8..8a3634ff5ab 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json index 69103951e33..bd2a8a305ef 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json index 41bdb40f6c1..5ea42fad34d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json index 83a13fc2172..e4a6126a32e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json index e6a2456adf8..c28a7947695 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json index 9938c5feb91..658d37d7b33 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json index eba51894d02..72d9ab11c87 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json index 35e05041f75..05cb74cd7b7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json index b77e9434391..c027d3eeab9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json index aa7e11b10c9..1a24723a0e1 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json index f32a7834ecc..c769ca9eb09 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json index 895f01ac4e7..8c1dc45ba06 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json index 5dcdc830c19..b75f11ee6de 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json index 4d4a36f6fac..f782804356e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json index 8ea5bd2be60..89b69878687 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json index 6fd108933b9..e5c879d2218 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json index f4b4a6576e7..33fcc07fafc 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json index f1c39daf967..a1c6b7025c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json index eec62b76a58..b927cc7fa84 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json index ca5f852b678..76dec0b5239 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json index 782caa89cbd..f667be864d2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json index dc7f2ebfc0b..3c6b2ebdb6d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json index c6bad41ea5c..f1784e04ce7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json index a5c0c85b66a..a9c65ee16e5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json index 2060916a4bf..e82953e3c8b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json index a738ed53b3d..58e578b620d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json index fb0ac449993..6c76f1c0812 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json index 0aa1365c328..6179e41210d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json index 9bb778e5dbc..db7a2306c4c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json index d5e47a92f43..9d5f1db304f 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json index 0fc5441fb70..5e55961d81e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json index 7b84a9ee3f1..c0728ae9537 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json index 317d05e89cd..fa0b001660f 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json index 1d46b8cb543..36f8d607a02 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json index 4e6900f9c3c..35ef6be3ed6 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json index 0632e412cea..afa5d552af8 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json index 26e30bda713..2f90f26f689 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json index 2b59c43016e..2318c112f6d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json index dd3261fac6d..3cf8afef016 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json index 1c4f6e8d449..9fcc85f04cc 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json index 93de68eb77b..b2031f72e81 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json index 04fc071c991..57ba09805df 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json index 1c946d451b4..b6eb1e5b521 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json index e573f898896..9a37705f4c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json index beebf240ecc..843815f870c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json index 3a33228ba13..3d53e93c82c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json index e5096bd5609..608732bf332 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json index 5412f0aa704..1b696602057 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json index 6a0c4b2d20d..afc1763a299 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json index 5f566166d7c..0303623adb5 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json index f3cca3e83dc..76a601eadad 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json index 24a6b327ea8..bbc681391e9 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json index 9ac54d8234b..88b6541810f 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json index 25355ba2aa6..c326eba2024 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json index 3d0950d887d..821609e83ec 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json index 8edc55a3cae..d1f897e5b46 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json index 4222003e02c..86f083bdd1c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json index 9648600cd0b..98cd8504e31 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json index 78bd94371e2..fde0c2c05a7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json index aa38a079c17..e3d7747b5c1 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json index 6d6ff546eb3..1a78e58c32a 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json index 2433a222fb3..02f7fe04baa 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json index 5190e2f1243..69bbca21967 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json index 54d3406c0c3..e7c33fbf3a6 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json index 38352cc271e..68936584892 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json index 5e21ae38877..f9e705a2abc 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json index f1987472c99..3abc59347be 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json index 5419fa7776d..a8518f90e27 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json index e68efcd8310..2a8dae2e42e 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json index fc2f788c2ad..3714d5dd0ef 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json index e1990ed11a1..a024da885d3 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json index f773a3353bf..e979c611063 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json index 9bfd006843e..a850f3db768 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json index 0cc70ca28ed..d984a3c106a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json index 2c0d1191f59..bfceb99788d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json index c844f6d84b7..e519d0d829d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json index b38705e3fc0..debc3ee3162 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json index 2c347580e4e..4f3573f0269 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json index f70aa0784aa..d4cba15f214 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json index 05c987158a4..0719c33ccfe 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json index fe4ae7760da..24fce83ff7b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json index 738700fa618..81fb3dbe272 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json index b2bc9a7c466..8f5a64ca24b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json index 1c9d549f4ee..6618568d3f4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json index cbfecde65a7..040f3356512 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json index d0a8beef27f..eaa413e5c7d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json index 9ffa4fd09f5..40427129408 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json index 026535f9c5b..c188eaa8b4d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json index 5df6ab8a123..fafacc01403 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json index 4acb4320ea2..43e387e169b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json index 578685a5fb8..23084f8776b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json index d1a1f1075ee..c0c5b17ead8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json index 8bf181851b8..f8d34dd7984 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json index 4e4927dcef1..7453c17754b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json index e2d2832b5ee..bdf0252dd85 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json index fb13f486e88..23d555230f5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json index 3dab0676dbc..28416c910f6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json index bcb4ef849d4..f29300fe8c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json index a2058f3839c..1b5985bc8ca 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json index 05eddb607b6..1708ff1c9ce 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json index e872951bdda..4820705c82a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json index 30252ff7779..ef94c26da33 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json index 35ca7259162..dc1fd9a3fbb 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json index 585a0ce1a53..c0fd94978e5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json index a331c042414..dfecc9ab555 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json index e568ed7deb2..0c3f2870432 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json index 09a8623e12d..af13fc900c1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json index 3a9a431b0d3..aa3ef556e9e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json index 42cc25ea0f4..cb0bc768523 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json index e72e3708c06..b308b806279 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json index 56b5c985e08..7a22cd6827a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json index 43ebd12480b..362fc7f48d7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json index 1df9758b390..c4858604361 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json index 2d571a50b27..4a81d9f29e4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json index be2b0697498..1ed913bc13e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json index 04dafc45c2e..724c576da5f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json index 2a3fcf77a47..3472563ac98 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json index b4431143e5c..202a2c847a1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json index d71226e7392..69ec6c30c61 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json index 153999c8fa3..4ab04fc1aeb 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json index 670fbf334b6..4fb852b526b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json index e9e8591fb4b..21eea5c6006 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json index f2cde8c7876..69bdbf29573 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json index 68f7c60b216..59d6a3c5161 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json index 93de2be604a..c3fc39c7dc5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json index 0a95358d600..1245f9ae2b4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json index 295175d0694..519d6c1a8f8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json index d3c1183ceef..fc692402aa4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json index 3912c126137..e792a51787e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json index 60bf2d3acc4..28a41065c6d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json index ebd38177b39..c330a4123e2 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json index aafb025f855..e47de01d5f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json index b9be225d833..24d246e9d14 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json index d98a5dd9112..8bbe7199fc5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json index 935ae8eee83..8b0442fbfad 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json index 7b6a0350757..c385b6b5349 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json index f91b2bb82a6..389f8075069 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json index 2c027586e2c..cfd88ba1524 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json index ed692389850..fe3fa7a289d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json index 9efb85efffd..298df2fb62d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json index 4623c580bb8..08ae9237746 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json index df9d28e0f9b..ab0c1c70fb0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json index 06b12c2163f..e7d99410686 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json index 7bdd0059243..6f509ea490d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json index 30b6ad18272..8c260366c65 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "feb6cee1ab7ecff1ba98bfba22d4924c748d3bb6b749db460cb617ee50b92f2c", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json index bb1f713f98c..569f625a772 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json index 15af2f98ce0..42528d40ae0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json index 0b1bd8d5eaa..3e5723b93d9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json index ddfbeb459c5..644df7bca18 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json index 6df34401109..7f7badd336c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json index 09a3cd9056c..73b28d11a31 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json index 8c3f5770e6b..08b9f1816e1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json index 0c257530c36..2486a3c2b8c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json index c11d58ddddd..b9fb5cd179b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json index 9dc4d447e03..43dbc278c92 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json index db48d87ee35..f7b36434132 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json index 293c629d0cc..84ca54046a0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json index 43b521f9190..67586fcd9c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json index 65fe3e75195..9f1a178d248 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json index 436c99c9db9..7e179549035 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json index a900f6a226e..d7f925cc07c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json index 5106704d7af..bf3bb349727 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json index 6c6c50622a0..c3cd7995a4d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json index 8abb1282d7c..5f8450a9d5d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json index 5e210ecbc2c..adde30ae4f2 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json index 4e38810a165..915c1063a45 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json index 8ca0604a783..857a6cd33e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json index 9dd92a3633d..b1c26a0dc24 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json index 380472cd0d8..c55ad0b19da 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json index 3e239fa6b68..1465d90d212 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json index bc489eca7de..54b048936e0 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json index c6b765448c2..98f2d1556f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json index dcb62bbe991..89fce09243c 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json index eda010626d0..5b4c4b7541d 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json index 608b3649bd9..13f845b65e8 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json index a8102b51879..50db9bcdfd0 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json index e78faed10b4..c621f0608fc 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json @@ -3,7 +3,7 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json index f3ed566d096..1975933b971 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json index 1556bd80d2a..ba1cb67e4b8 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json index 4575f60e48d..991b51d629f 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json index 822399e247f..9fd0225c4ba 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json index 11edd88340b..3bb7bde5b78 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json index 72e04ff0a32..0746ecd1949 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json index 507dcf8bd9e..6d63d772304 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json index 76cc9013278..0a33cb50fbe 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json index 8a69535a78c..71b9916263f 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json index 27422acce04..0cb61f41d2b 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json index d6f0d35e6b4..4455e0de59e 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json index 956a5e9c8a7..5968b729e0f 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json index b1401dfc86c..124c8e30ba4 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json index 34b7a3cde17..b87953408d0 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json index 69848989291..4868868c136 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json index c7c9110bc84..997b75e00a9 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json index fad3cf9c12e..0d55bb2f44a 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json index 1265fd5ce64..e9cf28af310 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json index 33050390c03..981bd0ff22b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json index 284e04c5b1f..63c0662d53d 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json index 3c13d7e7a46..d3e33aa8122 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json index 3e9c0116c49..17a7f52e6ba 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json index e449bf6319c..3dec3aa6688 100644 --- a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json +++ b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json index 039fa9c363e..9848931a62a 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json index 87908902563..cb2c0cf505a 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json index c1fa8c47da8..7c5c4a4ae36 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json index 1d8ece2ebad..e563e993204 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json index 14f75a8fbbb..d6d0e645a18 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json index 724de3fe781..ed348bfebc0 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json index c679056c415..b784e6e7685 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json index bb702bed6f4..27844a7d067 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json index 666f89b5879..b3990d326f0 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json index b7caeca64b2..7aef359f8d1 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json index f3debdaeb2c..09e4fb830e1 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json index 2ee43c70bd8..52d729e95e5 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json index f16bf79f939..aae6d75f589 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json index 398a4e3206b..05f1ec30931 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/new-tab-local-agents.json b/mobile/rpc-foundation/goldens/new-tab-local-agents.json index 7cad9cf79c3..aecc7544199 100644 --- a/mobile/rpc-foundation/goldens/new-tab-local-agents.json +++ b/mobile/rpc-foundation/goldens/new-tab-local-agents.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json index 77b47e280f7..8706eff0c5b 100644 --- a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json +++ b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json index e4c4ae6304f..e15f942cf64 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json index bf1f6f0c525..b1e03f75fe3 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json index e363a8790b4..bfeff416697 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json index ae0a3deba66..ee69c1a478c 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json index b39af2d6b3f..73366f1bf49 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json index ce2e3ad34c1..a647e6d111a 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json index 964660efa3d..2d9e6dfeb76 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "12a04986290715d5db23a1eb5192c1138bb2d375d1b1dd37ba310ea89eb11566", diff --git a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json index 372503d08f4..f3c5afecf65 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json +++ b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/notifications-push-registered.json b/mobile/rpc-foundation/goldens/notifications-push-registered.json index fc9ce141ee1..610ca780c74 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-push-registered.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json index 719bbb2c806..2391d6ea2c0 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json index 20fcdbcf0bb..110c744d057 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json index ce47a62911a..7358c69c277 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/pr-branch-identity.json b/mobile/rpc-foundation/goldens/pr-branch-identity.json index 8cbe1323348..74bd735daf8 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-identity.json +++ b/mobile/rpc-foundation/goldens/pr-branch-identity.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json index 81056ec0c87..d392895a9aa 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json +++ b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-comment-mutation.json b/mobile/rpc-foundation/goldens/pr-comment-mutation.json index 7ae487db0c1..eb8c7052141 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-comment-mutation.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json index b91b86e8c63..5ff8d8af318 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json index 096dbf4bad6..7ac430556d4 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-status.json b/mobile/rpc-foundation/goldens/pr-mutation-status.json index 0eeb24aeb2c..e389053d88f 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-status.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-status.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json index b45fa26e9a6..001f73b69cf 100644 --- a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json +++ b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-read-surface.json b/mobile/rpc-foundation/goldens/pr-read-surface.json index 43bf9c1bf2b..e5f7a1bdaf6 100644 --- a/mobile/rpc-foundation/goldens/pr-read-surface.json +++ b/mobile/rpc-foundation/goldens/pr-read-surface.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json index 965bcc25200..def63522f6f 100644 --- a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json +++ b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json index e50a91fbe0c..378a1e506ff 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-load.json b/mobile/rpc-foundation/goldens/pr-sidebar-load.json index d22b3326902..a1f09350f71 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-load.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-load.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-title-mutation.json b/mobile/rpc-foundation/goldens/pr-title-mutation.json index 26e35d30722..16a29149a40 100644 --- a/mobile/rpc-foundation/goldens/pr-title-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-title-mutation.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json index 310b7b63410..a39a5e361f3 100644 --- a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json b/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json index 6cd9f8fc76b..4f915df6b88 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json +++ b/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch.json b/mobile/rpc-foundation/goldens/pr-triage-launch.json index 5cf8796ee6c..5110ab9853c 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-triage-send-locked.json b/mobile/rpc-foundation/goldens/pr-triage-send-locked.json index 0cd444dc8a8..6694925d545 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-send-locked.json +++ b/mobile/rpc-foundation/goldens/pr-triage-send-locked.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json index 82377a1aaf0..e8d0b4c168c 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json index 46a5f4a8c13..4ea227b2067 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json index 738a0c210b8..7bf2d6b66a0 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json index 736ad95562a..e20a3634e85 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json index 808aacb3831..fe5768589e9 100644 --- a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json +++ b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", diff --git a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json index d3450316af3..89857b62ef0 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json +++ b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json index 37889fad0dd..660b52a56ba 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json +++ b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json index bb270dfdf4f..db4a65234ca 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json +++ b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json index e5f09bb3c85..cf7dfe61483 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json index 22805bddef6..838b09b3b5a 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json index 388d1e5a313..01ee393351f 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json index 003fd578314..3c8ea06796c 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json index 677042deaef..acbfe8cb09b 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json index 5d3f174cd90..154019551f7 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", diff --git a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json index 79f78d328b6..d1b702688b3 100644 --- a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json index 158364c10f8..5799217ae1e 100644 --- a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json index 93fa7bd8af2..8e7dc93f049 100644 --- a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-git-mutations-run.json b/mobile/rpc-foundation/goldens/review-git-mutations-run.json index b7d7028bc70..f3707b15ff7 100644 --- a/mobile/rpc-foundation/goldens/review-git-mutations-run.json +++ b/mobile/rpc-foundation/goldens/review-git-mutations-run.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json index 512093321d8..fad202f44bf 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json index 515a7cc72b4..b3df2206577 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-open-in-session.json b/mobile/rpc-foundation/goldens/review-open-in-session.json index 1e760d2300c..960b8374044 100644 --- a/mobile/rpc-foundation/goldens/review-open-in-session.json +++ b/mobile/rpc-foundation/goldens/review-open-in-session.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json index d0f820f0bd6..469ef9cee40 100644 --- a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json +++ b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json index de968c3a13b..96a6655c666 100644 --- a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json +++ b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json @@ -3,7 +3,7 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-stage-file.json b/mobile/rpc-foundation/goldens/review-stage-file.json index 74868cca6f0..2d276ceeac1 100644 --- a/mobile/rpc-foundation/goldens/review-stage-file.json +++ b/mobile/rpc-foundation/goldens/review-stage-file.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-stage-refused.json b/mobile/rpc-foundation/goldens/review-stage-refused.json index a8680fcaf8f..a5e12920316 100644 --- a/mobile/rpc-foundation/goldens/review-stage-refused.json +++ b/mobile/rpc-foundation/goldens/review-stage-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-default.json b/mobile/rpc-foundation/goldens/sc-base-ref-default.json index 36189b62622..0309bd2e894 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-default.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-default.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json index 72ef300b85d..b4d42798517 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json index b9f121b5e68..6c6bc28b26b 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json index 907eb6e804b..a9f9f10c290 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json index dcdbd7dba6e..ef0dc26beb2 100644 --- a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json +++ b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-changes-loaded.json b/mobile/rpc-foundation/goldens/sc-changes-loaded.json index 66c03a1ce52..b163b16122c 100644 --- a/mobile/rpc-foundation/goldens/sc-changes-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-changes-loaded.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json index c94829d8039..1e16e211dc6 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json index 5c26f36f6b9..7c5ffb4c112 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json index 2a472aec29a..91ede0ad194 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-create-existing-review.json b/mobile/rpc-foundation/goldens/sc-create-existing-review.json index 01d6ffae0c3..20b32b06a3d 100644 --- a/mobile/rpc-foundation/goldens/sc-create-existing-review.json +++ b/mobile/rpc-foundation/goldens/sc-create-existing-review.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json index e9f44f57bfd..99fb0975945 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json index 200c0c900fa..f5479a3acc9 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json index 64205c7cf8a..4e6dfe19036 100644 --- a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json +++ b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json index 73ce0ca2035..ba557610860 100644 --- a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json +++ b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json index 9f8a45e5e04..a84d435ac94 100644 --- a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json index c847b221df3..58f8d900345 100644 --- a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json index d6a9f4bc894..b884d45e76c 100644 --- a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json +++ b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-history-commit-files.json b/mobile/rpc-foundation/goldens/sc-history-commit-files.json index 397c405c07e..b62b7d27bb3 100644 --- a/mobile/rpc-foundation/goldens/sc-history-commit-files.json +++ b/mobile/rpc-foundation/goldens/sc-history-commit-files.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-history-loaded.json b/mobile/rpc-foundation/goldens/sc-history-loaded.json index e918b6aeadc..dfc2be3b184 100644 --- a/mobile/rpc-foundation/goldens/sc-history-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-history-loaded.json @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json index 221039ca4ba..5a9fc698a8f 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-read.json b/mobile/rpc-foundation/goldens/sc-pr-link-read.json index 0621f4938b9..6ccb6bd34ef 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-read.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-read.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-set.json b/mobile/rpc-foundation/goldens/sc-pr-link-set.json index 311cccfa9c2..4199b87bd60 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-set.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-set.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json index 772a182ba82..050e88210ba 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json index 48b992d2d19..667e10f63a2 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json index 8a04952f9bf..336facb7fdb 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json index 8907e3e81c2..884c5ad3194 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json index ec9d9073448..8e5a42c4d84 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json index 6a11d6bfdf6..903aba078ff 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json index 1a5b3349f6f..591558bc654 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json index 47816ddc2a5..159d9fc4a9b 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json index dcb8cd11b55..012bb3c7f04 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json index 7d14be2af8e..606a2b9a61d 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json index 036416078dc..1e98a83df45 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit.json b/mobile/rpc-foundation/goldens/sc-review-commit.json index 9e0a3d40baf..10f7fc03f6a 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json index c465d93beff..77c3040bf73 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json index b2a2f9bac19..433e1363670 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/schedules-b3.json b/mobile/rpc-foundation/goldens/schedules-b3.json index c89136c1efc..3a9bcded701 100644 --- a/mobile/rpc-foundation/goldens/schedules-b3.json +++ b/mobile/rpc-foundation/goldens/schedules-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json index b43ec58c4be..319465421eb 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json index eaa46f1e337..d5f4c2eafe5 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json index f92b11bdccc..ebaa17f3c6c 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json index 1d70e1b046c..9c58fdfd2f4 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json index d84fc521691..bbe5265016a 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json index 50df5a1664f..db1bd368f55 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/session-browser-tab-created.json b/mobile/rpc-foundation/goldens/session-browser-tab-created.json index a98478db1f1..d2892a14745 100644 --- a/mobile/rpc-foundation/goldens/session-browser-tab-created.json +++ b/mobile/rpc-foundation/goldens/session-browser-tab-created.json @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-refused.json b/mobile/rpc-foundation/goldens/session-create-browser-refused.json index a3a448cd82c..b29717bf582 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-refused.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-tab.json b/mobile/rpc-foundation/goldens/session-create-browser-tab.json index 04ecd4dc1b4..3c2aef5cc5d 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-tab.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json index db01505f56f..c4dbbc85d83 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-note.json b/mobile/rpc-foundation/goldens/session-create-markdown-note.json index 897c5db1e00..a3d0c057b58 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-note.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-note.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json index 19b65dfcb13..81103273f85 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json index 859c60a4576..f2758ae3de6 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json index c8c87e93e2e..9d367b50282 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json index abc4f0ab3c0..5b7cee5c989 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json index 43bda3f3618..2931b0e10fe 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json index 78febc06d30..5d833a228d5 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json index 8b94f47d641..39fc2781e50 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json index b02219910d5..510c2ee07d9 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json index fdb74097444..42b5c37a089 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json index 7887cdac1a8..e08d842290f 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-file-tab-read.json b/mobile/rpc-foundation/goldens/session-file-tab-read.json index 379452e0d6d..46db85248fc 100644 --- a/mobile/rpc-foundation/goldens/session-file-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-file-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json index de82fdf8fa2..2e8f73ca08f 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json index b52f4e9a2c3..c7af57ba348 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json index 520513b7533..f4d66b87931 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json +++ b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-markdown-saved.json b/mobile/rpc-foundation/goldens/session-markdown-saved.json index c8a05d25441..2ed8a82d127 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-saved.json +++ b/mobile/rpc-foundation/goldens/session-markdown-saved.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json index 91707c3827d..f81a55847ca 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json index ab9580fb2be..1e46f8dd24e 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json index 0881ac812f3..987d7fcfcc7 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json index 56ac593798e..d56e7ec8d75 100644 --- a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json +++ b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json index 87c42acaedc..9b8fe3dcebc 100644 --- a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json +++ b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json index 4647e085c44..df54f6a43ad 100644 --- a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json +++ b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json index 7cf9af5e559..a4ca63c58b8 100644 --- a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json +++ b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json index e6d67fb3c7c..df183c23349 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json index 83cdc6521ca..11ef44875a3 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json index 155d32f08e9..3676c936f19 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json index 6b017291d2a..f8bb41349ab 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json index 6b60979b207..4a75efad809 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json index 27cb3712c02..e85530b7ef9 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-closed.json b/mobile/rpc-foundation/goldens/session-tab-closed.json index b8c85ed3945..ef4e1ef57b5 100644 --- a/mobile/rpc-foundation/goldens/session-tab-closed.json +++ b/mobile/rpc-foundation/goldens/session-tab-closed.json @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-rename.json b/mobile/rpc-foundation/goldens/session-tab-rename.json index 28c6d985885..12ac94e4dbb 100644 --- a/mobile/rpc-foundation/goldens/session-tab-rename.json +++ b/mobile/rpc-foundation/goldens/session-tab-rename.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-renamed.json b/mobile/rpc-foundation/goldens/session-tab-renamed.json index aef86e0995d..125bd10b4d3 100644 --- a/mobile/rpc-foundation/goldens/session-tab-renamed.json +++ b/mobile/rpc-foundation/goldens/session-tab-renamed.json @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json index cfa8953d600..bd81a5fcfa3 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json index ec52eac1992..9c95d07f70b 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json index 330c8becc32..8b87eba7723 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json index a544d94becf..a8807c00d57 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json index 65429ed3703..dbf42e7bc7d 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json index e0390068e7d..ebf85a61c7b 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json index 929156e84e7..ad8a7a736ee 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json index 6697f43c705..64e4bdb5f7a 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json index 2449bb28d63..24b4f029a1b 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json index 2bad4800947..eb77c9dff8f 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json index e2b209182a6..d10dba94e7f 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json index a562b814680..38832335df8 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json index 3719f1e6e0f..c8eff5d73a8 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json index 67600385781..6b96fd33264 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json index a692328dc6b..1b22aca40e1 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json index 28d5aa7b492..0bbcd57c7b0 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json index 5acc7dc254f..110287e240e 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-coalesced.json b/mobile/rpc-foundation/goldens/settings-home-coalesced.json index 607ceb8f96e..7e6d1f1ae37 100644 --- a/mobile/rpc-foundation/goldens/settings-home-coalesced.json +++ b/mobile/rpc-foundation/goldens/settings-home-coalesced.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json index 965b2a51383..1ae0255c940 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json index 5e678441b0a..46386114f9f 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json index fa6d25c24f9..3d8fcfcae8d 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json index 318203dc3b8..e84e5bae56f 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json index 899f5a41bb2..f0848c61166 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json index 98f1ad0abb2..49cc1778f56 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json index 760fb48bd37..413faed3c24 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index a81fa8fc825..5451ad7ddc8 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index 84a4f4d8498..18c876c6427 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json index 6b078c2a563..ec969267713 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json index c1e24d6d268..e5d805f98c2 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index a47257b0c59..1abbab3b4dd 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json index f3a7f6c397c..cc0c15eb09f 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json index a385b5cb36d..3d328ce38ee 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json index 7f171eb0637..c6bbb341838 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json index b610203208b..a14b79bd990 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json index 058901c2d7c..77542cc021b 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json index 0b42ea1231e..86d41404a56 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json index 90949ed775a..f7fca5ecec8 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json index c18e3069c45..01e900e53db 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json index 5c4f2b0a2f2..3411096a59f 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json index 53c8b4b28a3..b9fc3b07a5a 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json index 21abaa2256f..e2c1e87ae43 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json index 7cddc3196a6..5a0ceae3b32 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json index 04481fdd1e0..67c744ee498 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json index 7a9e5b06aed..535e3f1dab2 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json index 25f63e2876d..2713e2babe8 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-write.json b/mobile/rpc-foundation/goldens/settings-task-write.json index a4e31adf30e..807fb3a9e52 100644 --- a/mobile/rpc-foundation/goldens/settings-task-write.json +++ b/mobile/rpc-foundation/goldens/settings-task-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json index 20d79753238..273d8a77e72 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json index a2b4543745f..aaf2d59c729 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json index 8c40c37ea97..b4019fdbcf4 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json index 2808fb6c0f3..2eeec2b19e5 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json index 72f0520ce70..df62263d541 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json index 82684ec2ec7..87e3d11918b 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json index 33e1ca27c8a..ef5d44795eb 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json index f85a254717c..09f615fb775 100644 --- a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json +++ b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json index 59a78b492fe..f200ba6f8ad 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json index 953ac766d09..611719b9c71 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json index 9f329ccd7cb..8e6d4b5113c 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json index f8492c20525..03e6d090bca 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json index dc5a9db18db..a396f486db1 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json index 39188439a58..445346f81a8 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json index eda46d8d76d..a8bd182332c 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json index 8c843be7ef4..b80ba46fc77 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json index 89ad5b48ce2..97fcb041b0c 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "3ddb44511547ae2fc97340e5a49393233f6716f9704b7b994f43bc030788b25b", diff --git a/mobile/rpc-foundation/goldens/structured-agent-session-created.json b/mobile/rpc-foundation/goldens/structured-agent-session-created.json index ae657fd7af0..768d96857c6 100644 --- a/mobile/rpc-foundation/goldens/structured-agent-session-created.json +++ b/mobile/rpc-foundation/goldens/structured-agent-session-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-created.json b/mobile/rpc-foundation/goldens/structured-launch-created.json index b5ff53f74bc..6cd7852bf6d 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-created.json +++ b/mobile/rpc-foundation/goldens/structured-launch-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json index d51c62db122..3e5c1981f24 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json +++ b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json index 9f9a9c51d46..67d9546c7eb 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json +++ b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json index a277bf59c43..adb6e85a0fb 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json +++ b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json index 851a6426370..6755c3d99ee 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json +++ b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json index 61e02631a16..04de922fd32 100644 --- a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json +++ b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "feb6cee1ab7ecff1ba98bfba22d4924c748d3bb6b749db460cb617ee50b92f2c", diff --git a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json index 4d4fae94526..c4cb46dff38 100644 --- a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json +++ b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json index 20b10bf9f51..528a7ab1ba3 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json index 6c79115b042..63c709c8621 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json index a5d891ad2da..593f1d70eb8 100644 --- a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json index 91c67bbc905..5b1192fe006 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-refused.json b/mobile/rpc-foundation/goldens/terminal-paste-refused.json index 03437e393f1..4baeaf22542 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json index bbfe2e5f97a..2be9fa6b77b 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json index 778fe54ab12..54b1db45231 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json index cffb8e0cebb..69c185b5fc8 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json index 982e02e225d..dd2dfb9a4c0 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json index 0016f896946..4f172323cc5 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json index d9b8b3a635f..4203f7de41a 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json index 291fc99b141..9643483326e 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json index 36f6d239604..d0350a09079 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json index a13462a4c1b..ccd1347c097 100644 --- a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json +++ b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/tk-create-github.json b/mobile/rpc-foundation/goldens/tk-create-github.json index 056be111f1f..9e76a3af9de 100644 --- a/mobile/rpc-foundation/goldens/tk-create-github.json +++ b/mobile/rpc-foundation/goldens/tk-create-github.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-gitlab.json b/mobile/rpc-foundation/goldens/tk-create-gitlab.json index f9c781b59cc..f67b6610538 100644 --- a/mobile/rpc-foundation/goldens/tk-create-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-create-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-linear.json b/mobile/rpc-foundation/goldens/tk-create-linear.json index 3c715ca7b0c..574810aa217 100644 --- a/mobile/rpc-foundation/goldens/tk-create-linear.json +++ b/mobile/rpc-foundation/goldens/tk-create-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-item-checks-files.json b/mobile/rpc-foundation/goldens/tk-item-checks-files.json index e4f5ba3a006..69051f0ffbc 100644 --- a/mobile/rpc-foundation/goldens/tk-item-checks-files.json +++ b/mobile/rpc-foundation/goldens/tk-item-checks-files.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-github.json b/mobile/rpc-foundation/goldens/tk-item-comment-github.json index 1a3196812ee..c0e34fef3aa 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json index e71ceb122ad..1149d67abae 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json index 04b99bf80ab..e2bef79a73f 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json index 0edc6709092..9eb96731d20 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github.json b/mobile/rpc-foundation/goldens/tk-item-detail-github.json index 5923ba4bf1f..42fc7c8f6c8 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json index 2979d93acfb..f3fe49ceba5 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json index cc3ff056765..0bf3e462396 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json index 15e6aba81bd..0a2e58e4c5f 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json index 2a499e70032..0efc50bbabf 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json index 6fd0875b846..0d963edb004 100644 --- a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json index e8d38bbb06e..a31d7dec1fe 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json index 8dff1ae8d34..05ae9e28006 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json index db660a9866d..7bb9687c054 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json index fae209129b1..f9befd51bab 100644 --- a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json +++ b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-review-github.json b/mobile/rpc-foundation/goldens/tk-item-review-github.json index 57fd9af7473..76daf0f5639 100644 --- a/mobile/rpc-foundation/goldens/tk-item-review-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-review-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json index b7cde92e644..f65aee5bb5a 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json index d922a9114b2..d1d7435f96c 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-linear-connect.json b/mobile/rpc-foundation/goldens/tk-linear-connect.json index 6d2936bd9f1..062043314a6 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-connect.json +++ b/mobile/rpc-foundation/goldens/tk-linear-connect.json @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-linear-item.json b/mobile/rpc-foundation/goldens/tk-linear-item.json index 5e9ec14fed4..4ca462b73e0 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-item.json +++ b/mobile/rpc-foundation/goldens/tk-linear-item.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-linear-team-context.json b/mobile/rpc-foundation/goldens/tk-linear-team-context.json index e81f0c4226a..838f57601ba 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-team-context.json +++ b/mobile/rpc-foundation/goldens/tk-linear-team-context.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json index b9c4d68ec4e..c48af8a9b70 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json index 16a274f76c7..23bc86a1f33 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-linear.json b/mobile/rpc-foundation/goldens/tk-list-linear.json index 5d0ff05f9e3..0f6166bb817 100644 --- a/mobile/rpc-foundation/goldens/tk-list-linear.json +++ b/mobile/rpc-foundation/goldens/tk-list-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-project-board-load.json b/mobile/rpc-foundation/goldens/tk-project-board-load.json index ed74988dcdd..5ab0a66d7f6 100644 --- a/mobile/rpc-foundation/goldens/tk-project-board-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-board-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json index 45fae56b77c..699f6d2c0d2 100644 --- a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json +++ b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json index f7e9f7c3ee0..89c9cc6556b 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json index 690f9b33360..ec4d4ecef32 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-detail.json b/mobile/rpc-foundation/goldens/tk-project-row-detail.json index 49e5913b0f9..4f41b75bba5 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-detail.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-detail.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-fields.json b/mobile/rpc-foundation/goldens/tk-project-row-fields.json index a3bb0476de5..016db420bcb 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-fields.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-fields.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json index 018c5975f31..b5c4364a1ef 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json index 94b92d97f50..bb5b0ee2761 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json index 7c2627b8619..cf3a629dc9e 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-threads.json b/mobile/rpc-foundation/goldens/tk-project-row-threads.json index a6a234eea3b..d924d57dce9 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-threads.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-threads.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-provider-load.json b/mobile/rpc-foundation/goldens/tk-provider-load.json index c60c07a063c..8372bc274ff 100644 --- a/mobile/rpc-foundation/goldens/tk-provider-load.json +++ b/mobile/rpc-foundation/goldens/tk-provider-load.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json index d18e4f70910..a038e2306bd 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json index ab1afc2965d..dab93433ce7 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json index 6d31cb48ba9..661edd98165 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json index bf4ebf9a20b..17fb0af1a5b 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json index 3787c1d6c15..ae15750331a 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json index f6b7263c5b4..92c70279bb1 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json index dac18e8dfee..3a56fda2fde 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json index 7cd051f2b0b..4d7474e245d 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json index d46784c8394..278c0680c43 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json index fe52b9266b1..f6b460cb49e 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json index 8198d6343ae..04eb58d2b46 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json index 980e2d18517..f6b15522de3 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json index 0bb5e8c6291..bec974d6924 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json index cd2e6851aa8..3d3c26470b8 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json index ec9f4ca6a09..10dbacba304 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json @@ -3,7 +3,7 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json index 64329f03ba6..b1fb537c7ea 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json index f94b3aa50b7..6dfe4b94097 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json index cea4816faab..e8c770b3053 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-created.json b/mobile/rpc-foundation/goldens/tw-create-retry-created.json index 2ad976ac03d..990d08f4275 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-created.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-created.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json index d56fb59f848..20ad0493054 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json index bdf3a62334d..e29cc643b2d 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json index 47d93d1fffa..d7f0417d063 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json index c67998503f3..df7820b71ef 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json index 28f32fd2b42..ea6491becf7 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json index 3cd6698ae60..f317d8455eb 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json index a1ae250a957..9a061e6d302 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json index bdbb6ef710f..e43ecc028e4 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json index 2b52b990d95..2fa8f8f7052 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json index acb19a7c0cf..76c7fc8a15f 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json index 9e35e07e804..33b331114f0 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json index 7bdd56a1f96..93ed3b279cf 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json index 6d94a4e7b96..628bcdca094 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json index 779c8c60a1a..289db6635aa 100644 --- a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json +++ b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json index d2cb946fd8d..878a06f31a3 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json index 6cbe4aaa513..280db06a889 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json index 4d90a6fac9c..797007de70c 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json index 1fcaf981e87..05a6de08505 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json index 10d5cf3612c..919dd88d383 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json index 2013e441497..936e8666eda 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json index 41f0d873dd2..1ccc55e305c 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json index 5e33d63d533..72059273b71 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json index 01a477cc887..eb5a266f1eb 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json index 295ddfad359..c2bd0c5cf40 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-home-catalog.json b/mobile/rpc-foundation/goldens/worktree-home-catalog.json index efeba0e1e19..105792a2325 100644 --- a/mobile/rpc-foundation/goldens/worktree-home-catalog.json +++ b/mobile/rpc-foundation/goldens/worktree-home-catalog.json @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-retired-names.json b/mobile/rpc-foundation/goldens/worktree-retired-names.json index 562e03f3840..23ad5d36da2 100644 --- a/mobile/rpc-foundation/goldens/worktree-retired-names.json +++ b/mobile/rpc-foundation/goldens/worktree-retired-names.json @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "1d8cf6624653d15b5b32bd2871d4d93739d4092a06f10dab24a795702422bb31", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index db5a78e7eab..633eb265034 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baseline": "5741d48e592dad21660d995a3758319413a4baf0", + "baseline": "1e3795de9968056199b71bfef25526520d57b6d5", "scenarios": [ { "id": "b1", From 01a33bc427c0c4c786909831362f0b3183ee82e2 Mon Sep 17 00:00:00 2001 From: Luke Son <91464689+KAPUIST@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:53:09 +0900 Subject: [PATCH 010/224] fix(git): respect existing .orca ignore rules Respects effective local, WSL, linked-worktree, runtime, and SSH Git ignore rules before updating .gitignore. Fixes #21212. --- src/main/hooks-issue-command.test.ts | 27 ++- .../register-worktree-hook-file-handlers.ts | 16 +- .../worktrees-issue-command-overrides.test.ts | 99 ++++++++++- src/main/issue-command-file.ts | 38 +++- src/main/issue-command-ignore.test.ts | 109 ++++++++++++ .../runtime/orca-runtime-file-commands.ts | 3 +- .../runtime-repository-issue-command.test.ts | 167 ++++++++++++++++++ .../runtime-repository-issue-command.ts | 15 +- 8 files changed, 463 insertions(+), 11 deletions(-) create mode 100644 src/main/issue-command-ignore.test.ts create mode 100644 src/main/runtime/runtime-repository-issue-command.test.ts diff --git a/src/main/hooks-issue-command.test.ts b/src/main/hooks-issue-command.test.ts index e5a1f2f8e3e..b303b9d724f 100644 --- a/src/main/hooks-issue-command.test.ts +++ b/src/main/hooks-issue-command.test.ts @@ -37,6 +37,10 @@ vi.mock('./git/runner', async () => ({ gitExecFileSync: gitExecFileSyncMock })) +vi.mock('./git/check-ignored-paths', () => ({ + checkIgnoredPaths: vi.fn().mockResolvedValue([]) +})) + describe('readIssueCommand', () => { it('prefers the local override over the shared orca.yaml command', async () => { const fs = await import('node:fs') @@ -85,6 +89,25 @@ describe('readIssueCommand', () => { }) describe('writeIssueCommand', () => { + it('checks file ignore rules in the selected WSL distro', async () => { + const { writeIssueCommand } = await import('./issue-command-file') + const { checkIgnoredPaths } = await import('./git/check-ignored-paths') + const fs = await import('node:fs') + vi.mocked(checkIgnoredPaths).mockResolvedValueOnce(['.orca/issue-command']) + vi.mocked(fs.writeFileSync).mockClear() + + await writeIssueCommand(TEST_REPO_PATH, 'local command', { wslDistro: 'Ubuntu' }) + + expect(checkIgnoredPaths).toHaveBeenLastCalledWith(TEST_REPO_PATH, ['.orca/issue-command'], { + wslDistro: 'Ubuntu' + }) + expect(fs.writeFileSync).toHaveBeenCalledExactlyOnceWith( + TEST_ISSUE_COMMAND_PATH, + 'local command\n', + 'utf-8' + ) + }) + it('writes only the local override file and keeps .orca ignored locally', async () => { const fs = await import('node:fs') vi.mocked(fs.existsSync).mockImplementation( @@ -98,7 +121,7 @@ describe('writeIssueCommand', () => { }) const { writeIssueCommand } = await import('./issue-command-file') - writeIssueCommand(TEST_REPO_PATH, 'local command') + await writeIssueCommand(TEST_REPO_PATH, 'local command') expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( TEST_GITIGNORE_PATH, @@ -115,7 +138,7 @@ describe('writeIssueCommand', () => { it('deletes the local override when the override is cleared', async () => { const { writeIssueCommand } = await import('./issue-command-file') const fs = await import('node:fs') - writeIssueCommand(TEST_REPO_PATH, ' ') + await writeIssueCommand(TEST_REPO_PATH, ' ') expect(vi.mocked(fs.rmSync)).toHaveBeenCalledWith(TEST_ISSUE_COMMAND_PATH, { force: true diff --git a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts index 881a5e3bd0a..1ddf379f784 100644 --- a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts +++ b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts @@ -1,3 +1,4 @@ +import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../../shared/execution-host' import { isFolderRepo } from '../../../shared/repo-kind' @@ -5,10 +6,15 @@ import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { isENOENT } from '../filesystem-path-containment' import { parseOrcaYaml } from '../../hooks' -import { readIssueCommand, writeIssueCommand } from '../../issue-command-file' +import { + isIssueCommandIgnoredByGit, + readIssueCommand, + writeIssueCommand +} from '../../issue-command-file' import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' +/** Route private command overrides to the owning host without changing shared hook settings. */ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): void { const { store } = context @@ -104,6 +110,10 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v return } await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) + if (await isIssueCommandIgnoredByGit(repo.path, repo.connectionId)) { + await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) + return + } const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') try { const result = await fsProvider.readFile(gitignorePath) @@ -120,7 +130,9 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) return } - writeIssueCommand(repo.path, args.content) + await writeIssueCommand(repo.path, args.content, () => + getLocalProjectWorktreeGitOptions(store, repo) + ) } ) } diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index ae6e89d51e1..05287f3d713 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -1,7 +1,13 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as issueCommandFile from '../issue-command-file' +import * as projectGitOptions from '../project-runtime-git-options' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createIssueCommandRunnerScriptMock, - getSshFilesystemProviderMock + getSshFilesystemProviderMock, + getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -92,6 +98,65 @@ describe('registerWorktreeHandlers', () => { setupWorktreeHandlers() }) + it.each(['command', ' '])( + 'preserves local file writes with an unavailable runtime: %j', + async (content) => { + const root = mkdtempSync(join(tmpdir(), 'orca-runtime-ignore-')) + const repo = { + id: 'repo-1', + path: root, + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + mkdirSync(join(root, '.orca')) + writeFileSync(join(root, '.orca', 'issue-command'), 'old command\n') + store.getRepo.mockReturnValue(repo) + store.getRepos.mockReturnValue([repo]) + const resolver = vi + .spyOn(projectGitOptions, 'getLocalProjectWorktreeGitOptions') + .mockImplementation(() => { + throw new Error('Project runtime requires repair') + }) + try { + await handlers['hooks:writeIssueCommand'](null, { repoId: repo.id, content }) + if (content.trim()) { + expect(readFileSync(join(root, '.orca', 'issue-command'), 'utf8')).toBe('command\n') + expect(readFileSync(join(root, '.gitignore'), 'utf8')).toBe('.orca\n') + } else { + expect(existsSync(join(root, '.orca', 'issue-command'))).toBe(false) + expect(existsSync(join(root, '.gitignore'))).toBe(false) + expect(resolver).not.toHaveBeenCalled() + } + } finally { + resolver.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + } + ) + + it('forwards the resolved WSL options when writing a local override', async () => { + const resolveOptions = vi + .spyOn(projectGitOptions, 'getLocalProjectWorktreeGitOptions') + .mockReturnValue({ wslDistro: 'Ubuntu' }) + const write = vi.spyOn(issueCommandFile, 'writeIssueCommand').mockResolvedValue(undefined) + try { + await handlers['hooks:writeIssueCommand'](null, { repoId: 'repo-1', content: 'command' }) + const options = write.mock.calls[0]?.[2] + expect(typeof options).toBe('function') + expect(typeof options === 'function' ? options() : options).toEqual({ wslDistro: 'Ubuntu' }) + expect(resolveOptions).toHaveBeenCalledWith(store, expect.objectContaining({ id: 'repo-1' })) + expect(write).toHaveBeenCalledExactlyOnceWith( + '/workspace/repo', + 'command', + expect.any(Function) + ) + } finally { + resolveOptions.mockRestore() + write.mockRestore() + } + }) + it('creates an issue-command runner for an existing repo/worktree pair', async () => { const result = await handlers['hooks:createIssueCommandRunner'](null, { repoId: 'repo-1', @@ -271,4 +336,36 @@ describe('registerWorktreeHandlers', () => { }) ).rejects.toThrow('Remote filesystem unavailable') }) + + it('preserves .gitignore when the SSH host already ignores .orca', async () => { + store.getRepo.mockReturnValue({ + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + }) + const checkIgnoredPaths = vi.fn().mockResolvedValue(['.orca/issue-command']) + getSshGitProviderMock.mockReturnValue({ checkIgnoredPaths }) + const fsProvider = { + createDir: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn(), + writeFile: vi.fn().mockResolvedValue(undefined) + } + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + + await handlers['hooks:writeIssueCommand'](null, { + repoId: 'repo-ssh', + content: 'local command' + }) + + expect(getSshGitProviderMock).toHaveBeenCalledWith('conn-1') + expect(checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', ['.orca/issue-command']) + expect(fsProvider.readFile).not.toHaveBeenCalled() + expect(fsProvider.writeFile).toHaveBeenCalledExactlyOnceWith( + '/remote/repo/.orca/issue-command', + 'local command\n' + ) + }) }) diff --git a/src/main/issue-command-file.ts b/src/main/issue-command-file.ts index 39601bf07aa..daeb1f5f611 100644 --- a/src/main/issue-command-file.ts +++ b/src/main/issue-command-file.ts @@ -2,6 +2,11 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { join } from 'node:path' import { loadHooks } from './hooks' +import type { GitRuntimeOptions } from './git/git-runtime-options' +import { checkIgnoredPaths } from './git/check-ignored-paths' +import { requireSshGitProvider } from './providers/ssh-git-dispatch' + +type IssueCommandGitOptions = GitRuntimeOptions | (() => GitRuntimeOptions) const ORCA_DIR = '.orca' const ISSUE_COMMAND_FILENAME = 'issue-command' @@ -54,7 +59,11 @@ export function readIssueCommand(repoPath: string): ResolvedIssueCommand { * Write the per-user issue command override to `{repoRoot}/.orca/issue-command`. * Empty content deletes the override so the shared `orca.yaml` command applies again. */ -export function writeIssueCommand(repoPath: string, content: string): void { +export async function writeIssueCommand( + repoPath: string, + content: string, + options: IssueCommandGitOptions = {} +): Promise { const filePath = getIssueCommandFilePath(repoPath) const trimmed = content.trim() @@ -68,7 +77,9 @@ export function writeIssueCommand(repoPath: string, content: string): void { if (!existsSync(orcaDir)) { mkdirSync(orcaDir, { recursive: true }) } - ensureOrcaDirIgnored(repoPath) + if (!(await isIssueCommandIgnoredByGit(repoPath, undefined, options))) { + ensureOrcaDirIgnored(repoPath) + } writeFileSync(filePath, `${trimmed}\n`, 'utf-8') } catch (err) { console.error('[hooks] Failed to write issue command:', err) @@ -77,6 +88,29 @@ export function writeIssueCommand(repoPath: string, content: string): void { } } +/** Consult the execution host before changing shared ignore rules for a private override. */ +export async function isIssueCommandIgnoredByGit( + repoPath: string, + connectionId?: string, + options: IssueCommandGitOptions = {} +): Promise { + try { + const issueCommandPath = `${ORCA_DIR}/${ISSUE_COMMAND_FILENAME}` + const ignored = connectionId + ? await requireSshGitProvider(connectionId).checkIgnoredPaths(repoPath, [issueCommandPath]) + : await checkIgnoredPaths( + repoPath, + [issueCommandPath], + // Runtime repair must not block saving or clearing the local override. + typeof options === 'function' ? options() : options + ) + return ignored.includes(issueCommandPath) + } catch { + // Preserve the existing ignore-file fallback if Git cannot inspect the rules. + return false + } +} + /** Ensure `.orca` is in `.gitignore` so the per-user directory is never committed. */ function ensureOrcaDirIgnored(repoPath: string): void { const gitignorePath = join(repoPath, '.gitignore') diff --git a/src/main/issue-command-ignore.test.ts b/src/main/issue-command-ignore.test.ts new file mode 100644 index 00000000000..7023befe0a2 --- /dev/null +++ b/src/main/issue-command-ignore.test.ts @@ -0,0 +1,109 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { gitExecFileAsync } from './git/runner' +import { writeIssueCommand } from './issue-command-file' + +describe('issue command ignore rules', () => { + let root: string + let repo: string + let globalConfig: string + + const git = (args: string[]) => gitExecFileAsync(args, { cwd: repo }) + + beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'orca-issue-ignore-')) + repo = join(root, 'repo with spaces') + mkdirSync(repo) + globalConfig = join(root, 'gitconfig') + const globalIgnore = join(root, 'ignore') + writeFileSync(globalConfig, '') + writeFileSync(globalIgnore, '') + vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig) + vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') + await git(['init', '-q']) + await git(['config', '--file', globalConfig, 'core.excludesFile', globalIgnore]) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) + }) + + it.each(['.orca', '.orca/', '/.orca/', '.orca/*', '.orca/issue-command'])( + 'respects global ignore pattern %s', + async (pattern) => { + writeFileSync(join(root, 'ignore'), `${pattern}\n`) + writeFileSync(join(repo, '.gitignore'), 'node_modules/\n') + + await writeIssueCommand(repo, 'local command') + + expect(readFileSync(join(repo, '.gitignore'), 'utf8')).toBe('node_modules/\n') + expect(readFileSync(join(repo, '.orca', 'issue-command'), 'utf8')).toBe('local command\n') + } + ) + + it('does not create .gitignore when the repository exclude already ignores .orca', async () => { + writeFileSync(join(repo, '.git', 'info', 'exclude'), '.orca/\n') + + await writeIssueCommand(repo, 'local command') + + expect(existsSync(join(repo, '.gitignore'))).toBe(false) + expect((await git(['status', '--porcelain'])).stdout).toBe('') + }) + + it('respects anchored repository rules', async () => { + writeFileSync(join(repo, '.gitignore'), '/.orca/\n') + + await writeIssueCommand(repo, 'local command') + + expect(readFileSync(join(repo, '.gitignore'), 'utf8')).toBe('/.orca/\n') + }) + + it('creates .gitignore when no ignore rules exist', async () => { + await writeIssueCommand(repo, 'local command') + + expect(readFileSync(join(repo, '.gitignore'), 'utf8')).toBe('.orca\n') + }) + + it('respects shared repository excludes from a linked worktree', async () => { + await git([ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--allow-empty', + '-qm', + 'initial' + ]) + const worktree = join(root, 'linked worktree') + await git(['worktree', 'add', '-q', '-b', 'issue-command-test', worktree]) + writeFileSync(join(repo, '.git', 'info', 'exclude'), '.orca/\n') + + await writeIssueCommand(worktree, 'local command') + + expect(existsSync(join(worktree, '.gitignore'))).toBe(false) + expect((await gitExecFileAsync(['status', '--porcelain'], { cwd: worktree })).stdout).toBe('') + }) + + it('adds the rule once when .orca is not ignored', async () => { + writeFileSync(join(repo, '.gitignore'), 'node_modules/') + + await writeIssueCommand(repo, 'first command') + await writeIssueCommand(repo, 'second command') + + expect(readFileSync(join(repo, '.gitignore'), 'utf8')).toBe('node_modules/\n.orca\n') + expect(readFileSync(join(repo, '.orca', 'issue-command'), 'utf8')).toBe('second command\n') + }) + + it('honors a repository rule that negates a global ignore', async () => { + writeFileSync(join(root, 'ignore'), '.orca/\n') + writeFileSync(join(repo, '.gitignore'), '!.orca/\n') + + await writeIssueCommand(repo, 'local command') + + expect(readFileSync(join(repo, '.gitignore'), 'utf8')).toBe('!.orca/\n.orca\n') + }) +}) diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index 46a25083bef..679728ca1f0 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -200,7 +200,8 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC }) protected readonly repositoryIssueCommand = new RuntimeRepositoryIssueCommand({ - resolveRepo: (selector) => this.resolveRepoSelector(selector) + resolveRepo: (selector) => this.resolveRepoSelector(selector), + getLocalGitArgs: (repo) => this.getLocalGitExecutionOptionArgs(repo) }) protected readonly orchestrationPointerAdmissionByPtyId = new Map< diff --git a/src/main/runtime/runtime-repository-issue-command.test.ts b/src/main/runtime/runtime-repository-issue-command.test.ts new file mode 100644 index 00000000000..aba55791607 --- /dev/null +++ b/src/main/runtime/runtime-repository-issue-command.test.ts @@ -0,0 +1,167 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as issueCommandFile from '../issue-command-file' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RuntimeRepositoryIssueCommand } from './runtime-repository-issue-command' + +const mocks = vi.hoisted(() => ({ + localCheck: vi.fn(), + remoteCheck: vi.fn(), + requireGit: vi.fn(), + fs: { + createDir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + deletePath: vi.fn() + } +})) + +vi.mock('../git/check-ignored-paths', () => ({ checkIgnoredPaths: mocks.localCheck })) +vi.mock('../providers/ssh-git-dispatch', () => ({ requireSshGitProvider: mocks.requireGit })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: () => mocks.fs +})) + +describe('remote issue command ignore rules', () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo with spaces', + displayName: 'remote', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const commands = new RuntimeRepositoryIssueCommand({ + resolveRepo: async () => repo, + getLocalGitArgs: () => [] + }) + + beforeEach(() => { + vi.resetAllMocks() + mocks.requireGit.mockReturnValue({ checkIgnoredPaths: mocks.remoteCheck }) + mocks.remoteCheck.mockResolvedValue([]) + mocks.fs.createDir.mockResolvedValue(undefined) + mocks.fs.writeFile.mockResolvedValue(undefined) + mocks.fs.deletePath.mockResolvedValue(undefined) + mocks.fs.readFile.mockResolvedValue({ content: 'node_modules/\n', isBinary: false }) + }) + + it('uses the remote ignore rules and leaves .gitignore untouched', async () => { + mocks.remoteCheck.mockResolvedValue(['.orca/issue-command']) + + await commands.write(repo.id, 'local command') + + expect(mocks.requireGit).toHaveBeenCalledWith('conn-1') + expect(mocks.remoteCheck).toHaveBeenCalledWith(repo.path, ['.orca/issue-command']) + expect(mocks.localCheck).not.toHaveBeenCalled() + expect(mocks.fs.readFile).not.toHaveBeenCalled() + expect(mocks.fs.writeFile).toHaveBeenCalledExactlyOnceWith( + `${repo.path}/.orca/issue-command`, + 'local command\n' + ) + }) + + it('adds the rule if the remote host does not ignore .orca', async () => { + await commands.write(repo.id, 'local command') + + expect(mocks.fs.writeFile).toHaveBeenCalledWith( + `${repo.path}/.gitignore`, + 'node_modules/\n.orca\n' + ) + expect(mocks.localCheck).not.toHaveBeenCalled() + }) + + it.each(['unavailable', 'failed'])( + 'keeps the remote fallback when Git is %s', + async (failure) => { + if (failure === 'unavailable') { + mocks.requireGit.mockImplementation(() => { + throw new Error('remote Git unavailable') + }) + } else { + mocks.remoteCheck.mockRejectedValue(new Error('remote Git failed')) + } + + await expect(commands.write(repo.id, 'local command')).resolves.toEqual({ ok: true }) + + expect(mocks.localCheck).not.toHaveBeenCalled() + expect(mocks.fs.writeFile).toHaveBeenCalledWith( + `${repo.path}/.gitignore`, + 'node_modules/\n.orca\n' + ) + } + ) + + it('does not inspect ignore rules when clearing an override', async () => { + await commands.write(repo.id, ' ') + + expect(mocks.requireGit).not.toHaveBeenCalled() + expect(mocks.fs.writeFile).not.toHaveBeenCalled() + expect(mocks.fs.deletePath).toHaveBeenCalledWith(`${repo.path}/.orca/issue-command`, false) + }) +}) + +describe('local issue command runtime routing', () => { + it.each(['command', ' '])( + 'preserves local file writes with an unavailable runtime: %j', + async (content) => { + const root = mkdtempSync(join(tmpdir(), 'orca-runtime-ignore-')) + const repo = { + id: 'repo-1', + path: root, + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + mkdirSync(join(root, '.orca')) + writeFileSync(join(root, '.orca', 'issue-command'), 'old command\n') + const getLocalGitArgs = vi.fn((): [] => { + throw new Error('Project runtime requires repair') + }) + const commands = new RuntimeRepositoryIssueCommand({ + resolveRepo: async () => repo, + getLocalGitArgs + }) + try { + await commands.write(repo.id, content) + if (content.trim()) { + expect(readFileSync(join(root, '.orca', 'issue-command'), 'utf8')).toBe('command\n') + expect(readFileSync(join(root, '.gitignore'), 'utf8')).toBe('.orca\n') + } else { + expect(existsSync(join(root, '.orca', 'issue-command'))).toBe(false) + expect(existsSync(join(root, '.gitignore'))).toBe(false) + expect(getLocalGitArgs).not.toHaveBeenCalled() + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + + it('forwards the resolved WSL options to the local writer', async () => { + const repo = { + id: 'local', + path: '/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const write = vi.spyOn(issueCommandFile, 'writeIssueCommand').mockResolvedValue(undefined) + const getLocalGitArgs = vi.fn((): [{ wslDistro: string }] => [{ wslDistro: 'Ubuntu' }]) + try { + const commands = new RuntimeRepositoryIssueCommand({ + resolveRepo: async () => repo, + getLocalGitArgs + }) + await commands.write(repo.id, 'command') + const options = write.mock.calls[0]?.[2] + expect(typeof options).toBe('function') + expect(typeof options === 'function' ? options() : options).toEqual({ wslDistro: 'Ubuntu' }) + expect(getLocalGitArgs).toHaveBeenCalledWith(repo) + expect(write).toHaveBeenCalledExactlyOnceWith(repo.path, 'command', expect.any(Function)) + } finally { + write.mockRestore() + } + }) +}) diff --git a/src/main/runtime/runtime-repository-issue-command.ts b/src/main/runtime/runtime-repository-issue-command.ts index 1ce2ed6130a..84509b8b159 100644 --- a/src/main/runtime/runtime-repository-issue-command.ts +++ b/src/main/runtime/runtime-repository-issue-command.ts @@ -1,6 +1,11 @@ +import type { GitRuntimeOptions } from '../git/git-runtime-options' import type { Repo } from '../../shared/repo-types' import { parseOrcaYaml } from '../hooks' -import { readIssueCommand, writeIssueCommand } from '../issue-command-file' +import { + isIssueCommandIgnoredByGit, + readIssueCommand, + writeIssueCommand +} from '../issue-command-file' import { isENOENT } from '../ipc/filesystem-auth' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from '../providers/types' @@ -9,6 +14,7 @@ import { joinWorktreeRelativePath } from './runtime-relative-paths' type RuntimeRepositoryIssueCommandDeps = { resolveRepo: (selector: string) => Promise + getLocalGitArgs: (repo: Repo) => [] | [GitRuntimeOptions] } export class RuntimeRepositoryIssueCommand { @@ -54,13 +60,14 @@ export class RuntimeRepositoryIssueCommand { } } + /** Save a private override on its execution host; blank content restores the shared command. */ async write(repoSelector: string, content: string): Promise<{ ok: true }> { const repo = await this.deps.resolveRepo(repoSelector) if (isFolderRepo(repo)) { return { ok: true } } if (!repo.connectionId) { - writeIssueCommand(repo.path, content) + await writeIssueCommand(repo.path, content, () => this.deps.getLocalGitArgs(repo)[0] ?? {}) return { ok: true } } const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') @@ -78,7 +85,9 @@ export class RuntimeRepositoryIssueCommand { return { ok: true } } await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - await ensureRemoteOrcaDirIgnored(fsProvider, repo.path) + if (!(await isIssueCommandIgnoredByGit(repo.path, repo.connectionId))) { + await ensureRemoteOrcaDirIgnored(fsProvider, repo.path) + } await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) return { ok: true } } From d139760c06290ae2274e090cbf618b50a9bb9450 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 18 Sep 2026 01:10:32 -0700 Subject: [PATCH 011/224] fix(sessions): cancel transcript acquisition during host teardown (#21006) * fix(sessions): cancel TUI transcript acquisition during teardown * fix(sessions): settle canceled handoffs without replacement launches * test(sessions): assert fenced teardown release --------- Co-authored-by: m4air Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../tui-transcript-acquisition/README.md | 34 ++ .../tui-transcript-acquisition/fix.patch | 294 ++++++++++++++++++ .../tui-transcript-acquisition/reproduce.mjs | 151 +++++++++ .../tui-transcript-acquisition/results.json | 50 +++ ...tructured-agent-session-handoff-forward.ts | 19 +- ...tured-agent-session-handoff-restart-tui.ts | 13 + ...tructured-agent-session-handoff-restart.ts | 19 +- .../structured-agent-session-handoff-types.ts | 11 +- .../structured-agent-session-handoff.test.ts | 113 ++++++- ...ent-session-teardown-handoff-drain.test.ts | 9 +- .../structured-tui-transcript-catchup.ts | 91 ++++-- ...tructured-tui-transcript-ownership.test.ts | 130 ++++++++ ...ed-tui-transcript-teardown-test-fixture.ts | 104 +++++++ ...structured-tui-transcript-teardown.test.ts | 190 +++++++++++ 14 files changed, 1182 insertions(+), 46 deletions(-) create mode 100644 docs/audits/tui-transcript-acquisition/README.md create mode 100644 docs/audits/tui-transcript-acquisition/fix.patch create mode 100644 docs/audits/tui-transcript-acquisition/reproduce.mjs create mode 100644 docs/audits/tui-transcript-acquisition/results.json create mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts diff --git a/docs/audits/tui-transcript-acquisition/README.md b/docs/audits/tui-transcript-acquisition/README.md new file mode 100644 index 00000000000..cbd22d8318d --- /dev/null +++ b/docs/audits/tui-transcript-acquisition/README.md @@ -0,0 +1,34 @@ +# Transcript catchup can outlive host teardown + +Host teardown stops TUI transcript catchup before draining in-flight handoffs. Previously, catchup setup registered its state only after asynchronous path resolution and stored its unsubscribe function only after asynchronous subscription acquisition. Teardown could miss either resource. A handoff that had not entered preparation yet could also start a watcher after `stopAll`. Actual-host tests reproduced a surviving watcher after the host session was removed. Stopping an already acquired watcher before its first snapshot instead left preparation waiting indefinitely for that snapshot. + +## Ownership fix + +Catchup now registers its state before its first await and owns an abort controller throughout setup. It passes the existing resolver/subscriber cancellation signal, releases late subscriptions, settles the initial-ready wait on stop, and preserves a newer same-session acquisition. `stopAll` permanently closes this host's admission; ordinary per-session `stop` still permits a replacement. + +Preparation returns its signal internally so the handoff checks cancellation immediately before and after launching a TUI. A dedicated internal cancellation error, while no TUI owner or process identity has been committed, releases the unused reservation through the existing fenced `abandonStoredAgentSessionHandoffAttempt` transition. If launch returns after cancellation with an owner, the existing proven cleanup path is invoked; if cleanup is unavailable or fails, ownership is retained and manual recovery remains required. It leaves a recoverable native lease without acquiring a replacement. This distinction matters: ordinary preparation failure invokes native recovery, and a delayed replacement acquisition can finish after the five-second teardown drain. Ordinary read failures retain that recovery behavior. Canceled recovery of a live TUI stops without retrying or relabeling its live lease, and settles any original durable operation that was still pending. + +These are internal lifecycle changes. They add no wire type and infer no remote process death. A launch that remains in flight beyond the bounded teardown drain, and boundary/import I/O already admitted before cancellation, remain outside this change's cancellation guarantee. + +## Reproduce + +```sh +ORCA_BACKGROUND_LAUNCH=1 node docs/audits/tui-transcript-acquisition/reproduce.mjs +``` + +The script runs seven tests through the actual host, handoff coordinator, durable record store, journal, and transcript watcher. Real file resolution, watcher installation, and initial read are paused at explicit asynchronous boundaries; provider processes use the existing fake adapter/transport. No real shell or app window launches. + +`fix.patch` is reversed inside a temporary Vite transform for the baseline. The new internal error declaration remains available to the same assertions; it does not change baseline control flow. Source hashes and exact failing cases are recorded in `results.json`. Each runner uses a 512 MiB old-space limit, a 90-second deadline, and the repository's cross-platform `runProcess`. The proof requires the fixed runner to exit successfully in addition to matching its seven-pass/zero-fail report. Temporary runner/configuration files and acquired watchers are cleaned up. + +| Version | Passed | Failed | +| ---------- | -----: | -----: | +| Before fix | 1 | 6 | +| With fix | 7 | 0 | + +The five preparation cases cover resolution, subscription return, initial snapshot, admission after teardown, and a completed preparation whose caller has not resumed. The recovery case preserves the live TUI lease. The control delays native acquisition after an ordinary resolver error and verifies the original error and recovery behavior. Six additional ownership tests cover overlapping prepare/recover replacements, per-session restart, repeated shutdown, and the signal returned when no supported record is available. Existing catchup tests preserve live appends and restart gap replay. + +Two additional handoff regressions cover a TUI launch returning after cancellation and cancellation during recovery of a pending durable operation. Both fail against the original PR head and pass with the review fix. A late owner is stopped through the existing proven-cleanup contract, then the reservation is abandoned without acquiring a replacement native owner. If cleanup is unavailable or cannot prove the owner stopped, the existing manual-recovery path retains ownership instead. Recovery cancellation marks the original operation failed without relabeling the live TUI lease. + +## Version and attribution + +Named-path reads confirm the same setup gaps, unguarded forward launch, and stop-before-drain ordering in `v1.4.198`. In that tag the teardown phases are inline in `structured-agent-session-host.ts:254`; current source extracts them into `structured-agent-session-host-teardown.ts`. The executable proof compares current source before/after this fix. It establishes an execution-host watcher retaining path present in the reported version, without proving that #19831 or #19768 exercised this teardown race or explaining either report's memory magnitude. diff --git a/docs/audits/tui-transcript-acquisition/fix.patch b/docs/audits/tui-transcript-acquisition/fix.patch new file mode 100644 index 00000000000..33ba3fabce8 --- /dev/null +++ b/docs/audits/tui-transcript-acquisition/fix.patch @@ -0,0 +1,294 @@ +diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts +index a73e8b2111..b9559868f4 100644 +--- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts ++++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts +@@ -12,7 +12,10 @@ import type { + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner + } from './structured-agent-session-handoff-types' +-import { StructuredTuiLaunchCleanupError } from './structured-agent-session-handoff-types' ++import { ++ StructuredTuiCatchupStoppedError, ++ StructuredTuiLaunchCleanupError ++} from './structured-agent-session-handoff-types' + + export async function handoffStructuredSessionToTui( + context: StructuredAgentSessionHandoffFlowContext, +@@ -75,7 +78,8 @@ export async function handoffStructuredSessionToTui( + let owner: StructuredTuiOwner | null = null + let processIdentityCommitted = false + try { +- await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) ++ const prepared = await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) ++ prepared?.throwIfAborted() + owner = await deps.transport!.launchTui({ + record, + fence: record.lease.runtimeFence, +@@ -91,6 +95,7 @@ export async function handoffStructuredSessionToTui( + processIdentityCommitted = true + } + }) ++ prepared?.throwIfAborted() + if (!processIdentityCommitted) { + await deps.store.commitProcessIdentity({ + sessionId, +@@ -128,6 +133,16 @@ export async function handoffStructuredSessionToTui( + ) + } + } ++ if (error instanceof StructuredTuiCatchupStoppedError && (owner || !processIdentityCommitted)) { ++ await abandonStoredAgentSessionHandoffAttempt(deps.store, { ++ sessionId, ++ expectedFence: record.lease.runtimeFence, ++ operationId, ++ recoverableRuntimeKind: 'native', ++ now: deps.now() ++ }) ++ throw error ++ } + await recoverNativeAfterTuiFailure(context, sessionId, operationId) + throw error + } +diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts +index c16ac68122..3bf0bc08e8 100644 +--- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts ++++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts +@@ -96,3 +96,16 @@ export async function persistReprovedTuiOwner( + }) + } + } ++ ++export async function startRecoveredTuiCatchup( ++ input: StructuredAgentSessionRestartAccess, ++ record: AgentSessionRecord ++): Promise { ++ const prepared = await input.deps.recoverTuiHistoryCatchup?.( ++ record.sessionId, ++ record.lease.runtimeFence ++ ) ++ prepared?.throwIfAborted() ++ await input.deps.activateTuiHistoryCatchup?.(record.sessionId) ++ prepared?.throwIfAborted() ++} +diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts +index 13a26f2a7a..a6ad92d91e 100644 +--- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts ++++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts +@@ -12,10 +12,12 @@ import { + structuredTuiRecoveryProofIsAdmissible + } from './structured-agent-session-handoff-status' + import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' ++import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' + import { + persistReprovedTuiOwner, + recoverTuiOwnerOrContinue, + recoverUnavailableTuiAsNative, ++ startRecoveredTuiCatchup, + type StructuredAgentSessionRestartAccess + } from './structured-agent-session-handoff-restart-tui' + +@@ -57,6 +59,15 @@ export async function restoreStructuredAgentSessionHandoff( + } + return + } catch (error) { ++ if (error instanceof StructuredTuiCatchupStoppedError) { ++ if (operationId) { ++ await input.deps.store.recordOperationOutcome({ ++ operationId, ++ outcome: { status: 'failed', code: 'agent_session_handoff_failed' } ++ }) ++ } ++ throw error ++ } + lastError = error + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) +@@ -278,14 +289,6 @@ async function restoreProving(input: RestartAccess, record: AgentSessionRecord): + await continueHandoff(input, stopped) + } + +-async function startRecoveredTuiCatchup( +- input: RestartAccess, +- record: AgentSessionRecord +-): Promise { +- await input.deps.recoverTuiHistoryCatchup?.(record.sessionId, record.lease.runtimeFence) +- await input.deps.activateTuiHistoryCatchup?.(record.sessionId) +-} +- + async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise { + const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native' + const operationId = record.lease.handoffOperationId! +diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts +index cc343c9231..10ce2416a3 100644 +--- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts ++++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts +@@ -18,12 +18,15 @@ import { + type NativeChatTranscriptSubscription + } from '../transcript-watch' + import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' ++import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' + import { + readStructuredTuiTranscriptBoundary, + writeStructuredTuiTranscriptBoundary + } from './structured-tui-transcript-boundary' + + type CatchupState = { ++ controller: AbortController ++ initialReady: (() => void) | null + active: boolean + fence: number + agent: AgentSessionHandleProvider +@@ -35,6 +38,7 @@ type CatchupState = { + + export class StructuredTuiTranscriptCatchup { + private readonly states = new Map() ++ private readonly teardown = new AbortController() + + constructor( + private readonly input: { +@@ -47,15 +51,16 @@ export class StructuredTuiTranscriptCatchup { + } + ) {} + +- async prepare(sessionId: string, fence: number): Promise { +- await this.start(sessionId, fence, false) ++ async prepare(sessionId: string, fence: number): Promise { ++ return this.start(sessionId, fence, false) + } + +- async recover(sessionId: string, fence: number): Promise { +- await this.start(sessionId, fence, true) ++ async recover(sessionId: string, fence: number): Promise { ++ return this.start(sessionId, fence, true) + } + +- private async start(sessionId: string, fence: number, recovering: boolean): Promise { ++ private async start(sessionId: string, fence: number, recovering: boolean): Promise { ++ this.teardown.signal.throwIfAborted() + this.stop(sessionId) + const record = this.input.store.getRecord(sessionId) + const head = record?.providerHandleChain.at(-1) +@@ -64,7 +69,7 @@ export class StructuredTuiTranscriptCatchup { + !head || + (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') + ) { +- return ++ return this.teardown.signal + } + const agent = head.handle.provider + const providerSessionId = agent === 'claude' ? head.handle.sessionId : head.handle.threadId +@@ -73,17 +78,9 @@ export class StructuredTuiTranscriptCatchup { + agent === 'claude' + ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } + : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } +- const boundary = recovering +- ? await readStructuredTuiTranscriptBoundary(journal.directory) +- : null +- const filePath = await resolveSessionFilePath(agent, providerSessionId, { +- ...transcriptOptions, +- ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) +- }) +- let initialReady: (() => void) | null = null +- let baselineOffset = 0 +- const ready = filePath ? new Promise((resolve) => (initialReady = resolve)) : null + const state: CatchupState = { ++ controller: new AbortController(), ++ initialReady: null, + active: false, + fence, + agent, +@@ -95,20 +92,42 @@ export class StructuredTuiTranscriptCatchup { + const receive = (messages: NativeChatMessage[]) => this.receive(sessionId, state, messages) + this.states.set(sessionId, state) + try { +- state.subscription = await subscribeNativeChatTranscript({ ++ const signal = state.controller.signal ++ const boundary = recovering ++ ? await readStructuredTuiTranscriptBoundary(journal.directory) ++ : null ++ signal.throwIfAborted() ++ const filePath = await resolveSessionFilePath( + agent, +- sessionId: providerSessionId, +- ...transcriptOptions, +- ...(filePath ? { filePath, initialLimit: 0 } : {}), +- onInitialSnapshot: (messages, _hasMore, beforeOffset) => { +- baselineOffset = beforeOffset +- receive(messages) +- initialReady?.() +- initialReady = null ++ providerSessionId, ++ { ++ ...transcriptOptions, ++ ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) + }, +- onAppend: receive +- }) ++ signal ++ ) ++ signal.throwIfAborted() ++ let baselineOffset = 0 ++ const ready = filePath ? new Promise((resolve) => (state.initialReady = resolve)) : null ++ state.subscription = await subscribeNativeChatTranscript( ++ { ++ agent, ++ sessionId: providerSessionId, ++ ...transcriptOptions, ++ ...(filePath ? { filePath, initialLimit: 0 } : {}), ++ onInitialSnapshot: (messages, _hasMore, beforeOffset) => { ++ baselineOffset = beforeOffset ++ receive(messages) ++ state.initialReady?.() ++ state.initialReady = null ++ }, ++ onAppend: receive ++ }, ++ signal ++ ) ++ signal.throwIfAborted() + await ready ++ signal.throwIfAborted() + if (!recovering) { + await writeStructuredTuiTranscriptBoundary(journal.directory, { + providerSessionId, +@@ -134,13 +153,21 @@ export class StructuredTuiTranscriptCatchup { + if (!imported.ok) { + throw new Error(imported.error) + } ++ signal.throwIfAborted() + this.input.reset(sessionId, fence) + } ++ signal.throwIfAborted() ++ return signal + } catch (error) { ++ const stopped = state.controller.signal.aborted + if (this.states.get(sessionId) === state) { +- this.states.delete(sessionId) ++ this.stop(sessionId) ++ } else { ++ state.subscription?.unsubscribe() ++ } ++ if (stopped) { ++ state.controller.signal.throwIfAborted() + } +- state.subscription?.unsubscribe() + throw error + } + } +@@ -197,10 +224,16 @@ export class StructuredTuiTranscriptCatchup { + stop(sessionId: string): void { + const state = this.states.get(sessionId) + this.states.delete(sessionId) ++ state?.controller.abort(new StructuredTuiCatchupStoppedError()) ++ state?.initialReady?.() ++ if (state) { ++ state.initialReady = null ++ } + state?.subscription?.unsubscribe() + } + + stopAll(): void { ++ this.teardown.abort(new StructuredTuiCatchupStoppedError()) + for (const sessionId of this.states.keys()) { + this.stop(sessionId) + } diff --git a/docs/audits/tui-transcript-acquisition/reproduce.mjs b/docs/audits/tui-transcript-acquisition/reproduce.mjs new file mode 100644 index 00000000000..1e03797114d --- /dev/null +++ b/docs/audits/tui-transcript-acquisition/reproduce.mjs @@ -0,0 +1,151 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { applyPatch, parsePatch, reversePatch } from 'diff' +import { build } from 'esbuild' + +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.') +} + +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8') +const beforeSources = {} +const sourceHashes = {} +for (const parsed of parsePatch(patch)) { + const path = parsed.newFileName.replace(/^b\//, '') + const absolute = resolve(root, path) + const current = await readFile(absolute, 'utf8') + const before = applyPatch(current, reversePatch(parsed)) + if (before === false) { + throw new Error(`Source changed; review the proof patch: ${path}`) + } + beforeSources[absolute.replaceAll('\\', '/')] = before + sourceHashes[path] = { + before: createHash('sha256').update(before).digest('hex'), + after: createHash('sha256').update(current).digest('hex') + } +} + +for (const path of [ + 'src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts', + 'src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts', + 'src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts' +]) { + sourceHashes[path] = { + current: createHash('sha256') + .update(await readFile(resolve(root, path))) + .digest('hex') + } +} + +const scratch = await mkdtemp(join(tmpdir(), 'orca-tui-transcript-acquisition-')) +const require = createRequire(import.meta.url) +let runnerModuleId +try { + const runnerPath = join(scratch, 'run-process.cjs') + await build({ + absWorkingDir: root, + entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')], + outfile: runnerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + runnerModuleId = require.resolve(runnerPath) + const { runProcess } = require(runnerModuleId) + const baselineConfig = join(scratch, 'before.config.mjs') + const fixedConfig = join(scratch, 'after.config.mjs') + const includes = [ + 'src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts' + ] + const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href) + await writeFile( + baselineConfig, + `import base from ${configImport}; +const beforeSources = ${JSON.stringify(beforeSources)}; +export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{ + name: 'tui-transcript-acquisition-before-fix', enforce: 'pre', + transform(_code, id) { + const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]]; + return before === undefined ? null : {code: before, map: null}; + } +}]};\n` + ) + + await writeFile( + fixedConfig, + `import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n` + ) + + async function run(label, config) { + const report = join(scratch, `${label}.json`) + const result = await runProcess({ + program: process.execPath, + args: [ + resolve(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--config', + config, + '--reporter=json', + `--outputFile=${report}` + ], + cwd: root, + env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=512' }, + timeoutMs: 90_000, + maxOutputBytes: 4 * 1024 * 1024 + }) + let parsed + try { + parsed = JSON.parse(await readFile(report, 'utf8')) + } catch (error) { + throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error }) + } + return { + exitCode: result.code, + passed: parsed.numPassedTests, + failed: parsed.numFailedTests, + failedCases: parsed.testResults.flatMap((suite) => + suite.assertionResults + .filter((test) => test.status === 'failed') + .map((test) => test.fullName) + ) + } + } + + const before = await run('before', baselineConfig) + const after = await run('after', fixedConfig) + const passed = + before.failed === 6 && + before.passed === 1 && + before.passed + before.failed === 7 && + after.exitCode === 0 && + after.passed === 7 && + after.failed === 0 + console.log( + JSON.stringify( + { + comparison: + 'Actual structured host teardown, record store, journal, and transcript watcher; baseline reverses catchup/forward/restart behavior through a temporary Vite transform', + sourceHashes, + before, + after, + passed + }, + null, + 2 + ) + ) + if (!passed) { + process.exitCode = 1 + } +} finally { + if (runnerModuleId) { + delete require.cache[runnerModuleId] + } + await rm(scratch, { recursive: true, force: true }) +} diff --git a/docs/audits/tui-transcript-acquisition/results.json b/docs/audits/tui-transcript-acquisition/results.json new file mode 100644 index 00000000000..25055ea55c2 --- /dev/null +++ b/docs/audits/tui-transcript-acquisition/results.json @@ -0,0 +1,50 @@ +{ + "comparison": "Actual structured host teardown, record store, journal, and transcript watcher; baseline reverses catchup/forward/restart behavior through a temporary Vite transform", + "sourceHashes": { + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts": { + "before": "c9bb6fbf8ca3fc3fad815f35a21c73e392dd6be267335984deb0b5c9319210f1", + "after": "99204872e4432ea841be493012c23b00b67fedabe07a83332c995dec632839cc" + }, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts": { + "before": "fcfcbd821816f33d1cf8bb71e6ecb40b03d4139affe8629f5baaa0a45f423921", + "after": "58a1b23f9390234e39bdb9681e43e9b32fd4d741a4242101a8d25e85a7001c6e" + }, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts": { + "before": "8f2dc4f31fd2f96f3e9393afcc0826b712591c5d3bfa80965113e63be65f69ab", + "after": "73461453fba97bd0630471a224fc718ac2ce497c18fc95afb2ee264e7e42f791" + }, + "src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts": { + "before": "36085d52e44152c7d8906ac2691242e8e31e54511fc908510c0d3aee10615973", + "after": "2d0dc6dcfbfba666a0bdebb229b78706c8a137b8427aa2a8b8bc679f0d43749b" + }, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts": { + "current": "225283eaf80f976fcad35554b330ad24e81cd4c1dd275996dc471c53de46ba67" + }, + "src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts": { + "current": "cc8be5277db229dcf52d1c72bfddfc86b2f07fd2f77eba3f11af01d1877f2604" + }, + "src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts": { + "current": "d186aeab78e31f0aa493f92d5f472708e81791670e82637e37481ebca9918756" + } + }, + "before": { + "exitCode": 1, + "passed": 1, + "failed": 6, + "failedCases": [ + "cancels transcript acquisition during host teardown at resolve", + "cancels transcript acquisition during host teardown at subscribe", + "cancels transcript acquisition during host teardown at initial-ready", + "cancels transcript acquisition during host teardown at before-prepare", + "cancels transcript acquisition during host teardown at after-prepare", + "cancels recovered TUI catchup without relabeling the live owner or retrying" + ] + }, + "after": { + "exitCode": 0, + "passed": 7, + "failed": 0, + "failedCases": [] + }, + "passed": true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts index a73e8b21116..b9559868f4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts @@ -12,7 +12,10 @@ import type { StructuredAgentSessionHandoffFlowContext, StructuredTuiOwner } from './structured-agent-session-handoff-types' -import { StructuredTuiLaunchCleanupError } from './structured-agent-session-handoff-types' +import { + StructuredTuiCatchupStoppedError, + StructuredTuiLaunchCleanupError +} from './structured-agent-session-handoff-types' export async function handoffStructuredSessionToTui( context: StructuredAgentSessionHandoffFlowContext, @@ -75,7 +78,8 @@ export async function handoffStructuredSessionToTui( let owner: StructuredTuiOwner | null = null let processIdentityCommitted = false try { - await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) + const prepared = await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) + prepared?.throwIfAborted() owner = await deps.transport!.launchTui({ record, fence: record.lease.runtimeFence, @@ -91,6 +95,7 @@ export async function handoffStructuredSessionToTui( processIdentityCommitted = true } }) + prepared?.throwIfAborted() if (!processIdentityCommitted) { await deps.store.commitProcessIdentity({ sessionId, @@ -128,6 +133,16 @@ export async function handoffStructuredSessionToTui( ) } } + if (error instanceof StructuredTuiCatchupStoppedError && (owner || !processIdentityCommitted)) { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + throw error + } await recoverNativeAfterTuiFailure(context, sessionId, operationId) throw error } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts index c16ac681226..3bf0bc08e8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts @@ -96,3 +96,16 @@ export async function persistReprovedTuiOwner( }) } } + +export async function startRecoveredTuiCatchup( + input: StructuredAgentSessionRestartAccess, + record: AgentSessionRecord +): Promise { + const prepared = await input.deps.recoverTuiHistoryCatchup?.( + record.sessionId, + record.lease.runtimeFence + ) + prepared?.throwIfAborted() + await input.deps.activateTuiHistoryCatchup?.(record.sessionId) + prepared?.throwIfAborted() +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts index 13a26f2a7a9..a6ad92d91ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts @@ -12,10 +12,12 @@ import { structuredTuiRecoveryProofIsAdmissible } from './structured-agent-session-handoff-status' import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' import { persistReprovedTuiOwner, recoverTuiOwnerOrContinue, recoverUnavailableTuiAsNative, + startRecoveredTuiCatchup, type StructuredAgentSessionRestartAccess } from './structured-agent-session-handoff-restart-tui' @@ -57,6 +59,15 @@ export async function restoreStructuredAgentSessionHandoff( } return } catch (error) { + if (error instanceof StructuredTuiCatchupStoppedError) { + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + } + throw error + } lastError = error if (attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) @@ -278,14 +289,6 @@ async function restoreProving(input: RestartAccess, record: AgentSessionRecord): await continueHandoff(input, stopped) } -async function startRecoveredTuiCatchup( - input: RestartAccess, - record: AgentSessionRecord -): Promise { - await input.deps.recoverTuiHistoryCatchup?.(record.sessionId, record.lease.runtimeFence) - await input.deps.activateTuiHistoryCatchup?.(record.sessionId) -} - async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise { const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native' const operationId = record.lease.handoffOperationId! diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts index 5a36c691098..c1115f33a2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -28,6 +28,13 @@ export class StructuredTuiLaunchCleanupError extends Error { } } +export class StructuredTuiCatchupStoppedError extends Error { + constructor() { + super('TUI transcript catchup was stopped.') + this.name = 'StructuredTuiCatchupStoppedError' + } +} + export type StructuredAgentSessionHandoffTransport = { hostLabel: string launchTui(input: { @@ -84,8 +91,8 @@ export type StructuredAgentSessionHandoffDeps = { transcriptPath?: string }) => Promise retryPendingSettlement: (sessionId: string) => Promise - prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise - recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise + prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise + recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise activateTuiHistoryCatchup?: (sessionId: string) => Promise stopTuiHistoryCatchup?: (sessionId: string) => void publish: (sessionId: string, status: AgentSessionHandoffStatus) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts index d92bdcd7957..3c6277601af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -21,6 +21,7 @@ import type { StructuredAgentSessionHandoffTransport, StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' const journals = createTrackedJournalOpener() @@ -49,7 +50,7 @@ let acquireNativeStop: ReturnType Promise let operations: number -type HistoryCatchup = (sessionId: string, fence: number) => Promise +type HistoryCatchup = (sessionId: string, fence: number) => Promise let prepareTuiHistoryCatchup: ReturnType> let recoverTuiHistoryCatchup: ReturnType> let activateTuiHistoryCatchup: ReturnType Promise>> @@ -317,6 +318,54 @@ describe('structured session handoff failure handling', () => { ownerProcess: null }) }) + it('settles cancellation after a TUI launch returns without retaining the new owner', async () => { + const operation = operationId() + const controller = new AbortController() + const launchEntered = Promise.withResolvers() + const launchRelease = Promise.withResolvers() + prepareTuiHistoryCatchup.mockResolvedValueOnce(controller.signal) + launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => { + launchEntered.resolve() + await launchRelease.promise + return makeTuiOwner(fence, spawnToken) + }) + + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + await store.admitOperation({ + callerKey: 'test', + operationId: operation, + fingerprint: 'late-launch', + now: NOW + }) + const pending = coordinator.restore(SESSION) + const rejection = expect(pending).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) + await launchEntered.promise + const acquisitionsBeforeCancellation = acquireNativeCalls + controller.abort(new StructuredTuiCatchupStoppedError()) + launchRelease.resolve() + await rejection + + expect(stopFailedTuiLaunch).toHaveBeenCalledOnce() + expect(acquireNativeCalls).toBe(acquisitionsBeforeCancellation) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'released', + handoffStage: 'old-owner-stopped', + ownerProcess: null + }) + expect(store.listOperationRows().find((row) => row.operationId === operation)?.outcome).toEqual( + { + status: 'failed', + code: 'agent_session_handoff_failed' + } + ) + }) }) // The direction-agnostic restore path is the crash-during-acquisition recovery every @@ -392,6 +441,68 @@ describe('structured session ownership recovery on restore', () => { ) }) + it('settles the interrupted recovery operation after catchup cancellation', async () => { + const operation = operationId() + let record = await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + record = await stopStoredAgentSessionOwnerForHandoff(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + operationId: operation, + now: NOW + }) + record = await reserveStoredAgentSessionHandoffOwner(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'tui', + spawnToken: 'recovery-tui', + operationId: operation, + claimKeyId: 'key-1', + now: NOW + }) + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: record.lease.runtimeFence, + process: process('recovery-tui', 4401), + now: NOW + }) + await store.admitOperation({ + callerKey: 'test', + operationId: operation, + fingerprint: 'recovery', + now: NOW + }) + const controller = new AbortController() + recoverTuiHistoryCatchup.mockResolvedValueOnce(controller.signal) + activateTuiHistoryCatchup.mockImplementationOnce(async () => { + controller.abort(new StructuredTuiCatchupStoppedError()) + }) + coordinator = createCoordinator() + + await expect(coordinator.restore(SESSION)).rejects.toBeInstanceOf( + StructuredTuiCatchupStoppedError + ) + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null, + ownerProcess: expect.any(Object) + }) + expect(store.listOperationRows().find((row) => row.operationId === operation)?.outcome).toEqual( + { + status: 'failed', + code: 'agent_session_handoff_failed' + } + ) + }) + it('continues only the persisted TUI handoff after a store restart', async () => { const plainOperation = operationId() await store.reserveOwner({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts index 44b0ae71328..76adcb82e6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts @@ -66,6 +66,7 @@ function gatedTransport(): StructuredAgentSessionHandoffTransport { recoverTuiOwner: async (record) => tuiOwner(record.lease.runtimeFence, record.lease.reservedSpawnToken ?? 'recovered'), stopRecoveredOwner: async () => undefined, + stopFailedTuiLaunch: async () => undefined, closeTuiOwner: async (owner) => ({ transcriptPath: owner.transcriptPath }), waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), waitForTuiIdleOrExit: async () => 'idle', @@ -139,11 +140,11 @@ describe('structured agent-session host teardown', () => { launchGate.resolve() await teardown - // The new owner was proven while the session was still indexed, not after it vanished. + // Teardown stops the late TUI owner and fences the reservation before dropping the session. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - claimStatus: 'live', - handoffStage: null + runtimeKind: 'native', + claimStatus: 'released', + handoffStage: 'old-owner-stopped' }) expect(host.hasSession(SESSION)).toBe(false) }) diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts index cc343c9231c..10ce2416a32 100644 --- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts @@ -18,12 +18,15 @@ import { type NativeChatTranscriptSubscription } from '../transcript-watch' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' import { readStructuredTuiTranscriptBoundary, writeStructuredTuiTranscriptBoundary } from './structured-tui-transcript-boundary' type CatchupState = { + controller: AbortController + initialReady: (() => void) | null active: boolean fence: number agent: AgentSessionHandleProvider @@ -35,6 +38,7 @@ type CatchupState = { export class StructuredTuiTranscriptCatchup { private readonly states = new Map() + private readonly teardown = new AbortController() constructor( private readonly input: { @@ -47,15 +51,16 @@ export class StructuredTuiTranscriptCatchup { } ) {} - async prepare(sessionId: string, fence: number): Promise { - await this.start(sessionId, fence, false) + async prepare(sessionId: string, fence: number): Promise { + return this.start(sessionId, fence, false) } - async recover(sessionId: string, fence: number): Promise { - await this.start(sessionId, fence, true) + async recover(sessionId: string, fence: number): Promise { + return this.start(sessionId, fence, true) } - private async start(sessionId: string, fence: number, recovering: boolean): Promise { + private async start(sessionId: string, fence: number, recovering: boolean): Promise { + this.teardown.signal.throwIfAborted() this.stop(sessionId) const record = this.input.store.getRecord(sessionId) const head = record?.providerHandleChain.at(-1) @@ -64,7 +69,7 @@ export class StructuredTuiTranscriptCatchup { !head || (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') ) { - return + return this.teardown.signal } const agent = head.handle.provider const providerSessionId = agent === 'claude' ? head.handle.sessionId : head.handle.threadId @@ -73,17 +78,9 @@ export class StructuredTuiTranscriptCatchup { agent === 'claude' ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } - const boundary = recovering - ? await readStructuredTuiTranscriptBoundary(journal.directory) - : null - const filePath = await resolveSessionFilePath(agent, providerSessionId, { - ...transcriptOptions, - ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) - }) - let initialReady: (() => void) | null = null - let baselineOffset = 0 - const ready = filePath ? new Promise((resolve) => (initialReady = resolve)) : null const state: CatchupState = { + controller: new AbortController(), + initialReady: null, active: false, fence, agent, @@ -95,20 +92,42 @@ export class StructuredTuiTranscriptCatchup { const receive = (messages: NativeChatMessage[]) => this.receive(sessionId, state, messages) this.states.set(sessionId, state) try { - state.subscription = await subscribeNativeChatTranscript({ + const signal = state.controller.signal + const boundary = recovering + ? await readStructuredTuiTranscriptBoundary(journal.directory) + : null + signal.throwIfAborted() + const filePath = await resolveSessionFilePath( agent, - sessionId: providerSessionId, - ...transcriptOptions, - ...(filePath ? { filePath, initialLimit: 0 } : {}), - onInitialSnapshot: (messages, _hasMore, beforeOffset) => { - baselineOffset = beforeOffset - receive(messages) - initialReady?.() - initialReady = null + providerSessionId, + { + ...transcriptOptions, + ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) }, - onAppend: receive - }) + signal + ) + signal.throwIfAborted() + let baselineOffset = 0 + const ready = filePath ? new Promise((resolve) => (state.initialReady = resolve)) : null + state.subscription = await subscribeNativeChatTranscript( + { + agent, + sessionId: providerSessionId, + ...transcriptOptions, + ...(filePath ? { filePath, initialLimit: 0 } : {}), + onInitialSnapshot: (messages, _hasMore, beforeOffset) => { + baselineOffset = beforeOffset + receive(messages) + state.initialReady?.() + state.initialReady = null + }, + onAppend: receive + }, + signal + ) + signal.throwIfAborted() await ready + signal.throwIfAborted() if (!recovering) { await writeStructuredTuiTranscriptBoundary(journal.directory, { providerSessionId, @@ -134,13 +153,21 @@ export class StructuredTuiTranscriptCatchup { if (!imported.ok) { throw new Error(imported.error) } + signal.throwIfAborted() this.input.reset(sessionId, fence) } + signal.throwIfAborted() + return signal } catch (error) { + const stopped = state.controller.signal.aborted if (this.states.get(sessionId) === state) { - this.states.delete(sessionId) + this.stop(sessionId) + } else { + state.subscription?.unsubscribe() + } + if (stopped) { + state.controller.signal.throwIfAborted() } - state.subscription?.unsubscribe() throw error } } @@ -197,10 +224,16 @@ export class StructuredTuiTranscriptCatchup { stop(sessionId: string): void { const state = this.states.get(sessionId) this.states.delete(sessionId) + state?.controller.abort(new StructuredTuiCatchupStoppedError()) + state?.initialReady?.() + if (state) { + state.initialReady = null + } state?.subscription?.unsubscribe() } stopAll(): void { + this.teardown.abort(new StructuredTuiCatchupStoppedError()) for (const sessionId of this.states.keys()) { this.stop(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts new file mode 100644 index 00000000000..6a9080d4a9e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type * as Resolver from '../session-file-resolver' +import type * as TranscriptWatch from '../transcript-watch' +import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' +import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' +import { createTuiTranscriptTeardownFixture } from './structured-tui-transcript-teardown-test-fixture' + +const gate = vi.hoisted(() => ({ + mode: '', + entered: Promise.withResolvers(), + release: Promise.withResolvers(), + cleanups: new Set<() => void>() +})) + +vi.mock('../session-file-resolver', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveSessionFilePath: async (...args: Parameters) => { + if (gate.mode === 'resolve') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return actual.resolveSessionFilePath(...args) + } + } +}) + +vi.mock('../transcript-watch', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + subscribeNativeChatTranscript: async ( + ...args: Parameters + ) => { + const subscription = await actual.subscribeNativeChatTranscript(...args) + gate.cleanups.add(subscription.unsubscribe) + if (gate.mode === 'subscribe') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return subscription + } + } +}) + +let fixture: Awaited> +let catchup: StructuredTuiTranscriptCatchup + +beforeEach(async () => { + gate.mode = '' + gate.entered = Promise.withResolvers() + gate.release = Promise.withResolvers() + fixture = await createTuiTranscriptTeardownFixture() + catchup = new StructuredTuiTranscriptCatchup({ + store: fixture.store, + session: (sessionId) => { + const session = fixture.host['sessions'].get(sessionId) + if (!session) { + throw new Error('Session fixture missing') + } + return session + }, + schedule: (_sessionId, task) => task(), + publish: vi.fn(), + reset: vi.fn() + }) +}) + +afterEach(() => { + gate.release.resolve() + catchup.stopAll() + for (const cleanup of gate.cleanups) { + cleanup() + } + gate.cleanups.clear() + vi.restoreAllMocks() +}) + +it.each([ + { method: 'prepare', mode: 'resolve' }, + { method: 'prepare', mode: 'subscribe' }, + { method: 'recover', mode: 'resolve' }, + { method: 'recover', mode: 'subscribe' } +] as const)( + 'preserves replacement ownership after canceled $method at $mode completes', + async ({ method, mode }) => { + gate.mode = mode + const old = catchup[method](SESSION, 1) + const rejected = expect(old).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) + await gate.entered.promise + const replacement = await catchup[method === 'prepare' ? 'recover' : 'prepare'](SESSION, 2) + gate.release.resolve() + await rejected + expect(replacement.aborted).toBe(false) + expect(catchup['states'].get(SESSION)?.fence).toBe(2) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline + 1) + catchup.stop(SESSION) + expect(replacement.aborted).toBe(true) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) + } +) + +it('allows a new per-session catchup after stop but rejects every start after stopAll', async () => { + const first = await catchup.prepare(SESSION, 1) + catchup.stop(SESSION) + expect(first.aborted).toBe(true) + const replacement = await catchup.prepare(SESSION, 2) + expect(replacement.aborted).toBe(false) + catchup.stopAll() + catchup.stopAll() + await expect(catchup.prepare(SESSION, 3)).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) + await expect(catchup.recover(SESSION, 3)).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) + expect(catchup['states'].size).toBe(0) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) +}) + +it('fences an unsupported preparation result when teardown runs before its consumer', async () => { + vi.spyOn(fixture.store, 'getRecord').mockReturnValueOnce(null) + const prepared = await catchup.prepare(SESSION, 1) + expect(prepared.aborted).toBe(false) + expect(catchup['states'].size).toBe(0) + catchup.stopAll() + expect(() => prepared.throwIfAborted()).toThrow(StructuredTuiCatchupStoppedError) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts new file mode 100644 index 00000000000..71f9576f368 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts @@ -0,0 +1,104 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, vi } from 'vitest' +import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' +import { + CALLER, + adapter, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { StructuredHandoffTestRequests } from './structured-agent-session-handoff-test-requests' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +function rolloutLine(message: string): string { + return `${JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-11T10:00:00.000Z', + payload: { type: 'agent_message', message } + })}\n` +} + +function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, + process: { hostId: 'local', pid: 5200, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `tui-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + } +} + +export async function createTuiTranscriptTeardownFixture() { + const initial = hostTestState() + await initial.host.flushAllStreamedEvents() + const watcherBaseline = getActiveNativeChatWatcherCount() + const closeTuiOwner = vi.fn(async () => ({})) + const launchTui = vi.fn( + async ({ fence, spawnToken }) => tuiOwner(fence, spawnToken) + ) + const host = new StructuredAgentSessionHost({ + ...initial.host.deps, + adapter: { ...adapter(), closeSession: vi.fn(async () => true) }, + handoffTransport: { + hostLabel: 'Test host', + launchTui, + reproveTuiOwner: async ({ owner }) => owner, + recoverTuiOwner: async (record) => + tuiOwner(record.lease.runtimeFence, record.lease.reservedSpawnToken ?? 'recovered'), + stopRecoveredOwner: async () => undefined, + closeTuiOwner, + waitForTuiExit: async () => ({}), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle' + } + }) + replaceHostTestState({ host, store: initial.store }) + const accountHome = join(initial.root, 'codex-home') + const sessionsDir = join(accountHome, 'sessions', '2026', '08', '11') + await mkdir(sessionsDir, { recursive: true }) + const rollout = join(sessionsDir, `rollout-2026-08-11T10-00-00-${THREAD}.jsonl`) + await writeFile(rollout, rolloutLine('before handoff')) + expect( + await host.attach( + CALLER, + hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) + ) + ).toMatchObject({ ok: true }) + const requests = new StructuredHandoffTestRequests( + NOW, + SESSION, + () => initial.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + ) + return { + host, + store: initial.store, + acquire: initial.acquire, + launchTui, + rollout, + watcherBaseline, + async requestHandoff() { + expect( + await host.requestHandoff( + CALLER, + requests.request('to-tui', 'now', { operationId: hostTestOperationId() }) + ) + ).toMatchObject({ ok: true }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts new file mode 100644 index 00000000000..fdf18e4f9df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type * as Resolver from '../session-file-resolver' +import type * as TranscriptWatch from '../transcript-watch' +import type * as TranscriptTail from '../transcript-tail-reader' +import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' +import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' +import { createTuiTranscriptTeardownFixture } from './structured-tui-transcript-teardown-test-fixture' + +const gate = vi.hoisted(() => ({ + mode: '', + entered: Promise.withResolvers(), + release: Promise.withResolvers(), + cleanups: new Set<() => void>() +})) + +vi.mock('../session-file-resolver', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveSessionFilePath: async (...args: Parameters) => { + if (gate.mode === 'resolve-error') { + gate.mode = '' + throw new Error('transcript read failed') + } + if (gate.mode === 'resolve') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return actual.resolveSessionFilePath(...args) + } + } +}) + +vi.mock('../transcript-watch', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + subscribeNativeChatTranscript: async ( + ...args: Parameters + ) => { + const subscription = await actual.subscribeNativeChatTranscript(...args) + gate.cleanups.add(subscription.unsubscribe) + if (gate.mode === 'subscribe') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return subscription + } + } +}) + +vi.mock('../transcript-tail-reader', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readNativeChatTranscriptTailFile: async ( + ...args: Parameters + ) => { + if (gate.mode === 'initial-ready') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return actual.readNativeChatTranscriptTailFile(...args) + } + } +}) + +let fixture: Awaited> + +beforeEach(async () => { + gate.mode = '' + gate.entered = Promise.withResolvers() + gate.release = Promise.withResolvers() + fixture = await createTuiTranscriptTeardownFixture() +}) + +afterEach(async () => { + gate.release.resolve() + for (const cleanup of gate.cleanups) { + cleanup() + } + gate.cleanups.clear() + vi.restoreAllMocks() +}) + +async function beginTeardown() { + const stopped = Promise.withResolvers() + const handoffs = fixture.host['handoffs'] + const stop = handoffs.stopTuiHistoryCatchup.bind(handoffs) + vi.spyOn(handoffs, 'stopTuiHistoryCatchup').mockImplementation(() => { + stop() + stopped.resolve() + }) + const completed = fixture.host.flushAllStreamedEvents() + await stopped.promise + return { completed } +} + +it.each(['resolve', 'subscribe', 'initial-ready', 'before-prepare', 'after-prepare'])( + 'cancels transcript acquisition during host teardown at %s', + async (mode) => { + gate.mode = mode + if (mode === 'before-prepare') { + vi.spyOn(fixture.host.deps.adapter, 'closeSession').mockImplementationOnce(async () => { + gate.entered.resolve() + await gate.release.promise + return true + }) + } else if (mode === 'after-prepare') { + const prepare = StructuredTuiTranscriptCatchup.prototype.prepare + vi.spyOn(StructuredTuiTranscriptCatchup.prototype, 'prepare').mockImplementation( + async function (this: StructuredTuiTranscriptCatchup, sessionId, fence) { + const signal = await prepare.call(this, sessionId, fence) + gate.entered.resolve() + await gate.release.promise + return signal + } + ) + } + await fixture.requestHandoff() + await gate.entered.promise + const teardown = await beginTeardown() + gate.release.resolve() + await teardown.completed + expect(fixture.host.hasSession(SESSION)).toBe(false) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) + expect(fixture.launchTui).not.toHaveBeenCalled() + expect(fixture.acquire).toHaveBeenCalledOnce() + expect(fixture.host['handoffs']['flowRunner']['active'].size).toBe(0) + expect(fixture.store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'released', + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null + }) + } +) + +it('keeps native recovery for an ordinary preparation failure', async () => { + gate.mode = 'resolve-error' + const acquire = fixture.acquire.getMockImplementation() + if (!acquire) { + throw new Error('Native acquisition fixture missing') + } + fixture.acquire.mockImplementationOnce(async (...args) => { + gate.entered.resolve() + await gate.release.promise + return acquire(...args) + }) + await fixture.requestHandoff() + await gate.entered.promise + expect(fixture.acquire).toHaveBeenCalledTimes(2) + gate.release.resolve() + await fixture.host['handoffs'].drain() + expect(fixture.launchTui).not.toHaveBeenCalled() + expect(fixture.store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + expect((await fixture.host.handoffStatus(SESSION)).error?.details).toBe('transcript read failed') +}) + +it('cancels recovered TUI catchup without relabeling the live owner or retrying', async () => { + await fixture.requestHandoff() + await fixture.host['handoffs'].drain() + const recover = vi.spyOn(StructuredTuiTranscriptCatchup.prototype, 'recover') + gate.mode = 'resolve' + const restoring = fixture.host['handoffs'].restore(SESSION) + const rejected = expect(restoring).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) + await gate.entered.promise + const teardown = await beginTeardown() + gate.release.resolve() + await rejected + await teardown.completed + expect(recover).toHaveBeenCalledOnce() + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) + expect(fixture.acquire).toHaveBeenCalledOnce() + expect(fixture.store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) +}) From 9d1826ae657116e05514022be027bd2c3d779d51 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:11:03 -0700 Subject: [PATCH 012/224] fix(session): repoint the rows a worktree re-key strands (latent; producer is flag-disabled) (#20057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(session): keep a renamed worktree's rows from matching on the id it lost Three persisted session fields survived a worktree re-key still naming the old identity. Two of them are suppression records, so a stale id does not read as residue -- it silently re-admits state the user removed: - closedTerminalTabTombstonesByTabId: the remote merge only suppresses a host tab when the tombstone's worktree equals the tab's, and no snapshot ever covers the old id, so the tombstone never retires either. - clientHostedBrowserCloseIntentsByEnvironment: the replay targets the intent's worktree, and an unresolvable selector answers selector_not_found -- which the replay reads as definitively gone and uses to DROP the intent. - clientHostedBrowserPagesByWorktree: keyed by worktree and re-checked against the row's own workspaceId, so both halves have to move or the pages are never rehydrated. Fixed on both sides of the rename: the main-process persisted migration and the renderer's live store, which would otherwise write the stale values straight back. The coverage test drives off WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND, the census these three fell out of, with the shipping owner collector as its oracle. * docs(session): record why a re-key clobbering an existing target stays unfixed Not a missing guard -- an unresolvable one. Keeping the target is correct when it holds a real closed-last-terminal tombstone; keeping the source is correct when the target row is a stub; nothing records which is newer. The recency map is the only one that can settle it, because Math.max needs no such ordering. * test(persistence): measure the downgrade direction for worktree identity The stack widens migrateWorktreeIdentity to repoint worktreeId inside session rows. That changes what lands on disk with no wire change, which is Rule 3's shape applied to persistence, so it is measured against v1.4.199 rather than reasoned about. Result: new-build state does not break the old build. The old build renames over it without throwing and loses no row; the two row kinds it cannot repoint stay stale, which is exactly what its own renames already produce. The numbers are measured. A first draft asserted the old build repointed no inner rows at all; it repoints two of four, and the probe is what caught that. * test(ci): run the worktree-identity downgrade lane instead of describing it The cross-version job names its files explicitly, so a new one is inert until it is listed; the sharded unit job excludes the whole directory and the E2E router only takes `*.spec.ts`. Also pairs the forward-compat case against the current build — the stack's own field-list walk is the guarantee that matters, and only the frozen build was exercised. * refactor(session): drop the type assertions the rename migration leaned on `consistent-type-assertions` landed on main after this branch last built, and three of the `as never` fixtures were hiding real contract drift: a browser workspace row missing six required fields, a tab group naming three fields the type does not have while omitting the two it requires, and a sleeping-agent row whose `providerSession` had neither `key` nor `id` and whose `state` was not in `AgentStatusState`. Indexing the session by a computed field name is what forced the casts in the migration, so the four row maps are now spelled out; the census test is what keeps a fifth from joining silently. The renderer test builds its state from the real slice instead of casting a four-field partial. * refactor(test): name the module namespace the skew harness reads `object` is too broad for the anti-slop gate, and the import helper already declares what it hands back. * docs(test): say which maps the harness actually supplies The two under test live in slices this harness does not mount, so calling it "the real slice's state" overclaimed. --- .github/workflows/pr.yml | 1 + ...-identity-migration-field-coverage.test.ts | 248 ++++++++++++++++++ .../worktree-identity-migration.ts | 136 ++++++++-- ...e-identity-rename-row-worktree-ids.test.ts | 94 +++++++ .../session/worktree-identity-rename-state.ts | 41 +++ ...n-worktree-identity-downgrade.unit.test.ts | 199 ++++++++++++++ 6 files changed, 691 insertions(+), 28 deletions(-) create mode 100644 src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e2112de521b..64ef4dbfede 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -695,6 +695,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts + tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts b/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts new file mode 100644 index 00000000000..deabb7197ca --- /dev/null +++ b/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts @@ -0,0 +1,248 @@ +/** + * Every persisted session field that can name a worktree must lose the old identity when the + * worktree is re-keyed. + * + * Driven by `WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND` rather than a list of its own: that table + * is already a compile-error-to-skip census of how each field names an owner, and the migration + * was the one path with no census at all. Three fields had fallen out of it — + * `clientHostedBrowserPagesByWorktree` (key AND row `workspaceId`), + * `closedTerminalTabTombstonesByTabId` and `clientHostedBrowserCloseIntentsByEnvironment` — each + * one a row that keeps matching on an id nothing answers to any more. + * + * The oracle is `collectWorkspaceSessionWorktreeOwners`, the shipping collector, so a fixture + * cannot be "the shape the assertion expects": it only counts as a reference if the collector + * already reads it as one. + */ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + collectWorkspaceSessionWorktreeOwners, + WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND +} from '../restoring-sessions/session-worktree-ownership' +import { migrateWorktreeIdentity } from './worktree-identity-migration' + +const REPO = 'repo' +const OLD = `${REPO}::/old/path` +const NEW = `${REPO}::/new/path` +const CANDIDATES = new Set([OLD, NEW]) + +type SessionField = keyof WorkspaceSessionState + +/** One fixture per field, each holding exactly that field's reference to OLD. */ +const REFERENCE_FIXTURES: Partial>> = { + activeWorkspaceKey: { activeWorkspaceKey: worktreeWorkspaceKey(OLD) }, + activeWorktreeId: { activeWorktreeId: OLD }, + tabsByWorktree: { + tabsByWorktree: { + [OLD]: [ + { + id: 'tab-1', + ptyId: null, + worktreeId: OLD, + title: 't', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }, + activeWorktreeIdsOnShutdown: { activeWorktreeIdsOnShutdown: [OLD] }, + openFilesByWorktree: { + openFilesByWorktree: { + [OLD]: [ + { + filePath: '/old/path/a.ts', + relativePath: 'a.ts', + worktreeId: OLD, + language: 'ts', + dirtyDraftContent: 'unsaved' + } + ] + } + }, + activeFileIdByWorktree: { activeFileIdByWorktree: { [OLD]: '/old/path/a.ts' } }, + browserTabsByWorktree: { + browserTabsByWorktree: { + [OLD]: [ + { + id: 'bw', + worktreeId: OLD, + activePageId: 'p', + url: 'https://e.com', + title: 'b', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } + }, + browserPagesByWorkspace: { + browserPagesByWorkspace: { + bw: [ + { + id: 'p', + workspaceId: 'bw', + worktreeId: OLD, + url: 'https://e.com', + title: 'E', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } + }, + activeBrowserTabIdByWorktree: { activeBrowserTabIdByWorktree: { [OLD]: 'bw' } }, + clientHostedBrowserPagesByWorktree: { + clientHostedBrowserPagesByWorktree: { + [OLD]: [ + { + v: 1, + browserPageId: 'chp', + workspaceId: OLD, + browserProfileId: 'profile', + url: 'https://e.com', + title: 'E', + pairedDeviceId: 'device', + savedAt: 1 + } + ] + } + }, + clientHostedBrowserCloseIntentsByEnvironment: { + clientHostedBrowserCloseIntentsByEnvironment: { + 'env-1': [{ browserPageId: 'chp', worktreeId: OLD, closedAt: 3 }] + } + }, + activeTabTypeByWorktree: { activeTabTypeByWorktree: { [OLD]: 'terminal' } }, + activeTabIdByWorktree: { activeTabIdByWorktree: { [OLD]: 'tab-1' } }, + unifiedTabs: { + unifiedTabs: { + [OLD]: [ + { + id: 'tab-1', + entityId: 'tab-1', + groupId: 'g', + worktreeId: OLD, + contentType: 'terminal', + label: 't', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }, + tabGroups: { + tabGroups: { + [OLD]: [{ id: 'g', worktreeId: OLD, activeTabId: 'tab-1', tabOrder: ['tab-1'] }] + } + }, + tabGroupLayouts: { tabGroupLayouts: { [OLD]: { type: 'leaf', groupId: 'g' } } }, + activeGroupIdByWorktree: { activeGroupIdByWorktree: { [OLD]: 'g' } }, + lastVisitedAtByWorktreeId: { + lastVisitedAtByWorktreeId: { [OLD]: 10, [`ssh:target|${OLD}`]: 20 } + }, + defaultTerminalTabsAppliedByWorktreeId: { + defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true } + }, + sleepingAgentSessionsByPaneKey: { + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf': { + paneKey: 'tab-1:leaf', + worktreeId: OLD, + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'p', + state: 'done', + capturedAt: 1, + updatedAt: 1 + } + } + }, + terminalSurfaceTombstonesByPaneKey: { + terminalSurfaceTombstonesByPaneKey: { + 'tab-1:leaf': { + worktreeId: OLD, + parentTabId: 'tab-1', + leafId: 'leaf', + ptyId: 'pty', + incarnationId: 'inc', + retiredAt: 1 + } + } + }, + closedTerminalTabTombstonesByTabId: { + closedTerminalTabTombstonesByTabId: { 'tab-1': { closedAt: 5, worktreeId: OLD } } + } +} + +function persistedState(session: WorkspaceSessionState): PersistedState { + return { ...getDefaultPersistedState('/home/test'), workspaceSession: session } +} + +// The census is `satisfies Record`, so every key passes; the +// guard exists to keep `Object.keys`'s `string[]` from indexing the fixture table as `any`. +function isSessionField(field: string): field is SessionField { + return field in WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND +} + +const referencingFields = Object.keys(WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND) + .filter(isSessionField) + .filter((field) => WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND[field] !== 'none') + .sort() + +describe('migrateWorktreeIdentity worktree-reference coverage', () => { + it('has a fixture for every field the ownership census says can name a worktree', () => { + const missing = referencingFields.filter((field) => !REFERENCE_FIXTURES[field]) + expect(missing).toEqual([]) + }) + + for (const field of referencingFields) { + it(`re-points ${field} off the old identity`, () => { + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + ...REFERENCE_FIXTURES[field] + } + // The fixture is only a reference if the shipping collector reads it as one. + expect([...collectWorkspaceSessionWorktreeOwners(session, CANDIDATES)]).toEqual([OLD]) + migrateWorktreeIdentity(persistedState(session), OLD, NEW) + expect([...collectWorkspaceSessionWorktreeOwners(session, CANDIDATES)]).toEqual([NEW]) + }) + } + + // The collector reads this map by key only, so the row's own copy of the id needs its own check: + // rehydration republishes a page only while `workspaceId` still equals the key it is filed under. + it('re-points the workspaceId inside each client-hosted browser page row', () => { + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + ...REFERENCE_FIXTURES.clientHostedBrowserPagesByWorktree + } + migrateWorktreeIdentity(persistedState(session), OLD, NEW) + expect(session.clientHostedBrowserPagesByWorktree?.[NEW]?.[0]?.workspaceId).toBe(NEW) + }) + + it('migrates host partitions, not just the local blob', () => { + const hostSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + ...REFERENCE_FIXTURES.closedTerminalTabTombstonesByTabId + } + const state = persistedState(getDefaultWorkspaceSession()) + state.workspaceSessionsByHostId = { 'ssh:target': hostSession } + expect(migrateWorktreeIdentity(state, OLD, NEW)).toBe(true) + expect(hostSession.closedTerminalTabTombstonesByTabId?.['tab-1']?.worktreeId).toBe(NEW) + }) +}) diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index 2d2fa484ad6..31b318b3143 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -10,6 +10,56 @@ import { } from '../../../shared/worktree/host-qualified-identity' import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' +type WorktreeNamingRow = { worktreeId: string } + +/** + * A session map keyed by pane, tab or environment whose VALUE names the worktree it belongs to. + * Returns the repointed record, or null when no row named the old id — so the caller assigns to + * the concrete field and the row type is never widened. + */ +function repointRowRecord( + record: Record | undefined, + oldWorktreeId: string, + newWorktreeId: string +): Record | null { + if (!record) { + return null + } + let changed = false + const next: Record = { ...record } + for (const [key, row] of Object.entries(record)) { + if (row?.worktreeId !== oldWorktreeId) { + continue + } + next[key] = { ...row, worktreeId: newWorktreeId } + changed = true + } + return changed ? next : null +} + +/** Same, but each value is an array of such rows. */ +function repointRowArrays( + record: Record | undefined, + oldWorktreeId: string, + newWorktreeId: string +): Record | null { + if (!record) { + return null + } + let changed = false + const next: Record = { ...record } + for (const [key, rows] of Object.entries(record)) { + if (!Array.isArray(rows) || !rows.some((row) => row?.worktreeId === oldWorktreeId)) { + continue + } + next[key] = rows.map((row) => + row?.worktreeId === oldWorktreeId ? { ...row, worktreeId: newWorktreeId } : row + ) + changed = true + } + return changed ? next : null +} + /** * Re-keys every worktreeId-keyed record in `state` from `oldWorktreeId` to `newWorktreeId`. Mutates `state` in place; * returns whether anything changed so the caller can gate its save. No-op when the ids match. @@ -60,6 +110,14 @@ export function migrateWorktreeIdentity( return false } let sessionChanged = false + /** Known and deliberately unresolved: when the target key ALREADY exists, the source wins and + * the target's row is lost. `lastVisitedAtByWorktreeId` below is the one map that settles it + * (`Math.max`), and its comment names the case — a partial migration leaves both identities + * behind. There is no safe blanket rule here: "keep the target" is right when the target holds + * a real closed-last-terminal tombstone (`tabsByWorktree[target] === []` is user intent, see + * runtime/workspace-session-worktree-id.ts), and "keep the source" is right when the target row + * is a stub, and nothing records which is newer. Reachable only by a repeated or partial + * migration: on a normal rename this store holds rows under the old id alone. */ const moveSessionKey = ( record: Record | undefined, mapValue: (value: T) => T = (value) => value @@ -114,6 +172,14 @@ export function migrateWorktreeIdentity( sessionChanged = true } } + // Why the row too: rehydration only republishes a row whose `workspaceId` still equals the key + // it is filed under, so re-keying the map alone would strand every page under the new id. + sessionChanged = + moveSessionKey(session.clientHostedBrowserPagesByWorktree, (rows) => + rows.map((row) => + row.workspaceId === oldWorktreeId ? { ...row, workspaceId: newWorktreeId } : row + ) + ) || sessionChanged sessionChanged = moveSessionKey(session.activeBrowserTabIdByWorktree) || sessionChanged sessionChanged = moveSessionKey(session.activeTabTypeByWorktree) || sessionChanged sessionChanged = moveSessionKey(session.activeTabIdByWorktree) || sessionChanged @@ -162,35 +228,49 @@ export function migrateWorktreeIdentity( session.activeWorkspaceKey = newWorkspaceKey sessionChanged = true } - if (session.sleepingAgentSessionsByPaneKey) { - let sleepingChanged = false - const nextSleeping = { ...session.sleepingAgentSessionsByPaneKey } - for (const [paneKey, record] of Object.entries(nextSleeping)) { - if (record.worktreeId !== oldWorktreeId) { - continue - } - nextSleeping[paneKey] = { ...record, worktreeId: newWorktreeId } - sleepingChanged = true - } - if (sleepingChanged) { - session.sleepingAgentSessionsByPaneKey = nextSleeping - sessionChanged = true - } + // Why every row-valued map and not just the two that used to be here: a record keyed by pane or + // tab id still names its worktree in the value, and a stale one silently stops matching. A + // `closedTerminalTabTombstonesByTabId` row left on the old id never suppresses the tab it was + // minted for and never gets acknowledged, so the remote merge re-adds a tab the user closed. + // Spelled out per field rather than driven by a name list: indexing the session by a + // computed key cannot be written back without widening the row type, and the census test + // (`worktree-identity-migration-field-coverage.test.ts`) is what keeps a fourth field of this + // class from joining silently. + const nextSleeping = repointRowRecord( + session.sleepingAgentSessionsByPaneKey, + oldWorktreeId, + newWorktreeId + ) + if (nextSleeping) { + session.sleepingAgentSessionsByPaneKey = nextSleeping + sessionChanged = true } - if (session.terminalSurfaceTombstonesByPaneKey) { - let tombstonesChanged = false - const nextTombstones = { ...session.terminalSurfaceTombstonesByPaneKey } - for (const [paneKey, tombstone] of Object.entries(nextTombstones)) { - if (tombstone.worktreeId !== oldWorktreeId) { - continue - } - nextTombstones[paneKey] = { ...tombstone, worktreeId: newWorktreeId } - tombstonesChanged = true - } - if (tombstonesChanged) { - session.terminalSurfaceTombstonesByPaneKey = nextTombstones - sessionChanged = true - } + const nextSurfaceTombstones = repointRowRecord( + session.terminalSurfaceTombstonesByPaneKey, + oldWorktreeId, + newWorktreeId + ) + if (nextSurfaceTombstones) { + session.terminalSurfaceTombstonesByPaneKey = nextSurfaceTombstones + sessionChanged = true + } + const nextClosedTombstones = repointRowRecord( + session.closedTerminalTabTombstonesByTabId, + oldWorktreeId, + newWorktreeId + ) + if (nextClosedTombstones) { + session.closedTerminalTabTombstonesByTabId = nextClosedTombstones + sessionChanged = true + } + const nextCloseIntents = repointRowArrays( + session.clientHostedBrowserCloseIntentsByEnvironment, + oldWorktreeId, + newWorktreeId + ) + if (nextCloseIntents) { + session.clientHostedBrowserCloseIntentsByEnvironment = nextCloseIntents + sessionChanged = true } return sessionChanged } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts new file mode 100644 index 00000000000..039b1b5ca91 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts @@ -0,0 +1,94 @@ +/** + * Rename has to re-point the maps that name their worktree in the VALUE, not the key. + * + * `WORKTREE_ID_KEYED_MAP_KEYS` covers the `*ByWorktree` maps, and the rename path deliberately + * skips tab- and file-keyed ones because those ids survive a rename. Two of the skipped maps carry + * the worktree id inside each row, and a stale one there is not residue — it is a suppression that + * silently stops matching: + * + * - `closedTerminalTabTombstonesByTabId`: the remote merge only suppresses a host tab when the + * tombstone's worktree equals the tab's, so a tombstone left on the old id re-admits a terminal + * tab the user closed, and never gets acknowledged because no snapshot covers the old id. + * - `clientHostedBrowserCloseIntentsByEnvironment`: the replay targets `intent.worktreeId`, and an + * unresolvable selector answers `selector_not_found` — a code the replay reads as "definitively + * gone" and uses to DROP the intent, leaving the page the user closed open forever. + * + * Main-process counterpart: worktree-identity-migration-field-coverage.test.ts. + */ +import { describe, expect, it } from 'vitest' +import type { AppState } from '../../../types' +import { createTestStore } from '../../worktrees-slice-test-harness' +import { buildWorktreeRenameState } from './worktree-identity-rename-state' + +const OLD = 'repo1::/ws/old' +const NEW = 'repo1::/ws/new' +const OTHER = 'repo1::/ws/other' + +/** + * The real worktree slice, so every map the rename walks past the two under test is the shape it + * actually is. The two under test are hand-supplied: they live in the terminals and browser slices, + * which this harness does not mount, so a missing override reads as `undefined` and exercises the + * `?? {}` path rather than masking a regression. + */ +function appState(overrides: Partial): AppState { + const store = createTestStore() + store.setState(overrides) + return store.getState() +} + +describe('buildWorktreeRenameState value-owned worktree rows', () => { + it('re-points a closed-terminal-tab tombstone onto the new worktree id', () => { + const next = buildWorktreeRenameState( + appState({ + closedTerminalTabTombstonesByTabId: { + 'tab-1': { closedAt: 5, worktreeId: OLD, ackRevision: 3 }, + 'tab-2': { closedAt: 6, worktreeId: OTHER } + } + }), + OLD, + NEW + ) + expect(next.closedTerminalTabTombstonesByTabId).toEqual({ + 'tab-1': { closedAt: 5, worktreeId: NEW, ackRevision: 3 }, + 'tab-2': { closedAt: 6, worktreeId: OTHER } + }) + }) + + it('re-points a client-hosted browser close intent onto the new worktree id', () => { + const next = buildWorktreeRenameState( + appState({ + clientHostedBrowserCloseIntentsByEnvironment: { + 'env-1': [ + { browserPageId: 'page-1', worktreeId: OLD, closedAt: 3 }, + { browserPageId: 'page-2', worktreeId: OTHER, closedAt: 4 } + ], + 'env-2': [{ browserPageId: 'page-3', worktreeId: OTHER, closedAt: 5 }] + } + }), + OLD, + NEW + ) + expect(next.clientHostedBrowserCloseIntentsByEnvironment).toEqual({ + 'env-1': [ + { browserPageId: 'page-1', worktreeId: NEW, closedAt: 3 }, + { browserPageId: 'page-2', worktreeId: OTHER, closedAt: 4 } + ], + 'env-2': [{ browserPageId: 'page-3', worktreeId: OTHER, closedAt: 5 }] + }) + }) + + it('emits neither map when no row names the renamed worktree', () => { + const next = buildWorktreeRenameState( + appState({ + closedTerminalTabTombstonesByTabId: { 'tab-2': { closedAt: 6, worktreeId: OTHER } }, + clientHostedBrowserCloseIntentsByEnvironment: { + 'env-1': [{ browserPageId: 'page-2', worktreeId: OTHER, closedAt: 4 }] + } + }), + OLD, + NEW + ) + expect(Object.hasOwn(next, 'closedTerminalTabTombstonesByTabId')).toBe(false) + expect(Object.hasOwn(next, 'clientHostedBrowserCloseIntentsByEnvironment')).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts index dee9ebb7f84..1d863890685 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts @@ -192,6 +192,43 @@ export function buildWorktreeRenameState( const pendingReconnectWorktreeIds = s.pendingReconnectWorktreeIds?.includes(oldWorktreeId) ? s.pendingReconnectWorktreeIds.map((id) => (id === oldWorktreeId ? newWorktreeId : id)) : s.pendingReconnectWorktreeIds + // Why these two and not just the pane records below: both are keyed by something other than the + // worktree, so the rename path skipped them, but each row names the worktree in its VALUE. A + // close tombstone on the old id never matches the merge's worktree scope, so a terminal tab the + // user closed is re-added by the next host snapshot; a close intent on the old id replays against + // a selector that no longer resolves, which reads as `definitively gone` and drops the intent + // while the page is still open. Both are resurrections the maps exist to prevent. + const repointRows = ( + rows: readonly T[] + ): { rows: T[]; changed: boolean } => { + let changed = false + const next = rows.map((row) => { + if (row.worktreeId !== oldWorktreeId) { + return row + } + changed = true + return { ...row, worktreeId: newWorktreeId } + }) + return { rows: next, changed } + } + const currentClosedTombstones = s.closedTerminalTabTombstonesByTabId ?? {} + const closedTombstoneEntries = repointRows( + Object.entries(currentClosedTombstones).map(([tabId, tombstone]) => ({ ...tombstone, tabId })) + ) + const closedTerminalTabTombstonesByTabId = closedTombstoneEntries.changed + ? Object.fromEntries( + closedTombstoneEntries.rows.map(({ tabId, ...tombstone }) => [tabId, tombstone]) + ) + : s.closedTerminalTabTombstonesByTabId + const currentCloseIntents = s.clientHostedBrowserCloseIntentsByEnvironment ?? {} + let closeIntentsChanged = false + const clientHostedBrowserCloseIntentsByEnvironment = Object.fromEntries( + Object.entries(currentCloseIntents).map(([environmentId, intents]) => { + const repointed = repointRows(intents) + closeIntentsChanged = closeIntentsChanged || repointed.changed + return [environmentId, repointed.changed ? repointed.rows : intents] + }) + ) const currentSleepingAgentSessionsByPaneKey = s.sleepingAgentSessionsByPaneKey ?? {} const sleepingAgentSessionsByPaneKey = Object.values(currentSleepingAgentSessionsByPaneKey).some( (record) => record.worktreeId === oldWorktreeId @@ -220,6 +257,10 @@ export function buildWorktreeRenameState( ...(sleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey ? { sleepingAgentSessionsByPaneKey } : {}), + ...(closedTerminalTabTombstonesByTabId !== s.closedTerminalTabTombstonesByTabId + ? { closedTerminalTabTombstonesByTabId } + : {}), + ...(closeIntentsChanged ? { clientHostedBrowserCloseIntentsByEnvironment } : {}), ...(s.activeWorktreeId === oldWorktreeId ? { activeWorktreeId: newWorktreeId } : {}), // The active workspace key derives from the worktree id, so keep it in sync when the active worktree is renamed. ...(s.activeWorkspaceKey === worktreeWorkspaceKey(oldWorktreeId) diff --git a/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts new file mode 100644 index 00000000000..96ab3760546 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts @@ -0,0 +1,199 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +/** + * The downgrade direction for persisted worktree identity. + * + * Upgrade is the easy direction. The risk PR #19955 records is the other one: a user runs a new + * build, it writes durable state, then they roll back. State the new build wrote must stay + * readable by the old one. + * + * The stack widens `migrateWorktreeIdentity` to repoint the `worktreeId` INSIDE session rows the + * pre-stack build leaves pointing at the old id. A renamed worktree therefore leaves different + * bytes on disk depending on which build did the rename, with no wire change anywhere — Rule 3's + * shape applied to persistence, which is why it is measured here rather than reasoned about. + */ +const PRE_STACK_REF = 'v1.4.199' +const SUITE_TIMEOUT_MS = 180_000 + +const OLD_ID = 'repo::/worktrees/before' +const NEW_ID = 'repo::/worktrees/after' +const THIRD_ID = 'repo::/worktrees/third' +const PANE_KEY = 'pane-1' + +/** + * Declared locally, NOT as today's `WorkspaceSessionState`: the blob crosses two builds, so typing + * it against either one would let the current contract rewrite what the other build sees. + */ +type Row = { + worktreeId: string +} +type CrossVersionSession = { + tabsByWorktree: Record + sleepingAgentSessionsByPaneKey: Record + terminalSurfaceTombstonesByPaneKey: Record + closedTerminalTabTombstonesByTabId: Record + clientHostedBrowserCloseIntentsByEnvironment: Record + /** A field neither build under test knows; the forward-compat cells plant it. */ + someFutureFieldByKey?: Record +} +type CrossVersionPersistedState = { + worktreeMeta: Record + worktreeLineageById: Record + workspaceLineageByChildKey: Record + workspaceSession: CrossVersionSession + workspaceSessionsByHostId: Record + mobileClientTabSelectionsByDeviceId: Record + ui: { showDotfilesByWorktree: Record } +} +type Migrate = (state: CrossVersionPersistedState, oldId: string, newId: string) => boolean + +function isMigrate(value: unknown): value is Migrate { + return typeof value === 'function' +} + +/** What both sides of the skew hand back: a frozen build's namespace and the current one's. */ +type MigrationModuleNamespace = Record + +/** Both builds' exports resolve the same way, so neither is typed against its own build's state. */ +function migrateExportOf(module: MigrationModuleNamespace): Migrate { + const candidate = module.migrateWorktreeIdentity + if (!isMigrate(candidate)) { + throw new Error('module does not export migrateWorktreeIdentity') + } + return candidate +} + +function sessionWithRows(): CrossVersionSession { + return { + tabsByWorktree: { [OLD_ID]: [] }, + sleepingAgentSessionsByPaneKey: { [PANE_KEY]: { worktreeId: OLD_ID, agent: 'claude' } }, + terminalSurfaceTombstonesByPaneKey: { [PANE_KEY]: { worktreeId: OLD_ID, retiredAt: 1 } }, + closedTerminalTabTombstonesByTabId: { tab: { worktreeId: OLD_ID, closedAt: 1 } }, + clientHostedBrowserCloseIntentsByEnvironment: { + env: [{ worktreeId: OLD_ID, url: 'https://example.test' }] + } + } +} + +function persistedStateAfterRename(): CrossVersionPersistedState { + return { + worktreeMeta: { [OLD_ID]: { createdAt: 1 } }, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + workspaceSession: sessionWithRows(), + workspaceSessionsByHostId: {}, + mobileClientTabSelectionsByDeviceId: {}, + ui: { showDotfilesByWorktree: {} } + } +} + +/** The `worktreeId` each row kind names after a migration, which is what downgrade turns on. */ +function rowsById(state: CrossVersionPersistedState): Record { + const session = state.workspaceSession + return { + sleepingAgentSessionsByPaneKey: session.sleepingAgentSessionsByPaneKey[PANE_KEY]?.worktreeId, + terminalSurfaceTombstonesByPaneKey: + session.terminalSurfaceTombstonesByPaneKey[PANE_KEY]?.worktreeId, + closedTerminalTabTombstonesByTabId: session.closedTerminalTabTombstonesByTabId.tab?.worktreeId, + clientHostedBrowserCloseIntentsByEnvironment: + session.clientHostedBrowserCloseIntentsByEnvironment.env?.[0]?.worktreeId + } +} + +let preStackMigrate: Migrate +let stackMigrate: Migrate + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(PRE_STACK_REF) + const [oldModule, newModule] = await Promise.all([ + importReleaseCheckoutModule( + checkout, + 'src/main/persistence/tracking-repos/worktree-identity-migration.ts' + ), + import('../../../src/main/persistence/tracking-repos/worktree-identity-migration') + ]) + preStackMigrate = migrateExportOf(oldModule) + stackMigrate = migrateExportOf(newModule) +}, SUITE_TIMEOUT_MS) + +describe('cross-version worktree identity downgrade', () => { + it('pairs two real builds', () => { + expect(typeof preStackMigrate).toBe('function') + expect(typeof stackMigrate).toBe('function') + // Anti-vacuous-pass oracle: one module resolved twice would make every cell same-version. + expect(preStackMigrate).not.toBe(stackMigrate) + }) + + it('the pre-stack build repoints two of the four row kinds, and strands two', () => { + const state = persistedStateAfterRename() + expect(preStackMigrate(state, OLD_ID, NEW_ID)).toBe(true) + // Measured, not assumed: an earlier draft of this suite asserted the old build repointed + // nothing at all, and the probe that produced these four values is what corrected it. + expect(rowsById(state)).toEqual({ + sleepingAgentSessionsByPaneKey: NEW_ID, + terminalSurfaceTombstonesByPaneKey: NEW_ID, + closedTerminalTabTombstonesByTabId: OLD_ID, + clientHostedBrowserCloseIntentsByEnvironment: OLD_ID + }) + }) + + it('the stack repoints all four', () => { + const state = persistedStateAfterRename() + expect(stackMigrate(state, OLD_ID, NEW_ID)).toBe(true) + expect(rowsById(state)).toEqual({ + sleepingAgentSessionsByPaneKey: NEW_ID, + terminalSurfaceTombstonesByPaneKey: NEW_ID, + closedTerminalTabTombstonesByTabId: NEW_ID, + clientHostedBrowserCloseIntentsByEnvironment: NEW_ID + }) + }) + + it('DOWNGRADE: the old build reads new-build state without loss or throw', () => { + const state = persistedStateAfterRename() + stackMigrate(state, OLD_ID, NEW_ID) + // The rolled-back build renames again over state the new build wrote. Nothing it does not + // understand may throw, and no row may vanish. + expect(() => preStackMigrate(state, NEW_ID, THIRD_ID)).not.toThrow() + expect(rowsById(state)).toEqual({ + sleepingAgentSessionsByPaneKey: THIRD_ID, + terminalSurfaceTombstonesByPaneKey: THIRD_ID, + // The two this build cannot repoint stay where the NEW build put them — stale, but present, + // and no worse than this build's own renames already leave them. That is the #19955 check: + // new-build state does not break the old build. + closedTerminalTabTombstonesByTabId: NEW_ID, + clientHostedBrowserCloseIntentsByEnvironment: NEW_ID + }) + }) + + it('UPGRADE: the stack inherits, and does not resurrect, rows an old build stranded', () => { + const state = persistedStateAfterRename() + preStackMigrate(state, OLD_ID, NEW_ID) + stackMigrate(state, NEW_ID, THIRD_ID) + expect(rowsById(state)).toEqual({ + sleepingAgentSessionsByPaneKey: THIRD_ID, + terminalSurfaceTombstonesByPaneKey: THIRD_ID, + // Still on the id the old build stranded them under: the stack repoints from the id it is + // renaming, and these never reached it. It fixes new renames, not damage already on disk. + closedTerminalTabTombstonesByTabId: OLD_ID, + clientHostedBrowserCloseIntentsByEnvironment: OLD_ID + }) + }) + + // Both builds, because the load-bearing forward-compat guarantee is the CURRENT build's: the + // stack repoints rows by walking a fixed field list, and a field a later build adds must pass + // through untouched rather than be swept in by anything name-shaped. + it.each([ + ['the pre-stack build', (): Migrate => preStackMigrate], + ['the stack', (): Migrate => stackMigrate] + ])('%s drops no row shape it does not recognise', (_label, migrateOf) => { + const state = persistedStateAfterRename() + state.workspaceSession.someFutureFieldByKey = { + k: { worktreeId: OLD_ID, fromANewerBuild: true } + } + expect(() => migrateOf()(state, OLD_ID, NEW_ID)).not.toThrow() + expect(state.workspaceSession.someFutureFieldByKey).toEqual({ + k: { worktreeId: OLD_ID, fromANewerBuild: true } + }) + }) +}) From 1e7a69710da7a72d2752f94851a12473c2f1e1f5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:27:45 -0400 Subject: [PATCH 013/224] feat(mobile): update wall for the desktop-served mobile web bundle (OTA phase B, 1/4) (#21411) * feat(mobile): decide whether a web bundle may open against its host A pure verdict for the bundle update wall, ordered so the answer names the soonest cause: a host with no bundle has no manifest to disagree about, and an unknown manifest schema makes the protocol window inside it unreadable. Same `?? 0` defaults as `evaluateCompat`, so an absent status field reads as the oldest host that could have answered rather than as permission. Every blocked verdict is terminal. There is no native workspace fallback, so each one carries the numbers it compared for the support breadcrumb. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read an unknown bundle schemaVersion through to the wall The client reader pinned `schemaVersion` to the one schema this shell knows, so a future schema 2 failed the parse before `evaluateMobileWebBundleCompat` could call the shell too old. The user would have seen a transport error where the update wall belongs. The host's own manifest stays closed in both directions, where it is written. Goldens are unaffected: every recorded reply carries schema 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the block screen copy for the bundle walls One component still renders every wall. `updateSide` picks the app to update from the reason, so the copy and the store link cannot disagree, and a new reason is a compile error there rather than a mobile title over a desktop button. The existing protocol copy is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): type the bundle protocol window the update wall compares The loose reader left `runtimeProtocolVersion` and `minCompatibleRuntimeProtocolVersion` as unknown index members, so the parsed manifest could not reach `evaluateMobileWebBundleCompat` without a cast. Both are now read as non-negative ints, and the reply-schema test pins it at the call site: the wall is invoked on a parsed manifest, so dropping either field stops compiling. A host that omits the window is now refused. Only a host too old to advertise `mobileWeb.bundle.v1` can send one, and the phone never asks such a host for a manifest. The probe test's fake manifest gained the fields it was missing, which is the typed reader catching its first stale fixture. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): say whether a bundle verdict actually checked a manifest `ok` meant two different things: the manifest was read and its window contains the host, or no manifest had been read at all. A caller that mounted on the second would mount an unchecked bundle, so `manifestChecked` separates permission to fetch from permission to open. The host-status input is now a `Pick` of `HostStatusReply` instead of a hand-copied pair. Both fields default through `?? 0`, so an upstream rename would have silently blocked every host rather than failing a build. Drops two assertions that restated the module's own literal back at it. What proves today's bundle opens is that the shared contract's schema version is a member of the supported list, so that is the assertion left standing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): offer a refetch, not a store, for a bundle the host outgrew `bundle-incompatible` on the mobile side means the workspace cached for this host is older than the host's client floor. A store update cannot clear that and a reconnect can, so the screen no longer sends the user to a download that would change nothing. The button is gone rather than relabelled, because the recovery is leaving this screen, and the note drops its "already updated?" opener for the same reason. `blockRemedy` replaces `updateSide` and is now passed to the copy instead of recomputed there, so the title, the body, and the button are decided once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the platform assertion from the block-screen mock The mocked `Platform.OS` was widened with an assertion so a test could switch stores. An annotation on the binding does the same widening in a position the compiler checks, which is what the changed-code quality gate asks for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say which bundle-compat default is fail-open, and drop a dead field Both comments claimed the two host-status defaults point the same way. Only `protocolVersion` is absent-means-oldest. An absent `minCompatibleMobileVersion` is `?? 0`, which is no floor at all, so the mobile arm is fail-open by design and matches `evaluateCompat`. A reader taking the old sentence at face value would have gone looking for a bug. `supportedSchemaVersions` had no consumer on the verdict: the block screen renders a title and body, and B4 reads neither. The exported constant stays, since that is what the wall is decided against. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../components/ProtocolBlockScreen.test.ts | 168 +++++++++++++++ mobile/src/components/ProtocolBlockScreen.tsx | 96 +++++++-- .../use-mobile-web-bundle-probe.test.tsx | 2 + .../mobile-web-bundle-compat.test.ts | 191 ++++++++++++++++++ .../src/transport/mobile-web-bundle-compat.ts | 114 +++++++++++ .../mobile-web-bundle-reply-schemas.test.ts | 34 +++- .../mobile-web-bundle-reply-schemas.ts | 17 +- 7 files changed, 595 insertions(+), 27 deletions(-) create mode 100644 mobile/src/components/ProtocolBlockScreen.test.ts create mode 100644 mobile/src/transport/mobile-web-bundle-compat.test.ts create mode 100644 mobile/src/transport/mobile-web-bundle-compat.ts diff --git a/mobile/src/components/ProtocolBlockScreen.test.ts b/mobile/src/components/ProtocolBlockScreen.test.ts new file mode 100644 index 00000000000..e9e0be31af5 --- /dev/null +++ b/mobile/src/components/ProtocolBlockScreen.test.ts @@ -0,0 +1,168 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockedVerdict } from './ProtocolBlockScreen' +import { ProtocolBlockScreen } from './ProtocolBlockScreen' + +const nativeTestState = vi.hoisted(() => { + // Declared wide so a test can switch stores; an assertion here would only widen the same literal. + const platform: { OS: 'ios' | 'android' } = { OS: 'ios' } + return { openUrl: vi.fn(), platform } +}) + +vi.mock('react-native', () => ({ + Linking: { openURL: nativeTestState.openUrl }, + Platform: nativeTestState.platform, + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) + +vi.mock('expo-router', () => ({ + router: { replace: vi.fn() } +})) + +const RELEASES_URL = 'https://github.com/stablyai/orca/releases' + +let renderer: ReactTestRenderer | null = null + +function render(verdict: BlockedVerdict): string { + act(() => { + renderer = create(createElement(ProtocolBlockScreen, { verdict })) + }) + return JSON.stringify(renderer?.toJSON()) +} + +/** The mocked host components are plain strings, which `ElementType` does not admit. */ +function isMockedHostElement(type: unknown, name: string): boolean { + return type === name +} + +function pressableCount(): number { + return renderer?.root.findAll((node) => isMockedHostElement(node.type, 'Pressable')).length ?? 0 +} + +function primaryActionUrl(): unknown { + const pressable = renderer?.root.findAll((node) => isMockedHostElement(node.type, 'Pressable'))[0] + act(() => pressable?.props.onPress()) + return nativeTestState.openUrl.mock.calls[0]?.[0] +} + +describe('ProtocolBlockScreen', () => { + beforeEach(() => { + nativeTestState.openUrl.mockClear() + nativeTestState.platform.OS = 'ios' + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + // Why: the protocol wall shipped before the bundle one; its copy is what users already see. + it('keeps the existing protocol wall rendering unchanged', () => { + const mobile = render({ + kind: 'blocked', + reason: 'mobile-too-old', + desktopVersion: 5, + requiredMobileVersion: 99 + }) + expect(mobile).toContain('Update Orca Mobile') + expect(mobile).toContain( + 'This desktop needs a newer Orca Mobile app. Update Orca Mobile from the App Store, then try this host again.' + ) + expect(mobile).toContain('Open App Store') + act(() => renderer?.unmount()) + + const desktop = render({ + kind: 'blocked', + reason: 'desktop-too-old', + desktopVersion: 0, + requiredDesktopVersion: 2 + }) + expect(desktop).toContain('Update Orca on your computer') + expect(desktop).toContain( + 'This paired desktop app is too old for your current Orca Mobile app. Update Orca on your computer, then try this host again.' + ) + expect(desktop).toContain('Open GitHub Releases') + }) + + it('sends a host without a bundle to the desktop update', () => { + const output = render({ kind: 'blocked', reason: 'bundle-unavailable' }) + expect(output).toContain('Update Orca on your computer') + expect(output).toContain( + 'This paired desktop app does not include the mobile workspace yet. Update Orca on your computer, then try this host again.' + ) + expect(primaryActionUrl()).toBe(RELEASES_URL) + }) + + it('sends an unknown manifest schema to the mobile update', () => { + const output = render({ + kind: 'blocked', + reason: 'bundle-shell-too-old', + schemaVersion: 2 + }) + expect(output).toContain('Update Orca Mobile') + expect(output).toContain( + "This desktop's mobile workspace needs a newer Orca Mobile app. Update Orca Mobile from the App Store, then try this host again." + ) + expect(primaryActionUrl()).toBe('itms-apps://apps.apple.com/app/orca-ide/id6766130217') + }) + + it('offers no download for a cached bundle the host outgrew, because none would clear it', () => { + const output = render({ + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'mobile', + bundleRuntimeProtocolVersion: 3, + requiredBundleRuntimeProtocolVersion: 4 + }) + + expect(output).toContain('Refresh the mobile workspace') + expect(output).toContain( + 'The workspace cached for this host is older than the desktop expects. Reconnect to this host to download the current one.' + ) + // A store update cannot replace a stale cache, so neither store link is offered. + expect(output).not.toContain('Open App Store') + expect(output).not.toContain('Open GitHub Releases') + expect(output).not.toContain('Update Orca') + // Back to hosts is the only button left, and it is not a download. + expect(pressableCount()).toBe(1) + expect(output).toContain('Back to hosts') + // Nothing was "already updated" here; the note keeps only the pairing fallback. + expect(output).not.toContain('Already updated?') + expect(output).toContain('If this message stays, remove this host and pair it again.') + }) + + it('sends a host older than its own bundle to the desktop update', () => { + const output = render({ + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'desktop', + hostProtocolVersion: 1, + requiredHostProtocolVersion: 2 + }) + expect(output).toContain('Update Orca on your computer') + expect(output).toContain('This paired desktop app is too old for your current Orca Mobile app') + expect(primaryActionUrl()).toBe(RELEASES_URL) + }) + + it('routes an Android bundle wall to GitHub Releases, not a store that has no listing', () => { + nativeTestState.platform.OS = 'android' + const output = render({ + kind: 'blocked', + reason: 'bundle-shell-too-old', + schemaVersion: 2 + }) + expect(output).toContain('Update Orca Mobile from GitHub Releases') + expect(primaryActionUrl()).toBe(RELEASES_URL) + }) + + it('keeps the update walls on two buttons and the full recovery note', () => { + const output = render({ kind: 'blocked', reason: 'bundle-unavailable' }) + expect(output).toContain('Already updated? Go back to Hosts and refresh the connection.') + // The presence precondition for the absence asserted on the refresh wall above. + expect(pressableCount()).toBe(2) + }) +}) diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx index ed8fc2bcddd..9946e9a2cf3 100644 --- a/mobile/src/components/ProtocolBlockScreen.tsx +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -2,45 +2,105 @@ import { Linking, Platform, Pressable, StyleSheet, Text, View } from 'react-nati import { router } from 'expo-router' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { CompatVerdict } from '../transport/protocol-compat' +import type { MobileWebBundleCompatVerdict } from '../transport/mobile-web-bundle-compat' const RELEASES_URL = 'https://github.com/stablyai/orca/releases' const IOS_APP_STORE_URL = 'itms-apps://apps.apple.com/app/orca-ide/id6766130217' +/** Every wall this screen renders: the protocol one and the bundle one. Both are terminal — there + * is no native workspace to fall back to, so the only way out is updating one of the two apps. */ +export type BlockedVerdict = + | Extract + | Extract + type Props = { - verdict: Extract + verdict: BlockedVerdict +} + +const DESKTOP_TOO_OLD_BODY = + 'This paired desktop app is too old for your current Orca Mobile app. Update Orca on your computer, then try this host again.' + +/** What clears the wall. `refresh-bundle` is the one that no store can: the cached workspace is + * older than this host's client floor, so a download fixes it and an app update does not. */ +type BlockRemedy = 'update-mobile' | 'update-desktop' | 'refresh-bundle' + +function blockRemedy(verdict: BlockedVerdict): BlockRemedy { + switch (verdict.reason) { + case 'mobile-too-old': + case 'bundle-shell-too-old': + return 'update-mobile' + case 'desktop-too-old': + case 'bundle-unavailable': + return 'update-desktop' + case 'bundle-incompatible': + return verdict.side === 'desktop' ? 'update-desktop' : 'refresh-bundle' + } +} + +function blockTitle(remedy: BlockRemedy): string { + switch (remedy) { + case 'update-mobile': + return 'Update Orca Mobile' + case 'update-desktop': + return 'Update Orca on your computer' + case 'refresh-bundle': + return 'Refresh the mobile workspace' + } +} + +function blockBody(verdict: BlockedVerdict, remedy: BlockRemedy, storeName: string): string { + if (remedy === 'refresh-bundle') { + return 'The workspace cached for this host is older than the desktop expects. Reconnect to this host to download the current one.' + } + if (verdict.reason === 'mobile-too-old') { + return `This desktop needs a newer Orca Mobile app. Update Orca Mobile from ${storeName}, then try this host again.` + } + if (verdict.reason === 'bundle-unavailable') { + return 'This paired desktop app does not include the mobile workspace yet. Update Orca on your computer, then try this host again.' + } + if (remedy === 'update-mobile') { + return `This desktop's mobile workspace needs a newer Orca Mobile app. Update Orca Mobile from ${storeName}, then try this host again.` + } + return DESKTOP_TOO_OLD_BODY } export function ProtocolBlockScreen({ verdict }: Props) { - const isMobileTooOld = verdict.reason === 'mobile-too-old' + const remedy = blockRemedy(verdict) // Why: Android APKs ship through GitHub Releases until a Play Store listing exists. const mobileUpdateTarget = Platform.OS === 'ios' ? { label: 'Open App Store', url: IOS_APP_STORE_URL, storeName: 'the App Store' } : { label: 'Open GitHub Releases', url: RELEASES_URL, storeName: 'GitHub Releases' } - const primaryAction = isMobileTooOld - ? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url } - : { label: 'Open GitHub Releases', url: RELEASES_URL } + // No download to offer when the fix is a refetch: reconnecting is what this screen leaves you to do. + const primaryAction = + remedy === 'refresh-bundle' + ? null + : remedy === 'update-mobile' + ? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url } + : { label: 'Open GitHub Releases', url: RELEASES_URL } - const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca on your computer' - const body = isMobileTooOld - ? `This desktop needs a newer Orca Mobile app. Update Orca Mobile from ${mobileUpdateTarget.storeName}, then try this host again.` - : 'This paired desktop app is too old for your current Orca Mobile app. Update Orca on your computer, then try this host again.' + const title = blockTitle(remedy) + const body = blockBody(verdict, remedy, mobileUpdateTarget.storeName) const recoveryNote = - 'Already updated? Go back to Hosts and refresh the connection. If this message stays, remove this host and pair it again.' + remedy === 'refresh-bundle' + ? 'If this message stays, remove this host and pair it again.' + : 'Already updated? Go back to Hosts and refresh the connection. If this message stays, remove this host and pair it again.' return ( {title} {body} - [styles.primaryButton, pressed && styles.pressed]} - onPress={() => { - void Linking.openURL(primaryAction.url) - }} - > - {primaryAction.label} - + {primaryAction ? ( + [styles.primaryButton, pressed && styles.pressed]} + onPress={() => { + void Linking.openURL(primaryAction.url) + }} + > + {primaryAction.label} + + ) : null} [styles.secondaryButton, pressed && styles.pressed]} onPress={() => { diff --git a/mobile/src/diagnostics/use-mobile-web-bundle-probe.test.tsx b/mobile/src/diagnostics/use-mobile-web-bundle-probe.test.tsx index e339d6125de..8113e09356e 100644 --- a/mobile/src/diagnostics/use-mobile-web-bundle-probe.test.tsx +++ b/mobile/src/diagnostics/use-mobile-web-bundle-probe.test.tsx @@ -105,6 +105,8 @@ function fetchedBundle(): MobileWebBundleFetchResult { manifest: { schemaVersion: 1, buildId: 'a'.repeat(64), + minCompatibleRuntimeProtocolVersion: 2, + runtimeProtocolVersion: 2, entrypoint: 'index.html', totalBytes: 3, assets: [ diff --git a/mobile/src/transport/mobile-web-bundle-compat.test.ts b/mobile/src/transport/mobile-web-bundle-compat.test.ts new file mode 100644 index 00000000000..639d41ef7db --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-compat.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import { MOBILE_WEB_BUNDLE_SCHEMA_VERSION } from '../../../src/shared/mobile-web-bundle/manifest-contract' +import { + evaluateMobileWebBundleCompat, + SUPPORTED_MOBILE_WEB_BUNDLE_SCHEMA_VERSIONS, + type MobileWebBundleCompatManifest, + type MobileWebBundleCompatVerdict, + type MobileWebBundleHostStatus +} from './mobile-web-bundle-compat' + +const CAPABLE: readonly string[] = ['browser.screencast.v1', MOBILE_WEB_BUNDLE_CAPABILITY] + +/** `HostStatusReply` keeps every member present and possibly undefined, so a host that answered + * neither version is this rather than `{}`. */ +const ANSWERED_NEITHER: MobileWebBundleHostStatus = { + protocolVersion: undefined, + minCompatibleMobileVersion: undefined +} + +function manifest( + overrides: Partial = {} +): MobileWebBundleCompatManifest { + return { + schemaVersion: 1, + runtimeProtocolVersion: 3, + minCompatibleRuntimeProtocolVersion: 2, + ...overrides + } +} + +function evaluate(input: { + hostCapabilities?: readonly string[] + hostStatus?: MobileWebBundleHostStatus + manifest?: MobileWebBundleCompatManifest | null +}): MobileWebBundleCompatVerdict { + return evaluateMobileWebBundleCompat({ + hostCapabilities: input.hostCapabilities ?? CAPABLE, + hostStatus: input.hostStatus ?? { protocolVersion: 3, minCompatibleMobileVersion: 2 }, + manifest: input.manifest === undefined ? manifest() : input.manifest + }) +} + +describe('evaluateMobileWebBundleCompat', () => { + it('opens a bundle whose window contains the host', () => { + expect(evaluate({})).toEqual({ kind: 'ok', manifestChecked: true }) + }) + + it('answers the capability question before a manifest exists', () => { + expect(evaluate({ manifest: null })).toEqual({ kind: 'ok', manifestChecked: false }) + expect(evaluate({ hostCapabilities: [], manifest: null })).toEqual({ + kind: 'blocked', + reason: 'bundle-unavailable' + }) + }) + + it('separates permission to fetch a manifest from permission to open one', () => { + // Why: both are `ok`, and a caller that mounted on the first would mount an unchecked bundle. + expect(evaluate({ manifest: null })).toEqual({ kind: 'ok', manifestChecked: false }) + expect(evaluate({})).toEqual({ kind: 'ok', manifestChecked: true }) + }) + + it('blocks a host that ships no bundle', () => { + expect(evaluate({ hostCapabilities: ['browser.screencast.v1'] })).toEqual({ + kind: 'blocked', + reason: 'bundle-unavailable' + }) + }) + + it('blocks a manifest schema this shell does not know', () => { + expect(evaluate({ manifest: manifest({ schemaVersion: 2 }) })).toMatchObject({ + kind: 'blocked', + reason: 'bundle-shell-too-old', + schemaVersion: 2 + }) + // A schema below the known one is just as unreadable as one above it. + expect(evaluate({ manifest: manifest({ schemaVersion: 0 }) })).toMatchObject({ + reason: 'bundle-shell-too-old', + schemaVersion: 0 + }) + }) + + it('blocks a host older than the bundle it serves', () => { + expect( + evaluate({ + hostStatus: { protocolVersion: 1, minCompatibleMobileVersion: 0 }, + manifest: manifest({ minCompatibleRuntimeProtocolVersion: 2 }) + }) + ).toEqual({ + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'desktop', + hostProtocolVersion: 1, + requiredHostProtocolVersion: 2 + }) + }) + + it('blocks a bundle older than the host expects', () => { + expect( + evaluate({ + hostStatus: { protocolVersion: 9, minCompatibleMobileVersion: 4 }, + manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 0 }) + }) + ).toEqual({ + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'mobile', + bundleRuntimeProtocolVersion: 3, + requiredBundleRuntimeProtocolVersion: 4 + }) + }) + + it('reports the missing capability first when the host also fails every later check', () => { + expect( + evaluate({ + hostCapabilities: [], + hostStatus: { protocolVersion: 0, minCompatibleMobileVersion: 99 }, + manifest: manifest({ schemaVersion: 7, minCompatibleRuntimeProtocolVersion: 5 }) + }) + ).toEqual({ kind: 'blocked', reason: 'bundle-unavailable' }) + }) + + it('reports an unknown schema before reading the protocol window inside it', () => { + expect( + evaluate({ + hostStatus: { protocolVersion: 0, minCompatibleMobileVersion: 99 }, + manifest: manifest({ schemaVersion: 2, minCompatibleRuntimeProtocolVersion: 5 }) + }) + ).toMatchObject({ reason: 'bundle-shell-too-old' }) + }) + + it('reports the desktop side before the mobile side when both windows miss', () => { + expect( + evaluate({ + hostStatus: { protocolVersion: 1, minCompatibleMobileVersion: 99 }, + manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 5 }) + }) + ).toMatchObject({ reason: 'bundle-incompatible', side: 'desktop' }) + }) + + it('treats an omitted host protocolVersion as the oldest host that could have answered', () => { + expect( + evaluate({ + hostStatus: ANSWERED_NEITHER, + manifest: manifest({ minCompatibleRuntimeProtocolVersion: 1 }) + }) + ).toEqual({ + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'desktop', + hostProtocolVersion: 0, + requiredHostProtocolVersion: 1 + }) + }) + + it('treats an omitted host minCompatibleMobileVersion as no floor at all', () => { + expect( + evaluate({ + hostStatus: ANSWERED_NEITHER, + manifest: manifest({ runtimeProtocolVersion: 0, minCompatibleRuntimeProtocolVersion: 0 }) + }) + ).toEqual({ kind: 'ok', manifestChecked: true }) + }) + + it('opens at the boundary of both windows, so equality is not a block', () => { + expect( + evaluate({ + hostStatus: { protocolVersion: 2, minCompatibleMobileVersion: 3 }, + manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 2 }) + }) + ).toEqual({ kind: 'ok', manifestChecked: true }) + // One below either boundary is the block the equality case sits next to. + expect( + evaluate({ + hostStatus: { protocolVersion: 1, minCompatibleMobileVersion: 3 }, + manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 2 }) + }) + ).toMatchObject({ reason: 'bundle-incompatible', side: 'desktop' }) + expect( + evaluate({ + hostStatus: { protocolVersion: 2, minCompatibleMobileVersion: 4 }, + manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 2 }) + }) + ).toMatchObject({ reason: 'bundle-incompatible', side: 'mobile' }) + }) + + it('supports the schema the desktop writes today, so a current bundle opens', () => { + // The only claim worth pinning: a contract bump this shell has not adopted becomes a wall. + expect(SUPPORTED_MOBILE_WEB_BUNDLE_SCHEMA_VERSIONS).toContain(MOBILE_WEB_BUNDLE_SCHEMA_VERSION) + }) +}) diff --git a/mobile/src/transport/mobile-web-bundle-compat.ts b/mobile/src/transport/mobile-web-bundle-compat.ts new file mode 100644 index 00000000000..b331540df66 --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-compat.ts @@ -0,0 +1,114 @@ +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import type { HostStatusReply } from './host-status-reply-schema' + +/** The manifest schemas this app shell can mount. Widening it is a shell release, so the list is + * stated here rather than read off the contract's current version: the contract names the schema + * the desktop writes, which is exactly the number this shell may not recognise. */ +export const SUPPORTED_MOBILE_WEB_BUNDLE_SCHEMA_VERSIONS = [1] as const + +/** Only the two `status.get` fields `host-status-gates.ts` already feeds `evaluateCompat`, taken + * from the reply type rather than restated: an upstream rename would otherwise leave a hand-copied + * shape behind and silently change every verdict through `?? 0` without failing a build. The two + * defaults point opposite ways, which is `evaluateCompat`'s own choice, not an accident here: an + * absent `protocolVersion` reads as the oldest host that could have answered, while an absent + * `minCompatibleMobileVersion` reads as no floor at all, so a host that states no floor does not + * get one invented for it. */ +export type MobileWebBundleHostStatus = Pick< + HostStatusReply, + 'protocolVersion' | 'minCompatibleMobileVersion' +> + +/** The manifest fields the wall reads. Null means no manifest has been read yet, which is still + * enough to answer the capability question. */ +export type MobileWebBundleCompatManifest = { + schemaVersion: number + runtimeProtocolVersion: number + minCompatibleRuntimeProtocolVersion: number +} + +export type MobileWebBundleCompatVerdict = + /** `manifestChecked` false means only the capability was answered; no manifest had been read + * yet, so this is permission to fetch one, not permission to open it. */ + | { kind: 'ok'; manifestChecked: boolean } + /** This desktop build ships no bundle at all. */ + | { kind: 'blocked'; reason: 'bundle-unavailable' } + /** The bundle is written in a manifest schema this shell does not know. */ + | { kind: 'blocked'; reason: 'bundle-shell-too-old'; schemaVersion: number } + /** The host is older than the bundle it is serving. */ + | { + kind: 'blocked' + reason: 'bundle-incompatible' + side: 'desktop' + hostProtocolVersion: number + requiredHostProtocolVersion: number + } + /** The bundle is older than the host expects; the caller refetches. */ + | { + kind: 'blocked' + reason: 'bundle-incompatible' + side: 'mobile' + bundleRuntimeProtocolVersion: number + requiredBundleRuntimeProtocolVersion: number + } + +function knowsSchemaVersion(schemaVersion: number): boolean { + return SUPPORTED_MOBILE_WEB_BUNDLE_SCHEMA_VERSIONS.some( + (supported) => supported === schemaVersion + ) +} + +/** + * Whether a mobile web bundle may be opened against the host that served it. + * + * Pure and terminal: every blocked verdict is a wall the user leaves by updating one of the two + * apps, never by falling back to a native workspace. Order matters — the capability answer comes + * first because a host without a bundle has no manifest to disagree about, and the schema answer + * comes before the protocol window because an unknown schema makes the numbers in it unreadable. + * + * Same `?? 0` defaults as `evaluateCompat`, and they are not symmetric. An absent + * `protocolVersion` is the oldest host that could have answered, so it never reads as permission. + * An absent `minCompatibleMobileVersion` is fail-open by design: a host that declares no floor for + * the bundle it serves does not get one guessed at, and the desktop-side check above is what still + * catches a host too old for that bundle. + */ +export function evaluateMobileWebBundleCompat(input: { + hostCapabilities: readonly string[] + hostStatus: MobileWebBundleHostStatus + manifest: MobileWebBundleCompatManifest | null +}): MobileWebBundleCompatVerdict { + if (!input.hostCapabilities.includes(MOBILE_WEB_BUNDLE_CAPABILITY)) { + return { kind: 'blocked', reason: 'bundle-unavailable' } + } + const { manifest } = input + if (manifest === null) { + return { kind: 'ok', manifestChecked: false } + } + if (!knowsSchemaVersion(manifest.schemaVersion)) { + return { + kind: 'blocked', + reason: 'bundle-shell-too-old', + schemaVersion: manifest.schemaVersion + } + } + const hostProtocolVersion = input.hostStatus.protocolVersion ?? 0 + if (hostProtocolVersion < manifest.minCompatibleRuntimeProtocolVersion) { + return { + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'desktop', + hostProtocolVersion, + requiredHostProtocolVersion: manifest.minCompatibleRuntimeProtocolVersion + } + } + const requiredBundleRuntimeProtocolVersion = input.hostStatus.minCompatibleMobileVersion ?? 0 + if (manifest.runtimeProtocolVersion < requiredBundleRuntimeProtocolVersion) { + return { + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'mobile', + bundleRuntimeProtocolVersion: manifest.runtimeProtocolVersion, + requiredBundleRuntimeProtocolVersion + } + } + return { kind: 'ok', manifestChecked: true } +} diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts index 3b80906d836..c80073ea511 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts @@ -8,11 +8,14 @@ import { MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES } from '../../../src/shared/mobile-web-bundle/manifest-contract' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import { evaluateMobileWebBundleCompat } from './mobile-web-bundle-compat' import { mobileWebBundleChunkRead, mobileWebBundleManifestRead, readMobileWebBundleErrorCode } from './mobile-web-bundle-operations' +import { MobileWebBundleManifestReplySchema } from './mobile-web-bundle-reply-schemas' import type { RpcReadResult } from './rpc-operation-contract' const BUILD_ID = 'a'.repeat(64) @@ -149,9 +152,36 @@ describe('mobile web bundle manifest reply reader', () => { ).toBe(false) }) - it('refuses a schemaVersion it does not know rather than guessing at the shape', () => { - expect(readManifest(manifestReply({ schemaVersion: 2 })).compatible).toBe(false) + it('reads an unknown schemaVersion through so the update wall can name it', () => { + // Refusing it here would fail the parse before `evaluateMobileWebBundleCompat` could say + // `bundle-shell-too-old`, leaving a transport error where the wall belongs. + expect(readManifest(manifestReply({ schemaVersion: 2 })).compatible).toBe(true) expect(readManifest(manifestReply({ schemaVersion: undefined })).compatible).toBe(false) + for (const schemaVersion of [1.5, 'one', null]) { + expect(readManifest(manifestReply({ schemaVersion })).compatible).toBe(false) + } + }) + + it('types the protocol window the update wall compares, without a cast at the call site', () => { + const parsed = MobileWebBundleManifestReplySchema.parse(manifestReply()) + // The pin is this call: `manifest` only assigns if the reader still types both window fields. + const verdict = evaluateMobileWebBundleCompat({ + hostCapabilities: [MOBILE_WEB_BUNDLE_CAPABILITY], + hostStatus: { protocolVersion: 2, minCompatibleMobileVersion: 2 }, + manifest: parsed.manifest + }) + + expect(verdict).toEqual({ kind: 'ok', manifestChecked: true }) + }) + + it('refuses a manifest with no protocol window, which only a host without the capability sends', () => { + expect(readManifest(manifestReply({ runtimeProtocolVersion: undefined })).compatible).toBe( + false + ) + expect( + readManifest(manifestReply({ minCompatibleRuntimeProtocolVersion: undefined })).compatible + ).toBe(false) + expect(readManifest(manifestReply({ runtimeProtocolVersion: -1 })).compatible).toBe(false) }) it('bounds every manifest field the fetch reads', () => { diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts index aa1ba5ec662..ca0787b7568 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts @@ -4,8 +4,7 @@ import { MobileWebBundleAssetPathSchema, MOBILE_WEB_BUNDLE_MAX_ASSETS, MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, - MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES, - MOBILE_WEB_BUNDLE_SCHEMA_VERSION + MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES } from '../../../src/shared/mobile-web-bundle/manifest-contract' // Hoisted, never built inside a reader: a schema constructed per parse cost 2275 ns against 156 ns @@ -32,15 +31,19 @@ const assetSchema = z.looseObject({ }) /** Everything the fetch reads: the id it caches under, the assets it pages, and the entry it will - * later load. `desktopVersion` and the protocol window pass through untyped — Phase B's update - * wall reads them, this phase does not. + * later load, plus the protocol window the update wall compares against the host. + * `desktopVersion` still passes through untyped; nothing reads it yet. * - * `schemaVersion` stays a literal because the manifest is closed in both directions: a bump is the - * only change path, and an unrecognised one is an unusable bundle to re-fetch, never a crash. */ + * `schemaVersion` is read as a number, not pinned to the one this shell knows: refusing it here + * would fail the parse before `evaluateMobileWebBundleCompat` could name the shell as too old, and + * an unreadable schema is a wall to show, not a shape to guess at. The manifest stays closed in + * both directions on the host's side, where it is written. */ const manifestSchema = z .looseObject({ - schemaVersion: z.literal(MOBILE_WEB_BUNDLE_SCHEMA_VERSION), + schemaVersion: z.number().int(), buildId: z.string().regex(SHA256_PATTERN), + minCompatibleRuntimeProtocolVersion: z.number().int().nonnegative(), + runtimeProtocolVersion: z.number().int().nonnegative(), entrypoint: MobileWebBundleAssetPathSchema, totalBytes: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES), assets: z.array(assetSchema).min(1).max(MOBILE_WEB_BUNDLE_MAX_ASSETS) From 002ff3ddb8da77765eb5bb1d6cd16245d74637ea Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:44:36 -0400 Subject: [PATCH 014/224] feat(mobile): per-host generation store for the mobile web bundle (OTA phase B, 2/4) (#21409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(mobile): export the mobile web manifest read schema The generation store re-parses the manifest it cached, and it must read it back with the same loose reader the fetch accepted it under: parsing strictly after accepting loosely would turn a host's added field into a forced redownload on every launch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): add the per-host mobile web generation store Turns a verified bundle into an atomically activated, host-scoped generation directory under the OS cache, and reads it back. No RPC, no UI, no flag: the native view is later handed the directory read-only and never writes to it. The single directory under `generations/` is the activation, so there is no activation file to edit: a commit deletes every other generation before the rename, an interrupted one leaves zero generations for the runbook's redownload rule, and two directories or an unreadable manifest drop the host tree instead of guessing. `tmp/` is never an activation candidate and every one of them goes at launch. `hosts.json` carries recency only, so losing it costs eviction order rather than a generation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): never evict the host a commit just activated `now()` is a wall clock. With four hosts cached, one backward jump made the fifth commit's own entry the oldest, so it evicted the host it had just activated and handed back an ActiveGeneration whose directory was gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * Revert "fix(mobile): never evict the host a commit just activated" This reverts commit a082ac1777. That commit carried all six round-1 fixes under a subject naming only one of them; the six land again below, one per commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the generation store's unused eviction entry point `evictHostsBeyond` had no caller: commit enforces the four-host ceiling itself, and a launch-time sweep for a shrunk limit can be added when something shrinks it. The two `createDirectory` calls went with it, since the port already creates intermediates, plus a line on what the Android rename fallback leaves behind. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): never evict the host a commit just activated `now()` is a wall clock. With four hosts cached, one backward jump made the fifth commit's own entry the oldest, so it evicted the host it had just activated and handed back an ActiveGeneration whose directory was gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fold case when refusing an asset named like the manifest APFS and NTFS are case-insensitive by default, so `Manifest.JSON` landed on the store's own `manifest.json` and the activation read back as the asset's bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a completed activation when the recency index cannot be written `hosts.json` is written after the rename, so a disk that filled between the two turned a generation already on disk into a thrown commit. The index carries recency, not truth, and the next activation rewrites it whole. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse to commit a staged handle whose tree is gone Commit deleted every other generation before it looked at the staged tree, so committing an aborted or swept handle destroyed the live generation and only then threw. The check moves ahead of the first delete. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close four surviving generation-store mutants Sweeping only the first host's tmp, staging over residue, dropping the serial queue, and dropping the stale-index pruning all passed the suite. The stage race needed two differing asset lists under one build id to be visible at all: with identical ones an interleaved pair ends on the same bytes as a serial one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): activate only a build-id entry that holds a manifest An entry under `generations/` matching the staged build id was taken as the activation on its name alone, so an empty directory of that name — what a crash between the rename and the post-rename check leaves on Android under API 26 — or a plain file made the commit drop the verified staged tree and return a generation that cannot be read back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): never delete a cache because a manifest read failed The adapter mapped every `file.text()` throw to null and the reader treated null as corruption, so one iOS data-protection or I/O blip deleted the only verified generation a host had. Missing stays null and still drops the tree; a failed read now throws, and the reader returns no activation without touching disk, leaving the caller to redownload. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the generation store's unreachable build-id guard `MobileWebBundleManifestReadSchema` already pins `buildId` to the sha256 pattern, so no manifest reaching the store can fail the second check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that eviction ignores a non-host directory Dropping the host-key filter in `listHostDirectories` passed the whole suite; the ceiling would then count and evict anything else under the OS cache directory. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a mid-download host out of the cache ceiling A host holding only a staging tree was counted against the four-host limit and, having no index entry, sorted first for eviction, so four cached hosts plus one download meant the next activation deleted the tree that download was about to commit. The ceiling now counts hosts with a generation; sweeping still walks every host directory. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): record recency when a commit finds the build already active The same-build early return skipped the index write, so a host that redownloaded the bundle it already had stayed the least recently activated and was the first evicted. No eviction pass on that path: the host count is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): honour only staged handles the store itself issued `StagedGeneration` is structurally typed, so any object of that shape made `commitGeneration` rename over, and `abortStagedGeneration` delete, a directory of the caller's choosing. Handles are tracked in a per-store `WeakSet` and anything else is refused before a filesystem call. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../generation-store-file-system.ts | 86 +++ .../mobile-web-shell/generation-store.test.ts | 633 ++++++++++++++++++ .../src/mobile-web-shell/generation-store.ts | 339 ++++++++++ mobile/src/mobile-web-shell/host-cache-key.ts | 19 + .../mobile-web-bundle-reply-schemas.ts | 10 +- 5 files changed, 1084 insertions(+), 3 deletions(-) create mode 100644 mobile/src/mobile-web-shell/generation-store-file-system.ts create mode 100644 mobile/src/mobile-web-shell/generation-store.test.ts create mode 100644 mobile/src/mobile-web-shell/generation-store.ts create mode 100644 mobile/src/mobile-web-shell/host-cache-key.ts diff --git a/mobile/src/mobile-web-shell/generation-store-file-system.ts b/mobile/src/mobile-web-shell/generation-store-file-system.ts new file mode 100644 index 00000000000..4c5718fabca --- /dev/null +++ b/mobile/src/mobile-web-shell/generation-store-file-system.ts @@ -0,0 +1,86 @@ +import { Directory, File, Paths } from 'expo-file-system' + +/** Root of the whole mobile-web cache, one level under the OS cache directory. */ +export const MOBILE_WEB_CACHE_DIRECTORY_NAME = 'mobile-web' + +export type GenerationDirectoryEntry = { + readonly name: string + readonly isDirectory: boolean +} + +/** + * Everything the generation store does to disk, as plain `file://` uris. + * + * The store never imports `expo-file-system`, so its tests run the real write ordering, failure and + * interruption paths against an in-memory tree instead of a simulator. + */ +export type GenerationFileSystem = { + readonly rootUri: string + /** Empty when the directory is missing, so a first run is not a special case. */ + list(uri: string): Promise + /** Creates intermediate directories and succeeds when the directory already exists. */ + createDirectory(uri: string): Promise + /** Both writes create intermediate directories. */ + writeBytes(uri: string, bytes: Uint8Array): Promise + writeText(uri: string, text: string): Promise + /** Null only when the file is missing. A read that fails throws, because "absent" and "could not + * be read" lead the store to opposite decisions about deleting the cache. */ + readText(uri: string): Promise + fileExists(uri: string): Promise + /** Recursive, and a no-op when the path is missing. */ + delete(uri: string): Promise + /** Renames a directory. The destination must not exist: expo moves a directory *into* an existing + * destination rather than over it. */ + moveDirectory(fromUri: string, toUri: string): Promise +} + +export function createExpoGenerationFileSystem(): GenerationFileSystem { + return { + rootUri: new Directory(Paths.cache, MOBILE_WEB_CACHE_DIRECTORY_NAME).uri, + async list(uri) { + const directory = new Directory(uri) + if (!directory.exists) { + return [] + } + return directory + .list() + .map((entry) => ({ name: entry.name, isDirectory: entry instanceof Directory })) + }, + async createDirectory(uri) { + new Directory(uri).create({ intermediates: true, idempotent: true }) + }, + async writeBytes(uri, bytes) { + const file = new File(uri) + file.create({ intermediates: true, overwrite: true }) + file.write(bytes) + }, + async writeText(uri, text) { + const file = new File(uri) + file.create({ intermediates: true, overwrite: true }) + file.write(text) + }, + async readText(uri) { + const file = new File(uri) + // The throw is deliberate: iOS data protection and I/O errors reach the store as failures + // rather than as a missing file. + return file.exists ? await file.text() : null + }, + async fileExists(uri) { + return new File(uri).exists + }, + async delete(uri) { + const directory = new Directory(uri) + if (directory.exists) { + directory.delete() + return + } + const file = new File(uri) + if (file.exists) { + file.delete() + } + }, + async moveDirectory(fromUri, toUri) { + new Directory(fromUri).move(new Directory(toUri)) + } + } +} diff --git a/mobile/src/mobile-web-shell/generation-store.test.ts b/mobile/src/mobile-web-shell/generation-store.test.ts new file mode 100644 index 00000000000..e258998550d --- /dev/null +++ b/mobile/src/mobile-web-shell/generation-store.test.ts @@ -0,0 +1,633 @@ +import { describe, expect, it } from 'vitest' +import { createGenerationStore, MAX_CACHED_HOSTS } from './generation-store' +import { deriveHostCacheKey } from './host-cache-key' +import type { + createExpoGenerationFileSystem, + GenerationFileSystem +} from './generation-store-file-system' +import type { MobileWebBundleFetchResult } from '../transport/mobile-web-bundle-fetch' + +// The adapter is deliberately untested at runtime — it would need a device filesystem — so this is +// the check that it still answers the port the store is written against. +type AdapterIsPort = + ReturnType extends GenerationFileSystem ? true : false +const adapterSatisfiesPort: AdapterIsPort = true + +const ROOT = 'file:///cache/mobile-web' +const HOST = deriveHostCacheKey('host-a') + +type FakeNode = { kind: 'directory' } | { kind: 'file'; bytes: Uint8Array } + +type FakeFileSystem = GenerationFileSystem & { + readonly writes: string[] + paths(): readonly string[] + seed(path: string, node: FakeNode): void + failWritesAt(path: string | null): void + failReadsAt(path: string | null): void + loseContentsOnMove(): void + text(path: string): string | null +} + +function createFakeFileSystem(): FakeFileSystem { + const nodes = new Map() + const writes: string[] = [] + let failAt: string | null = null + let failReadAt: string | null = null + let moveKeepsContents = true + const uri = (path: string): string => `${ROOT}/${path}` + const parentOf = (target: string): string => target.slice(0, target.lastIndexOf('/')) + + const makeDirectory = (target: string): void => { + for (let at = target; at.startsWith(ROOT); at = parentOf(at)) { + nodes.set(at, { kind: 'directory' }) + } + } + const write = (target: string, bytes: Uint8Array): void => { + if (failAt !== null && target === uri(failAt)) { + throw new Error('simulated disk-full write') + } + makeDirectory(parentOf(target)) + nodes.set(target, { kind: 'file', bytes }) + writes.push(target.slice(ROOT.length + 1)) + } + + return { + rootUri: ROOT, + writes, + paths: () => + [...nodes.keys()] + .filter((key) => key !== ROOT) + .map((key) => key.slice(ROOT.length + 1)) + .sort(), + seed: (path, node) => { + makeDirectory(parentOf(uri(path))) + nodes.set(uri(path), node) + }, + failWritesAt: (path) => { + failAt = path + }, + failReadsAt: (path) => { + failReadAt = path + }, + loseContentsOnMove: () => { + moveKeepsContents = false + }, + text: (path) => { + const node = nodes.get(uri(path)) + return node?.kind === 'file' ? new TextDecoder().decode(node.bytes) : null + }, + async list(target) { + if (nodes.get(target)?.kind !== 'directory') { + return [] + } + return [...nodes.entries()] + .filter( + ([key]) => key.startsWith(`${target}/`) && !key.slice(target.length + 1).includes('/') + ) + .map(([key, node]) => ({ + name: key.slice(target.length + 1), + isDirectory: node.kind === 'directory' + })) + }, + async createDirectory(target) { + makeDirectory(target) + }, + async writeBytes(target, bytes) { + write(target, bytes) + }, + async writeText(target, value) { + write(target, new TextEncoder().encode(value)) + }, + async readText(target) { + if (failReadAt !== null && target === uri(failReadAt)) { + throw new Error('simulated unreadable file') + } + const node = nodes.get(target) + return node?.kind === 'file' ? new TextDecoder().decode(node.bytes) : null + }, + async fileExists(target) { + return nodes.get(target)?.kind === 'file' + }, + async delete(target) { + for (const key of Array.from(nodes.keys())) { + if (key === target || key.startsWith(`${target}/`)) { + nodes.delete(key) + } + } + }, + async moveDirectory(fromUri, toUri) { + if (nodes.has(toUri)) { + throw new Error(`fake filesystem refuses to move onto ${toUri}`) + } + for (const [key, node] of Array.from(nodes.entries())) { + if (key === fromUri || key.startsWith(`${fromUri}/`)) { + nodes.delete(key) + if (moveKeepsContents || key === fromUri) { + nodes.set(toUri + key.slice(fromUri.length), node) + } + } + } + } + } +} + +function buildResult(options: { + buildId?: string + assets?: readonly { path: string; byteLength: number }[] + bytes?: ReadonlyMap +}): MobileWebBundleFetchResult { + const listed = options.assets ?? [ + { path: 'index.html', byteLength: 4 }, + { path: 'assets/app.js', byteLength: 2 } + ] + const assets = listed.map((asset, index) => ({ + path: asset.path, + sha256: String(index).repeat(64).slice(0, 64), + byteLength: asset.byteLength, + contentType: 'text/html; charset=utf-8' + })) + const totalBytes = assets.reduce((sum, asset) => sum + asset.byteLength, 0) + return { + manifest: { + schemaVersion: 1, + buildId: options.buildId ?? 'a'.repeat(64), + minCompatibleRuntimeProtocolVersion: 2, + runtimeProtocolVersion: 2, + entrypoint: 'index.html', + totalBytes, + assets + }, + assets: + options.bytes ?? + new Map(assets.map((asset) => [asset.path, new Uint8Array(asset.byteLength).fill(7)])), + totalBytes, + elapsedMs: 1 + } +} + +async function activate( + store: ReturnType, + hostKey: string, + result = buildResult({}) +): Promise { + await store.commitGeneration(await store.stageGeneration(hostKey, result)) +} + +describe('generation store', () => { + it('stages and commits exactly the manifest, with the manifest written last', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs, now: () => 10 }) + + await activate(store, HOST) + + const build = 'a'.repeat(64) + expect(fs.paths()).toEqual([ + HOST, + `${HOST}/generations`, + `${HOST}/generations/${build}`, + `${HOST}/generations/${build}/assets`, + `${HOST}/generations/${build}/assets/app.js`, + `${HOST}/generations/${build}/index.html`, + `${HOST}/generations/${build}/manifest.json`, + `${HOST}/tmp`, + 'hosts.json' + ]) + const staged = fs.writes.filter((path) => path.includes('/tmp/')) + expect(staged.at(-1)).toBe(`${HOST}/tmp/${build}/manifest.json`) + expect(staged).toHaveLength(3) + expect(fs.text('hosts.json')).toBe(JSON.stringify({ [HOST]: 10 })) + }) + + it('reads back the activation it committed', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + + await activate(store, HOST) + const active = await store.readActiveGeneration(HOST) + + expect(active?.buildId).toBe('a'.repeat(64)) + expect(active?.directory).toBe(`${ROOT}/${HOST}/generations/${'a'.repeat(64)}`) + expect(active?.manifest.entrypoint).toBe('index.html') + expect(await store.readActiveGeneration(deriveHostCacheKey('never-opened'))).toBeNull() + }) + + it('refuses an asset that is missing or the wrong length, leaving no generation', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + const missing = buildResult({ bytes: new Map([['index.html', new Uint8Array(4)]]) }) + const short = buildResult({ + bytes: new Map([ + ['index.html', new Uint8Array(4)], + ['assets/app.js', new Uint8Array(1)] + ]) + }) + + await expect(store.stageGeneration(HOST, missing)).rejects.toThrow('assets/app.js is absent') + await expect(store.stageGeneration(HOST, short)).rejects.toThrow("not the manifest's 2") + expect(fs.paths()).toEqual([]) + expect(await store.readActiveGeneration(HOST)).toBeNull() + }) + + it('drops the staged tree when a write fails', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + fs.failWritesAt(`${HOST}/tmp/${'a'.repeat(64)}/assets/app.js`) + + await expect(store.stageGeneration(HOST, buildResult({}))).rejects.toThrow('disk-full') + + expect(fs.paths().some((path) => path.includes(`tmp/${'a'.repeat(64)}`))).toBe(false) + expect(await store.readActiveGeneration(HOST)).toBeNull() + }) + + it('leaves no generation and no tmp for any host when a download is interrupted', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + const other = deriveHostCacheKey('host-b') + + await store.stageGeneration(HOST, buildResult({})) + await store.stageGeneration(other, buildResult({})) + await store.sweepStagedGenerations() + + expect(fs.paths().some((path) => path.includes('/tmp'))).toBe(false) + expect(await store.readActiveGeneration(HOST)).toBeNull() + expect(await store.readActiveGeneration(other)).toBeNull() + }) + + it('treats a second commit of the same build as a no-op', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs, now: () => 10 }) + + await activate(store, HOST) + const before = fs.paths() + const staged = await store.stageGeneration(HOST, buildResult({})) + const active = await store.commitGeneration(staged) + + expect(active.buildId).toBe('a'.repeat(64)) + expect(fs.paths()).toEqual(before) + }) + + it('replaces the previous generation when the build id changes', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + + await activate(store, HOST) + await activate(store, HOST, buildResult({ buildId: 'b'.repeat(64) })) + + expect(fs.paths().some((path) => path.includes('a'.repeat(64)))).toBe(false) + expect((await store.readActiveGeneration(HOST))?.buildId).toBe('b'.repeat(64)) + }) + + it('reads two generations as no activation and drops the host tree', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + await activate(store, HOST) + fs.seed(`${HOST}/generations/${'c'.repeat(64)}/manifest.json`, { + kind: 'file', + bytes: new TextEncoder().encode('{}') + }) + + expect(await store.readActiveGeneration(HOST)).toBeNull() + expect(fs.paths().some((path) => path.startsWith(HOST))).toBe(false) + }) + + it('reads an unparseable or mismatched manifest as no activation and drops the host tree', async () => { + for (const body of [ + 'not json', + JSON.stringify({ ...buildResult({}).manifest, buildId: 'd'.repeat(64) }) + ]) { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + await activate(store, HOST) + fs.seed(`${HOST}/generations/${'a'.repeat(64)}/manifest.json`, { + kind: 'file', + bytes: new TextEncoder().encode(body) + }) + + expect(await store.readActiveGeneration(HOST)).toBeNull() + expect(fs.paths().some((path) => path.startsWith(HOST))).toBe(false) + } + }) + + it('evicts the least recently activated host past the ceiling', async () => { + const fs = createFakeFileSystem() + let clock = 0 + const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) + const hosts = ['a', 'b', 'c', 'd', 'e'].map((name) => deriveHostCacheKey(name)) + + for (const host of hosts) { + await activate(store, host) + } + + expect(await store.readActiveGeneration(hosts[0])).toBeNull() + expect(fs.paths().some((path) => path.startsWith(hosts[0]))).toBe(false) + for (const host of hosts.slice(1)) { + expect((await store.readActiveGeneration(host))?.buildId).toBe('a'.repeat(64)) + } + expect(Object.keys(JSON.parse(fs.text('hosts.json') ?? '{}'))).toHaveLength(MAX_CACHED_HOSTS) + }) + + it('evicts a host with no index entry before the least recently activated one', async () => { + const fs = createFakeFileSystem() + let clock = 0 + const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) + const oldest = deriveHostCacheKey('a') + const orphan = deriveHostCacheKey('orphan') + for (const name of ['a', 'b', 'c']) { + await activate(store, deriveHostCacheKey(name)) + } + // Activated last, so recency alone would keep it; its index entry is what goes missing. + await activate(store, orphan) + const index: Record = JSON.parse(fs.text('hosts.json') ?? '{}') + delete index[orphan] + fs.seed('hosts.json', { kind: 'file', bytes: new TextEncoder().encode(JSON.stringify(index)) }) + + await activate(store, deriveHostCacheKey('d')) + + expect(fs.paths().some((path) => path.startsWith(orphan))).toBe(false) + expect((await store.readActiveGeneration(oldest))?.buildId).toBe('a'.repeat(64)) + }) + + it('counts a recommit of the build a host already has as use of that host', async () => { + const fs = createFakeFileSystem() + let clock = 0 + const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) + const kept = deriveHostCacheKey('a') + const evicted = deriveHostCacheKey('b') + for (const name of ['a', 'b', 'c', 'd']) { + await activate(store, deriveHostCacheKey(name)) + } + // A redownload of the bundle host A already has, which takes the same-build commit path. + await activate(store, kept) + + await activate(store, deriveHostCacheKey('e')) + + expect(fs.paths().some((path) => path.startsWith(evicted))).toBe(false) + expect((await store.readActiveGeneration(kept))?.buildId).toBe('a'.repeat(64)) + }) + + it('never counts or evicts a host that is only mid-download', async () => { + const fs = createFakeFileSystem() + let clock = 0 + const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) + const oldest = deriveHostCacheKey('a') + const downloading = deriveHostCacheKey('downloading') + for (const name of ['a', 'b', 'c', 'd']) { + await activate(store, deriveHostCacheKey(name)) + } + const staged = await store.stageGeneration(downloading, buildResult({})) + + await activate(store, deriveHostCacheKey('e')) + + // The ceiling is four cached generations, so the fifth activation evicts the least recently + // activated host and leaves the download alone. + expect(fs.paths().some((path) => path.startsWith(oldest))).toBe(false) + expect(fs.text(`${staged.directory.slice(ROOT.length + 1)}/manifest.json`)).not.toBeNull() + await store.commitGeneration(staged) + expect((await store.readActiveGeneration(downloading))?.buildId).toBe('a'.repeat(64)) + }) + + it('serializes two stage calls for one host and build', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + // One build id cannot really carry two asset lists; differing ones are what make an interleaved + // pair visible, because unserialized both of them land in the one staged directory. + const staging = `${HOST}/tmp/${'a'.repeat(64)}` + const earlier = buildResult({ assets: [{ path: 'assets/earlier.js', byteLength: 2 }] }) + const later = buildResult({ assets: [{ path: 'assets/later.js', byteLength: 3 }] }) + + const [first, second] = await Promise.all([ + store.stageGeneration(HOST, earlier), + store.stageGeneration(HOST, later) + ]) + + expect(first.directory).toBe(second.directory) + // Each staging is a contiguous run ending in its manifest; interleaved they would alternate. + expect(fs.writes).toEqual([ + `${staging}/assets/earlier.js`, + `${staging}/manifest.json`, + `${staging}/assets/later.js`, + `${staging}/manifest.json` + ]) + expect(fs.paths().filter((path) => path.startsWith(`${staging}/assets/`))).toEqual([ + `${staging}/assets/later.js` + ]) + }) + + it('drops residue from an earlier attempt instead of staging over it', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + const staging = `${HOST}/tmp/${'a'.repeat(64)}` + fs.seed(`${staging}/assets/orphan.js`, { kind: 'file', bytes: new Uint8Array(1) }) + + await store.stageGeneration(HOST, buildResult({})) + + expect(fs.paths().some((path) => path.endsWith('orphan.js'))).toBe(false) + }) + + it('refuses a path that escapes the staged tree, and a host key that is not one', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + const escapes = [ + '../outside.js', + 'assets/../../outside.js', + '/etc/passwd', + 'assets//app.js', + 'manifest.json', + 'Manifest.JSON' + ] + + for (const path of escapes) { + const result = buildResult({ assets: [{ path, byteLength: 1 }] }) + await expect(store.stageGeneration(HOST, result)).rejects.toThrow('refuses to stage') + } + await expect(store.stageGeneration('host-a', buildResult({}))).rejects.toThrow( + 'not a host cache key' + ) + expect(fs.paths()).toEqual([]) + }) + + it('deletes one host tree without touching another', async () => { + const fs = createFakeFileSystem() + const other = deriveHostCacheKey('host-b') + const store = createGenerationStore({ fileSystem: fs }) + await activate(store, HOST) + await activate(store, other) + + await store.deleteHostCache(HOST) + + expect(await store.readActiveGeneration(HOST)).toBeNull() + expect((await store.readActiveGeneration(other))?.buildId).toBe('a'.repeat(64)) + expect(Object.keys(JSON.parse(fs.text('hosts.json') ?? '{}'))).toEqual([other]) + }) + + it('refuses a rename that did not carry the tree, as Android below API 26 can', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + fs.loseContentsOnMove() + + const staged = await store.stageGeneration(HOST, buildResult({})) + await expect(store.commitGeneration(staged)).rejects.toThrow('did not carry its manifest') + + expect(await store.readActiveGeneration(HOST)).toBeNull() + expect(fs.paths().some((path) => path.includes('generations/'))).toBe(false) + }) + + it('keeps the host tree when the manifest read fails, and drops it when it is missing', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + const manifest = `${HOST}/generations/${'a'.repeat(64)}/manifest.json` + await activate(store, HOST) + const before = fs.paths() + + fs.failReadsAt(manifest) + expect(await store.readActiveGeneration(HOST)).toBeNull() + expect(fs.paths()).toEqual(before) + + fs.failReadsAt(null) + await fs.delete(`${ROOT}/${manifest}`) + expect(await store.readActiveGeneration(HOST)).toBeNull() + expect(fs.paths().some((path) => path.startsWith(HOST))).toBe(false) + }) + + it('activates normally when the recency index cannot be read', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs, now: () => 10 }) + fs.seed('hosts.json', { kind: 'file', bytes: new TextEncoder().encode('{}') }) + fs.failReadsAt('hosts.json') + + await activate(store, HOST) + + expect((await store.readActiveGeneration(HOST))?.buildId).toBe('a'.repeat(64)) + }) + + it('replaces an entry named for the build id that is not a readable generation', async () => { + const build = 'a'.repeat(64) + // Exactly what a crash between the rename and the post-rename check can leave behind. + for (const seeded of [ + { kind: 'directory' }, + { kind: 'file', bytes: new Uint8Array(1) } + ] as const) { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + fs.seed(`${HOST}/generations/${build}`, seeded) + + await activate(store, HOST) + + expect((await store.readActiveGeneration(HOST))?.buildId).toBe(build) + expect(fs.text(`${HOST}/generations/${build}/index.html`)).not.toBeNull() + } + }) + + it('drops an aborted staging without touching the activation', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + await activate(store, HOST) + + const staged = await store.stageGeneration(HOST, buildResult({ buildId: 'b'.repeat(64) })) + await store.abortStagedGeneration(staged) + + expect(fs.paths().some((path) => path.includes('b'.repeat(64)))).toBe(false) + expect((await store.readActiveGeneration(HOST))?.buildId).toBe('a'.repeat(64)) + }) + + it('keeps the host it just activated when the clock jumps backward', async () => { + const fs = createFakeFileSystem() + const times = [100, 200, 300, 400, 1] + let tick = 0 + const store = createGenerationStore({ fileSystem: fs, now: () => times[tick++] ?? 0 }) + const hosts = ['a', 'b', 'c', 'd', 'e'].map((name) => deriveHostCacheKey(name)) + + for (const host of hosts) { + await activate(store, host) + } + + expect((await store.readActiveGeneration(hosts[4]))?.directory).toBe( + `${ROOT}/${hosts[4]}/generations/${'a'.repeat(64)}` + ) + expect(await store.readActiveGeneration(hosts[0])).toBeNull() + for (const host of hosts.slice(1)) { + expect((await store.readActiveGeneration(host))?.buildId).toBe('a'.repeat(64)) + } + }) + + it('returns the activation even when the recency index cannot be written', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + fs.failWritesAt('hosts.json') + + const staged = await store.stageGeneration(HOST, buildResult({})) + const active = await store.commitGeneration(staged) + + expect(active.buildId).toBe('a'.repeat(64)) + expect((await store.readActiveGeneration(HOST))?.buildId).toBe('a'.repeat(64)) + expect(fs.text('hosts.json')).toBeNull() + }) + + it('refuses a handle whose staged tree is gone without touching the activation', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + await activate(store, HOST) + + const staged = await store.stageGeneration(HOST, buildResult({ buildId: 'b'.repeat(64) })) + await store.abortStagedGeneration(staged) + + await expect(store.commitGeneration(staged)).rejects.toThrow('no longer on disk') + expect((await store.readActiveGeneration(HOST))?.buildId).toBe('a'.repeat(64)) + }) + + it('prunes an index entry whose host tree is gone', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs, now: () => 10 }) + const stale = deriveHostCacheKey('uninstalled') + fs.seed('hosts.json', { + kind: 'file', + bytes: new TextEncoder().encode(JSON.stringify({ [stale]: 5 })) + }) + + await activate(store, HOST) + + expect(fs.text('hosts.json')).toBe(JSON.stringify({ [HOST]: 10 })) + }) + + it('refuses a staged handle it did not issue', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + await activate(store, HOST) + const before = fs.paths() + const forged = { + hostKey: HOST, + buildId: 'b'.repeat(64), + // Aimed at the live generation, which commit would rename over and abort would delete. + directory: `${ROOT}/${HOST}/generations/${'a'.repeat(64)}`, + manifest: buildResult({}).manifest + } + + await expect(store.commitGeneration(forged)).rejects.toThrow('did not issue') + await expect(store.abortStagedGeneration(forged)).rejects.toThrow('did not issue') + expect(fs.paths()).toEqual(before) + expect((await store.readActiveGeneration(HOST))?.buildId).toBe('a'.repeat(64)) + }) + + it('ignores a directory under the cache root that is not a host key', async () => { + const fs = createFakeFileSystem() + let clock = 0 + const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) + // Whatever else lives under the OS cache directory is not this store's to count or delete. + fs.seed('not-a-host-key/stray.txt', { kind: 'file', bytes: new Uint8Array(1) }) + const hosts = ['a', 'b', 'c', 'd'].map((name) => deriveHostCacheKey(name)) + + for (const host of hosts) { + await activate(store, host) + } + await store.sweepStagedGenerations() + + expect(fs.paths()).toContain('not-a-host-key/stray.txt') + for (const host of hosts) { + expect((await store.readActiveGeneration(host))?.buildId).toBe('a'.repeat(64)) + } + }) + + it('keeps the adapter aligned with the port', () => { + expect(adapterSatisfiesPort).toBe(true) + }) +}) diff --git a/mobile/src/mobile-web-shell/generation-store.ts b/mobile/src/mobile-web-shell/generation-store.ts new file mode 100644 index 00000000000..157c4cc17ac --- /dev/null +++ b/mobile/src/mobile-web-shell/generation-store.ts @@ -0,0 +1,339 @@ +import { z } from 'zod' +import { + MobileWebBundleManifestReadSchema, + type MobileWebBundleManifestRead +} from '../transport/mobile-web-bundle-reply-schemas' +import type { MobileWebBundleFetchResult } from '../transport/mobile-web-bundle-fetch' +import type { GenerationDirectoryEntry, GenerationFileSystem } from './generation-store-file-system' +import { isHostCacheKey } from './host-cache-key' + +const GENERATIONS_DIRECTORY_NAME = 'generations' +const STAGING_DIRECTORY_NAME = 'tmp' +const MANIFEST_FILE_NAME = 'manifest.json' +const HOST_INDEX_FILE_NAME = 'hosts.json' + +/** The architecture reference's cache ceiling: four hosts, least recently activated evicted. */ +export const MAX_CACHED_HOSTS = 4 + +export type ActiveGeneration = { + readonly buildId: string + /** Read-only input for the native view; nothing but this store writes under it. */ + readonly directory: string + readonly manifest: MobileWebBundleManifestRead +} + +export type StagedGeneration = { + readonly hostKey: string + readonly buildId: string + readonly directory: string + readonly manifest: MobileWebBundleManifestRead +} + +export type GenerationStore = { + readActiveGeneration(hostKey: string): Promise + stageGeneration(hostKey: string, result: MobileWebBundleFetchResult): Promise + commitGeneration(staged: StagedGeneration): Promise + abortStagedGeneration(staged: StagedGeneration): Promise + sweepStagedGenerations(): Promise + deleteHostCache(hostKey: string): Promise +} + +/** Recency only, so anything unreadable degrades to "evict this host first". */ +const HostIndexSchema = z.record(z.string(), z.number().int().nonnegative()) + +export function createGenerationStore(options: { + fileSystem: GenerationFileSystem + now?: () => number +}): GenerationStore { + const fs = options.fileSystem + const now = options.now ?? Date.now + // `StagedGeneration` is structurally typed, so any object of that shape would otherwise let + // `commitGeneration` rename over, and `abortStagedGeneration` delete, a directory of the caller's + // choosing. Only handles this store minted are honoured. + const issuedHandles = new WeakSet() + + const hostRoot = (hostKey: string): string => joinUri(fs.rootUri, requireHostKey(hostKey)) + const generationsRoot = (hostKey: string): string => + joinUri(hostRoot(hostKey), GENERATIONS_DIRECTORY_NAME) + const stagingRoot = (hostKey: string): string => + joinUri(hostRoot(hostKey), STAGING_DIRECTORY_NAME) + + async function readHostIndex(): Promise> { + // Unreadable is treated as absent here, unlike a manifest: an index nobody can read costs + // eviction order, and the next activation rewrites it whole. + const text = await fs.readText(joinUri(fs.rootUri, HOST_INDEX_FILE_NAME)).catch(() => null) + const parsed = text === null ? null : HostIndexSchema.safeParse(parseJson(text)) + return new Map(Object.entries(parsed?.success === true ? parsed.data : {})) + } + + async function writeHostIndex(index: ReadonlyMap): Promise { + // Recency, not truth: a full disk here must not turn an activation that is already on disk + // into a thrown commit, and the next activation rewrites the whole index anyway. + await fs + .writeText( + joinUri(fs.rootUri, HOST_INDEX_FILE_NAME), + JSON.stringify(Object.fromEntries(index)) + ) + .catch(() => undefined) + } + + async function listHostDirectories(): Promise { + const entries = await fs.list(fs.rootUri) + return entries.filter((entry) => entry.isDirectory && isHostCacheKey(entry.name)) + } + + /** The ceiling counts cached generations, so a host that only holds a download in progress is + * neither counted nor evictable: evicting it would delete the tree its own commit is about to + * rename. Sweeping still walks every host directory, staged-only ones included. */ + async function listActivatedHosts(): Promise { + const activated: string[] = [] + for (const host of await listHostDirectories()) { + const generations = await fs.list(joinUri(fs.rootUri, host.name, GENERATIONS_DIRECTORY_NAME)) + if (generations.some((entry) => entry.isDirectory)) { + activated.push(host.name) + } + } + return activated + } + + async function dropHostTree(hostKey: string): Promise { + await fs.delete(hostRoot(hostKey)) + } + + async function enforceHostLimit(index: Map, activated: string): Promise { + const hosts = await listActivatedHosts() + const present = new Set(hosts) + for (const key of Array.from(index.keys())) { + if (!present.has(key)) { + index.delete(key) + } + } + // A host with no index entry sorts first: the index is recency, not truth, so a lost or + // truncated one costs eviction order rather than a generation. The host just activated is + // never a candidate, because `now()` is a wall clock: one backward jump would otherwise make + // the newest entry the oldest and evict the tree the caller is about to open. + const candidates = hosts + .filter((host) => host !== activated) + .sort((left, right) => (index.get(left) ?? 0) - (index.get(right) ?? 0)) + for (const host of candidates.slice(0, Math.max(0, hosts.length - MAX_CACHED_HOSTS))) { + await dropHostTree(host) + index.delete(host) + } + await writeHostIndex(index) + } + + async function readActive(hostKey: string): Promise { + const generations = generationsRoot(hostKey) + const directories = (await fs.list(generations)).filter((entry) => entry.isDirectory) + if (directories.length === 0) { + return null + } + // Two directories means a commit was interrupted between dropping the old generation and + // renaming the new one. There is no activation file to break the tie, and a manifest that + // names another build is a tree from some other bundle, so the host's cache goes and the next + // open redownloads it. + const only = directories.length === 1 ? directories[0] : null + if (only !== null) { + const directory = joinUri(generations, only.name) + let text: string | null + try { + text = await fs.readText(joinUri(directory, MANIFEST_FILE_NAME)) + } catch { + // A failed read is not evidence of a bad generation, so nothing is deleted: the caller + // redownloads, and a transient I/O blip must not cost a cache that verified. + return null + } + const manifest = parseManifest(text) + if (manifest !== null && manifest.buildId === only.name) { + return { buildId: manifest.buildId, directory, manifest } + } + } + await dropHostTree(hostKey) + return null + } + + async function stage( + hostKey: string, + result: MobileWebBundleFetchResult + ): Promise { + const manifest = result.manifest + const directory = joinUri(stagingRoot(hostKey), manifest.buildId) + const assets = manifest.assets.map((asset) => ({ + uri: joinUri(directory, requireStorablePath(asset.path)), + bytes: requireExactBytes(result.assets.get(asset.path), asset) + })) + // Residue from an earlier attempt is dropped rather than written over: a half-written tree + // plus a fresh write is not a generation either side verified. + await fs.delete(directory) + try { + for (const asset of assets) { + await fs.writeBytes(asset.uri, asset.bytes) + } + // Last, always: a tree without it never reads back as an activation, which is what makes an + // interrupted write recoverable rather than ambiguous. + await fs.writeText(joinUri(directory, MANIFEST_FILE_NAME), JSON.stringify(manifest)) + } catch (error) { + await fs.delete(directory).catch(() => undefined) + throw error + } + const handle: StagedGeneration = { hostKey, buildId: manifest.buildId, directory, manifest } + issuedHandles.add(handle) + return handle + } + + function requireIssuedHandle(staged: StagedGeneration): StagedGeneration { + if (!issuedHandles.has(staged)) { + throw new Error('generation store was handed a staged handle it did not issue') + } + return staged + } + + async function commit(staged: StagedGeneration): Promise { + requireIssuedHandle(staged) + const generations = generationsRoot(staged.hostKey) + const target = joinUri(generations, staged.buildId) + const active: ActiveGeneration = { + buildId: staged.buildId, + directory: target, + manifest: staged.manifest + } + const entries = await fs.list(generations) + // The build id names an asset list, not evidence those bytes landed, so a directory of that name + // is this activation only once its manifest is on disk. An empty one — what a crash between the + // rename and the check below leaves on Android under API 26 — or a plain file of that name falls + // through and is replaced by the staged tree, which was verified byte for byte. + const existing = entries.find((entry) => entry.name === staged.buildId) + if ( + existing?.isDirectory === true && + (await fs.fileExists(joinUri(target, MANIFEST_FILE_NAME))) + ) { + // Still an activation, so it still counts as use: without this a host that redownloads the + // bundle it already has stays the least recently activated and is evicted first. No eviction + // pass, because the host count did not change. + const index = await readHostIndex() + index.set(staged.hostKey, now()) + await writeHostIndex(index) + await fs.delete(staged.directory) + return active + } + // Before any delete: an aborted or swept handle must not cost the live generation, and a tree + // that is no longer on disk cannot be renamed into one either. + if (!(await fs.fileExists(joinUri(staged.directory, MANIFEST_FILE_NAME)))) { + throw new Error(`staged generation ${staged.buildId} is no longer on disk`) + } + // Every other generation goes before the rename, never after. A crash between the two leaves + // zero generations, which the runbook's redownload rule already covers; the other order can + // leave two directories under `generations/` with nothing to say which one is the activation. + for (const entry of entries) { + await fs.delete(joinUri(generations, entry.name)) + } + await fs.createDirectory(generations) + await fs.moveDirectory(staged.directory, target) + // Android below API 26 implements a directory move as a non-recursive copy plus a delete + // (expo-file-system android FileSystemPath.kt:158-173), which can land an empty directory. Its + // `delete()` then fails on the non-empty source, so the tmp tree survives for the next sweep. + if (!(await fs.fileExists(joinUri(target, MANIFEST_FILE_NAME)))) { + await fs.delete(target) + throw new Error(`generation ${staged.buildId} did not carry its manifest through the rename`) + } + const index = await readHostIndex() + index.set(staged.hostKey, now()) + // Enforced here rather than left to a caller: the four-host ceiling is this module's invariant. + await enforceHostLimit(index, staged.hostKey) + return active + } + + async function sweep(): Promise { + // Every host's `tmp`, not just the one being opened: an interrupted download must not survive a + // restart, and it may belong to a host this launch never selects. + for (const host of await listHostDirectories()) { + await fs.delete(joinUri(fs.rootUri, host.name, STAGING_DIRECTORY_NAME)) + } + } + + async function deleteHost(hostKey: string): Promise { + await dropHostTree(hostKey) + const index = await readHostIndex() + if (index.delete(hostKey)) { + await writeHostIndex(index) + } + } + + // One queue for the whole store rather than one per host: every operation is a short burst of + // cache I/O, and a single order answers the stage/commit/sweep/delete interleavings at once. A + // second `stageGeneration` for the same host and build waits for the first rather than writing + // into the tree it is still filling. + let tail: Promise = Promise.resolve() + function serialize(operation: () => Promise): Promise { + const run = tail.then(operation, operation) + tail = run.catch(() => undefined) + return run + } + + return { + readActiveGeneration: (hostKey) => serialize(() => readActive(hostKey)), + stageGeneration: (hostKey, result) => serialize(() => stage(hostKey, result)), + commitGeneration: (staged) => serialize(() => commit(staged)), + abortStagedGeneration: (staged) => + serialize(() => fs.delete(requireIssuedHandle(staged).directory)), + sweepStagedGenerations: () => serialize(sweep), + deleteHostCache: (hostKey) => serialize(() => deleteHost(hostKey)) + } +} + +function joinUri(...segments: readonly string[]): string { + return segments.map((segment) => segment.replace(/\/+$/, '')).join('/') +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return null + } +} + +function parseManifest(text: string | null): MobileWebBundleManifestRead | null { + if (text === null) { + return null + } + const parsed = MobileWebBundleManifestReadSchema.safeParse(parseJson(text)) + return parsed.success ? parsed.data : null +} + +function requireHostKey(hostKey: string): string { + if (!isHostCacheKey(hostKey)) { + throw new Error('generation store was handed something that is not a host cache key') + } + return hostKey +} + +/** The manifest schema bans traversal already, but this is the last code between a manifest and a + * write, and `manifest.json` is the store's own name rather than an asset's to take — folded, + * because APFS and NTFS are case-insensitive and `Manifest.JSON` would land on the same file. */ +function requireStorablePath(path: string): string { + const segments = path.split('/') + const storable = + path.length > 0 && + path.toLowerCase() !== MANIFEST_FILE_NAME && + !path.includes('\\') && + segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..') + if (!storable) { + throw new Error(`generation store refuses to stage the asset path ${path}`) + } + return path +} + +function requireExactBytes( + bytes: Uint8Array | undefined, + asset: { path: string; byteLength: number } +): Uint8Array { + // Only complete generations activate, so the check is before the first write rather than after + // the last: a manifest asset that is absent or the wrong length never reaches disk. + if (bytes === undefined || bytes.byteLength !== asset.byteLength) { + throw new Error( + `bundle asset ${asset.path} is ${bytes?.byteLength ?? 'absent'}, not the manifest's ${asset.byteLength}` + ) + } + return bytes +} diff --git a/mobile/src/mobile-web-shell/host-cache-key.ts b/mobile/src/mobile-web-shell/host-cache-key.ts new file mode 100644 index 00000000000..0058c4eebca --- /dev/null +++ b/mobile/src/mobile-web-shell/host-cache-key.ts @@ -0,0 +1,19 @@ +import { sha256 } from '@noble/hashes/sha256' + +/** Full sha256 hex, never a slice of the host id and never the id itself: the key names the + * directory that holds one host's bundle, two hosts sharing one is the cross-host cache use the + * rollback runbook escalates as a security incident, and a host id is free-form text that would + * otherwise reach a path. `deriveHostFingerprint` is not this: it hashes the host public key and + * truncates to 16 chars for the push gateway. */ +export function deriveHostCacheKey(hostId: string): string { + return Array.from(sha256(new TextEncoder().encode(hostId)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') +} + +const HOST_CACHE_KEY_PATTERN = /^[a-f0-9]{64}$/ + +/** The store checks every key it is handed, so a caller passing a raw host id cannot build a path. */ +export function isHostCacheKey(value: string): boolean { + return HOST_CACHE_KEY_PATTERN.test(value) +} diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts index ca0787b7568..f42d50c3015 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts @@ -37,8 +37,12 @@ const assetSchema = z.looseObject({ * `schemaVersion` is read as a number, not pinned to the one this shell knows: refusing it here * would fail the parse before `evaluateMobileWebBundleCompat` could name the shell as too old, and * an unreadable schema is a wall to show, not a shape to guess at. The manifest stays closed in - * both directions on the host's side, where it is written. */ -const manifestSchema = z + * both directions on the host's side, where it is written. + * + * Exported because the generation store re-parses the manifest it cached, and reading it back + * strictly after accepting it loosely would make a host's added field a forced redownload on every + * launch. */ +export const MobileWebBundleManifestReadSchema = z .looseObject({ schemaVersion: z.number().int(), buildId: z.string().regex(SHA256_PATTERN), @@ -63,7 +67,7 @@ const manifestSchema = z /** `chunkBytes` is read, never assumed: the host may shrink it without a client release. Capped at * the constant because a larger value would overshoot `dataBase64` above. */ export const MobileWebBundleManifestReplySchema = z.looseObject({ - manifest: manifestSchema, + manifest: MobileWebBundleManifestReadSchema, chunkBytes: z.number().int().positive().max(MOBILE_WEB_BUNDLE_CHUNK_BYTES) }) From 5c2d3322c1cb08c0ca71c9bf9b9a7596cded6ac7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:56:07 -0700 Subject: [PATCH 015/224] fix(runtime): name a terminal whose pane a graph republish dropped (#19860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): name a terminal whose pane the graph dropped `buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose `paneKey` still parses to its own `tabId` passes that forever, including long after the session graph dropped the pane, so a terminal that had lost its surface reported `orphaned: false, connected: true, writable: true` and a `tabId` no tab has: field-for-field identical to a healthy one (#18191). Consult the leaf topology instead, gated on a graph statement having had the standing to contradict the record. `graphSequence` counts authoritative graph statements; every statement re-records the surface of every pane it publishes, so a pane the current graph holds carries the current stamp and is answered without touching the leaf map. That covers the two absences that are not evidence, without a second flag: a surface recorded since the last statement (spawn records the pane before the graph carrying it arrives, #7587), and a lost graph clearing every leaf at once without advancing the sequence. A pane already observed dropped keeps its stale stamp and stays named, because losing the ability to re-check is not a reason to un-see it. `orphaned: true` is shipped vocabulary that both consumers already read, so no capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and now reaches this population, and the duplicate-surface index (`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's live owner. * fix(runtime): publish a terminal retirement proof on the exit's own evidence A paired client may drop a mirrored terminal on exactly two kinds of host evidence: a `retiredTerminalSurfaces` proof naming the handle, or two authoritative `terminal.list` inventories that omit it. The second needs two host publications, and a quiet workspace publishes one, so the proof is the only evidence that rides the frame carrying the retraction. That proof was minted only as a byproduct of persistence *accepting a change*, which made one value carry two meanings: "a change was accepted" and "the PTY exited". The host renderer's close transaction de-persists the surface and republishes without it, so when it got there first the exit found nothing left to accept and the attestation died with it. Measured on a real paired client: the host retracted in under 500ms, published no proof, then froze its snapshotVersion for 60s while the client kept a dead pane in its tab bar. Persistence still gates *removal* — publishing absence before the membership fence is durable would let a crash resurrect the surface. It no longer gates the proof: the observed exit is itself the attestation. The exit-first ordering already had a passing test; the renderer-first ordering had none, and that is the one users hit. Both orderings are now pinned, with exit-first as the control that makes the renderer-first failures mean something. Wire: `retiredTerminalSurfaces` is an existing optional field on an existing path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is Rule 1 — an old client that ignores it degrades to the two-inventory route it already uses today, so no capability gate is needed. The sentence "the host starts sending a frame it did not send before" reads like Rule 3; it is not, because the frame shape, the field, and the reader contract are all unchanged. * test(runtime): pin the removal frame retiring a still-live publisher KNOWN RED (`it.fails`), no product change. Found while verifying the close retraction fix: once the emptying actually reaches paired clients — a state the previous behaviour never allowed, because nothing propagated — re-adoption of a later create is flaky. Measured 1 failure in 6 runs of the two-client journey. `decideWebSessionTabsSnapshot` treats the host's synthetic `removed:` retraction as a publisher handover: it retires the still-live renderer epoch and installs the retraction as current, while the removal also clears the live freshness record. The next frame from that same running publisher then matches no lineage and reads as a retired generation, so it is outranked and the publisher is locked out of the worktree until its generation changes. `local-structured-session-tabs-sync/snapshot-apply.ts` documents this exact scenario and has a revive escape; the mirror path has none. The suffix case explains the 1-in-6: `hasRetiredValue` is an exact string match, so a republication carrying `:headless-merge:` walks past the fence and only a bare same-epoch republication is locked out. Not fixed here on purpose. Dropping the retirement makes the red case pass but breaks `web-session-tabs-sync.test.ts > keeps a removed worktree fenced against delayed predecessor epochs`, which asserts a same-epoch higher-version frame after a removal must be rejected. At this layer those are the same frame — this function holds no `receivedFrame`, so it cannot separate a delayed predecessor from the live publisher speaking again. The fix belongs in `shouldApplyRecoveredWebSessionTabsSnapshot`, which does hold that ordering and currently defers to the same epoch fence. That is a contract change across two functions and an existing invariant, not a one-liner. * fix(runtime): a removal retraction is not a publisher handover The host drops a worktree's entry when its last tab closes and announces it with a synthetic `removed:` epoch. Both receipt sites treated that as a publication: `decideWebSessionTabsSnapshot` and `recordReceivedWebSessionTabsSnapshot` each noted the retraction epoch as current, which pushed the still-live renderer epoch onto `retired`. The removal also drops the live freshness record, so the next frame from that same running publisher matched no lineage, read as a retired generation, and was outranked. The live publisher was locked out of its own worktree until its generation changed. That is fail-closed, and it is why re-adoption after an emptying was flaky once the emptying actually reached paired clients. A retraction and the live publisher's next frame are the same epoch at a higher version, so epoch identity cannot separate them and never could. Delivery order can. `recordReceivedWebSessionTabsRemoval` now records the retraction as the worktree's newest received evidence instead of deleting the ledger, so `shouldApplyRecoveredWebSessionTabsSnapshot` — the gate every production apply path passes before `decideWebSessionTabsSnapshot` — fences a frame that reserved its received frame before the retraction while admitting one that arrives after it. The boundary carries the retraction's own epoch, which never matches a host publication, so a later live frame may still restart its version counter. `local-structured-session-tabs-sync/snapshot-apply.ts` documents the same conclusion for the local path: a retired epoch is not proof of a dead generation. `keeps a removed worktree fenced against delayed predecessor epochs` pinned the delayed predecessor at the raw decision layer, which is the same call as the live publisher's republication. It now pins the identical scenario — same epoch, higher version, still rejected — through the receive-and-apply path that actually holds the ordering, plus the composed gate as production spells it. The committed `it.fails` repro is not sufficient on its own: it records no received frame, so dropping only the `decideWebSessionTabsSnapshot` retirement turns it green while the publisher stays locked out on every real path. A receive-and-apply case is added alongside it to close that gap. * test(runtime): pin the retraction boundary against a stale inventory omission Mutation testing left a survivor: writing the boundary unconditionally, instead of only when it advances the ledger, passed the whole runtime suite. It is not inert. A visibility-resume inventory reserves its received frame before it lists, so an omission it reports can be older than a stream frame that landed meanwhile; without the guard that stale omission rewinds the ledger, forgetting the stream frame's version, and a delayed list reserved in between is then readmitted instead of outranked. This pins that ordering. The one remaining survivor is the boundary's `snapshotVersion`, and it is inert: the ledger's version is read at exactly two sites, both reachable only when the incoming frame's epoch equals the stored one, and a retraction epoch never equals a live publication. * test(runtime): cover the fences the retraction change narrowed Two gaps found by mutating the fences themselves rather than the fix. Deleting the epoch fence in `shouldApplyRecoveredWebSessionTabsSnapshot` passed the entire runtime suite. It is not unreachable: a superseded generation whose sibling stream delivers its frame after the handover outranks the successor on delivery order, and only the retired-epoch check rejects it. Retractions used to exercise that fence too; now that they no longer retire anything, a genuine handover is the only thing left that reaches it, and nothing covered that. The fence is narrower than it was, not dead. The second case pins rate-independence. The defect surfaced 1 run in 6 because `hasRetiredValue` is an exact string match while `sameSessionTabsPublicationLineage` treats `:headless-merge:` as the same publisher, so a merged republication walked past a fence a bare one hit. The removal path is now asserted over both epoch shapes through the full path, so a fix that only re-rated the defect instead of removing it would fail here. * fix(runtime): give "same publisher" one answer across the epoch fences Separable from the retraction fix beneath it, and it changes handover-path behaviour: a superseded generation that republishes under a merged epoch is now rejected where it was previously accepted. Take it independently or not at all. `publisher-identity-fences.ts` held two answers to "is this the same publisher". `noteRetiredValue` treated a `:headless-merge:` epoch as a SUCCESSOR of its base and retired the base when the merged form became current, while `sameSessionTabsPublicationLineage` treated the two as ONE publisher. Those are contradictory, and the retired-value check's exact-string match was the shim that kept them from ever meeting: a merged frame was a different string, so it never looked retired no matter what had been retired. The cost was that the same predecessor was accepted or rejected depending on which shape it arrived in. A generation a successor had replaced was fenced when it republished bare and admitted when it republished merged — the fail-open half of the same disagreement whose fail-closed half was the removal defect, and the reason that defect reproduced 1 run in 6 rather than every time. This cannot be fixed in the fence alone. Making the fence lineage-aware while a merged epoch still retires its base has the generation retire itself: the rebuild arrives, retires its own base, and the fence then rejects it as a retired generation. So both sides move together — a lineage sibling advances the current epoch instead of superseding it, and inherits its generation's retirement instead of escaping it. Scoped to the publication-epoch functions. Runtime-id retirement keeps exact matching, and `local-structured-session-tabs-sync` keeps its own `hasRetiredValue` call, where a lineage sibling is already excused explicitly and a retired epoch is deliberately not treated as proof of a dead generation. * test(e2e): journeys for a reopened client and two clients on one host Two gaps this suite had no coverage for, both driven end to end against a real paired desktop client rather than at a seam. A relaunched client holding a live remote terminal: every paired restart spec here restarts around a browser pane, none around the terminal the user is actually mid-work in. The host-side fixture's on-disk sink is the oracle — one READY for the whole run proves the host never re-spawned the session, and a recorded line for input sent after the relaunch proves the restored pane is wired to that same process rather than painted with its scrollback. Two clients on one host across an emptied workspace: the tombstone is client-local on the runtime path, so a client that never held a row still seeds into a workspace another client deliberately emptied. That asymmetry is by design; a client falling out of step with the host and staying there is not. Phase 0 is the control — without it a later divergence cannot be attributed to the emptying rather than to mirroring never having worked. The input probe goes through `pane.terminal.input`, not `window.api.pty.write`: a mirrored pane's handle is a `remote:` id that no local PTY answers to, so a direct write is swallowed and the assertion passes on nothing. The pre-restart control exists to catch exactly that, and did. * test(e2e): keep the two-client journey spec type-clean * test(e2e): pin the close retraction a paired host does not publish * docs(e2e): say why the red close-retraction spec sits on this PR The spec was written on a branch carrying neither of this PR's publish-side fixes, and its own diagnosis -- the fault is the host's publish-after-close, not any client's mirror -- names exactly what they change. Landing it here makes CI the measurement rather than leaving a red spec parked on a branch with no fix in it. Records the one thing a reader needs to not do: skip-tagging it. And why the obvious split is not a block move -- phase 2 depends on phase 1b's emptying and both share the two-client pairing fixture, so splitting means duplicating the fixture. * test(e2e): the close-retraction spec is green on this branch, measured It was written to pin a defect and was red where it was written. On this branch, with `publish a terminal retirement proof on the exit's own evidence` and `a removal retraction is not a publisher handover` both present, it passes -- twice, independently: phase1a A=9ms/B=158ms then A=2ms/B=1ms, against a prior baseline of "none reached either client within 90 seconds". So the KNOWN RED header had become the thing it warned about: a test carrying prose asserting the very behaviour the commits beside it remove. Rewritten to record the measurement and the numbers to regress against, and to keep the one instruction that still applies -- if it reddens again, do not skip-tag it; the failure shape is a 90s timeout on both clients at once while creates still propagate. No assertion changed. Comment only. * test(wire): pair the session-tabs retirement proof across two builds The stack makes a host start sending a retirement proof on its own frame when no surface removal carries one. The change argues Rule 1; Rule 3's fourth bullet covers a frame the host starts sending on an existing path, so the claim is measured against v1.4.199 rather than accepted. Neither existing cross-version suite reaches session-tabs: the terminal one covers the binary stream, the agent-session one covers agentSession.*. Result: the old client acts on the proof-only frame, because the whole client half of this surface is unchanged. The old-host cells are pinned to a release that cannot publish the frame at all, which is what makes the new-host cells mean something. * fix(lint): clear the casting gate on the surface-lost inventory main tightened typescript/consistent-type-assertions to assertionStyle: never, which the rebase brings onto these added lines. The retraction read narrows on the property instead of casting; the fixture and cross-build-import casts carry per-site SAFETY rationales. * fix(lint): bind the protected-stamp cast to a name The leading-semicolon parenthesised call put the suppression on a line oxfmt then reflowed away from the assertion it covers. Naming the narrowed handle keeps the directive next to the cast. * fix(runtime): route every non-null surface write through the stamped writer `ptyHoldsRecordedSurface` trusts a record only while its stamp is current; after that the leaf map answers. Four writers still named a pane with a bare `tabId = / paneKey =` — orphan adoption (both branches), split, create on an adopted stable pane, and TUI-owner recovery — so a record that had already been contradicted stayed contradicted after the claim, and `terminal list` reported the just-claimed PTY `orphaned: true` until the renderer's next graph statement re-recorded it. Before this branch those sites read as attached at once, so this was a regression window of one round-trip, and `indexLiveTerminalSurfaceOwners` reads `orphaned` as "unowned". `recordPtySurface` is now the one writer; the adoption module reaches it through a port because it has no `graphSequence` of its own. The nulling writers are untouched: a null surface is never held, stamped or not. * test(runtime): keep one copy of each publisher-fence case The removed-frame suite asserted four properties that another case in the same suite or the lineage suite already pinned: - the decide-only readmit and the bare full-path readmit are the bare arm of the parameterized full-path readmit, verbatim; - the merged-suffix decide-only readmit is the merged arm of the same loop; - "still fences a predecessor a successor replaced" is the lineage suite's bare arm with different version numbers; - the recovery-gate handover case is the lineage suite's recovery-gate case with a bare late frame instead of a merged one, so that test now runs both shapes and this copy goes. Mutation-checked: reverting each of the five renderer changes on this branch (retire-on-removal in decide, noting a retraction current, the exact-match retired fence, merge-supersedes-base, dropping the ledger on removal) still fails at least one of the remaining ten cases. Also corrects the suite header: a retraction carries a synthetic `removed:` epoch, so it is the in-flight predecessor frame, not the retraction, that shares the live publisher's epoch and needs delivery order to be separated. * test(e2e): fail the two-client journey when phase 1a cannot run Phase 1a sat inside `if (beforePartialClose.length > 1)`. A host workspace that starts with one terminal skipped the control silently while 1b and 2 still ran, and the spec passed green without ever exercising the close-with-others-open retraction it was written to measure. The skip is now a recorded failure naming the host count. * fix(runtime): order every session-tabs apply path against the retraction A closed terminal came back on the other client because "this worktree was retracted" was neither durable nor universal: - `refreshWebRuntimeSessionTabsSnapshot` reached `decide` with no place in receipt order at all, so a list the host answered before the close applied after the retraction had already cleared the worktree. It is a production path for close, create, activation, split and PTY reconnect. - the boundary lived in a single receipt slot the next stream frame overwrote, and in a fence that only existed when a recovery happened to be pending when the retraction landed, so a pre-close list could out-rank the republication on `snapshotVersion` alone. Replace both with one raise-only removal watermark per (environment, worktree) and give the list path a receipt position, reserved by the request and carried in its answer so a dedupe joiner inherits it rather than minting a newer one. The pending-recovery fence and its bookkeeping are dead once the boundary is monotonic. The exact-match retirement check in the receipt ledger becomes the one lineage-aware predicate, so a `:headless-merge:` rebuild can no longer be noted as current and retire the live publisher out of its own worktree. On the main side, `recordPtyWorktree` stamped `surfaceRecordedAtGraphSequence` at write time, so any `paneKey` write claimed the standing of a fresh graph statement. The inventory restore in `terminal list` therefore un-dropped the very pane the read was meant to report, on every listing. A surface claim now carries no graph standing unless its writer names one: the graph statement, live leaf output and spawn do, while the inventory restore, the floating liveness restore and the mobile projection replay do not. Defaulting this way means a writer that says nothing fails safe and self-corrects, which the type alone could not guarantee across the projection contract's own `recordPty`. Spawn claims now span the one graph statement the renderer may already have in flight, and retirement proofs compare by identity instead of by position, so a re-delivered exit no longer fans out a `snapshotVersion` bump carrying nothing. * fix(runtime): stop an unpublished-worktree placeholder retiring the live publisher A worktree the host has published nothing for still answers a forced list, with a synthesized `none`/v0 frame that means "ask me later" (host-session-snapshot-authority.ts). Every post-close list and every activation of an emptied worktree gets one. Noting it as a publication retired the renderer generation that is still live, and because that epoch is per-process, the terminal the user created next never reached this client — the same lockout the retraction path was already careful to avoid, through a door it did not cover. `local-structured-session-tabs-sync` already skips the placeholder for this exact reason; the web mirror now does too, on both the receipt ledger and the frame decision. Bound the receipt ledgers by frame age rather than entry count. One bootstrap inventory records a receipt per worktree under a single reserved frame, so evicting by insertion order dropped that batch's own earlier entries, and an absent receipt is what the recovery gate reads as "no evidence for this worktree". Only a receipt no in-flight frame can still be ranked against is droppable. Take the receipt gate off the `web-session-tabs-sync` barrel in the refresh path. Ordering is that path's gate, not an optional collaborator a caller's module mock may leave out, and being reachable only through the barrel is how the path came to have no ordering at all. * fix(runtime): let the TUI-owner recovery name its pane without claiming the graph holds it `recoverStructuredTuiOwner` rebinds a recovered PTY from the persisted owner binding — the same replayed-evidence class as the inventory restore — but stamped it with the current graph sequence, so a pane the renderer had already dropped read as attached for one more statement. The guard below it needs the tabId and paneKey, not the standing. Also say plainly in `decideWebSessionTabsSnapshot` what the affirms check does and does not cover: an unpublished-worktree placeholder is withheld from epoch noting only. It still applies, because rejecting it outright would drop the terminal reconciliation that legitimately rides on it. * fix(runtime): keep the retraction boundary out of the receipt bound Bounding the removal watermark alongside the receipt ledger reintroduced the defect the watermark exists to prevent: past 512 retracted worktrees, evicting a boundary readmits every pre-close frame it was fencing, and a delayed list resurrects the closed tab. A boundary is not a cache. One number per worktree ever retracted on an environment is the cheaper price, and environment teardown drains it; only the receipt ledger stays bounded, by frame age. Split the orphan-adoption port by provenance so the last writer that disagreed with the surface-standing rule stops disagreeing. `adoptRuntimeTerminalOrphans` replays the persisted binding when the claim already matches it and writes a new one otherwise, and both went through a single `recordSurface` that stamped the current graph sequence — so re-adopting an already-adopted orphan lifted a dropped pane's stale stamp and reported it attached, in a quiet workspace possibly forever. The replay now names the pane without standing and the fresh claim takes spawn standing, like every other writer. Replace a receipt-count assertion that was vacuous for a map keyed by environment and worktree with the mirror state and freshness it was standing in for. * fix(runtime): keep a closed-tab worktree under the epoch already publishing it `closeHeadlessMobileTerminalTab` minted `headless:` on every close. Its sibling headless writers carry the stored `publicationEpoch` forward and mint only when there is no snapshot to inherit from — because a write to a worktree is not a claim to publish it. The close was the one writer that claimed. A paired client retires the epoch a new publisher displaces, and the web mirror's retirement is final: there is no revive lane, and the per-worktree tracking teardown deliberately keeps the epoch history. So an ordinary close published a stranger for a worktree the renderer generation still owned, retired that generation on every client, and the renderer's next publication — carrying the epoch the close had just retired — was rejected forever. The user emptied a workspace, created a terminal, and it never arrived on either machine while `session.tabs.list` showed the host holding it. This is the same thesis the retraction path already states, through the door next to it: a retraction is not a handover, and neither is a close. Measured on `paired-two-client-emptied-workspace-reseed.spec.ts`, six runs each: phase 2 failed 3/6 before (`A=null B=null`, both clients blind for the full 30s budget) and 0/6 after, with both clients adopting in single-digit milliseconds. * fix(lint): give the fixtures real types instead of casting past them The casting gate failed on eight assertions this branch added. All eight were suppressible, but the suppressions were not the problem: the casts were hiding fixtures that did not match the contracts they stood in for. `sessionStillHoldingBothPanes` built tabs as `{id, title, type}` — `type` is not a `TerminalTab` field and eight required ones were missing — and layouts holding only `ptyIdsByLeafId`. `as never` made both compile. They are now real `TerminalTab` / `TerminalLayoutSnapshot` values, so the fixture is checked against the type `listTerminals` actually reads. `terminalTab` in the epoch suite built a *client* tab (`status`, `terminal`) for a field typed with *snapshot* tabs, which forced `as never` at the call and a cast on the snapshot itself. Production reads only `type`, `parentTabId`, `leafId`, `ptyId` and `parentLayout` from that tab, so the two client-only fields were inert; dropping them lets the declared `RuntimeMobileSessionTerminalTab` type the fixture end to end, and the closed tab is now held by name rather than recovered from `snapshot.tabs[0]`. The remaining three casts are unchanged in kind and now carry correctly placed SAFETY rationales: reaching a protected member is the only way to drive these paths. `graphSequence` folds into the reach-through that was already there rather than opening a second one, and the map read narrows instead of asserting. Mutation-tested, all three suites, regression re-introduced for each: - epoch mint on close restored -> 1 failed | 1 passed - orphan check reverted to self-consistency -> 4 failed | 4 passed - placeholder retirement guard removed -> 1 failed | 7 passed src/main/runtime 8169 passed | 31 skipped; src/renderer/src/runtime 1581 passed. `check:code-quality:changed` goes 8 findings -> 0. `pnpm tc` clean. * fix(runtime): stop the headless placeholder graph from dropping every restored pane A headless server publishes one empty graph at launch so status clients see a ready server. It names no renderer pane and is never replaced, but it was counted as an authoritative graph statement all the same: `graphSequence` went 0 -> 1 while the leaf map stayed empty for the life of the process. Every surface claim written without standing - a persisted replay, an inventory restore, the TUI-owner recovery - is stamped 0. Against `graphSequence` 1 the `>=` guard fails, the empty leaf map answers "no pane holds this", and the terminal reports `orphaned: true` under a `pty:` tabId. Nothing can re-stamp it, because the only graph that host will ever publish has already been published. On a headless or SSH host that is permanent, and it is the same lie #18191 is about, pointed the other way. The placeholder no longer spends a graph statement. A renderer graph still does, so a pane a real graph drops is still reported dropped - including on a desktop window promoted from headless, which the third case pins as a negative control. Mutation: restoring the unconditional bump fails the first two cases ("expected 1 to be +0", "expected true to be false"); the promoted-window control passes either way, as a control should. Also registers tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts in the cross-version-wire job. The file matches CROSS_VERSION_WIRE_PREFIXES, so adding it had switched the job's gate on, but the job runs an explicit file list that omitted it - the test executed nowhere in CI. It passes 8/8. --- .github/workflows/pr.yml | 1 + ...less-close-keeps-publication-epoch.test.ts | 108 +++++ ...placeholder-graph-surface-standing.test.ts | 110 +++++ ...-session-terminal-retirement-proof.test.ts | 20 + ...obile-session-terminal-retirement-proof.ts | 53 ++- ...e-adopt-terminal-orphans-from-inventory.ts | 9 + ...orca-runtime-build-pty-terminal-summary.ts | 10 +- ...time-close-headless-mobile-terminal-tab.ts | 5 +- .../runtime/orca-runtime-create-terminal.ts | 4 +- src/main/runtime/orca-runtime-on-pty-data.ts | 3 +- ...me-persist-terminal-surface-retirements.ts | 76 ++-- .../orca-runtime-record-pty-worktree.ts | 20 +- src/main/runtime/orca-runtime-register-pty.ts | 9 +- src/main/runtime/orca-runtime-runtime-id.ts | 4 + .../orca-runtime-split-pty-backed-terminal.ts | 9 +- ...uctured-agent-session-recover-tui-owner.ts | 10 +- .../runtime/orca-runtime-sync-window-graph.ts | 13 +- .../mobile-summaries-part-02.spec.ts | 12 +- .../mobile-summaries-part-03.spec.ts | 3 +- ...retirement-proof-publication-order.test.ts | 213 +++++++++ .../pty-recorded-surface-topology.test.ts | 107 +++++ .../runtime/pty-recorded-surface-topology.ts | 85 ++++ .../runtime-terminal-orphan-adoption.ts | 10 +- .../runtime/runtime-terminal-state-records.ts | 6 + .../terminal-list-surface-lost-orphan.test.ts | 244 ++++++++++ .../remote-runtime-pty-transport.ts | 61 +-- ...sion-mirror-settle-receipt-frames.test.tsx | 47 ++ ...mote-runtime-session-tabs-inflight.test.ts | 38 +- .../remote-runtime-session-tabs-inflight.ts | 40 +- .../runtime/web-runtime-session-snapshot.ts | 39 +- ...on-tabs-publisher-identity-lineage.test.ts | 114 +++++ ...moved-frame-retires-live-publisher.test.ts | 314 +++++++++++++ ...on-tabs-sync-visibility-collision.test.tsx | 35 +- ...ssion-tabs-sync-window-visibility.test.tsx | 1 - .../src/runtime/web-session-tabs-sync.test.ts | 54 ++- .../src/runtime/web-session-tabs-sync.ts | 2 +- .../active-session-subscription.ts | 7 - .../global-session-events.ts | 7 - .../global-session-inventory-event.ts | 13 - .../web-session-tabs-sync/load-initial.ts | 152 +++---- .../publisher-identity-fences.ts | 28 +- .../runtime/web-session-tabs-sync/state.ts | 46 +- .../tracking-decisions.ts | 20 +- .../tracking-lifecycle.ts | 31 +- .../runtime/web-session-tabs-sync/tracking.ts | 119 +++-- .../visibility-resume-inventory.ts | 3 +- ...session-tabs-retirement-proof.unit.test.ts | 258 +++++++++++ ...e-terminal-client-restart-survival.spec.ts | 371 ++++++++++++++++ ...wo-client-emptied-workspace-reseed.spec.ts | 415 ++++++++++++++++++ 49 files changed, 2999 insertions(+), 360 deletions(-) create mode 100644 src/main/runtime/headless-close-keeps-publication-epoch.test.ts create mode 100644 src/main/runtime/headless-placeholder-graph-surface-standing.test.ts create mode 100644 src/main/runtime/paired-close-retirement-proof-publication-order.test.ts create mode 100644 src/main/runtime/pty-recorded-surface-topology.test.ts create mode 100644 src/main/runtime/pty-recorded-surface-topology.ts create mode 100644 src/main/runtime/terminal-list-surface-lost-orphan.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-publisher-identity-lineage.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts create mode 100644 tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts create mode 100644 tests/e2e/paired-two-client-emptied-workspace-reseed.spec.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 64ef4dbfede..fc064fc5c99 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -696,6 +696,7 @@ jobs: tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts + tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 diff --git a/src/main/runtime/headless-close-keeps-publication-epoch.test.ts b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts new file mode 100644 index 00000000000..b33579b783c --- /dev/null +++ b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { + RuntimeMobileSessionTabsSnapshot, + RuntimeMobileSessionTerminalTab +} from '../../shared/runtime-types' + +/** + * Closing a tab is not a handover to a new publisher. + * + * Every other headless writer carries the stored `publicationEpoch` forward and mints one only when + * there is no snapshot to inherit from. The close minted unconditionally, so an ordinary close + * published a stranger's epoch for a worktree the renderer generation still owns. A paired client + * retires the epoch it displaces, and the web mirror's retirement is final — so the renderer's next + * publication, carrying the epoch the close had just retired, was rejected forever. The user + * emptied a workspace, created a terminal, and watched it never arrive. + */ +const WORKTREE_ID = 'repo-1::/tmp/headless-close' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const LIVE_EPOCH = 'renderer-generation-1' + +function makeStore() { + const session = getDefaultWorkspaceSession() + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn(), + getRepos: vi.fn(() => [ + { + id: 'repo-1', + path: '/tmp/headless-close', + displayName: 'headless', + badgeColor: '#000000', + addedAt: 0 + } + ]), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +function terminalTab(parentTabId: string, leafId: string): RuntimeMobileSessionTerminalTab { + return { + type: 'terminal', + id: `${parentTabId}::${leafId}`, + parentTabId, + leafId, + title: 'Terminal', + isActive: true + } +} + +/** A worktree the live renderer generation published, holding two terminals. */ +function storedSnapshot(tabs: RuntimeMobileSessionTerminalTab[]): RuntimeMobileSessionTabsSnapshot { + return { + worktree: WORKTREE_ID, + publicationEpoch: LIVE_EPOCH, + snapshotVersion: 4, + activeGroupId: null, + activeTabId: `tab-a::${LEAF_ID}`, + activeTabType: 'terminal', + tabs + } +} + +function closeOneTab(): RuntimeMobileSessionTabsSnapshot { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: makeStore covers the reads this suite drives. + const runtime = new OrcaRuntimeService(makeStore() as never) + const closedTab = terminalTab('tab-a', LEAF_ID) + const snapshot = storedSnapshot([closedTab, terminalTab('tab-b', LEAF_ID)]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: closeHeadlessMobileTerminalTab is protected; reaching it is the only way to drive a headless close. + const internals = runtime as unknown as { + closeHeadlessMobileTerminalTab: ( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab, + options?: Record + ) => void + mobileSessionTabsByWorktree: Map + } + internals.mobileSessionTabsByWorktree.set(WORKTREE_ID, snapshot) + internals.closeHeadlessMobileTerminalTab(WORKTREE_ID, snapshot, closedTab, { + allowMissingPersistedTab: true, + killPtys: false + }) + const published = internals.mobileSessionTabsByWorktree.get(WORKTREE_ID) + if (!published) { + throw new Error(`the close published no snapshot for ${WORKTREE_ID}`) + } + return published +} + +describe('closing a headless mobile terminal tab', () => { + it('keeps the worktree under the epoch that was already publishing it', () => { + expect(closeOneTab().publicationEpoch).toBe(LIVE_EPOCH) + }) + + it('still advances the version so clients accept the frame', () => { + const published = closeOneTab() + expect(published.snapshotVersion).toBe(5) + expect(published.tabs.map((tab) => tab.id)).toEqual([`tab-b::${LEAF_ID}`]) + }) +}) diff --git a/src/main/runtime/headless-placeholder-graph-surface-standing.test.ts b/src/main/runtime/headless-placeholder-graph-surface-standing.test.ts new file mode 100644 index 00000000000..7bbc22ea83b --- /dev/null +++ b/src/main/runtime/headless-placeholder-graph-surface-standing.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' +import { makePaneKey } from '../../shared/stable-pane-id' +import { SURFACE_CLAIM_WITHOUT_STANDING } from './pty-recorded-surface-topology' + +// #18191: a headless server publishes one empty placeholder graph at launch so status clients see +// a ready server. That statement names no renderer pane and is never replaced, so if it counts as +// a graph statement every claim written without standing — a persisted replay, an inventory +// restore, a TUI-owner recovery — is contradicted by an empty leaf map that can never re-stamp it. +// The terminal then reports `orphaned: true` under a `pty:` tabId for the life of the process. + +const WORKTREE_ID = 'repo-1::/tmp/probe-worktree' +const LEAF = '33333333-3333-4333-8333-333333333333' +const PTY = 'pty-headless-restored' + +function makeStore() { + return { + getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => [ + { + id: 'repo-1', + path: '/tmp/probe-worktree', + displayName: 'probe', + badgeColor: '#000000', + addedAt: 0 + } + ]), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +/** A headless host: no renderer ever attaches, and the only graph is the launch placeholder. */ +function makeHeadlessRuntime(): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: makeStore returns the repo and session reads this suite drives; the rest of Store is unreached. + const runtime = new OrcaRuntimeService(makeStore() as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub carries the members this suite drives; the PTY stays live throughout. + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => [{ id: PTY, cwd: '/tmp/probe-worktree' }]) + } as never) + return runtime +} + +/** Reaching `recordPtyWorktree` is the only way to write a claim the way a replay path does. */ +function recordSurfaceWithoutStanding(runtime: OrcaRuntimeService): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: recordPtyWorktree is protected; the replay paths this stands in for all reach it. + const internals = runtime as unknown as { + recordPtyWorktree: (ptyId: string, worktreeId: string, state: Record) => void + } + internals.recordPtyWorktree(PTY, WORKTREE_ID, { connected: true }) + internals.recordPtyWorktree(PTY, WORKTREE_ID, { + connected: true, + tabId: 'tab-restored', + paneKey: makePaneKey('tab-restored', LEAF), + surfaceRecordedAtGraphSequence: SURFACE_CLAIM_WITHOUT_STANDING + }) +} + +describe('headless placeholder graph and surface standing', () => { + it('does not spend a graph statement on the launch placeholder', () => { + const runtime = makeHeadlessRuntime() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: graphSequence is protected; the count is the whole property under test. + const internals = runtime as unknown as { graphSequence: number } + expect(internals.graphSequence).toBe(0) + + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + // The placeholder says "no renderer panes here", not "the pane you restored is gone". + expect(internals.graphSequence).toBe(0) + }) + + it('keeps a restored surface attached on a headless host', async () => { + const runtime = makeHeadlessRuntime() + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + recordSurfaceWithoutStanding(runtime) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const restored = terminals.find((terminal) => terminal.ptyId === PTY) + expect(restored).toBeDefined() + expect(restored?.orphaned).toBe(false) + // The projection an orphan verdict forces, which `terminal close --tab` cannot resolve. + expect(restored?.tabId).toBe('tab-restored') + }) + + it('still lets a real renderer graph contradict the same claim', async () => { + const runtime = makeHeadlessRuntime() + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + recordSurfaceWithoutStanding(runtime) + // Negative control: a desktop window promoted from headless publishes a graph that does have + // standing over panes. Its silence about this one is a retraction, and must still be read as + // such — otherwise this fix would have re-broken #18191 on every promoted host. + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const restored = terminals.find((terminal) => terminal.ptyId === PTY) + expect(restored?.orphaned).toBe(true) + expect(restored?.tabId).toBe(`pty:${PTY}`) + }) +}) diff --git a/src/main/runtime/mobile-session-terminal-retirement-proof.test.ts b/src/main/runtime/mobile-session-terminal-retirement-proof.test.ts index 79aa4b27f75..11426e50afe 100644 --- a/src/main/runtime/mobile-session-terminal-retirement-proof.test.ts +++ b/src/main/runtime/mobile-session-terminal-retirement-proof.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { appendRetiredTerminalSurfaceProofs, + attachRetirementProofsToSnapshot, preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' @@ -123,6 +124,25 @@ describe('mobile session terminal retirement proofs', () => { }) }) + it('does not bump the version when a re-delivered proof only changes position', () => { + // The append moves a re-supplied proof to the tail, so [A,B] re-supplied with A becomes [B,A]. + // Comparing by position called that a change and fanned a no-op version bump to every client. + const a = { ...retired, parentTabId: 'tab-a', leafId: 'leaf-a', ptyId: 'pty-a' } + const b = { ...retired, parentTabId: 'tab-b', leafId: 'leaf-b', ptyId: 'pty-b' } + const stored = snapshot({ retiredTerminalSurfaces: [a, b] }) + + expect(attachRetirementProofsToSnapshot(stored, [a])).toBeNull() + }) + + it('still bumps the version when a re-delivered proof names a new incarnation', () => { + const a = { ...retired, parentTabId: 'tab-a', leafId: 'leaf-a', ptyId: 'pty-a' } + const stored = snapshot({ retiredTerminalSurfaces: [a] }) + + expect( + attachRetirementProofsToSnapshot(stored, [{ ...a, incarnationId: 'inc-next' }]) + ).toMatchObject({ snapshotVersion: stored.snapshotVersion + 1 }) + }) + it('preserves each retired leaf identity independently', () => { const proofs = appendRetiredTerminalSurfaceProofs(undefined, [ { diff --git a/src/main/runtime/mobile-session-terminal-retirement-proof.ts b/src/main/runtime/mobile-session-terminal-retirement-proof.ts index d5b6c620954..26bf1cb974d 100644 --- a/src/main/runtime/mobile-session-terminal-retirement-proof.ts +++ b/src/main/runtime/mobile-session-terminal-retirement-proof.ts @@ -1,7 +1,11 @@ -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import type { + RuntimeMobileSessionRetiredTerminalSurface, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' import { appendRetiredTerminalSurfaceProofs, - dropRetirementProofsForLiveSurfaces + dropRetirementProofsForLiveSurfaces, + retirementProofKey } from '../../shared/terminal-retirement-proof-ledger' export { @@ -48,3 +52,48 @@ export function preserveTerminalRetirementProofs( ) } } + +/** + * Attaches durable retirement proofs to a stored snapshot, bumping its version so clients that + * gate on a strictly newer `snapshotVersion` accept the frame. Returns null when the snapshot + * already carries exactly these proofs, so a no-op cannot fan out. + * + * Separate from `retireTerminalSurfacesFromSnapshot`: that one only produces a proof as a + * byproduct of removing the surface, and by the time a close's durable half runs the surface may + * already be gone from the snapshot. The proof still has to ship — it is the only host evidence + * that rides the frame carrying the retraction. + */ +export function attachRetirementProofsToSnapshot( + snapshot: RuntimeMobileSessionTabsSnapshot, + proofs: readonly RuntimeMobileSessionRetiredTerminalSurface[] +): RuntimeMobileSessionTabsSnapshot | null { + if (proofs.length === 0) { + return null + } + const merged = appendRetiredTerminalSurfaceProofs(snapshot.retiredTerminalSurfaces, proofs) + const existing = snapshot.retiredTerminalSurfaces + // Why by key, not by index: the append moves a re-supplied proof to the tail, so comparing + // position would call a re-delivered exit a change and fan out a version bump carrying nothing. + const priorByKey = new Map( + (existing ?? []).map((proof) => [retirementProofKey(proof), proof] as const) + ) + const unchanged = + existing !== undefined && + merged.length === existing.length && + merged.every((proof) => { + const prior = priorByKey.get(retirementProofKey(proof)) + return ( + prior !== undefined && + proof.ptyId === prior.ptyId && + proof.incarnationId === prior.incarnationId + ) + }) + if (unchanged) { + return null + } + return { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + retiredTerminalSurfaces: merged + } +} diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index d062870c4d8..5dd2908413b 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -1,4 +1,9 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { + recordPtySurface, + spawnSurfaceClaimSequence, + SURFACE_CLAIM_WITHOUT_STANDING +} from './pty-recorded-surface-topology' import { observeStructuredWorker, resolveStructuredWorkerAuthority @@ -60,6 +65,10 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null, getLeaves: (ptyId) => this.getLeavesForPty(ptyId), getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)), + replayPersistedSurface: (pty, tabId, paneKey) => + recordPtySurface(pty, tabId, paneKey, SURFACE_CLAIM_WITHOUT_STANDING), + recordAdoptedSurface: (pty, tabId, paneKey) => + recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence)), getMobileSnapshots: () => this.mobileSessionTabsByWorktree.values(), getSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId), setSession: (worktreeId, next) => this.setWorkspaceSessionForWorktree(worktreeId, next), diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index 615353bbc13..159a709fd3c 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -5,12 +5,20 @@ import type { ResolvedWorktree } from './runtime-worktree-path-identity' import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../shared/runtime-types' import { getLatestPtyTitle } from './runtime-worktree-status-projection' import { parsePaneKey } from '../../shared/stable-pane-id' +import { ptyHoldsRecordedSurface, type PtySurfaceTopology } from './pty-recorded-surface-topology' import type { TerminalHandleRecord } from './runtime-terminal-contracts' import { readTerminalTail } from './terminal-tail-read' import { structuredWorkerTerminalRefusal } from './structured-worker-terminal-refusal' import { randomUUID } from 'node:crypto' export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPtyRecordForPaneKey { + protected ptySurfaceTopology(): PtySurfaceTopology { + return { + graphSequence: this.graphSequence, + ptyIdHoldingPane: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId))?.ptyId + } + } + protected buildPtyTerminalSummary( pty: RuntimePtyWorktreeRecord, worktreesById: Map @@ -19,7 +27,7 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt const title = getLatestPtyTitle(pty) const pane = parsePaneKey(pty.paneKey ?? '') - const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId + const orphaned = !ptyHoldsRecordedSurface(pty, this.ptySurfaceTopology()) return { handle: this.issuePtyHandle(pty), ptyId: pty.ptyId, diff --git a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts index c9f61edfdb8..b326d00ec6c 100644 --- a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts +++ b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts @@ -86,9 +86,12 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi return false }) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + // A close is not a handover: the generation publishing this worktree still is. Minting an epoch + // here published a stranger for a worktree the renderer owns, and a client that retires what it + // displaces then rejected that renderer's own next frame. The sibling headless writers carry the + // stored epoch forward for the same reason; `...snapshot` is what does it here. const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null, diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 5e7d4393a6d..69d26ae2a24 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -4,6 +4,7 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies' import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop' import { buildRuntimeAgentTeamsLaunchPlan } from './orca-runtime-agent-teams-launch-plan' import { createPtySpawnCommitReporter } from './orca-runtime-report-pty-spawn-commit' +import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication { async createTerminal( @@ -236,8 +237,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate pty.launchIncarnationId = launchToken ? pty.incarnationId : null pty.launchAgent = launchOpts.launchAgent ?? null } - pty.tabId = tabId - pty.paneKey = paneKey + recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence)) } const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) { diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 0d29d038746..160c99f2071 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -116,7 +116,8 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution lastOutputAt: pty?.lastOutputAt ?? at, preview: pty?.preview ?? leaf.preview, tabId: leaf.tabId, - paneKey: this.makeRuntimePaneKey(leaf) + paneKey: this.makeRuntimePaneKey(leaf), + surfaceRecordedAtGraphSequence: this.graphSequence }) leaf.connected = true leaf.writable = this.graphStatus === 'ready' diff --git a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts index 5c70a9e518a..6b9887d2cf8 100644 --- a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts +++ b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts @@ -2,10 +2,12 @@ import { OrcaRuntimeWithTouchMobileSessionTabsForWorktree } from './orca-runtime-touch-mobile-session-tabs-for-worktree' import type { RetiredTerminalSurface } from './mobile-session-terminal-retirement' import type { ExecutionHostId } from '../../shared/execution-host' +import type { RuntimeMobileSessionRetiredTerminalSurface } from '../../shared/runtime-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' +import { attachRetirementProofsToSnapshot } from './mobile-session-terminal-retirement-proof' import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' @@ -145,37 +147,59 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim ) } // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. - const publishableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] - if (publishableRetiredSurfaces.length === 0) { - return - } + const removableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - const retired = retireTerminalSurfacesFromSnapshot({ - snapshot, - ptyId, - exactSurfaces: publishableRetiredSurfaces.filter( - (surface) => surface.worktreeId === worktreeId - ), - // Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted. - exactOnly: true, - ...(terminalHandle - ? { - retirementProofs: publishableRetiredSurfaces - .filter((surface) => surface.worktreeId === worktreeId) - .map((surface) => ({ - parentTabId: surface.parentTabId, - leafId: surface.leafId, - ptyId: surface.ptyId, - terminal: terminalHandle, - incarnationId - })) - } - : {}) - }) + // Why proofs aren't gated on `removable`: the exit is the attestation, and a surface the + // renderer already de-persisted leaves persistence nothing to accept. Withholding the proof + // then strands the mirror's pane until a second inventory a quiet workspace never sends. + const retirementProofs = terminalHandle + ? retiredSurfaces + .filter((surface) => surface.worktreeId === worktreeId) + .map((surface) => ({ + parentTabId: surface.parentTabId, + leafId: surface.leafId, + ptyId: surface.ptyId, + terminal: terminalHandle, + incarnationId + })) + : [] + const removableSurfaces = removableRetiredSurfaces.filter( + (surface) => surface.worktreeId === worktreeId + ) + const retired = + removableSurfaces.length > 0 + ? retireTerminalSurfacesFromSnapshot({ + snapshot, + ptyId, + exactSurfaces: removableSurfaces, + // Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted. + exactOnly: true, + ...(retirementProofs.length > 0 ? { retirementProofs } : {}) + }) + : null if (retired) { this.storeMobileSessionSnapshot(worktreeId, retired.snapshot) this.notifyMobileSessionTabsChanged(worktreeId) + continue } + this.publishRetiredTerminalSurfaceProofs(worktreeId, retirementProofs) } } + + /** Ships durable retirement proofs on their own frame when no surface removal carries them. */ + protected publishRetiredTerminalSurfaceProofs( + worktreeId: string, + proofs: readonly RuntimeMobileSessionRetiredTerminalSurface[] + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return + } + const next = attachRetirementProofsToSnapshot(snapshot, proofs) + if (!next) { + return + } + this.storeMobileSessionSnapshot(worktreeId, next) + this.notifyMobileSessionTabsChanged(worktreeId) + } } diff --git a/src/main/runtime/orca-runtime-record-pty-worktree.ts b/src/main/runtime/orca-runtime-record-pty-worktree.ts index 2d382f3d5eb..cfe3b3e2355 100644 --- a/src/main/runtime/orca-runtime-record-pty-worktree.ts +++ b/src/main/runtime/orca-runtime-record-pty-worktree.ts @@ -10,6 +10,10 @@ import { maxTimestamp } from './runtime-worktree-status-projection' import type { RuntimeSyncedLeaf } from '../../shared/runtime-types' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { inferWorktreeIdFromPtyId } from './runtime-worktree-path-identity' +import { + recordPtySurfaceClaim, + SURFACE_CLAIM_WITHOUT_STANDING +} from './pty-recorded-surface-topology' export class OrcaRuntimeWithRecordPtyWorktree extends OrcaRuntimeWithRefreshRepoWorktreeScan { protected recordPtyWorktree( @@ -23,6 +27,7 @@ export class OrcaRuntimeWithRecordPtyWorktree extends OrcaRuntimeWithRefreshRepo | 'preview' | 'tabId' | 'paneKey' + | 'surfaceRecordedAtGraphSequence' | 'title' | 'connectionId' | 'runtimeSessionOwned' @@ -56,6 +61,13 @@ export class OrcaRuntimeWithRecordPtyWorktree extends OrcaRuntimeWithRefreshRepo wslDistro, tabId: state.tabId ?? null, paneKey: state.paneKey ?? null, + // A PTY the runtime is meeting for the first time has no prior observation for a graph + // statement to contradict, and the leaf map cannot answer for a pane no statement has ever + // named — a headless workspace has no renderer graph at all. The next statement decides it. + surfaceRecordedAtGraphSequence: Math.max( + this.graphSequence, + state.surfaceRecordedAtGraphSequence ?? this.graphSequence + ), launchConfig: null, launchToken: null, launchIncarnationId: null, @@ -147,7 +159,13 @@ export class OrcaRuntimeWithRecordPtyWorktree extends OrcaRuntimeWithRefreshRepo pty.tabId = state.tabId } if (state.paneKey !== undefined) { - pty.paneKey = state.paneKey + // A caller that does not say where the pane came from does not get graph standing for it: + // the unsafe default is what let a persisted replay un-drop a pane (#18191). + recordPtySurfaceClaim( + pty, + state.paneKey, + state.surfaceRecordedAtGraphSequence ?? SURFACE_CLAIM_WITHOUT_STANDING + ) } if (state.connected !== undefined) { pty.connected = state.connected diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index dae2519ca9f..b7afec6f2f6 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -5,6 +5,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isTuiAgent } from '../../shared/tui-agent-config' +import { spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHandlesForPty { registerPty( @@ -77,7 +78,13 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand ? { runtimeSessionOwned: true } : {}), ...(isWsl !== undefined ? { isWsl } : {}), - ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), + ...(binding && paneKey + ? { + tabId: binding.tabId, + paneKey, + surfaceRecordedAtGraphSequence: spawnSurfaceClaimSequence(this.graphSequence) + } + : {}), ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) }) const hostScope = this.getOrchestrationCompatibilityHostScope(pty) diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 00ccdde9da9..77fd49e8899 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -277,6 +277,10 @@ export class OrcaRuntimeWithRuntimeId { /** One-shot delivery retries, keyed by leaf. See checkDeliverySettledAndArmRecheck. */ protected deliveryRecheckTimersByLeafKey = new Map>() + // Why: counts authoritative graph statements so a PTY's recorded surface can be told apart + // from one the graph has simply not published yet (pty-recorded-surface-topology.ts). + protected graphSequence = 0 + protected leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by diff --git a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts index 9238af4ca56..8fe43de6950 100644 --- a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts +++ b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts @@ -4,6 +4,7 @@ import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { RuntimeTerminalSplit } from '../../shared/runtime-types' import { makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' import { randomUUID } from 'node:crypto' import { REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, ownerSurfacing } from './orca-runtime-core' @@ -89,8 +90,12 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT this.registerPty(result.id, workspace.id, workspace.connectionId) const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) if (createdPty) { - createdPty.tabId = parentTabId - createdPty.paneKey = paneKey + recordPtySurface( + createdPty, + parentTabId, + paneKey, + spawnSurfaceClaimSequence(this.graphSequence) + ) createdPty.runtimeSessionOwned = pty.runtimeSessionOwned this.setPairedRendererSessionOwnership( createdPty.ptyId, diff --git a/src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts b/src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts index 418ad3e701d..dcd53edf964 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts @@ -10,6 +10,7 @@ import { } from './runtime-worktree-path-identity' import { canonicalizeAgentSessionIdentity } from './agent-session-claim-identity' import { makePaneKey } from '../../shared/stable-pane-id' +import { recordPtySurface, SURFACE_CLAIM_WITHOUT_STANDING } from './pty-recorded-surface-topology' import { evaluateStructuredTuiRecoveryClaim } from './structured-tui-recovery-claim-match' import { cloneAgentSessionOwnerBinding, @@ -127,10 +128,13 @@ export class OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner extends OrcaRu throw new Error('The owning agent terminal could not be recovered.') } candidate = recovered.pty - candidate.tabId = recovered.owner.surface.tabId - candidate.paneKey = makePaneKey( + // The owner binding is persisted evidence, so it names the pane without claiming the graph + // still holds it; the guard below needs the names, not the standing. + recordPtySurface( + candidate, recovered.owner.surface.tabId, - recovered.owner.surface.leafId + makePaneKey(recovered.owner.surface.tabId, recovered.owner.surface.leafId), + SURFACE_CLAIM_WITHOUT_STANDING ) handle = this.issuePtyHandle(candidate) const recoveredIncarnationId = candidate.incarnationId diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index c7f261f88be..893c51c68cc 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -84,6 +84,16 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow ) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() + // Bumped before the leaf loop so surfaces this statement records are stamped with it, and a + // surface recorded after it is immune until the next one (pty-recorded-surface-topology.ts). + // The headless placeholder is exempt: it is published once at launch so status clients see a + // ready server, names no renderer pane, and is never replaced. Counting it as a statement left + // every claim written without standing — a persisted replay, an inventory restore, a TUI-owner + // recovery — permanently orphaned on a headless host, with no graph that could ever re-stamp + // it (#18191). + if (windowId !== HEADLESS_RUNTIME_WINDOW_ID) { + this.graphSequence += 1 + } // Why: renderer reloads can briefly republish the same leaf with no ptyId; // keep live CLI handles usable while the UI graph rebuilds. @@ -146,7 +156,8 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, preview: existing?.ptyId === leaf.ptyId ? existing.preview : '', tabId: leaf.tabId, - paneKey: this.makeRuntimePaneKey(leaf) + paneKey: this.makeRuntimePaneKey(leaf), + surfaceRecordedAtGraphSequence: this.graphSequence }) } diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts index 58fa4ae750b..8aa99d5b88f 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts @@ -514,7 +514,8 @@ describe('OrcaRuntimeService', () => { // paneKey-only record: the tabId rescue must not be what keeps this row. runtime['recordPtyWorktree']('daemon-pty', TEST_WORKTREE_ID, { connected: true, - paneKey + paneKey, + surfaceRecordedAtGraphSequence: runtime['graphSequence'] }) const { worktrees } = await runtime.getWorktreePs() @@ -552,7 +553,8 @@ describe('OrcaRuntimeService', () => { runtime['recordPtyWorktree']('daemon-pty-2', TEST_WORKTREE_ID, { connected: true, tabId: 'daemon-tab', - paneKey: 'daemon-tab:99999999-9999-4999-8999-999999999998' + paneKey: 'daemon-tab:99999999-9999-4999-8999-999999999998', + surfaceRecordedAtGraphSequence: runtime['graphSequence'] }) const { worktrees } = await runtime.getWorktreePs() @@ -579,7 +581,8 @@ describe('OrcaRuntimeService', () => { runtime['recordPtyWorktree']('osc-pty', TEST_WORKTREE_ID, { connected: true, tabId: 'osc-tab', - paneKey: 'osc-tab:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + paneKey: 'osc-tab:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + surfaceRecordedAtGraphSequence: runtime['graphSequence'] }) runtime.onPtyData( 'osc-pty', @@ -609,7 +612,8 @@ describe('OrcaRuntimeService', () => { runtime['recordPtyWorktree']('race-pty', TEST_WORKTREE_ID, { connected: true, tabId: 'race-tab', - paneKey + paneKey, + surfaceRecordedAtGraphSequence: runtime['graphSequence'] }) runtime.onPtyData( 'race-pty', diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts index b82e5863c2e..d28ca1027a8 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts @@ -34,7 +34,8 @@ describe('OrcaRuntimeService', () => { connected: true, connectionId: 'ssh-osc-1', tabId: 'ssh-tab', - paneKey: 'ssh-tab:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + paneKey: 'ssh-tab:cccccccc-cccc-4ccc-8ccc-cccccccccccc', + surfaceRecordedAtGraphSequence: runtime['graphSequence'] }) runtime.onPtyData( 'ssh-osc-pty', diff --git a/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts new file mode 100644 index 00000000000..640c54a753c --- /dev/null +++ b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { OrcaRuntimeService } from './orca-runtime' + +/** + * A paired client may only drop a mirrored terminal on host evidence: a `retiredTerminalSurfaces` + * proof naming the handle, or two authoritative `terminal.list` inventories that omit it. The + * second needs two host publications, and a quiet workspace produces one — so the proof is the + * only evidence that rides the frame carrying the retraction, and it has to be published whichever + * order the close's two halves (renderer republication, PTY exit) land in. + */ + +const WORKTREE_ID = 'repo::/worktree' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const LIVE_REPO = { + id: 'repo', + path: '/worktree', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 +} as const + +function makeSnapshot(): RuntimeMobileSessionTabsSnapshot { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `tab::${LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab::${LEAF_ID}`, + parentTabId: 'tab', + leafId: LEAF_ID, + ptyId: 'pty-left', + title: 'Left', + parentLayout: { + root: { type: 'leaf' as const, leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-left' } + }, + isActive: true + } + ] + } +} + +function makePersistedSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'tab', + ptyId: 'pty-left', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: { type: 'leaf' as const, leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-left' } + } + } + } +} + +function createHost(): { + runtime: OrcaRuntimeService + handle: string + retirePersistedSurface: () => void +} { + let session = makePersistedSession() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the store stub carries the four members this publication-order suite drives; the rest of Store is unreached. + const runtime = new OrcaRuntimeService({ + getRepos: () => [LIVE_REPO], + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushOrThrow: vi.fn() + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: { type: 'leaf', leafId: LEAF_ID } + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-left' + } + ], + mobileSessionTabs: [makeSnapshot()] + }) + runtime.registerPty('pty-left', WORKTREE_ID, null, { + tabId: 'tab', + leafId: LEAF_ID, + incarnationId: 'incarnation-a' + }) + // The mirror binds panes by terminal handle, so the handle has to exist before the close. + const handle = runtime.preAllocateHandleForPty('pty-left') + runtime.registerPreAllocatedHandleForPty('pty-left', handle) + return { + runtime, + handle, + // The renderer's close transaction de-persists the tab and flushes before it republishes. + retirePersistedSurface: () => { + session = { ...session, tabsByWorktree: {}, terminalLayoutsByTabId: {} } + } + } +} + +/** What the host renderer publishes once it has retired the tab it was told to close. */ +function republishWithoutTheSurface(runtime: OrcaRuntimeService): void { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 5, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) +} + +describe('retirement proof publication vs. renderer republication order', () => { + it('publishes the proof when the exit lands before the renderer drops the surface', async () => { + const { runtime, handle } = createHost() + + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + republishWithoutTheSurface(runtime) + + const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(published.tabs).toEqual([]) + expect(published.retiredTerminalSurfaces).toEqual([ + expect.objectContaining({ parentTabId: 'tab', leafId: LEAF_ID, terminal: handle }) + ]) + }) + + it('publishes the proof when the renderer drops the surface before the exit lands', async () => { + const { runtime, handle, retirePersistedSurface } = createHost() + + retirePersistedSurface() + republishWithoutTheSurface(runtime) + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + + const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(published.tabs).toEqual([]) + expect(published.retiredTerminalSurfaces).toEqual([ + expect.objectContaining({ parentTabId: 'tab', leafId: LEAF_ID, terminal: handle }) + ]) + }) + + // Why a subscriber and not just the stored snapshot: a mirror only ever sees frames. A proof + // that lands in state without a frame to carry it is the same silence from the client's side. + it('fans the proof out to a paired subscriber, not just into stored state', () => { + const { runtime, handle, retirePersistedSurface } = createHost() + const frames: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged( + (frame) => frames.push(frame), + 'paired-client' + ) + + try { + retirePersistedSurface() + republishWithoutTheSurface(runtime) + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + } finally { + unsubscribe() + } + + expect( + frames.some((frame) => + frame.retiredTerminalSurfaces?.some( + (proof) => + proof.terminal === handle && proof.parentTabId === 'tab' && proof.leafId === LEAF_ID + ) + ) + ).toBe(true) + }) +}) diff --git a/src/main/runtime/pty-recorded-surface-topology.test.ts b/src/main/runtime/pty-recorded-surface-topology.test.ts new file mode 100644 index 00000000000..702e62a112d --- /dev/null +++ b/src/main/runtime/pty-recorded-surface-topology.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { + ptyHoldsRecordedSurface, + recordPtySurface, + type PtySurfaceTopology +} from './pty-recorded-surface-topology' + +const TAB = 'tab-1' +const LEAF = '11111111-1111-4111-8111-111111111111' + +function pty(overrides: Partial[0]> = {}) { + return { + ptyId: 'pty-1', + tabId: TAB, + paneKey: `${TAB}:${LEAF}`, + surfaceRecordedAtGraphSequence: 0, + ...overrides + } +} + +function topology(overrides: Partial = {}): PtySurfaceTopology { + return { + graphSequence: 1, + ptyIdHoldingPane: () => 'pty-1', + ...overrides + } +} + +describe('ptyHoldsRecordedSurface', () => { + it('holds the surface when the graph binds the recorded pane to this PTY', () => { + expect(ptyHoldsRecordedSurface(pty(), topology())).toBe(true) + }) + + it('does not hold it when the graph has no such pane', () => { + // #18191: the record is self-consistent, so the incumbent check said "attached" forever. + expect(ptyHoldsRecordedSurface(pty(), topology({ ptyIdHoldingPane: () => undefined }))).toBe( + false + ) + }) + + it('does not hold it when the graph rebound that pane to another PTY', () => { + expect(ptyHoldsRecordedSurface(pty(), topology({ ptyIdHoldingPane: () => 'pty-2' }))).toBe( + false + ) + }) + + it('keeps every pane attached when a lost graph empties the leaf map', () => { + // Losing the graph clears every leaf without advancing the sequence, so the panes it held + // keep a current stamp. Reading that emptiness as absence would orphan them all at once. + expect( + ptyHoldsRecordedSurface( + pty({ surfaceRecordedAtGraphSequence: 4 }), + topology({ graphSequence: 4, ptyIdHoldingPane: () => undefined }) + ) + ).toBe(true) + }) + + it('keeps naming a pane already observed dropped after the graph goes away', () => { + // Losing the ability to re-check is not a reason to un-see the drop. + expect( + ptyHoldsRecordedSurface( + pty({ surfaceRecordedAtGraphSequence: 3 }), + topology({ graphSequence: 4, ptyIdHoldingPane: () => undefined }) + ) + ).toBe(false) + }) + + it('keeps a surface recorded since the last graph statement', () => { + // Spawn records the pane before the graph carrying it arrives (#7587); the only graph that + // has spoken since was already in flight, so its silence is not a retraction. + expect( + ptyHoldsRecordedSurface( + pty({ surfaceRecordedAtGraphSequence: 1 }), + topology({ graphSequence: 1, ptyIdHoldingPane: () => undefined }) + ) + ).toBe(true) + }) + + it('contradicts a surface once a later graph statement omits it', () => { + expect( + ptyHoldsRecordedSurface( + pty({ surfaceRecordedAtGraphSequence: 1 }), + topology({ graphSequence: 2, ptyIdHoldingPane: () => undefined }) + ) + ).toBe(false) + }) + + it('re-attaches a contradicted record once a claim re-records its surface', () => { + // Orphan adoption, split, and TUI-owner recovery all name a pane the graph has not been shown + // yet, exactly as spawn does; written through the one writer they are immune until it speaks. + const record = pty({ surfaceRecordedAtGraphSequence: 1 }) + const graph = topology({ graphSequence: 3, ptyIdHoldingPane: () => undefined }) + expect(ptyHoldsRecordedSurface(record, graph)).toBe(false) + + recordPtySurface(record, TAB, `${TAB}:${LEAF}`, graph.graphSequence) + expect(ptyHoldsRecordedSurface(record, graph)).toBe(true) + expect(ptyHoldsRecordedSurface(record, { ...graph, graphSequence: 4 })).toBe(false) + }) + + it('reports no surface when the record never named a pane', () => { + expect(ptyHoldsRecordedSurface(pty({ tabId: null, paneKey: null }), topology())).toBe(false) + }) + + it('reports no surface when the record disagrees with itself', () => { + expect(ptyHoldsRecordedSurface(pty({ paneKey: `other-tab:${LEAF}` }), topology())).toBe(false) + }) +}) diff --git a/src/main/runtime/pty-recorded-surface-topology.ts b/src/main/runtime/pty-recorded-surface-topology.ts new file mode 100644 index 00000000000..b40b75658a4 --- /dev/null +++ b/src/main/runtime/pty-recorded-surface-topology.ts @@ -0,0 +1,85 @@ +/** + * Whether the pane a PTY record names as its surface still exists and still holds it. + * + * Why a graph stamp and not self-consistency: a record whose `paneKey` parses to its own `tabId` + * agrees with itself forever, so a terminal the graph had dropped read as attached under a `tabId` + * no tab has (#18191). The stamp names the sequence a claim is good as of, so absence counts only + * against a claim some graph statement had the standing to contradict — which a spawn ahead of the + * graph (#7587) and a graph that went away (leaves cleared, sequence not bumped) do not. + */ +import { parsePaneKey } from '../../shared/stable-pane-id' + +export type RecordedPtySurface = { + ptyId: string + tabId: string | null + paneKey: string | null + /** Value of `graphSequence` when this surface was last written. */ + surfaceRecordedAtGraphSequence: number +} + +/** + * The standing a surface claim gets when its writer does not name one. A persisted replay, a stored + * mobile snapshot and an inventory restore are all derived from a graph that has already had its + * say, so none of them may speak over it — and defaulting the other way is what let the restore in + * `terminal list` un-drop a pane the graph dropped (#18191). + */ +export const SURFACE_CLAIM_WITHOUT_STANDING = 0 + +/** + * A spawn names its pane before the graph carrying it exists (#7587), so the one statement the + * renderer may already have in flight is not silence about that pane. Renderer syncs are + * serialized, so there is never more than one. + */ +export function spawnSurfaceClaimSequence(graphSequence: number): number { + return graphSequence + 1 +} + +/** The one way to name a PTY's surface: a bare `paneKey =` leaves the stamp behind. */ +export function recordPtySurfaceClaim( + pty: RecordedPtySurface, + paneKey: string | null, + graphSequence: number +): void { + // Replaying the claim already on the record is not new evidence, but it must not retract the + // standing that claim already had. + pty.surfaceRecordedAtGraphSequence = + paneKey === pty.paneKey + ? Math.max(pty.surfaceRecordedAtGraphSequence, graphSequence) + : graphSequence + pty.paneKey = paneKey +} + +export function recordPtySurface( + pty: RecordedPtySurface, + tabId: string, + paneKey: string, + graphSequence: number +): void { + pty.tabId = tabId + recordPtySurfaceClaim(pty, paneKey, graphSequence) +} + +export type PtySurfaceTopology = { + /** Monotonic count of authoritative graph statements applied so far. */ + graphSequence: number + /** The ptyId the graph currently binds to this pane, or undefined when it holds no such pane. */ + ptyIdHoldingPane: (tabId: string, leafId: string) => string | null | undefined +} + +/** + * True when the record names a pane the graph agrees this PTY occupies, or when nothing has had + * the standing to contradict it yet. False is the reportable state: a live PTY with no surface. + */ +export function ptyHoldsRecordedSurface( + pty: RecordedPtySurface, + topology: PtySurfaceTopology +): boolean { + const pane = parsePaneKey(pty.paneKey ?? '') + if (!pty.tabId || !pane || pane.tabId !== pty.tabId) { + return false + } + if (pty.surfaceRecordedAtGraphSequence >= topology.graphSequence) { + return true + } + return topology.ptyIdHoldingPane(pane.tabId, pane.leafId) === pty.ptyId +} diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index 239e461b2f2..59fbf76b857 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -19,6 +19,10 @@ type RuntimeTerminalOrphanAdoptionPorts = { getPty: (handle: string) => RuntimePtyWorktreeRecord | null getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[] getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined + /** Replays a binding the session already held: names the pane without claiming the graph holds it. */ + replayPersistedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void + /** Names a pane this adoption just wrote, ahead of the graph statement that will carry it. */ + recordAdoptedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void getMobileSnapshots: () => Iterable getSession: (worktreeId: string) => WorkspaceSessionState | null setSession: (worktreeId: string, session: WorkspaceSessionState) => void @@ -134,8 +138,7 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { }) if (isExactPersisted && sessionWorktreeId === workspace.id) { for (const { claim, pty, paneKey } of validated) { - pty.tabId = claim.tabId - pty.paneKey = paneKey + ports.replayPersistedSurface(pty, claim.tabId, paneKey) } return { adopted: false, @@ -235,8 +238,7 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { throw error } for (const { claim, pty, paneKey } of validated) { - pty.tabId = claim.tabId - pty.paneKey = paneKey + ports.recordAdoptedSurface(pty, claim.tabId, paneKey) } ports.hydrateSession(workspace.id) ports.notifySessionChanged(workspace.id) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 8f54856be0d..82e5124bf40 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -54,6 +54,12 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { wslDistro: string | null tabId: string | null paneKey: string | null + /** + * `graphSequence` when `paneKey` was last written. A surface recorded since the last graph + * statement has not yet been offered one that could contradict it — see + * pty-recorded-surface-topology.ts. + */ + surfaceRecordedAtGraphSequence: number launchConfig: SleepingAgentLaunchConfig | null launchToken: string | null launchIncarnationId: PtyIncarnationId | null diff --git a/src/main/runtime/terminal-list-surface-lost-orphan.test.ts b/src/main/runtime/terminal-list-surface-lost-orphan.test.ts new file mode 100644 index 00000000000..d64d14ea575 --- /dev/null +++ b/src/main/runtime/terminal-list-surface-lost-orphan.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../shared/terminal-tab-types' +import { makePaneKey } from '../../shared/stable-pane-id' +import { spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' + +// #18191: a terminal whose pane the graph dropped kept reporting `orphaned: false` with a +// `tabId` no tab has — "field-for-field identical to a healthy one", so an operator polling +// `terminal list` had no signal at all. The runtime asked whether the PTY record agreed with +// itself; it never asked the leaf topology whether that pane still exists. + +const WORKTREE_ID = 'repo-1::/tmp/probe-worktree' +const KEPT_LEAF = '11111111-1111-4111-8111-111111111111' +const DROPPED_LEAF = '22222222-2222-4222-8222-222222222222' +const KEPT_PTY = 'pty-ui-created' +const DROPPED_PTY = 'pty-cli-created' +const KEPT_INCARNATION = 'inc-kept' +const DROPPED_INCARNATION = 'inc-dropped' + +function persistedTab(id: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId: WORKTREE_ID, + title: '', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +/** Only ptyIdsByLeafId is read by indexPersistedPtySurfaceBindings; the rest stays inert. */ +function persistedLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +/** A session that still persists both panes, exactly as it is between a graph drop and the next save. */ +function sessionStillHoldingBothPanes(): WorkspaceSessionState { + const session = getDefaultWorkspaceSession() + session.tabsByWorktree = { + [WORKTREE_ID]: [persistedTab('tab-kept'), persistedTab('tab-dropped')] + } + session.terminalLayoutsByTabId = { + 'tab-kept': persistedLayout(KEPT_LEAF, KEPT_PTY), + 'tab-dropped': persistedLayout(DROPPED_LEAF, DROPPED_PTY) + } + session.terminalPtyIncarnationsByPaneKey = { + [makePaneKey('tab-kept', KEPT_LEAF)]: KEPT_INCARNATION, + [makePaneKey('tab-dropped', DROPPED_LEAF)]: DROPPED_INCARNATION + } + return session +} + +function makeStore(session: WorkspaceSessionState = getDefaultWorkspaceSession()) { + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => [ + { + id: 'repo-1', + path: '/tmp/probe-worktree', + displayName: 'probe', + badgeColor: '#000000', + addedAt: 0 + } + ]), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +function leaf(tabId: string, leafId: string, ptyId: string) { + return { + tabId, + worktreeId: WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId, + paneTitle: null, + title: '' + } +} + +function tab(tabId: string, activeLeafId: string) { + return { tabId, worktreeId: WORKTREE_ID, title: '', activeLeafId, layout: null } +} + +/** Both PTYs stay live on the host throughout; only the graph changes. */ +function makeRuntime(session?: WorkspaceSessionState): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: makeStore returns the repo and session reads this suite drives; the rest of Store is unreached. + const runtime = new OrcaRuntimeService(makeStore(session) as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub carries the four controller members this suite drives; both PTYs stay live throughout. + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => [ + { id: KEPT_PTY, cwd: '/tmp/probe-worktree', incarnationId: KEPT_INCARNATION }, + { id: DROPPED_PTY, cwd: '/tmp/probe-worktree', incarnationId: DROPPED_INCARNATION } + ]) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [tab('tab-kept', KEPT_LEAF), tab('tab-dropped', DROPPED_LEAF)], + leaves: [leaf('tab-kept', KEPT_LEAF, KEPT_PTY), leaf('tab-dropped', DROPPED_LEAF, DROPPED_PTY)] + }) + return runtime +} + +/** The restart republishes a graph that kept one pane and dropped the other. */ +function dropOnePane(runtime: OrcaRuntimeService): void { + runtime.syncWindowGraph(1, { + tabs: [tab('tab-kept', KEPT_LEAF)], + leaves: [leaf('tab-kept', KEPT_LEAF, KEPT_PTY)] + }) +} + +describe('terminal inventory after a pane is dropped', () => { + it('reports both terminals attached while both panes exist', async () => { + const runtime = makeRuntime() + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const byPty = new Map(terminals.map((terminal) => [terminal.ptyId, terminal])) + expect(byPty.get(KEPT_PTY)?.orphaned).toBe(false) + expect(byPty.get(DROPPED_PTY)?.orphaned).toBe(false) + }) + + it('distinguishes the surface-lost terminal from the healthy one', async () => { + const runtime = makeRuntime() + dropOnePane(runtime) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const byPty = new Map(terminals.map((terminal) => [terminal.ptyId, terminal])) + const kept = byPty.get(KEPT_PTY) + const dropped = byPty.get(DROPPED_PTY) + + // The whole defect in one line: these two readings used to be identical. + expect(dropped?.orphaned).not.toBe(kept?.orphaned) + expect(dropped?.orphaned).toBe(true) + expect(kept?.orphaned).toBe(false) + }) + + it('stops pointing callers at the tab that no longer exists', async () => { + const runtime = makeRuntime() + dropOnePane(runtime) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const dropped = terminals.find((terminal) => terminal.ptyId === DROPPED_PTY) + // `terminal close --tab tab-dropped` is what returned `tab_not_found` (#18191 §5). + expect(dropped?.tabId).not.toBe('tab-dropped') + expect(dropped?.tabId).toBe(`pty:${DROPPED_PTY}`) + }) + + it('keeps reporting the live PTY rather than dropping it from inventory', async () => { + const runtime = makeRuntime() + dropOnePane(runtime) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + // Losing a surface is not evidence the process ended; it must stay listed and connected. + const dropped = terminals.find((terminal) => terminal.ptyId === DROPPED_PTY) + expect(dropped).toBeDefined() + expect(dropped?.connected).toBe(true) + }) + + it('does not call a surface recorded since the last graph statement orphaned', async () => { + const runtime = makeRuntime() + dropOnePane(runtime) + // Spawn records the renderer's pane identity before the graph carrying it arrives (#7587). + // Re-recording the dropped pane stands in for that: the graph has not spoken since, so its + // silence is not a retraction. This also pins that the runtime stamps the record at all — + // orca-runtime-record-pty-worktree.ts is `@ts-nocheck`, so a missing stamp is silent there + // and would leave every freshly spawned terminal reporting orphaned for one graph. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: recordPtyWorktree is protected; reaching it is the only way to stamp a pane the graph never published. + const stamp = runtime as unknown as { + recordPtyWorktree: (ptyId: string, worktreeId: string, state: Record) => void + graphSequence: number + } + stamp.recordPtyWorktree(DROPPED_PTY, WORKTREE_ID, { + connected: true, + tabId: 'tab-dropped', + paneKey: `tab-dropped:${DROPPED_LEAF}`, + surfaceRecordedAtGraphSequence: spawnSurfaceClaimSequence(stamp.graphSequence) + }) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const dropped = terminals.find((terminal) => terminal.ptyId === DROPPED_PTY) + expect(dropped?.orphaned).toBe(false) + }) + + it('does not let the inventory restore un-drop a pane the graph dropped', async () => { + // `list` always refreshes PTY records from the controller inventory first, and that refresh + // replays the still-persisted paneKey. Stamping that replay at write time gave it the standing + // of a fresh graph statement, so the very read that reports the orphan erased it first. + const runtime = makeRuntime(sessionStillHoldingBothPanes()) + dropOnePane(runtime) + + const first = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const second = await runtime.listTerminals(`id:${WORKTREE_ID}`) + for (const { terminals } of [first, second]) { + const byPty = new Map(terminals.map((terminal) => [terminal.ptyId, terminal])) + expect(byPty.get(DROPPED_PTY)?.orphaned).toBe(true) + expect(byPty.get(DROPPED_PTY)?.tabId).toBe(`pty:${DROPPED_PTY}`) + expect(byPty.get(KEPT_PTY)?.orphaned).toBe(false) + } + }) + + it('does not call every pane orphaned when the graph goes away', async () => { + const runtime = makeRuntime() + // Losing the authoritative graph clears the leaf map wholesale + // (transitionGraphReloadToTerminalState). That emptiness says nothing about any individual + // PTY, so reading it as "no pane holds this" would report every live terminal orphaned at + // once — the same lie as #18191, pointed the other way. + runtime.markGraphUnavailable(1) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + expect(terminals.length).toBeGreaterThan(0) + for (const terminal of terminals) { + expect(terminal.orphaned).toBe(false) + } + }) + + it('keeps naming the dropped pane after the graph goes away', async () => { + const runtime = makeRuntime() + dropOnePane(runtime) + runtime.markGraphUnavailable(1) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + const byPty = new Map(terminals.map((terminal) => [terminal.ptyId, terminal])) + // Losing the graph must not retract an observation already made. + expect(byPty.get(DROPPED_PTY)?.orphaned).toBe(true) + expect(byPty.get(KEPT_PTY)?.orphaned).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 679208b3764..4d4769d02eb 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -645,18 +645,21 @@ export function createRemoteRuntimePtyTransport( try { snapshot = request === 'list' - ? await listRemoteRuntimeSessionTabsDeduped({ - environmentId: currentRuntimeEnvironmentId, - worktreeId, - load: () => - callRuntime( - 'session.tabs.list', - { - worktree - }, - requestRemainingMs - ) - }) + ? ( + await listRemoteRuntimeSessionTabsDeduped({ + environmentId: currentRuntimeEnvironmentId, + worktreeId, + load: async () => ({ + snapshot: await callRuntime( + 'session.tabs.list', + { + worktree + }, + requestRemainingMs + ) + }) + }) + ).snapshot : await activateHostSessionSurface(hostTabId, worktree, 'user', requestRemainingMs) } catch (error) { if (request === 'list') { @@ -806,18 +809,21 @@ export function createRemoteRuntimePtyTransport( try { const listed = request === 'list' - ? await listRemoteRuntimeSessionTabsDeduped({ - environmentId: currentRuntimeEnvironmentId, - worktreeId, - load: () => - callRuntime( - 'session.tabs.list', - { - worktree - }, - requestRemainingMs - ) - }) + ? ( + await listRemoteRuntimeSessionTabsDeduped({ + environmentId: currentRuntimeEnvironmentId, + worktreeId, + load: async () => ({ + snapshot: await callRuntime( + 'session.tabs.list', + { + worktree + }, + requestRemainingMs + ) + }) + }) + ).snapshot : // Why: reconnect recovery, not a user gesture — a pane the user slept // must stay slept even though it publishes the same pending status. await activateHostSessionSurface(hostTabId, worktree, 'automatic', requestRemainingMs) @@ -1232,13 +1238,14 @@ export function createRemoteRuntimePtyTransport( } if (terminal.worktreeId === undefined) { const worktree = toRuntimeWorktreeSelector(worktreeId) - const listed = await listRemoteRuntimeSessionTabsDeduped({ + const { snapshot: listed } = await listRemoteRuntimeSessionTabsDeduped({ environmentId: currentRuntimeEnvironmentId, worktreeId, - load: () => - callRuntime('session.tabs.list', { + load: async () => ({ + snapshot: await callRuntime('session.tabs.list', { worktree }) + }) }) const exactLegacyOwner = getHostSessionTerminalSurfaces(listed, tabId, { matchRequestedLeaf: true diff --git a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx index 920ce37bc20..a63086508eb 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx @@ -260,6 +260,53 @@ describe('the eager post-create list answers for its worktree', () => { expectReplayedResume(paneKey, WT, 'codex-session-eager-refresh') }) + it('does not resurrect a worktree the stream retracted while the list was in flight', async () => { + // The refresh path is a production apply path (close, create, activation, split, PTY + // reconnect) that reached `decide` with no place in receipt order at all. A list the host + // answered before the close then landed after the retraction and put the tab back. + renderHook(() => useWebSessionTabsSync()) + await act(settle) + + let resolveList!: (response: unknown) => void + runtimeCall.mockImplementation((request: { method: string }) => + request.method === 'session.tabs.list' + ? new Promise((resolve) => { + resolveList = resolve + }) + : new Promise(() => {}) + ) + const refreshed = refreshWebRuntimeSessionTabsSnapshot(ENV, WT) + await act(settle) + + // The close lands on the stream while that list is still out. + await publish(findSubscription('session.tabs.subscribeAll'), { + type: 'snapshot', + worktree: WT, + publicationEpoch: `removed:${(1_700_000_000_000).toString(36)}`, + snapshotVersion: 0, + removed: true, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) + + // The pre-close answer arrives last, at a higher version than anything since. + resolveList({ + id: 'list', + ok: true as const, + result: { ...makeHostSnapshot(WT, HOST_SURFACE_ID, HOST_PARENT_TAB_ID), snapshotVersion: 9 }, + _meta: { runtimeId: 'runtime-a' } + }) + await act(async () => { + await refreshed + await settle() + }) + + expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) + }) + it('settles nothing when the list answers for a workspace the mirror never writes', async () => { runtimeCall.mockImplementation((request: { method: string }) => request.method === 'session.tabs.list' diff --git a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts index 3d847fe5304..49678292008 100644 --- a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts +++ b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts @@ -18,10 +18,10 @@ const SNAPSHOT = { describe('remote runtime session-tabs in-flight requests', () => { it('shares one request within an environment/worktree and evicts it after settlement', async () => { - let resolveLoad: (snapshot: RuntimeMobileSessionTabsResult) => void = () => {} + let resolveLoad: (answer: { snapshot: RuntimeMobileSessionTabsResult }) => void = () => {} const load = vi.fn( () => - new Promise((resolve) => { + new Promise<{ snapshot: RuntimeMobileSessionTabsResult }>((resolve) => { resolveLoad = resolve }) ) @@ -32,11 +32,17 @@ describe('remote runtime session-tabs in-flight requests', () => { expect(load).toHaveBeenCalledOnce() expect(getRemoteRuntimeSessionTabsInFlightCountForTests()).toBe(1) - resolveLoad(SNAPSHOT) - await expect(Promise.all([first, second])).resolves.toEqual([SNAPSHOT, SNAPSHOT]) + resolveLoad({ snapshot: SNAPSHOT }) + // Why: a joiner inherits the request's receipt position instead of minting a newer one. + await expect(Promise.all([first, second])).resolves.toEqual([ + { snapshot: SNAPSHOT, receivedFrame: expect.any(Number) }, + { snapshot: SNAPSHOT, receivedFrame: expect.any(Number) } + ]) + const [firstAnswer, secondAnswer] = await Promise.all([first, second]) + expect(firstAnswer.receivedFrame).toBe(secondAnswer.receivedFrame) expect(getRemoteRuntimeSessionTabsInFlightCountForTests()).toBe(0) - const followupLoad = vi.fn(async () => SNAPSHOT) + const followupLoad = vi.fn(async () => ({ snapshot: SNAPSHOT })) await listRemoteRuntimeSessionTabsDeduped({ ...args, load: followupLoad @@ -46,7 +52,7 @@ describe('remote runtime session-tabs in-flight requests', () => { }) it('does not share requests across runtime or worktree ownership boundaries', async () => { - const load = vi.fn(async () => SNAPSHOT) + const load = vi.fn(async () => ({ snapshot: SNAPSHOT })) await Promise.all([ listRemoteRuntimeSessionTabsDeduped({ @@ -70,17 +76,17 @@ describe('remote runtime session-tabs in-flight requests', () => { }) it('waits out an older request before sharing a post-operation inventory', async () => { - let resolveCurrent: (snapshot: RuntimeMobileSessionTabsResult) => void = () => {} + let resolveCurrent: (answer: { snapshot: RuntimeMobileSessionTabsResult }) => void = () => {} const currentLoad = vi.fn( () => - new Promise((resolve) => { + new Promise<{ snapshot: RuntimeMobileSessionTabsResult }>((resolve) => { resolveCurrent = resolve }) ) - let resolveFresh: (snapshot: RuntimeMobileSessionTabsResult) => void = () => {} + let resolveFresh: (answer: { snapshot: RuntimeMobileSessionTabsResult }) => void = () => {} const freshLoad = vi.fn( () => - new Promise((resolve) => { + new Promise<{ snapshot: RuntimeMobileSessionTabsResult }>((resolve) => { resolveFresh = resolve }) ) @@ -97,11 +103,15 @@ describe('remote runtime session-tabs in-flight requests', () => { }) expect(freshLoad).not.toHaveBeenCalled() - resolveCurrent(SNAPSHOT) - await expect(current).resolves.toBe(SNAPSHOT) + resolveCurrent({ snapshot: SNAPSHOT }) + await expect(current.then((answer) => answer.snapshot)).resolves.toBe(SNAPSHOT) await vi.waitFor(() => expect(freshLoad).toHaveBeenCalledOnce()) - resolveFresh({ ...SNAPSHOT, snapshotVersion: 2 }) - await expect(Promise.all([firstFresh, secondFresh])).resolves.toEqual([ + resolveFresh({ snapshot: { ...SNAPSHOT, snapshotVersion: 2 } }) + await expect( + Promise.all([firstFresh, secondFresh]).then((answers) => + answers.map((answer) => answer.snapshot) + ) + ).resolves.toEqual([ { ...SNAPSHOT, snapshotVersion: 2 }, { ...SNAPSHOT, snapshotVersion: 2 } ]) diff --git a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts index 94302b184b8..1e371c94ea5 100644 --- a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts +++ b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts @@ -1,11 +1,25 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { nextReceivedSessionTabsFrame } from './web-session-tabs-sync/state' -const inFlightBySession = new Map>() +/** + * A list's answer, carrying the identity of the request that produced it. + * + * A joiner never runs `load`, so anything it needs to rank the answer has to travel with the answer: + * minting a fresh receipt position for a response that was reserved before the join would let a + * pre-close list out-rank the retraction that overtook it. + */ +export type RemoteRuntimeSessionTabsAnswer = { + snapshot: RuntimeMobileSessionTabsResult + receivedFrame: number + runtimeId?: string +} + +const inFlightBySession = new Map>() type RemoteRuntimeSessionTabsLoad = { environmentId: string worktreeId: string - load: () => Promise + load: () => Promise<{ snapshot: RuntimeMobileSessionTabsResult; runtimeId?: string }> } function remoteRuntimeSessionTabsKey(args: { environmentId: string; worktreeId: string }): string { @@ -14,26 +28,34 @@ function remoteRuntimeSessionTabsKey(args: { environmentId: string; worktreeId: export function listRemoteRuntimeSessionTabsDeduped( args: RemoteRuntimeSessionTabsLoad -): Promise { +): Promise { const key = remoteRuntimeSessionTabsKey(args) const existing = inFlightBySession.get(key) if (existing) { return existing } + const receivedFrame = nextReceivedSessionTabsFrame() // Why: one runtime snapshot answers every pane in the worktree, so split-pane // reconnects should share the same in-flight inventory RPC. - const request = args.load().finally(() => { - if (inFlightBySession.get(key) === request) { - inFlightBySession.delete(key) - } - }) + const request = args + .load() + .then(({ snapshot, runtimeId }) => ({ + snapshot, + receivedFrame, + ...(runtimeId ? { runtimeId } : {}) + })) + .finally(() => { + if (inFlightBySession.get(key) === request) { + inFlightBySession.delete(key) + } + }) inFlightBySession.set(key, request) return request } export async function listRemoteRuntimeSessionTabsAfterCurrentInFlight( args: RemoteRuntimeSessionTabsLoad -): Promise { +): Promise { const current = inFlightBySession.get(remoteRuntimeSessionTabsKey(args)) if (current) { // Why: a post-operation absence proof cannot join an inventory request that diff --git a/src/renderer/src/runtime/web-runtime-session-snapshot.ts b/src/renderer/src/runtime/web-runtime-session-snapshot.ts index e0d88d4c22e..86b5399abab 100644 --- a/src/renderer/src/runtime/web-runtime-session-snapshot.ts +++ b/src/renderer/src/runtime/web-runtime-session-snapshot.ts @@ -12,6 +12,13 @@ import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { captureRuntimeEnvironmentCall } from './web-runtime-session-environment' import { throwIfE2eWebRuntimeBrowserReconciliationFails } from './web-runtime-browser-creation-e2e-fault' import { getSessionTabsRuntimeIdFromResponse } from './web-session-tabs-sync/publisher-identity-fences' +import { WEB_SESSION_TABS_FRAME_OUTRANKED } from './web-session-tabs-sync/tracking-decisions' +// Not through the barrel: receipt ordering is this path's gate, not an optional collaborator a +// caller's module mock may leave out — doing so is what left this path unordered to begin with. +import { + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './web-session-tabs-sync/tracking' import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery' const pendingRuntimeWorktreeRecoveryRefreshes = new Map() @@ -57,9 +64,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( if (options.afterCurrentInFlight) { throwIfE2eWebRuntimeBrowserReconciliationFails() } - // Why: a joined in-flight list leaves this undefined, and recovery then fences on the adoption response instead. - let runtimeId: string | undefined - const snapshot = await listSessionTabs({ + const { snapshot, receivedFrame, runtimeId } = await listSessionTabs({ environmentId, worktreeId, load: async () => { @@ -70,10 +75,12 @@ export async function refreshWebRuntimeSessionTabsSnapshot( }, timeoutMs: 15_000 }) - runtimeId = getSessionTabsRuntimeIdFromResponse(response) - return unwrapRuntimeRpcResult( - response as RuntimeRpcResponse - ) + return { + snapshot: unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ), + runtimeId: getSessionTabsRuntimeIdFromResponse(response) + } } }) if (options.confirmAgentSessionHandoff) { @@ -91,6 +98,15 @@ export async function refreshWebRuntimeSessionTabsSnapshot( applyWebSessionTabsStorePatch, decideWebSessionTabsSnapshot } = webSessionTabsSync + // A list is evidence about a moment, not about now. Record its place in receipt order before + // ranking it, or a snapshot the host answered before a close lands after the retraction did. + recordReceivedWebSessionTabsSnapshot( + environmentId, + snapshot, + receivedFrame, + runtimeId, + 'bootstrap' + ) if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision) { return } @@ -113,7 +129,14 @@ export async function refreshWebRuntimeSessionTabsSnapshot( // Why: this list is the host answering, but only the frame's own decision // says whether that answer is evidence — a workspace the mirror never // writes is discarded with nothing accepted behind it. - const decision = decideWebSessionTabsSnapshot(recovered, environmentId) + const decision = shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + recovered, + receivedFrame, + runtimeId + ) + ? decideWebSessionTabsSnapshot(recovered, environmentId) + : WEB_SESSION_TABS_FRAME_OUTRANKED const settleMirror = applyWebSessionTabsStorePatch( (state) => { // Why: eager refreshes can resolve after the user switched worktrees; update tabs without stealing focus. diff --git a/src/renderer/src/runtime/web-session-tabs-publisher-identity-lineage.test.ts b/src/renderer/src/runtime/web-session-tabs-publisher-identity-lineage.test.ts new file mode 100644 index 00000000000..00722ed9133 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-publisher-identity-lineage.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { decideWebSessionTabsSnapshot } from './web-session-tabs-sync' +import { + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './web-session-tabs-sync/tracking' +import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) +vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn() +})) + +/** + * "Same publisher" had two answers that disagreed. `noteRetiredValue` treated a `:headless-merge:` + * epoch as a successor of its base and retired the base when it became current, while + * `sameSessionTabsPublicationLineage` treated the two as one publisher. The retired-value check + * matched exactly, which is what kept those two from ever meeting: a suffixed frame was simply a + * different string, so it never looked retired. + * + * The cost was that the same predecessor was accepted or rejected depending on which shape it + * arrived in. These pin the single answer: a lineage sibling is the same publisher everywhere — it + * advances the current epoch instead of superseding it, and it inherits its generation's + * retirement instead of escaping it. + */ +const ENV = 'remote-runtime' +const WORKTREE = 'repo::/worktree' +const GEN_1 = 'renderer-generation-1' +const GEN_2 = 'renderer-generation-2' +const MERGED_GEN_1 = `${GEN_1}:headless-merge:abc` + +function frame(publicationEpoch: string, snapshotVersion: number): RuntimeMobileSessionTabsResult { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the literal names every field this lineage suite reads; the cast only supplies the rest of the frame shape. + return { + worktree: WORKTREE, + publicationEpoch, + snapshotVersion, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } as RuntimeMobileSessionTabsResult +} + +describe('a headless merge is the same publisher as its base epoch', () => { + beforeEach(() => { + resetWebSessionTabsSyncTestState() + }) + + /** + * The fail-open half. A superseded generation used to walk straight back in by republishing + * under a merged epoch, because the fence compared strings and the merged form was a different + * string. The bare form of the identical frame was rejected. + */ + for (const [label, epoch] of [ + ['bare', GEN_1], + ['headless-merge', MERGED_GEN_1] + ] as const) { + it(`fences a ${label} frame from a generation a successor replaced`, () => { + expect(decideWebSessionTabsSnapshot(frame(GEN_1, 5), ENV).apply).toBe(true) + expect(decideWebSessionTabsSnapshot(frame(GEN_2, 1), ENV).apply).toBe(true) + + expect(decideWebSessionTabsSnapshot(frame(epoch, 9), ENV).apply).toBe(false) + }) + } + + /** + * The fail-closed half, and the reason this cannot be fixed in the fence alone. Making the fence + * lineage-aware while the base epoch is still retired by its own merged form has the generation + * retire itself: the rebuild arrives, retires `gen-1`, and is then rejected as a retired + * generation. A publisher must be able to add runtime-owned surfaces without fencing itself out. + */ + it('admits a generation rebuilding under a merged epoch, and returning to a bare one', () => { + expect(decideWebSessionTabsSnapshot(frame(GEN_1, 1), ENV).apply).toBe(true) + expect(decideWebSessionTabsSnapshot(frame(MERGED_GEN_1, 2), ENV).apply).toBe(true) + expect(decideWebSessionTabsSnapshot(frame(GEN_1, 3), ENV).apply).toBe(true) + }) + + /** + * The same single answer has to hold at the recovery gate, which fences on identity too. Since a + * retraction no longer retires anything, a handover is the only thing that reaches this fence: + * narrower than it was, not unreachable. + */ + for (const [label, epoch] of [ + ['bare', GEN_1], + ['headless-merge', MERGED_GEN_1] + ] as const) { + it(`fences a ${label} predecessor at the recovery gate as well`, () => { + const firstReceived = recordReceivedWebSessionTabsSnapshot(ENV, frame(GEN_1, 5)) + expect(decideWebSessionTabsSnapshot(frame(GEN_1, 5), ENV).apply).toBe(true) + + const successorReceived = recordReceivedWebSessionTabsSnapshot(ENV, frame(GEN_2, 1)) + expect(successorReceived).toBeGreaterThan(firstReceived) + expect(decideWebSessionTabsSnapshot(frame(GEN_2, 1), ENV).apply).toBe(true) + + // A sibling stream delivers it late enough to win on delivery order; retired by lineage, so + // it must still lose. + const late = frame(epoch, 9) + const lateReceived = recordReceivedWebSessionTabsSnapshot(ENV, late) + expect(lateReceived).toBeGreaterThan(successorReceived) + expect(shouldApplyRecoveredWebSessionTabsSnapshot(ENV, late, lateReceived)).toBe(false) + }) + } + + /** A retirement is per worktree: a sibling worktree's history must not fence this one. */ + it('keeps lineage retirement scoped to the worktree that retired it', () => { + expect(decideWebSessionTabsSnapshot(frame(GEN_1, 5), ENV).apply).toBe(true) + expect(decideWebSessionTabsSnapshot(frame(GEN_2, 1), ENV).apply).toBe(true) + + const sibling = { ...frame(MERGED_GEN_1, 1), worktree: 'repo::/other-worktree' } + expect(decideWebSessionTabsSnapshot(sibling, ENV).apply).toBe(true) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts b/src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts new file mode 100644 index 00000000000..e2bb6055d5b --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts @@ -0,0 +1,314 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { decideWebSessionTabsSnapshot } from './web-session-tabs-sync' +import { + recordReceivedWebSessionTabsRemoval, + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './web-session-tabs-sync/tracking' +import { + MAX_TRACKED_SESSION_TABS_RECEIPTS, + nextReceivedSessionTabsFrame, + VISIBILITY_INVENTORY_REMOVAL_EPOCH +} from './web-session-tabs-sync/state' +import { UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH } from '../../../shared/runtime-types' +import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) +vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn() +})) + +/** + * A host drops a worktree's entry when its last tab closes and announces that with a synthetic + * `removed:` epoch. That announcement is a retraction by a transient publisher, not a handover: + * the renderer generation that published the worktree is still the live one and will publish the + * worktree again the moment a client recreates a terminal in it. Recording the retraction as a + * publication retired that live generation and locked it out of its own worktree. + * + * A predecessor frame already in flight when the retraction landed and the live publisher's next + * frame are the same epoch at a higher version, so epoch identity cannot separate them and never + * could. Delivery order can: the first reserved its received frame before the retraction, and the + * second arrives after it, as the live publisher speaking again. `shouldApplyRecoveredWebSessionTabsSnapshot` holds that order and + * is the gate every production apply path passes through before `decideWebSessionTabsSnapshot`. + */ +const ENVIRONMENT_ID = 'remote-runtime' +const WORKTREE = 'repo::/worktree' +const LIVE_EPOCH = 'renderer-generation-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function liveFrame(snapshotVersion: number): RuntimeMobileSessionTabsResult { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the live frame names every field this suite reads; the cast only supplies the rest of the frame shape. + return { + worktree: WORKTREE, + publicationEpoch: LIVE_EPOCH, + snapshotVersion, + activeGroupId: null, + activeTabId: `host-tab::${LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `host-tab::${LEAF_ID}`, + parentTabId: 'host-tab', + leafId: LEAF_ID, + title: 'Terminal', + isActive: true, + status: 'ready', + terminal: 'term_live' + } + ] + } as RuntimeMobileSessionTabsResult +} + +function removalFrame(): RuntimeMobileSessionTabsResult { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the removal frame carries `removed: true`, which the published frame type does not declare. + return { + worktree: WORKTREE, + publicationEpoch: `removed:${(1_700_000_000_000).toString(36)}`, + snapshotVersion: 0, + removed: true, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } as RuntimeMobileSessionTabsResult +} + +/** The composed gate every production apply path runs: recovery ordering AND the frame decision. */ +function admits(snapshot: RuntimeMobileSessionTabsResult, receivedFrame: number): boolean { + return ( + shouldApplyRecoveredWebSessionTabsSnapshot(ENVIRONMENT_ID, snapshot, receivedFrame) && + decideWebSessionTabsSnapshot(snapshot, ENVIRONMENT_ID).apply + ) +} + +describe('a removal frame must not retire the publisher that is still live', () => { + beforeEach(() => { + resetWebSessionTabsSyncTestState() + }) + + /** + * The other side of the same contract, and the reason the fix is not simply "stop retiring": a + * frame that was already in flight when the retraction landed carries the same epoch at a higher + * version, and must still lose. Only its place in the delivery order says so. + */ + it('still fences a predecessor frame that was in flight when the removal landed', () => { + recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, liveFrame(1)) + expect(decideWebSessionTabsSnapshot(liveFrame(1), ENVIRONMENT_ID).apply).toBe(true) + + // A list for this worktree reserves its frame while the worktree still exists. + const delayedReceived = nextReceivedSessionTabsFrame() + + const removedReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, removalFrame()) + expect(decideWebSessionTabsSnapshot(removalFrame(), ENVIRONMENT_ID).apply).toBe(true) + expect(removedReceived).toBeGreaterThan(delayedReceived) + + const delayed = liveFrame(4) + recordReceivedWebSessionTabsSnapshot( + ENVIRONMENT_ID, + delayed, + delayedReceived, + undefined, + 'bootstrap' + ) + expect(admits(delayed, delayedReceived)).toBe(false) + }) + + /** + * The receipt ledger is bounded, and one bootstrap inventory records a receipt per worktree under + * a single reserved frame. Evicting by insertion count would drop that batch's own earlier + * entries, and an absent receipt is what the recovery gate reads as "no evidence for this + * worktree" — so the bound would silently discard the worktrees it was meant to protect. + */ + it('keeps every receipt an inventory recorded under one frame, past the bound', () => { + const requestReceivedFrame = nextReceivedSessionTabsFrame() + const worktrees = Array.from( + { length: MAX_TRACKED_SESSION_TABS_RECEIPTS + 64 }, + (_value, index) => `repo::/worktree-${index}` + ) + for (const worktree of worktrees) { + recordReceivedWebSessionTabsSnapshot( + ENVIRONMENT_ID, + { ...liveFrame(1), worktree }, + requestReceivedFrame, + undefined, + 'bootstrap' + ) + } + + for (const worktree of [worktrees[0]!, worktrees.at(-1)!]) { + expect( + shouldApplyRecoveredWebSessionTabsSnapshot( + ENVIRONMENT_ID, + { ...liveFrame(1), worktree }, + requestReceivedFrame + ) + ).toBe(true) + } + }) + + /** + * The boundary must outlive the bound. A ledger entry may be dropped once nothing can be ranked + * against it, but dropping a retraction boundary readmits every pre-close frame it was fencing — + * so a long-lived session that has seen many worktrees must not lose the one thing standing + * between a stale list and a resurrected tab. + */ + it('keeps a worktree fence after enough other worktrees to evict its receipt', () => { + const liveReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, liveFrame(1)) + expect(admits(liveFrame(1), liveReceived)).toBe(true) + + const delayedReceived = nextReceivedSessionTabsFrame() + const removedReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, removalFrame()) + expect(admits(removalFrame(), removedReceived)).toBe(true) + + // Churn other worktrees through the same open-and-close cycle, past the bound and past the + // frame-age horizon, so both ledgers are over capacity when the delayed list finally lands. + for (let index = 0; index < MAX_TRACKED_SESSION_TABS_RECEIPTS + 16; index += 1) { + const worktree = `repo::/churn-${index}` + recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, { ...liveFrame(1), worktree }) + recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, { ...removalFrame(), worktree }) + } + + const delayed = liveFrame(9) + recordReceivedWebSessionTabsSnapshot( + ENVIRONMENT_ID, + delayed, + delayedReceived, + undefined, + 'bootstrap' + ) + expect(admits(delayed, delayedReceived)).toBe(false) + }) + + /** + * A worktree the host has published nothing for still answers a forced list, with a synthesized + * `none`/v0 frame that means "ask me later" (host-session-snapshot-authority.ts). Every + * post-close list and every activation of an emptied worktree gets one. Noting it as a + * publication retires the renderer generation that is still live, and since that generation's + * epoch is per-process, the terminal the user creates next never reaches this client. + */ + it('does not let an unpublished-worktree placeholder retire the live publisher', () => { + const liveReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, liveFrame(1)) + expect(admits(liveFrame(1), liveReceived)).toBe(true) + + const removedReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, removalFrame()) + expect(admits(removalFrame(), removedReceived)).toBe(true) + + const placeholder: RuntimeMobileSessionTabsResult = { + ...liveFrame(1), + publicationEpoch: UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH, + snapshotVersion: 0, + tabs: [] + } + const placeholderReceived = recordReceivedWebSessionTabsSnapshot( + ENVIRONMENT_ID, + placeholder, + undefined, + undefined, + 'bootstrap' + ) + admits(placeholder, placeholderReceived) + + // The user creates a terminal; the same live generation publishes its worktree again. + const republished = liveFrame(2) + const republishedReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, republished) + expect(admits(republished, republishedReceived)).toBe(true) + }) + + /** + * The case the version fallback cannot decide. The receipt ledger is one slot, and the live + * republication overwrites it, so by the time the pre-close list lands the only record that a + * retraction ever happened is the boundary itself. Ranking on version instead readmits the list, + * because a host that touched the dying surface on its way out published a HIGHER version than + * the renderer's counter restarts at. + */ + it('fences a pre-close list that lands after the live publisher already republished', () => { + const liveReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, liveFrame(1)) + expect(admits(liveFrame(1), liveReceived)).toBe(true) + + // The list reserves its place while the terminal is still open. + const delayedReceived = nextReceivedSessionTabsFrame() + + const removedReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, removalFrame()) + expect(admits(removalFrame(), removedReceived)).toBe(true) + + // A client recreates a terminal; the live publisher speaks again and overwrites the slot. + const republished = liveFrame(2) + const republishedReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, republished) + expect(admits(republished, republishedReceived)).toBe(true) + + const delayed = liveFrame(9) + recordReceivedWebSessionTabsSnapshot( + ENVIRONMENT_ID, + delayed, + delayedReceived, + undefined, + 'bootstrap' + ) + expect(admits(delayed, delayedReceived)).toBe(false) + }) + + /** + * The boundary is evidence, so a retraction may only ever advance it. A visibility-resume + * inventory reserves its received frame before it lists, so an omission it reports can be older + * than a stream frame that landed meanwhile. Letting that stale omission rewind the ledger would + * forget the stream frame's version and readmit a delayed frame the ledger had already outranked. + */ + it('does not let an inventory omission older than the last stream frame rewind the boundary', () => { + const inventoryReceived = nextReceivedSessionTabsFrame() + const delayedReceived = nextReceivedSessionTabsFrame() + const streamReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, liveFrame(3)) + expect(delayedReceived).toBeGreaterThan(inventoryReceived) + expect(streamReceived).toBeGreaterThan(delayedReceived) + + // The inventory sweep finally reports this worktree missing, on its older frame. + recordReceivedWebSessionTabsRemoval( + ENVIRONMENT_ID, + WORKTREE, + inventoryReceived, + VISIBILITY_INVENTORY_REMOVAL_EPOCH + ) + + // A list reserved before the stream frame lands last, carrying a genuinely stale version. + const delayed = liveFrame(1) + recordReceivedWebSessionTabsSnapshot( + ENVIRONMENT_ID, + delayed, + delayedReceived, + undefined, + 'bootstrap' + ) + expect( + shouldApplyRecoveredWebSessionTabsSnapshot(ENVIRONMENT_ID, delayed, delayedReceived) + ).toBe(false) + }) + + /** + * Rate-independence, which is the point of fixing this at the root. The defect surfaced only 1 + * run in 6 because the retired-value check is an exact string match while the lineage check + * treats `:headless-merge:` as the same publisher, so a merged republication walked past a fence + * a bare one hit. The removal path must no longer care which shape arrives; if it did, the defect + * would not be fixed, only re-rated. + * + * Through the full path, not `decideWebSessionTabsSnapshot` alone: the receipt ledger retires + * epochs too, so dropping only the retirement inside the decision turns a decide-only case green + * while the publisher stays locked out on every real path. + */ + for (const [label, epoch] of [ + ['bare', LIVE_EPOCH], + ['headless-merge', `${LIVE_EPOCH}:headless-merge:abc`] + ] as const) { + it(`readmits a ${label} republication after a removal, through the full path`, () => { + const liveReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, liveFrame(1)) + expect(admits(liveFrame(1), liveReceived)).toBe(true) + + const removedReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, removalFrame()) + expect(admits(removalFrame(), removedReceived)).toBe(true) + + const republished = { ...liveFrame(2), publicationEpoch: epoch } + const republishedReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, republished) + expect(admits(republished, republishedReceived)).toBe(true) + }) + } +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx index d5f05b11c0b..041b6e453b6 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx @@ -47,7 +47,7 @@ import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revisi import { toRemoteRuntimePtyId } from './runtime-terminal-stream' import { subscribeAcceptedWebSessionTerminalHandle } from './web-session-terminal-handle-events' import { - _getWebSessionTabsRecoveryTrackingCountsForTest, + _getWebSessionTabsReceiptTrackingCountsForTest, _getWebSessionTabsTrackingCountsForTest, resetWebSessionTabsSnapshotFreshnessForTests, useWebSessionTabsSync, @@ -626,9 +626,9 @@ describe('useWebSessionTabsSync visibility collision recovery', () => { }) expect(useAppStore.getState().tabsByWorktree[WORKTREE]).toBeUndefined() - expect(_getWebSessionTabsRecoveryTrackingCountsForTest()).toEqual({ - pendingRecoveries: 1, - removalFrames: 1 + expect(_getWebSessionTabsReceiptTrackingCountsForTest()).toEqual({ + receipts: 1, + removalWatermarks: 1 }) const liveSnapshot = makeTerminalSnapshot('-a', 2) await publish(findActiveSubscription(ENV_A, 1), { @@ -646,9 +646,11 @@ describe('useWebSessionTabsSync visibility collision recovery', () => { liveTabId ]) expect(_getWebSessionTabsTrackingCountsForTest().freshness).toBe(1) - expect(_getWebSessionTabsRecoveryTrackingCountsForTest()).toEqual({ - pendingRecoveries: 0, - removalFrames: 0 + // The retraction boundary outlives the recovery that was pending when it landed; it is what + // still fences the stale frame after the live republication overwrote the receipt slot. + expect(_getWebSessionTabsReceiptTrackingCountsForTest()).toEqual({ + receipts: 1, + removalWatermarks: 1 }) hook.unmount() }) @@ -667,19 +669,19 @@ describe('useWebSessionTabsSync visibility collision recovery', () => { const newHook = renderHook(() => useWebSessionTabsSync()) await act(settle) await publish(findActiveSubscription(ENV_A, 1), { type: 'snapshot', ...snapshot }) - expect(_getWebSessionTabsRecoveryTrackingCountsForTest().pendingRecoveries).toBe(1) + // A recovery started by an unmounted generation must not write the store it no longer owns. oldRecovery.resolve(snapshot) await act(settle) - expect(_getWebSessionTabsRecoveryTrackingCountsForTest().pendingRecoveries).toBe(1) + expect(useAppStore.getState().tabsByWorktree[WORKTREE]).toBeUndefined() newRecovery.resolve(snapshot) await act(settle) - expect(_getWebSessionTabsRecoveryTrackingCountsForTest().pendingRecoveries).toBe(0) + expect(useAppStore.getState().tabsByWorktree[WORKTREE]?.length).toBe(1) newHook.unmount() }) - it('tracks repeated same-worktree recoveries in constant map space', async () => { + it('tracks repeated same-worktree frames in constant map space', async () => { const recoveries = [ createDeferred(), createDeferred(), @@ -697,13 +699,18 @@ describe('useWebSessionTabsSync visibility collision recovery', () => { ...makeTerminalSnapshot(index === 0 ? '-a' : '-b', index + 1) }) } - expect(_getWebSessionTabsRecoveryTrackingCountsForTest().pendingRecoveries).toBe(1) - for (const [index, recovery] of recoveries.entries()) { recovery.resolve(makeTerminalSnapshot(index === 0 ? '-a' : '-b', index + 1)) } await act(settle) - expect(_getWebSessionTabsRecoveryTrackingCountsForTest().pendingRecoveries).toBe(0) + // The receipt slot is per worktree, so what repeated frames could grow is the tracking behind + // it; the watermark stays absent because nothing retracted, and the mirror holds one tab. + expect(_getWebSessionTabsReceiptTrackingCountsForTest()).toEqual({ + receipts: 1, + removalWatermarks: 0 + }) + expect(_getWebSessionTabsTrackingCountsForTest().freshness).toBe(1) + expect(useAppStore.getState().tabsByWorktree[WORKTREE]?.length).toBe(1) hook.unmount() }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx index 01c051f8ad2..4750a2429a4 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx @@ -46,7 +46,6 @@ import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revisi import { clearHostLiveTerminalProbesForTests } from './host-live-terminal-probe' import { acceptReplayedWebSessionTabsSnapshot, - _getWebSessionTabsRecoveryTrackingCountsForTest, _getWebSessionTabsTrackingCountsForTest, resetWebSessionTabsSnapshotFreshnessForTests, useWebSessionTabsSync, diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 5f3bb980885..22c996d83b5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -20,6 +20,11 @@ import { shouldApplyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './web-session-tabs-sync/tracking' +import { nextReceivedSessionTabsFrame } from './web-session-tabs-sync/state' import { ENV, HOST_SURFACE_ID, @@ -305,6 +310,11 @@ describe('applyWebSessionTabsSnapshot', () => { expect(shouldApplyWebSessionTabsSnapshot(delayedOldEpoch, ENV, 'runtime-old')).toBe(true) }) + // The property is unchanged: a predecessor frame already in flight when the worktree was removed + // must not resurrect it, even carrying a HIGHER version than the last frame accepted before the + // removal. What changed is which layer proves it. Epoch identity cannot — the live publisher + // republishes under that same epoch, and fencing on it locked the publisher out of its own + // worktree. Delivery order can, and `shouldApplyRecoveredWebSessionTabsSnapshot` holds it. it('keeps a removed worktree fenced against delayed predecessor epochs', () => { const beforeRemoval = makeSnapshot([], { publicationEpoch: 'epoch-before-removal', @@ -322,27 +332,39 @@ describe('applyWebSessionTabsSnapshot', () => { removed: true as const } + const beforeFrame = recordReceivedWebSessionTabsSnapshot(ENV, beforeRemoval) expect(shouldApplyWebSessionTabsSnapshot(beforeRemoval, ENV)).toBe(true) + + // A list for this worktree reserves its received frame here, before the removal lands. + const delayedFrame = nextReceivedSessionTabsFrame() + expect(delayedFrame).toBeGreaterThan(beforeFrame) + + const removedFrame = recordReceivedWebSessionTabsSnapshot(ENV, removed) expect(shouldApplyWebSessionTabsSnapshot(removed, ENV)).toBe(true) + expect(removedFrame).toBeGreaterThan(delayedFrame) + + const delayed = makeSnapshot([], { + publicationEpoch: 'epoch-before-removal', + snapshotVersion: 4, + activeTabType: null + }) + recordReceivedWebSessionTabsSnapshot(ENV, delayed, delayedFrame, undefined, 'bootstrap') + expect(shouldApplyRecoveredWebSessionTabsSnapshot(ENV, delayed, delayedFrame)).toBe(false) + // The composed gate, exactly as every production apply path spells it. expect( - shouldApplyWebSessionTabsSnapshot( - makeSnapshot([], { - publicationEpoch: 'epoch-before-removal', - snapshotVersion: 4, - activeTabType: null - }), - ENV - ) + shouldApplyRecoveredWebSessionTabsSnapshot(ENV, delayed, delayedFrame) && + shouldApplyWebSessionTabsSnapshot(delayed, ENV) ).toBe(false) + + const recreated = makeSnapshot([], { + publicationEpoch: 'epoch-recreated', + snapshotVersion: 1, + activeTabType: null + }) + const recreatedFrame = recordReceivedWebSessionTabsSnapshot(ENV, recreated) expect( - shouldApplyWebSessionTabsSnapshot( - makeSnapshot([], { - publicationEpoch: 'epoch-recreated', - snapshotVersion: 1, - activeTabType: null - }), - ENV - ) + shouldApplyRecoveredWebSessionTabsSnapshot(ENV, recreated, recreatedFrame) && + shouldApplyWebSessionTabsSnapshot(recreated, ENV) ).toBe(true) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 693fb3133cb..ffaf111de41 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -19,7 +19,7 @@ export { getLatestWebSessionTabsPublicationEpoch, getWebSessionTabsTrackingGeneration, resetWebSessionTabsSnapshotFreshnessForTests, - _getWebSessionTabsRecoveryTrackingCountsForTest, + _getWebSessionTabsReceiptTrackingCountsForTest, _getWebSessionTabsTrackingCountsForTest } from './web-session-tabs-sync/tracking-lifecycle' export { resolveHostSessionTabIdForWebSessionTab } from './web-session-tabs-sync/tracking-mappings' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index 83789643f1f..391953c4f28 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -6,7 +6,6 @@ import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' import { installWindowVisibilitySubscriptionParking } from '../window-visibility-subscription-parking' import { - beginWebSessionTabsSnapshotRecovery, recordReceivedWebSessionTabsSnapshot, shouldApplyRecoveredWebSessionTabsSnapshot } from './tracking' @@ -252,11 +251,6 @@ export function installActiveSessionTabsSubscription({ runtimeId ) visibilitySnapshotReceipt.current(environmentId, event, frame, runtimeId) - const finish = beginWebSessionTabsSnapshotRecovery( - environmentId, - event.worktree, - frame - ) void applyActiveSnapshot(event, response, isCurrent, frame, runtimeId) .catch((error) => { if (isCurrent()) { @@ -265,7 +259,6 @@ export function installActiveSessionTabsSubscription({ return null }) .then((settle) => { - finish() if (isCurrent()) { settle?.() } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts index a5027d4c2d3..266aa98ec10 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts @@ -4,7 +4,6 @@ import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime- import { useAppStore } from '../../store' import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' import { - beginWebSessionTabsSnapshotRecovery, recordReceivedWebSessionTabsSnapshot, shouldApplyRecoveredWebSessionTabsSnapshot } from './tracking' @@ -91,11 +90,6 @@ export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void { runtimeId ) coordinator.recordSnapshotReceipt(environmentId, event, receivedFrame, runtimeId) - const finishRecovery = beginWebSessionTabsSnapshotRecovery( - environmentId, - event.worktree, - receivedFrame - ) let settleHydration: HostSessionMirrorSettle | null = null void recoverWebSessionTerminalOrphansBeforeApply(useAppStore.getState(), event, environmentId, { expectedEnvironmentPairingRevision, @@ -158,7 +152,6 @@ export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void { } }) .finally(() => { - finishRecovery() if (isCurrent()) { settleHydration?.() } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts index a5478c6519e..080a7c99a89 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -3,7 +3,6 @@ import { useAppStore } from '../../store' import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' import { - beginWebSessionTabsSnapshotRecovery, recordReceivedWebSessionTabsInventory, recordReceivedWebSessionTabsSnapshot, shouldApplyRecoveredWebSessionTabsSnapshot @@ -81,15 +80,6 @@ export function handleGlobalSessionInventoryEvent({ event.authoritative === true, runtimeId ) - const finishRecoveries = event.snapshots.map((snapshot, index) => - unchanged[index] - ? null - : beginWebSessionTabsSnapshotRecovery( - environmentId, - snapshot.worktree, - receivedFrames[index]! - ) - ) let settleHydration: (() => void) | null = null void Promise.all( event.snapshots.map((snapshot, index) => @@ -179,9 +169,6 @@ export function handleGlobalSessionInventoryEvent({ } }) .finally(() => { - for (const finishRecovery of finishRecoveries) { - finishRecovery?.() - } if (isCurrent()) { settleHydration?.() } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts index 5bd54724c17..ff08d599cfb 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts @@ -4,7 +4,6 @@ import { useAppStore } from '../../store' import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' import { - beginWebSessionTabsSnapshotRecovery, isSessionTabsListAllResult, recordReceivedWebSessionTabsSnapshot, shouldApplyRecoveredWebSessionTabsSnapshot @@ -87,91 +86,78 @@ export function loadInitialWebSessionTabs({ 'bootstrap' ) ) - const finishRecoveries = result.snapshots.map((snapshot, index) => - beginWebSessionTabsSnapshotRecovery( - environmentId, - snapshot.worktree, - receivedFrames[index]! - ) - ) - try { - const recovered = await Promise.all( - result.snapshots.map((snapshot) => - recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - snapshot, - environmentId, - { - expectedEnvironmentPairingRevision, - expectedRuntimeId: runtimeId, - getCurrentState: () => useAppStore.getState() - } - ) + const recovered = await Promise.all( + result.snapshots.map((snapshot) => + recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId, + { + expectedEnvironmentPairingRevision, + expectedRuntimeId: runtimeId, + getCurrentState: () => useAppStore.getState() + } ) ) - if ( - !isCurrent() || - getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision - ) { - return - } - const initialInventorySuperseded = - (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) > - requestReceivedFrame - const applicable = recovered.filter( - (snapshot, index): snapshot is RuntimeMobileSessionTabsResult => - snapshot !== null && - !initialInventorySuperseded && - shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - snapshot, - receivedFrames[index]!, - runtimeId - ) - ) - const decisions = applicable.map((snapshot) => - decideWebSessionTabsSnapshot(snapshot, environmentId, runtimeId) - ) - const freshSnapshots = applicable.filter((_snapshot, index) => decisions[index]!.apply) - const initialInventoryStillCurrent = - latestReceivedSessionTabsFrameByEnvironment.get(environmentId) === requestReceivedFrame && - (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) <= - requestReceivedFrame - settleHydration = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), - { - frames: applicable.map((snapshot, index) => ({ - environmentId, - worktreeId: snapshot.worktree, - decision: decisions[index]!, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration - })), - ...(initialInventoryStillCurrent - ? { - fullInventory: { - environmentId, - authoritative: result.authoritative === true, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration, - // Why: a workspace the mirror never writes is not part of the - // inventory the environment-wide verdict has to account for. - publishedSnapshotCount: result.snapshots.filter((snapshot) => - isHostMirroredWorktree(snapshot.worktree) - ).length - } - } - : {}) - }, - applicable - ) - } finally { - for (const finishRecovery of finishRecoveries) { - finishRecovery() - } + ) + if ( + !isCurrent() || + getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision + ) { + return } + const initialInventorySuperseded = + (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) > + requestReceivedFrame + const applicable = recovered.filter( + (snapshot, index): snapshot is RuntimeMobileSessionTabsResult => + snapshot !== null && + !initialInventorySuperseded && + shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + snapshot, + receivedFrames[index]!, + runtimeId + ) + ) + const decisions = applicable.map((snapshot) => + decideWebSessionTabsSnapshot(snapshot, environmentId, runtimeId) + ) + const freshSnapshots = applicable.filter((_snapshot, index) => decisions[index]!.apply) + const initialInventoryStillCurrent = + latestReceivedSessionTabsFrameByEnvironment.get(environmentId) === requestReceivedFrame && + (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) <= + requestReceivedFrame + settleHydration = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), + { + frames: applicable.map((snapshot, index) => ({ + environmentId, + worktreeId: snapshot.worktree, + decision: decisions[index]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + })), + ...(initialInventoryStillCurrent + ? { + fullInventory: { + environmentId, + authoritative: result.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + // Why: a workspace the mirror never writes is not part of the + // inventory the environment-wide verdict has to account for. + publishedSnapshotCount: result.snapshots.filter((snapshot) => + isHostMirroredWorktree(snapshot.worktree) + ).length + } + } + : {}) + }, + applicable + ) }) .catch((error) => { if (isCurrent()) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts b/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts index fbab01b591b..f6779d5266c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts @@ -131,11 +131,22 @@ export function acceptSessionTabsRuntimeId( return true } +/** + * Retirement is a property of the publishing generation, not of the exact string it published + * under. Matching `retired` exactly let a `:headless-merge:` rebuild of a superseded generation + * walk past this fence while the bare form hit it, so the same predecessor was accepted or + * rejected depending on which shape it happened to arrive in. + */ export function isRetiredSessionTabsPublicationEpoch( key: string, publicationEpoch: string ): boolean { - return hasRetiredValue(sessionTabsPublicationEpochHistoryByWorktree.get(key), publicationEpoch) + const history = sessionTabsPublicationEpochHistoryByWorktree.get(key) + return ( + history?.retired.some((retired) => + sameSessionTabsPublicationLineage(retired, publicationEpoch) + ) ?? false + ) } /** @@ -158,11 +169,16 @@ export function noteSessionTabsPublicationEpoch( key: string, publicationEpoch: string ): SessionTabsPublicationEpochHistory { - const history = noteRetiredValue( - sessionTabsPublicationEpochHistoryByWorktree.get(key), - publicationEpoch, - SESSION_TABS_RETIRED_EPOCH_LIMIT - ) + const existing = sessionTabsPublicationEpochHistoryByWorktree.get(key) + // A headless merge is the same publisher adding runtime-owned surfaces, so it advances the + // current epoch rather than superseding it. Retiring the base here would have the generation + // retire itself, and a lineage-aware fence then rejects its own next frame. + if (existing?.current && sameSessionTabsPublicationLineage(existing.current, publicationEpoch)) { + existing.current = publicationEpoch + sessionTabsPublicationEpochHistoryByWorktree.set(key, existing) + return existing + } + const history = noteRetiredValue(existing, publicationEpoch, SESSION_TABS_RETIRED_EPOCH_LIMIT) sessionTabsPublicationEpochHistoryByWorktree.set(key, history) return history } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index 8db00f5a238..581b3ffb6c1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -63,12 +63,6 @@ export type SessionTabsRuntimeHistory = RetiredValueHistory * roll the mirror back after the replacement epoch is accepted. */ export type SessionTabsPublicationEpochHistory = RetiredValueHistory -export type SessionTabsRecoveryState = { pendingCount: number } -export type SessionTabsRemovalFence = { - receivedFrame: number - recoveryState: SessionTabsRecoveryState - pendingCount: number -} export type WebSessionTabsSnapshotApplyOptions = { contentScope?: 'all' | 'agent-session' @@ -95,6 +89,33 @@ export const latestReceivedSessionTabsSnapshotByWorktree = new Map< string, ReceivedSessionTabsSnapshot >() +/** Receipt ledgers outlive the worktrees they order, so their keys need a bound of their own. */ +export const MAX_TRACKED_SESSION_TABS_RECEIPTS = 512 + +/** + * Bounds a receipt ledger by frame age, never by entry count. One inventory records a receipt per + * worktree under a single reserved frame, and evicting by insertion order would drop that batch's + * own earlier entries — which the recovery gate reads as "no evidence for this worktree" and uses + * to reject it. Only a receipt no in-flight frame can still be ranked against is droppable. + */ +export function setBoundedSessionTabsReceipt( + map: Map, + key: string, + value: T, + frameOf: (entry: T) => number +): void { + map.set(key, value) + if (map.size <= MAX_TRACKED_SESSION_TABS_RECEIPTS) { + return + } + const oldestRankableFrame = receivedSessionTabsFrameSequence - MAX_TRACKED_SESSION_TABS_RECEIPTS + for (const [entryKey, entry] of map) { + if (frameOf(entry) < oldestRankableFrame) { + map.delete(entryKey) + } + } +} + export const sessionTabsRuntimeHistoryByEnvironment = new Map() export const sessionTabsPublicationEpochHistoryByWorktree = new Map< string, @@ -102,8 +123,17 @@ export const sessionTabsPublicationEpochHistoryByWorktree = new Map< >() export const latestReceivedSessionTabsFrameByEnvironment = new Map() export const latestReceivedSessionTabsInventoryFrameByEnvironment = new Map() -export const latestSessionTabsRemovalFenceByWorktree = new Map() -export const sessionTabsRecoveryStateByWorktree = new Map() +/** + * Highest `receivedFrame` at which this worktree was retracted. Raise-only: a frame reserved before + * the retraction is stale evidence no matter what arrived since, so the boundary cannot be a slot a + * later frame overwrites, nor conditional on a recovery happening to be in flight when it landed. + * + * Deliberately not size-bounded, unlike the receipt ledger beside it. Evicting a boundary readmits + * every pre-close frame it was fencing, which is the defect this map exists to prevent; one number + * per worktree ever retracted on an environment is a cheaper price, and the environment teardown + * below drains it. + */ +export const sessionTabsRemovalWatermarkByWorktree = new Map() export const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() export const sessionTabsEnvironmentsByWorktree = new Map>() export const sessionTabsTrackingGenerationByEnvironment = new Map() diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts index a7985fc6ab8..a5a4c851b5d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts @@ -3,7 +3,6 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime- import { latestSessionTabsSnapshotByWorktree, replayableSessionTabsSnapshotByWorktree, - VISIBILITY_INVENTORY_REMOVAL_EPOCH, type SessionTabsStreamEvent } from './state' import { @@ -19,6 +18,7 @@ import { trackWebSessionTabsWorktree, recordAcceptedWebSessionTabsEnvironment } from './tracking' +import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' import { clearWebSessionTabsTrackingForWorktree } from './tracking-lifecycle' import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' @@ -64,13 +64,10 @@ export function decideWebSessionTabsSnapshot( const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) if ((snapshot as { removed?: unknown }).removed === true) { // Why: removed worktrees can stop publishing, so clean up their tracking now instead of waiting for a replacement snapshot that may never arrive. - // Retain the removal epoch transition before dropping the live freshness - // record; delayed sibling frames from the predecessor stay fenced. - // Inventory omissions use a client-only sentinel epoch; recording that - // sentinel would retire the host epoch and reject the next live frame. - if (snapshot.publicationEpoch !== VISIBILITY_INVENTORY_REMOVAL_EPOCH) { - noteSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) - } + // A retraction is not a handover. The generation that published this worktree is still the live + // one and republishes the moment a client recreates a terminal, so retiring it here would fence + // a publisher that never died out of its own worktree. A genuinely delayed predecessor frame is + // separated from that live republication by receivedFrame, not by epoch identity. clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree) queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) return WEB_SESSION_TABS_FRAME_APPLIED @@ -117,7 +114,12 @@ export function decideWebSessionTabsSnapshot( } rememberHostTerminalTabCount(environmentId, snapshot) replayableSessionTabsSnapshotByWorktree.delete(key) - noteSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) + // A frame that affirms nothing about the worktree has not taken over publishing it, so it must + // not be noted. It still applies: rejecting it outright would drop the terminal reconciliation + // that legitimately rides on it (host-session-snapshot-authority.ts). + if (hostSnapshotAffirmsWorktreeContents(snapshot)) { + noteSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) + } latestSessionTabsSnapshotByWorktree.set(key, { publicationEpoch: snapshot.publicationEpoch, snapshotVersion: snapshot.snapshotVersion diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts index 11fe86d5107..81ed68aea29 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts @@ -4,10 +4,9 @@ import { latestReceivedSessionTabsSnapshotByWorktree, latestReceivedSessionTabsFrameByEnvironment, latestReceivedSessionTabsInventoryFrameByEnvironment, - latestSessionTabsRemovalFenceByWorktree, sessionTabsPublicationEpochHistoryByWorktree, + sessionTabsRemovalWatermarkByWorktree, sessionTabsRuntimeHistoryByEnvironment, - sessionTabsRecoveryStateByWorktree, trackedSessionTabsWorktreeIdsByEnvironment, sessionTabsEnvironmentsByWorktree, sessionTabsTrackingGenerationByEnvironment, @@ -85,8 +84,7 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { sessionTabsPublicationEpochHistoryByWorktree.clear() latestReceivedSessionTabsFrameByEnvironment.clear() latestReceivedSessionTabsInventoryFrameByEnvironment.clear() - latestSessionTabsRemovalFenceByWorktree.clear() - sessionTabsRecoveryStateByWorktree.clear() + sessionTabsRemovalWatermarkByWorktree.clear() trackedSessionTabsWorktreeIdsByEnvironment.clear() sessionTabsEnvironmentsByWorktree.clear() resetReceivedSessionTabsFrameSequence() @@ -115,13 +113,13 @@ export function _getWebSessionTabsTrackingCountsForTest(): { } } -export function _getWebSessionTabsRecoveryTrackingCountsForTest(): { - pendingRecoveries: number - removalFrames: number +export function _getWebSessionTabsReceiptTrackingCountsForTest(): { + receipts: number + removalWatermarks: number } { return { - pendingRecoveries: sessionTabsRecoveryStateByWorktree.size, - removalFrames: latestSessionTabsRemovalFenceByWorktree.size + receipts: latestReceivedSessionTabsSnapshotByWorktree.size, + removalWatermarks: sessionTabsRemovalWatermarkByWorktree.size } } @@ -132,9 +130,9 @@ export function clearWebSessionTabsTrackingForWorktree( const key = sessionTabsFreshnessKey(environmentId, worktreeId) latestSessionTabsSnapshotByWorktree.delete(key) replayableSessionTabsSnapshotByWorktree.delete(key) - latestReceivedSessionTabsSnapshotByWorktree.delete(key) - // Keep the bounded epoch history as a tombstone fence. A sibling stream can - // still deliver an old frame after this removal has cleared the live view. + // The receipt ledger and removal watermark are deliberately kept: they order a delayed + // predecessor frame against the live publisher's next one, which is the whole point of a + // retraction. Clearing the live view is this function's job; forgetting what was received is not. untrackWebSessionTabsWorktree(environmentId, worktreeId) removeWebSessionTabsEnvironment(environmentId, worktreeId) lastHostTerminalTabCountByWorktree.delete(key) @@ -181,14 +179,9 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) } latestReceivedSessionTabsFrameByEnvironment.delete(trimmedEnvironmentId) latestReceivedSessionTabsInventoryFrameByEnvironment.delete(trimmedEnvironmentId) - for (const key of latestSessionTabsRemovalFenceByWorktree.keys()) { + for (const key of sessionTabsRemovalWatermarkByWorktree.keys()) { if (key.startsWith(keyPrefix)) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - } - } - for (const key of sessionTabsRecoveryStateByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - sessionTabsRecoveryStateByWorktree.delete(key) + sessionTabsRemovalWatermarkByWorktree.delete(key) } } trackedSessionTabsWorktreeIdsByEnvironment.delete(trimmedEnvironmentId) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts index 1d6eea41055..f117ef4a714 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts @@ -2,12 +2,12 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime- import { latestReceivedSessionTabsInventoryFrameByEnvironment, latestReceivedSessionTabsSnapshotByWorktree, - latestSessionTabsRemovalFenceByWorktree, latestSessionTabsSnapshotByWorktree, lastHostTerminalTabCountByWorktree, sessionTabsEnvironmentsByWorktree, sessionTabsPublicationEpochHistoryByWorktree, - sessionTabsRecoveryStateByWorktree, + sessionTabsRemovalWatermarkByWorktree, + setBoundedSessionTabsReceipt, trackedSessionTabsWorktreeIdsByEnvironment, nextReceivedSessionTabsFrame, type SnapshotFreshness, @@ -22,6 +22,7 @@ import { noteSessionTabsPublicationEpoch, recordReceivedWebSessionTabsEnvironmentFrame } from './publisher-identity-fences' +import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' export function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult { return ( @@ -102,12 +103,22 @@ export function recordReceivedWebSessionTabsSnapshot( } recordReceivedWebSessionTabsEnvironmentFrame(environmentId, frame) const publicationEpoch = snapshot.publicationEpoch + const isRetraction = 'removed' in snapshot && snapshot.removed === true const history = sessionTabsPublicationEpochHistoryByWorktree.get(key) - const isRetired = history?.retired.includes(publicationEpoch) ?? false - if (isRetired) { + // Retirement is a property of the lineage, not of the exact string: matching exactly here let a + // `:headless-merge:` rebuild of a retired generation be noted as current, which then retired the + // live one and locked it out of its own worktree. + if (isRetiredSessionTabsPublicationEpoch(key, publicationEpoch)) { return frame } - if (!history || history.current !== publicationEpoch) { + // Neither a retraction nor a "nothing published yet" placeholder takes over publishing this + // worktree, so neither may be noted as current: doing so retires the generation that is still + // live and fences its next frame out of its own worktree. + if ( + !isRetraction && + hostSnapshotAffirmsWorktreeContents(snapshot) && + (!history || history.current !== publicationEpoch) + ) { noteSessionTabsPublicationEpoch(key, publicationEpoch) } // Stream delivery order is the freshest evidence even when a host's version @@ -121,14 +132,19 @@ export function recordReceivedWebSessionTabsSnapshot( snapshot.snapshotVersion > current.snapshotVersion || (snapshot.snapshotVersion === current.snapshotVersion && current.receivedFrame <= frame) ) { - latestReceivedSessionTabsSnapshotByWorktree.set(key, { - receivedFrame: frame, - publicationEpoch, - snapshotVersion: snapshot.snapshotVersion, - ...(runtimeId ? { runtimeId } : {}) - }) - if ((snapshot as { removed?: unknown }).removed === true) { - recordReceivedWebSessionTabsRemoval(environmentId, snapshot.worktree, frame) + setBoundedSessionTabsReceipt( + latestReceivedSessionTabsSnapshotByWorktree, + key, + { + receivedFrame: frame, + publicationEpoch, + snapshotVersion: snapshot.snapshotVersion, + ...(runtimeId ? { runtimeId } : {}) + }, + (entry) => entry.receivedFrame + ) + if (isRetraction) { + recordReceivedWebSessionTabsRemoval(environmentId, snapshot.worktree, frame, publicationEpoch) } } return frame @@ -141,65 +157,34 @@ export function recordReceivedWebSessionTabsInventory(environmentId: string): nu return receivedFrame } -export function beginWebSessionTabsSnapshotRecovery( - environmentId: string, - worktreeId: string, - receivedFrame: number -): () => void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) ?? { pendingCount: 0 } - recoveryState.pendingCount += 1 - sessionTabsRecoveryStateByWorktree.set(key, recoveryState) - let settled = false - return () => { - if (settled) { - return - } - settled = true - recoveryState.pendingCount -= 1 - if ( - recoveryState.pendingCount === 0 && - sessionTabsRecoveryStateByWorktree.get(key) === recoveryState - ) { - sessionTabsRecoveryStateByWorktree.delete(key) - } - const removalFence = latestSessionTabsRemovalFenceByWorktree.get(key) - if ( - removalFence?.recoveryState === recoveryState && - receivedFrame < removalFence.receivedFrame - ) { - removalFence.pendingCount -= 1 - if (removalFence.pendingCount === 0) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - } - } - } -} - export function recordReceivedWebSessionTabsRemoval( environmentId: string, worktreeId: string, - receivedFrame: number + receivedFrame: number, + publicationEpoch: string ): void { const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const current = latestSessionTabsRemovalFenceByWorktree.get(key) - if (current && current.receivedFrame >= receivedFrame) { - return + const latest = latestReceivedSessionTabsSnapshotByWorktree.get(key) + // A retraction is this worktree's newest evidence, not an absence of it. The ledger slot lets the + // live publisher's next frame outrank the pre-close one on version; the watermark is what the + // slot cannot be, because a later frame overwrites the slot and the boundary has to outlive it. + if (!latest || latest.receivedFrame <= receivedFrame) { + setBoundedSessionTabsReceipt( + latestReceivedSessionTabsSnapshotByWorktree, + key, + { receivedFrame, publicationEpoch, snapshotVersion: 0 }, + (entry) => entry.receivedFrame + ) } - const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) - if (!recoveryState || recoveryState.pendingCount === 0) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - return + const watermark = sessionTabsRemovalWatermarkByWorktree.get(key) ?? 0 + if (receivedFrame > watermark) { + sessionTabsRemovalWatermarkByWorktree.set(key, receivedFrame) } - latestSessionTabsRemovalFenceByWorktree.set(key, { - receivedFrame, - recoveryState, - pendingCount: recoveryState.pendingCount - }) - // An inventory omission/removal is a new visibility boundary. A later live - // frame may legitimately restart its version counter, while recoveries - // queued before this boundary are fenced by receivedFrame above. - latestReceivedSessionTabsSnapshotByWorktree.delete(key) +} + +/** True for a frame whose place in receipt order was fixed before this worktree was last retracted. */ +export function precedesWebSessionTabsRemoval(key: string, receivedFrame: number): boolean { + return receivedFrame < (sessionTabsRemovalWatermarkByWorktree.get(key) ?? 0) } export function shouldApplyRecoveredWebSessionTabsSnapshot( @@ -219,10 +204,10 @@ export function shouldApplyRecoveredWebSessionTabsSnapshot( if (isRetiredSessionTabsPublicationEpoch(key, snapshot.publicationEpoch)) { return false } - const removalFrame = latestSessionTabsRemovalFenceByWorktree.get(key)?.receivedFrame - if (removalFrame !== undefined && receivedFrame < removalFrame) { + if (precedesWebSessionTabsRemoval(key, receivedFrame)) { return false } + const latest = latestReceivedSessionTabsSnapshotByWorktree.get(key) if (!latest || latest.receivedFrame === receivedFrame) { return latest !== undefined diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts index 16541f98a31..f52c198777b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts @@ -63,7 +63,8 @@ export function recordVisibilityResumeInventoryReceipt(args: { recordReceivedWebSessionTabsRemoval( environmentId, missing.snapshot.worktree, - inventoryReceivedFrame + inventoryReceivedFrame, + missing.snapshot.publicationEpoch ) return { environmentId, diff --git a/tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts new file mode 100644 index 00000000000..6e305b2766b --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts @@ -0,0 +1,258 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +/** + * The session-tabs retirement-proof surface, paired across two builds. + * + * `cross-version-terminal-wire` covers the terminal binary stream and + * `cross-version-agent-session-wire` covers `agentSession.*`; neither reaches the + * session-tabs frame, which is where a paired client learns that a mirrored terminal + * is gone. This pairs the two halves of that surface across versions: + * + * - the HOST half changed — a host now ships a retirement proof on its own frame when + * no surface removal carries one (`attachRetirementProofsToSnapshot`); + * - the CLIENT half did not change, which this asserts by running both builds' ledger + * over the same frames rather than by reading the diff. + * + * The claim under test is the one written into the change: that this is Rule 1, because + * `retiredTerminalSurfaces` is an existing optional field on an existing path. Rule 3's + * fourth bullet says "a frame the host ... starts sending, on an existing path" is a wire + * change even with no codec movement, so the claim is checked against an actual old + * build rather than accepted. + * + * The pre-stack ref is pinned rather than derived: this contract needs a release from + * before the proof-only frame existed, which is the fallback + * docs/reference/remote-wire-compatibility.md sanctions for exactly this case. + */ +const PRE_STACK_REF = 'v1.4.199' + +const SUITE_TIMEOUT_MS = 180_000 + +const WORKTREE_ID = 'repo::/worktree' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PARENT_TAB_ID = 'tab' +const PTY_ID = 'pty-left' +const TERMINAL_HANDLE = 'remote:terminal-handle-1' + +type Snapshot = { + worktree: string + publicationEpoch: string + snapshotVersion: number + activeGroupId: null + activeTabId: string | null + activeTabType: string | null + tabs: Record[] + retiredTerminalSurfaces?: Record[] +} + +type ProofLedger = { + appendRetiredTerminalSurfaceProofs: ( + existing: readonly Record[] | undefined, + retired: readonly Record[] + ) => Record[] + dropRetirementProofsForLiveSurfaces: ( + retired: readonly Record[], + tabs: readonly Record[] + ) => Record[] +} + +type HostProofPublisher = { + attachRetirementProofsToSnapshot?: ( + snapshot: Snapshot, + proofs: readonly Record[] + ) => Snapshot | null + retireTerminalSurfacesFromSnapshot: ( + args: Record + ) => { snapshot: Snapshot } | null +} + +type Build = { + label: string + ledger: ProofLedger + host: HostProofPublisher +} + +/** The surface as the host still holds it, before the close's two halves land. */ +function liveSnapshot(): Snapshot { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `tab::${LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab::${LEAF_ID}`, + parentTabId: PARENT_TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + title: 'Left', + isActive: true + } + ] + } +} + +/** + * The renderer-first ordering, which is the one users hit: the close transaction already + * de-persisted the surface and republished without it, so the PTY exit that follows finds + * nothing left for persistence to accept. + */ +function snapshotAfterRendererRepublished(): Snapshot { + return { ...liveSnapshot(), snapshotVersion: 2, tabs: [], activeTabId: null, activeTabType: null } +} + +function exitProof(): Record { + return { + parentTabId: PARENT_TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + terminal: TERMINAL_HANDLE, + incarnationId: 'inc-1' + } +} + +async function loadBuild(ref: string | null): Promise { + if (ref === null) { + const [ledger, proof, retirement] = await Promise.all([ + import('../../../src/shared/terminal-retirement-proof-ledger'), + import('../../../src/main/runtime/mobile-session-terminal-retirement-proof'), + import('../../../src/main/runtime/mobile-session-terminal-retirement') + ]) + return { + label: 'stack', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; this module is the proof ledger by path. + ledger: ledger as unknown as ProofLedger, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; the two modules together are the host publisher surface this spec drives. + host: { ...proof, ...retirement } as unknown as HostProofPublisher + } + } + const checkout = await materializeReleaseCheckout(ref) + const [ledger, proof, retirement] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/shared/terminal-retirement-proof-ledger.ts'), + importReleaseCheckoutModule( + checkout, + 'src/main/runtime/mobile-session-terminal-retirement-proof.ts' + ), + importReleaseCheckoutModule(checkout, 'src/main/runtime/mobile-session-terminal-retirement.ts') + ]) + return { + label: ref, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release checkout is loaded by path, so its exports arrive unknown. + ledger: ledger as unknown as ProofLedger, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release checkout is loaded by path, so its exports arrive unknown. + host: { ...proof, ...retirement } as unknown as HostProofPublisher + } +} + +/** + * What a host of this build publishes when the PTY exit lands after the renderer already + * dropped the surface. `null` means it publishes nothing, which is the stuck-pane defect. + */ +function hostPublishesOnExit(build: Build, snapshot: Snapshot): Snapshot | null { + const attach = build.host.attachRetirementProofsToSnapshot + if (typeof attach !== 'function') { + // Derived, not written down: this build's only route to a proof is the removal helper, + // and with nothing left to remove it declines to produce a frame. + return ( + build.host.retireTerminalSurfacesFromSnapshot({ + snapshot, + ptyId: PTY_ID, + exactSurfaces: [], + exactOnly: true, + retirementProofs: [exitProof()] + })?.snapshot ?? null + ) + } + return attach(snapshot, [exitProof()]) +} + +/** What this build's client retains after the host frame, i.e. the evidence it can act on. */ +function clientRetains(build: Build, frame: Snapshot | null): Record[] { + if (frame === null) { + return [] + } + return build.ledger.dropRetirementProofsForLiveSurfaces( + build.ledger.appendRetiredTerminalSurfaceProofs(undefined, frame.retiredTerminalSurfaces ?? []), + frame.tabs + ) +} + +let preStack: Build +let stack: Build + +beforeAll(async () => { + ;[preStack, stack] = await Promise.all([loadBuild(PRE_STACK_REF), loadBuild(null)]) +}, SUITE_TIMEOUT_MS) + +describe('cross-version session-tabs retirement proof', () => { + it('pairs the stack against a real pre-stack release', () => { + expect(preStack.label).toBe(PRE_STACK_REF) + expect(typeof preStack.ledger.dropRetirementProofsForLiveSurfaces).toBe('function') + expect(typeof stack.ledger.dropRetirementProofsForLiveSurfaces).toBe('function') + // The anti-vacuous-pass oracle. Two builds that resolved to one module would make every + // pairing below a same-version run wearing a skew label, and all of them would pass. + expect(preStack.ledger).not.toBe(stack.ledger) + expect(preStack.ledger.dropRetirementProofsForLiveSurfaces).not.toBe( + stack.ledger.dropRetirementProofsForLiveSurfaces + ) + // Load-bearing for reading the old-host cells: they mean "this release cannot publish a + // proof-only frame", not "the helper happened to decline". Safe to state against a pinned + // legacy ref, which is what PRE_STACK_REF is. + expect(preStack.host.attachRetirementProofsToSnapshot).toBeUndefined() + expect(typeof stack.host.attachRetirementProofsToSnapshot).toBe('function') + }) + + it('old host against old client publishes no proof on the renderer-first close (the defect)', () => { + const frame = hostPublishesOnExit(preStack, snapshotAfterRendererRepublished()) + expect(frame).toBeNull() + expect(clientRetains(preStack, frame)).toEqual([]) + }) + + it('new host against new client publishes a proof the client retains (the fix)', () => { + const frame = hostPublishesOnExit(stack, snapshotAfterRendererRepublished()) + expect(frame).not.toBeNull() + expect(clientRetains(stack, frame)).toEqual([exitProof()]) + }) + + it('new host against OLD client: the old client acts on the proof-only frame', () => { + const frame = hostPublishesOnExit(stack, snapshotAfterRendererRepublished()) + expect(frame).not.toBeNull() + // The claim under test. An old client that cannot act on this frame would leave the + // dead pane in its tab bar exactly as before the fix. + expect(clientRetains(preStack, frame)).toEqual([exitProof()]) + }) + + it('new host bumps snapshotVersion so a version-gating old client accepts the frame', () => { + const before = snapshotAfterRendererRepublished() + const frame = hostPublishesOnExit(stack, before) + // A client that drops a frame whose version did not advance would silently ignore the + // proof; this is what makes the proof-only frame reachable at all. + expect(frame?.snapshotVersion).toBeGreaterThan(before.snapshotVersion) + }) + + it('old host against NEW client degrades to the two-inventory route, with no crash', () => { + const frame = hostPublishesOnExit(preStack, snapshotAfterRendererRepublished()) + expect(frame).toBeNull() + expect(clientRetains(stack, frame)).toEqual([]) + }) + + it('both builds drop a proof whose surface is published live again, identically', () => { + const stillLive = liveSnapshot() + const proofs = [exitProof()] + // Rule 3 hazard: a proof naming a surface the host is still publishing must not retire + // it. Both builds must agree, or a skewed pairing retires a live pane. + expect(preStack.ledger.dropRetirementProofsForLiveSurfaces(proofs, stillLive.tabs)).toEqual([]) + expect(stack.ledger.dropRetirementProofsForLiveSurfaces(proofs, stillLive.tabs)).toEqual([]) + }) + + it('re-delivering the same exit does not fan out a second frame', () => { + const first = hostPublishesOnExit(stack, snapshotAfterRendererRepublished()) + expect(first).not.toBeNull() + // A version bump carrying nothing new would wake every paired client for no reason. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertion above already proves `first` is a published snapshot, not null. + expect(hostPublishesOnExit(stack, first as Snapshot)).toBeNull() + }) +}) diff --git a/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts b/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts new file mode 100644 index 00000000000..f4ff2572376 --- /dev/null +++ b/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts @@ -0,0 +1,371 @@ +/** + * JOURNEY: quit the desktop app while remote terminals are live on the host, then reopen it. + * + * TOPOLOGY: the `orcaPage` app is the host (orca server); a separate real Orca desktop client + * pairs to it, opens a host terminal, works in it, is force-quit, and relaunched on the same + * profile — the pairing credential and the persisted session survive, as they do for a real + * force-quit reopen. + * + * Why this exists: every paired restart spec in this suite restarts around a *browser* pane + * (paired-client-hosted-browser-*.spec.ts). None of them restarts a client holding a live remote + * *terminal*, which is the thing the user is actually mid-work in. + * + * The terminal is a fixture that appends one line per event to a file on disk. That sink is the + * oracle nothing on the client can fake: + * - exactly one `READY` for the whole run means the host never re-spawned the process, so the + * user came back to their session rather than a fresh shell wearing its name; + * - a `LINE:` for input sent after the relaunch means the restored pane is wired to that same + * process, not merely painted with its scrollback. + * + * Run: + * pnpm exec playwright test \ + * tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + toWebTerminalSurfaceTabId +} from '../../src/shared/terminal-surface-id' +import { closeElectronAppForE2E } from './helpers/electron-process-shutdown' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +/** What a user would accept for "my terminal is back" after reopening the app. */ +const RESTORE_BUDGET_MS = 60_000 + +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-client-restart-survival-')) +const fixturePath = path.join(scratch, 'restart-survival-terminal.mjs') +writeFileSync( + fixturePath, + [ + "import { appendFileSync } from 'node:fs'", + 'const sink = process.argv[2]', + 'const record = (line) => appendFileSync(sink, `${line}\\n`)', + "record('READY')", + "process.stdout.write('RESTART_SURVIVAL_READY\\r\\n')", + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const lines = pending.split(/\\r\\n|\\r|\\n/)', + " pending = lines.pop() ?? ''", + ' for (const line of lines) {', + ' record(`LINE:${line}`)', + ' process.stdout.write(`LINE:${line}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function fixtureCommand(sinkPath: string): string { + const command = [process.execPath, fixturePath, sinkPath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map(shellQuote).join(' ') +} + +function readSinkLines(sinkPath: string): string[] { + try { + return readFileSync(sinkPath, 'utf8').split('\n').filter(Boolean) + } catch { + return [] + } +} + +async function callEnvironment( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: page.evaluate is typed unknown across the bridge; TResult is the caller's declared RPC result. + return page.evaluate( + async ({ environmentId, method, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method, + params + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { environmentId, method, params } + ) as Promise +} + +async function focusWorkspace(page: Page, worktreeId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) +} + +async function waitForClientWorkspace(page: Page, worktreeId: string): Promise { + await expect + .poll( + () => + page.evaluate( + (id) => (window.__store?.getState().allWorktrees() ?? []).some((w) => w.id === id), + worktreeId + ), + { timeout: 60_000, message: 'paired client never received the host workspace' } + ) + .toBe(true) +} + +/** Milliseconds until the tab is mirrored again, or null if it never was. */ +async function waitForMirroredTab( + page: Page, + worktreeId: string, + webTabId: string, + budgetMs: number +): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < budgetMs) { + const present = await page.evaluate( + ({ id, worktreeId }) => + (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === id), + { id: webTabId, worktreeId } + ) + if (present) { + return Date.now() - startedAt + } + await page.waitForTimeout(500) + } + return null +} + +/** Milliseconds until the restored pane paints `marker`, or null if it never did. */ +async function waitForPanePaint( + page: Page, + webTabId: string, + marker: string, + budgetMs: number +): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < budgetMs) { + const content = await page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }, webTabId) + if (content.includes(marker)) { + return Date.now() - startedAt + } + await page.waitForTimeout(500) + } + return null +} + +async function selectClientTab(page: Page, worktreeId: string, webTabId: string): Promise { + await page.evaluate( + ({ webTabId, worktreeId }) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(worktreeId) + state?.setActiveTab(webTabId) + state?.setActiveTabType('terminal') + }, + { webTabId, worktreeId } + ) +} + +/** + * Types `marker` into the pane until the host-side process records it, or the budget ends. + * + * Why through `pane.terminal.input` and not `window.api.pty.write`: a mirrored pane's handle is + * a `remote:` id that no local PTY answers to, so a direct write is silently swallowed. This is + * the path a keystroke actually takes, and it is retried because a pane still reattaching can + * replay-suppress a write (helpers/restored-terminal-input-readiness.ts polls for that reason). + */ +async function driveInputUntilProcessSees( + client: PairedElectronClient, + webTabId: string, + sinkPath: string, + marker: string, + budgetMs: number +): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < budgetMs) { + await client.page.evaluate( + ({ id, text }) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + pane?.terminal?.input?.(text, true) + }, + { id: webTabId, text: `${marker}\r` } + ) + if (readSinkLines(sinkPath).some((line) => line.includes(marker))) { + return true + } + await client.page.waitForTimeout(1_000) + } + return false +} + +async function readTabPtyIds(client: PairedElectronClient, webTabId: string): Promise { + return client.page.evaluate((id) => window.__store?.getState().ptyIdsByTabId[id] ?? [], webTabId) +} + +test('a relaunched client gets its live remote terminal back, still attached to the same process', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(900_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('host has no active worktree') + } + return id + }) + + const sinkPath = path.join(scratch, `sink-${randomUUID()}.log`) + const failures: string[] = [] + let client: PairedElectronClient | null = null + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + try { + client = await launchPairedElectronClient(offer, testInfo, 'remote-terminal-restart-survival') + const userDataDir = client.userDataDir + await waitForClientWorkspace(client.page, worktreeId) + await focusWorkspace(client.page, worktreeId) + + const created = await callEnvironment<{ tab: { id: string; terminal: string | null } }>( + client.page, + client.environmentId, + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + command: fixtureCommand(sinkPath), + activate: true, + select: true, + navigation: 'caller' + } + ) + const hostTabId = created.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]! + const webTabId = toWebTerminalSurfaceTabId(hostTabId) + expect( + await waitForMirroredTab(client.page, worktreeId, webTabId, RESTORE_BUDGET_MS), + 'the client never mirrored the terminal it created' + ).not.toBeNull() + await selectClientTab(client.page, worktreeId, webTabId) + await expect + .poll(() => readSinkLines(sinkPath), { + timeout: RESTORE_BUDGET_MS, + message: 'the host terminal fixture never started' + }) + .toContain('READY') + expect( + await waitForPanePaint(client.page, webTabId, 'RESTART_SURVIVAL_READY', RESTORE_BUDGET_MS), + 'the pane never painted the live terminal before the restart' + ).not.toBeNull() + + // The control. Without it, "input did not arrive after the restart" cannot be told apart + // from "this input path never worked in this topology". + const ptyIdsBefore = await readTabPtyIds(client, webTabId) + expect(ptyIdsBefore, 'the live pane had no PTY handle before the restart').not.toHaveLength(0) + expect( + await driveInputUntilProcessSees( + client, + webTabId, + sinkPath, + 'PRE_RESTART_CONTROL', + RESTORE_BUDGET_MS + ), + 'input did not reach the host process even before the restart — the probe, not the product' + ).toBe(true) + + // ── The restart: force-quit and reopen on the same profile. ── + // Quit without disposing: the profile has to outlive the app, as it does for a real Cmd+Q. + const quitting = client.app + client = null + await closeElectronAppForE2E(quitting) + client = await launchPairedElectronClient( + offer, + testInfo, + 'remote-terminal-restart-survival-relaunch', + { reuseUserDataDir: userDataDir } + ) + await waitForClientWorkspace(client.page, worktreeId) + await focusWorkspace(client.page, worktreeId) + + const tabBackMs = await waitForMirroredTab(client.page, worktreeId, webTabId, RESTORE_BUDGET_MS) + console.error(`[client-restart] tabBackMs=${tabBackMs}`) + if (tabBackMs === null) { + failures.push('the remote terminal tab never came back after the app was reopened') + } else { + await selectClientTab(client.page, worktreeId, webTabId) + const paintedMs = await waitForPanePaint( + client.page, + webTabId, + 'RESTART_SURVIVAL_READY', + RESTORE_BUDGET_MS + ) + console.error(`[client-restart] paintedMs=${paintedMs}`) + if (paintedMs === null) { + failures.push( + 'the remote terminal came back empty — the tab is there but the transcript is not' + ) + } + } + + // Is the restored pane actually wired to the live process, or only painted with its past? + const marker = `POST_RESTART_${randomUUID().slice(0, 8)}` + const ptyIds = await readTabPtyIds(client, webTabId) + console.error(`[client-restart] ptyBefore=${ptyIdsBefore[0]} ptyAfter=${ptyIds[0] ?? 'none'}`) + if (ptyIds.length === 0) { + failures.push( + 'the restored tab has no PTY handle — nothing the user types can reach the host' + ) + } else { + const echoed = await driveInputUntilProcessSees( + client, + webTabId, + sinkPath, + marker, + RESTORE_BUDGET_MS + ) + console.error(`[client-restart] inputReachedProcess=${echoed}`) + if (!echoed) { + failures.push( + 'input typed into the restored terminal never reached the process the host is running' + ) + } + } + + // The sink is the fork oracle: a second READY means the host re-spawned the user's work. + const readyCount = readSinkLines(sinkPath).filter((line) => line === 'READY').length + console.error(`[client-restart] readyCount=${readyCount}`) + if (readyCount !== 1) { + failures.push( + `the host process was re-spawned across the client restart (READY x${readyCount}) — the user's session was replaced, not restored` + ) + } + } finally { + await client?.dispose() + } + expect(failures, failures.join('\n')).toEqual([]) +}) diff --git a/tests/e2e/paired-two-client-emptied-workspace-reseed.spec.ts b/tests/e2e/paired-two-client-emptied-workspace-reseed.spec.ts new file mode 100644 index 00000000000..09ced015470 --- /dev/null +++ b/tests/e2e/paired-two-client-emptied-workspace-reseed.spec.ts @@ -0,0 +1,415 @@ +/** + * JOURNEY: two desktop clients paired to one Orca server, working in the same workspace. + * + * TOPOLOGY: the `orcaPage` app is the host (orca server). Two separate real Orca desktop + * clients pair to it, exactly as two of the user's machines would. Nothing is faulted — this + * is the ordinary shape of using Orca from a laptop and a desktop at the same time. + * + * The emptied-workspace tombstone is an explicit `tabsByWorktree[worktreeId] = []` row and it + * is client-local on the runtime path: it never crosses the wire, so the second client cannot + * know the first emptied the workspace on purpose and still seeds into it. That asymmetry is + * by design. What is NOT by design is a client falling out of step with the host and staying + * there, which is what this spec measures. + * + * Phase 0 is the control: with both clients attached, does a terminal created on one reach the + * other at all? Without it a later divergence cannot be attributed to the emptying. + * + * WAS RED, NOW GREEN, AND THE MEASUREMENT IS THE POINT. This spec was written to pin a defect + * rather than to assert a fix. Across 8 runs on a branch that carried neither of this PR's + * publish-side changes, the close phases were all-or-nothing: either every retraction reached both + * clients in single-digit milliseconds, or none reached either client within 90 seconds. Phase 1a, + * which closes a terminal while others remain open, failed alongside phase 1b, so it was never + * about the workspace going empty. Creates always propagated, including the phase 2 create landing + * in ~3ms on the very clients that had just missed a close for 90s, so the subscription was + * demonstrably alive. Both clients failing together while the host's own window showed the correct + * count put the fault on the host's publish-after-close, not on any client's mirror. + * + * That diagnosis named exactly what this PR changes: `publish a terminal retirement proof on the + * exit's own evidence` and `a removal retraction is not a publisher handover`. Measured on this + * branch with both of them present, all phases pass and the close retractions arrive in + * single-digit to low-hundreds of milliseconds (phase1a A=9ms B=158ms, phase1b A=1ms B=192ms). + * So this is no longer a pinned defect; it is the end-to-end proof that the unit-level retirement + * proof actually reaches the wire. + * + * If it goes red again, that is a regression in the publish-after-close path and the numbers above + * are the baseline to compare against — do not skip-tag it. The failure shape to expect is the + * all-or-nothing one: a 90s timeout on both clients at once, with creates still propagating. + * + * Run: + * pnpm exec playwright test \ + * tests/e2e/paired-two-client-emptied-workspace-reseed.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +/** How long a client may lag the host before the user would call it broken. */ +const MIRROR_BUDGET_MS = 30_000 +/** A retraction may be slow; what matters is whether it arrives at all. */ +const RETRACTION_BUDGET_MS = 90_000 + +type HostTabRow = { id: string; parentTabId?: string; terminal?: string | null } + +async function callEnvironment( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: page.evaluate is typed unknown across the bridge; TResult is the caller's declared RPC result. + return page.evaluate( + async ({ environmentId, method, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method, + params + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { environmentId, method, params } + ) as Promise +} + +/** The host's own tab inventory — the only oracle that is not a client re-derivation. */ +async function readHostTerminalTabIds( + client: PairedElectronClient, + worktreeId: string +): Promise { + const inventory = await callEnvironment<{ tabs: HostTabRow[] }>( + client.page, + client.environmentId, + 'session.tabs.list', + { worktree: `id:${worktreeId}` } + ) + return [ + ...new Set( + inventory.tabs + .filter((tab) => tab.terminal !== undefined && tab.terminal !== null) + .map((tab) => tab.parentTabId ?? tab.id) + ) + ].sort() +} + +async function readMirroredTabCount(page: Page, worktreeId: string): Promise { + return page.evaluate( + (id) => (window.__store?.getState().tabsByWorktree[id] ?? []).length, + worktreeId + ) +} + +/** Whether the client holds an explicit empty row (the tombstone) versus no row at all. */ +async function readWorkspaceRowState( + page: Page, + worktreeId: string +): Promise<'missing' | 'tombstoned' | 'populated'> { + return page.evaluate((id) => { + const tabs = window.__store?.getState().tabsByWorktree + if (!tabs || !Object.hasOwn(tabs, id)) { + return 'missing' as const + } + return (tabs[id] ?? []).length === 0 ? ('tombstoned' as const) : ('populated' as const) + }, worktreeId) +} + +async function focusWorkspace(page: Page, worktreeId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) +} + +/** Milliseconds until the client's mirrored count matches the host's, or null if it never did. */ +async function waitForClientToMatchHost( + client: PairedElectronClient, + hostCount: number, + worktreeId: string, + budgetMs: number +): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < budgetMs) { + if ((await readMirroredTabCount(client.page, worktreeId)) === hostCount) { + return Date.now() - startedAt + } + await client.page.waitForTimeout(500) + } + return null +} + +async function waitForClientWorkspace(page: Page, worktreeId: string): Promise { + await expect + .poll( + () => + page.evaluate( + (id) => (window.__store?.getState().allWorktrees() ?? []).some((w) => w.id === id), + worktreeId + ), + { timeout: 60_000, message: 'paired client never received the host workspace' } + ) + .toBe(true) +} + +test('two paired clients stay in step with the host across an emptied workspace', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(600_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('host has no active worktree') + } + return id + }) + + let clientA: PairedElectronClient | null = null + let clientB: PairedElectronClient | null = null + const failures: string[] = [] + try { + clientA = await launchPairedElectronClient( + await createRuntimeDesktopPairingOffer(orcaPage), + testInfo, + 'emptied-workspace-client-a' + ) + clientB = await launchPairedElectronClient( + await createRuntimeDesktopPairingOffer(orcaPage), + testInfo, + 'emptied-workspace-client-b' + ) + for (const client of [clientA, clientB]) { + await waitForClientWorkspace(client.page, worktreeId) + await focusWorkspace(client.page, worktreeId) + } + + // ── Phase 0: the control. A creates a terminal; B must see it. ── + await callEnvironment(clientA.page, clientA.environmentId, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + activate: true, + select: true, + navigation: 'caller' + }) + const afterCreate = (await readHostTerminalTabIds(clientA, worktreeId)).length + const controlA = await waitForClientToMatchHost( + clientA, + afterCreate, + worktreeId, + MIRROR_BUDGET_MS + ) + const controlB = await waitForClientToMatchHost( + clientB, + afterCreate, + worktreeId, + MIRROR_BUDGET_MS + ) + console.error(`[two-client] phase0 host=${afterCreate} A=${controlA}ms B=${controlB}ms`) + if (controlA === null || controlB === null) { + failures.push( + `phase0: a terminal created on one client never reached the other (host=${afterCreate}, A=${controlA}, B=${controlB})` + ) + } + + // ── Phase 1a: A closes one terminal, but not the last one. ── + // Separated from the emptying below on purpose: it is the control that says whether a + // retraction propagates at all, so a failure in 1b can be attributed to the workspace going + // empty rather than to close retractions being broken in general. + const beforePartialClose = await readHostTerminalTabIds(clientA, worktreeId) + if (beforePartialClose.length > 1) { + await callEnvironment(clientA.page, clientA.environmentId, 'session.tabs.close', { + worktree: `id:${worktreeId}`, + tabId: beforePartialClose[0]!, + reason: 'user', + navigation: 'caller' + }) + const remaining = beforePartialClose.length - 1 + const partialA = await waitForClientToMatchHost( + clientA, + remaining, + worktreeId, + RETRACTION_BUDGET_MS + ) + const partialB = await waitForClientToMatchHost( + clientB, + remaining, + worktreeId, + RETRACTION_BUDGET_MS + ) + console.error(`[two-client] phase1a host=${remaining} A=${partialA}ms B=${partialB}ms`) + if (partialA === null || partialB === null) { + failures.push( + `phase1a: a client kept showing a terminal the host closed, with others still open (A=${partialA}, B=${partialB})` + ) + } + } else { + // A one-terminal workspace would skip the control silently and let 1b/2 pass green on their own. + failures.push( + `phase1a: needs more than one host terminal to close one and keep another (host=${beforePartialClose.length})` + ) + } + + // ── Phase 1b: A empties the workspace by hand. ── + for (const hostTabId of await readHostTerminalTabIds(clientA, worktreeId)) { + await callEnvironment(clientA.page, clientA.environmentId, 'session.tabs.close', { + worktree: `id:${worktreeId}`, + tabId: hostTabId, + reason: 'user', + navigation: 'caller' + }) + } + await expect + .poll(() => readHostTerminalTabIds(clientA!, worktreeId).then((ids) => ids.length), { + timeout: MIRROR_BUDGET_MS, + message: 'host still held terminals after client A closed them all' + }) + .toBe(0) + // Deliberately generous: the question is whether the retraction ever arrives, not whether + // it is prompt. A client still showing a terminal the host has destroyed is a dead pane the + // user will click. + const emptyA = await waitForClientToMatchHost(clientA, 0, worktreeId, RETRACTION_BUDGET_MS) + const emptyB = await waitForClientToMatchHost(clientB, 0, worktreeId, RETRACTION_BUDGET_MS) + const hostOwnView = await readMirroredTabCount(orcaPage, worktreeId) + console.error( + `[two-client] phase1b host=0 hostOwnView=${hostOwnView}` + + ` A=${emptyA}ms(${await readWorkspaceRowState(clientA.page, worktreeId)})` + + ` B=${emptyB}ms(${await readWorkspaceRowState(clientB.page, worktreeId)})` + ) + if (emptyA === null || emptyB === null) { + failures.push( + `phase1b: a client kept showing terminals the host no longer has (A=${emptyA}, B=${emptyB})` + ) + } + + // Neither client may seed a replacement into a workspace the user deliberately emptied: + // both hold a row for it, so both know it was emptied rather than never initialized. + await orcaPage.waitForTimeout(10_000) + const hostAfterSettle = (await readHostTerminalTabIds(clientA, worktreeId)).length + console.error(`[two-client] phase1b-settled host=${hostAfterSettle}`) + if (hostAfterSettle !== 0) { + failures.push( + `phase1b: the emptied workspace grew ${hostAfterSettle} terminal(s) back on its own` + ) + } + + // ── Phase 2: B creates a terminal again. Both clients must follow the host. ── + await callEnvironment(clientB.page, clientB.environmentId, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + activate: true, + select: true, + navigation: 'caller' + }) + const hostAfterB = (await readHostTerminalTabIds(clientB, worktreeId)).length + const rejoinB = await waitForClientToMatchHost( + clientB, + hostAfterB, + worktreeId, + MIRROR_BUDGET_MS + ) + const rejoinA = await waitForClientToMatchHost( + clientA, + hostAfterB, + worktreeId, + MIRROR_BUDGET_MS + ) + console.error(`[two-client] phase2 host=${hostAfterB} A=${rejoinA}ms B=${rejoinB}ms`) + if (rejoinA === null || rejoinB === null) { + failures.push( + `phase2: a client never adopted the terminal the host holds — the user sees an empty` + + ` workspace while work runs on it (host=${hostAfterB}, A=${rejoinA}, B=${rejoinB})` + ) + } + } finally { + await clientB?.dispose() + await clientA?.dispose() + } + expect(failures, failures.join('\n')).toEqual([]) +}) + +/** + * The same workspace, driven by a client that starts working the moment it finishes pairing — + * which is what a user does on a machine they have just added. + * + * Isolated from the two-client test above because the failure it hunts is a startup race, not a + * multi-client one: the earlier form of that test drove the close seconds after the pairing + * completed and repeatedly left the client's mirror stuck — sometimes still showing the terminal + * the host had closed, sometimes stuck empty afterwards — with the link demonstrably alive. + */ +test('a client that works immediately after pairing stays in step with the host', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(600_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('host has no active worktree') + } + return id + }) + + let client: PairedElectronClient | null = null + const failures: string[] = [] + try { + client = await launchPairedElectronClient( + await createRuntimeDesktopPairingOffer(orcaPage), + testInfo, + 'fresh-pairing-immediate-work' + ) + await waitForClientWorkspace(client.page, worktreeId) + await focusWorkspace(client.page, worktreeId) + + await callEnvironment(client.page, client.environmentId, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + activate: true, + select: true, + navigation: 'caller' + }) + const afterCreate = (await readHostTerminalTabIds(client, worktreeId)).length + const sawCreate = await waitForClientToMatchHost( + client, + afterCreate, + worktreeId, + MIRROR_BUDGET_MS + ) + console.error(`[fresh-pairing] create host=${afterCreate} client=${sawCreate}ms`) + if (sawCreate === null) { + failures.push( + `the client never mirrored the terminal it had just created (host=${afterCreate})` + ) + } + + for (const hostTabId of await readHostTerminalTabIds(client, worktreeId)) { + await callEnvironment(client.page, client.environmentId, 'session.tabs.close', { + worktree: `id:${worktreeId}`, + tabId: hostTabId, + reason: 'user', + navigation: 'caller' + }) + } + await expect + .poll(() => readHostTerminalTabIds(client!, worktreeId).then((ids) => ids.length), { + timeout: MIRROR_BUDGET_MS, + message: 'host still held terminals after the client closed them all' + }) + .toBe(0) + const sawClose = await waitForClientToMatchHost(client, 0, worktreeId, MIRROR_BUDGET_MS) + console.error( + `[fresh-pairing] close client=${sawClose}ms row=${await readWorkspaceRowState(client.page, worktreeId)}` + ) + if (sawClose === null) { + failures.push('the client kept showing a terminal the host had already closed') + } + } finally { + await client?.dispose() + } + expect(failures, failures.join('\n')).toEqual([]) +}) From db2ffe7afefea17d7e24f3ec1c5d1cd14f41fdde Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:59:56 -0400 Subject: [PATCH 016/224] fix(mobile): default injected timers to receiver-free wrappers (#21416) * fix(mobile): default injected timers to receiver-free wrappers Every transport class stored a global timer function on an object and then called it back through that object, so the receiver was the instance or the dependency bag rather than the global. Hermes ignores the receiver; browsers reject it with TypeError: Illegal invocation, which makes the web build fatal at the first retry, liveness probe, or relay grace timer. Default each injected timer to a wrapper that calls the global receiver-free, and narrow the seam's type from `typeof setTimeout` to the call signature it actually uses. Node's `typeof setTimeout` also demands a `__promisify__` member that no injected timer or wrapper can supply, so the wrapper cannot satisfy it. Pruning mobile-relay-background-grace.test.ts from the typecheck baseline follows: the narrower type makes that file check clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the default timers against a browser receiver check Both classes are now constructed with no injected timers under a global setTimeout/clearTimeout that throws Illegal invocation for any explicit non-global receiver, mirroring the WebIDL rule. The watchdog gets its own file because its existing test is grandfathered out of the typecheck ratchet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the default clear leg and drop bare timer injections The clear assertions were vacuous: cancel() and stop() also drop the state a fired callback checks, so a no-op default clearTimer stayed green. Both tests now assert the wrapped global clearTimeout received the exact handle setTimeout returned, which fails when that default is mutated to a no-op. Three relay tests injected bare setTimeout/clearTimeout into dependency bags, the same receiver shape the product fix removed; inert under node, fatal under jsdom. relay-host-signed-out-verdict drops two `as unknown as typeof setTimeout` casts, since ScheduleTimer now types those arrows contextually. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census bare global timers parked in properties and defaults mobile-endpoint-lifecycle could regress to bare globals with every other test green, because nothing there is reachable from a unit test. Walk every product file's AST and fail on a global timer parked where a later call reaches it through a receiver: a `??` or `||` default, an object literal member, or an assignment onto a property. A plain local capture stays legal, since calling it bare leaves the receiver undefined. A separate test asserts the walk sees the five fixed sites' wrapper shape, so an empty or misdirected scan fails instead of passing vacuously. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): define the receiver-free timer defaults once Five hand-written wrappers each restated the same invariant, so five places could drift. timer-scheduler now exports defaultScheduleTimer and defaultCancelTimer, and carries the reason for them; every site takes its default from there. The census keys its presence precondition on those two identifiers instead of the arrow shape. The census also missed `??=` and `||=`, which park a global exactly like their non-assigning forms. Both are handled now, with a parsed-source case per parking form and one for the local capture that stays legal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../files/mobile-file-preview-navigation.ts | 3 +- .../global-timer-receiver-test-fakes.ts | 34 +++++ .../host-open-retry-scheduler.test.ts | 23 +++- .../transport/host-open-retry-scheduler.ts | 10 +- .../transport/mobile-direct-return-probe.ts | 3 +- .../transport/mobile-endpoint-lifecycle.ts | 5 +- .../mobile-endpoint-supervisor-contract.ts | 3 +- .../mobile-endpoint-supervisor-test-fakes.ts | 5 +- .../mobile-relay-background-grace.test.ts | 6 +- .../mobile-relay-background-grace.ts | 3 +- .../mobile-relay-direct-grace-timer.ts | 3 +- .../mobile-relay-lease-rotation-timer.ts | 4 +- .../mobile-relay-reconnect-controller.test.ts | 4 +- .../mobile-relay-reconnect-controller.ts | 3 +- .../mobile-relay-runtime-failover.test.ts | 4 +- .../relay-host-signed-out-verdict.test.ts | 6 +- ...n-liveness-watchdog-default-timers.test.ts | 33 +++++ .../rpc-session-liveness-watchdog.ts | 10 +- .../transport/timer-receiver-census.test.ts | 124 ++++++++++++++++++ mobile/src/transport/timer-scheduler.ts | 7 + mobile/tests-typecheck-baseline.txt | 1 - 21 files changed, 265 insertions(+), 29 deletions(-) create mode 100644 mobile/src/transport/global-timer-receiver-test-fakes.ts create mode 100644 mobile/src/transport/rpc-session-liveness-watchdog-default-timers.test.ts create mode 100644 mobile/src/transport/timer-receiver-census.test.ts create mode 100644 mobile/src/transport/timer-scheduler.ts diff --git a/mobile/src/files/mobile-file-preview-navigation.ts b/mobile/src/files/mobile-file-preview-navigation.ts index e5024a4fbdc..3cd89212212 100644 --- a/mobile/src/files/mobile-file-preview-navigation.ts +++ b/mobile/src/files/mobile-file-preview-navigation.ts @@ -1,4 +1,5 @@ import { classifyMobileArtifact } from '../session/mobile-artifact-kind' +import { defaultScheduleTimer } from '../transport/timer-scheduler' import { createMobileFilePreviewHref, type MobileFilePreviewHref, @@ -24,7 +25,7 @@ export function navigateToMobileFilePreview( if (options.embedded && options.onRequestClose) { // Why: closing the dock immediately can unmount the subtree before Expo // commits the route transition. - const scheduleClose = options.scheduleClose ?? setTimeout + const scheduleClose = options.scheduleClose ?? defaultScheduleTimer scheduleClose(options.onRequestClose, 0) } } diff --git a/mobile/src/transport/global-timer-receiver-test-fakes.ts b/mobile/src/transport/global-timer-receiver-test-fakes.ts new file mode 100644 index 00000000000..8300f16d626 --- /dev/null +++ b/mobile/src/transport/global-timer-receiver-test-fakes.ts @@ -0,0 +1,34 @@ +import { vi } from 'vitest' + +// Mirrors the browser rule for WebIDL global operations: an explicit non-global +// receiver is rejected, while an absent one resolves to the global. +function assertGlobalReceiver(receiver: unknown): void { + if (receiver !== undefined && receiver !== globalThis) { + throw new TypeError('Illegal invocation') + } +} + +export type GuardedTimerHandles = { + scheduled: ReturnType[] + cleared: ReturnType[] +} + +// Wraps whatever timers are currently installed (real or vitest's fakes), so callers +// keep using vi.advanceTimersByTime. Undo with vi.unstubAllGlobals(). +export function installIllegalInvocationTimerGuards(): GuardedTimerHandles { + const scheduleTimer = globalThis.setTimeout + const cancelTimer = globalThis.clearTimeout + const handles: GuardedTimerHandles = { scheduled: [], cleared: [] } + vi.stubGlobal('setTimeout', function (this: unknown, handler: () => void, ms?: number) { + assertGlobalReceiver(this) + const handle = scheduleTimer(handler, ms) + handles.scheduled.push(handle) + return handle + }) + vi.stubGlobal('clearTimeout', function (this: unknown, handle: ReturnType) { + assertGlobalReceiver(this) + handles.cleared.push(handle) + cancelTimer(handle) + }) + return handles +} diff --git a/mobile/src/transport/host-open-retry-scheduler.test.ts b/mobile/src/transport/host-open-retry-scheduler.test.ts index 6ee2b18bea5..6235521b256 100644 --- a/mobile/src/transport/host-open-retry-scheduler.test.ts +++ b/mobile/src/transport/host-open-retry-scheduler.test.ts @@ -1,9 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installIllegalInvocationTimerGuards } from './global-timer-receiver-test-fakes' import { HostOpenRetryScheduler } from './host-open-retry-scheduler' describe('HostOpenRetryScheduler', () => { beforeEach(() => vi.useFakeTimers()) - afterEach(() => vi.useRealTimers()) + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) it('advances through bounded retry tiers', async () => { let generation = 1 @@ -45,6 +49,23 @@ describe('HostOpenRetryScheduler', () => { expect(open).toHaveBeenCalledTimes(2) }) + it('schedules and clears with no injected timers when the global rejects a non-global receiver', async () => { + const timers = installIllegalInvocationTimerGuards() + const open = vi.fn() + const scheduler = new HostOpenRetryScheduler({ canRetry: () => true, open }) + + scheduler.recordFailure('host-1', 1) + await vi.advanceTimersByTimeAsync(1_000) + expect(open).toHaveBeenCalledOnce() + + scheduler.recordFailure('host-1', 1) + scheduler.cancel('host-1') + expect(timers.cleared).toHaveLength(1) + expect(timers.cleared[0]).toBe(timers.scheduled[1]) + await vi.advanceTimersByTimeAsync(60_000) + expect(open).toHaveBeenCalledOnce() + }) + it('cancels retry delivery', async () => { const open = vi.fn() const scheduler = new HostOpenRetryScheduler({ canRetry: () => true, open }) diff --git a/mobile/src/transport/host-open-retry-scheduler.ts b/mobile/src/transport/host-open-retry-scheduler.ts index 94bf1cc5843..fd69d81fa43 100644 --- a/mobile/src/transport/host-open-retry-scheduler.ts +++ b/mobile/src/transport/host-open-retry-scheduler.ts @@ -1,3 +1,5 @@ +import { defaultCancelTimer, defaultScheduleTimer, type ScheduleTimer } from './timer-scheduler' + const RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 15_000, 30_000, 60_000] as const type RetryState = { @@ -9,18 +11,18 @@ type RetryState = { type HostOpenRetrySchedulerOptions = { canRetry: (hostId: string, generation: number) => boolean open: (hostId: string) => void - setTimer?: typeof setTimeout + setTimer?: ScheduleTimer clearTimer?: typeof clearTimeout } export class HostOpenRetryScheduler { private readonly states = new Map() - private readonly setTimer: typeof setTimeout + private readonly setTimer: ScheduleTimer private readonly clearTimer: typeof clearTimeout constructor(private readonly options: HostOpenRetrySchedulerOptions) { - this.setTimer = options.setTimer ?? setTimeout - this.clearTimer = options.clearTimer ?? clearTimeout + this.setTimer = options.setTimer ?? defaultScheduleTimer + this.clearTimer = options.clearTimer ?? defaultCancelTimer } recordFailure(hostId: string, generation: number): { failureCount: number; nextDelayMs: number } { diff --git a/mobile/src/transport/mobile-direct-return-probe.ts b/mobile/src/transport/mobile-direct-return-probe.ts index 3ae31edd07f..c3b3464a3ba 100644 --- a/mobile/src/transport/mobile-direct-return-probe.ts +++ b/mobile/src/transport/mobile-direct-return-probe.ts @@ -1,6 +1,7 @@ import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' import type { RpcClient } from './rpc-client' +import type { ScheduleTimer } from './timer-scheduler' import type { HostProfile } from './types' import type { MobileConnectionPath } from './stable-logical-rpc-client' @@ -17,7 +18,7 @@ export class DirectReturnProbe { constructor( private readonly deps: { now: () => number - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout openDirect: (endpoint: string) => RpcClient }, diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index 7ec5f28b945..b7cab59c49a 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -11,6 +11,7 @@ import { import { saveHost } from './host-store' import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade' import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller' +import { defaultCancelTimer, defaultScheduleTimer } from './timer-scheduler' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' type EndpointLifecycle = { @@ -104,7 +105,7 @@ function createSupervisor( onLog, now: Date.now, randomBytes: ExpoCrypto.getRandomBytes, - setTimer: setTimeout, - clearTimer: clearTimeout + setTimer: defaultScheduleTimer, + clearTimer: defaultCancelTimer }) } diff --git a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts index 2a784fd8895..247c2ec051e 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts @@ -4,6 +4,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' import type { RpcClient } from './rpc-client' +import type { ScheduleTimer } from './timer-scheduler' import type { ConnectionLogSink, HostProfile } from './types' export type MobileEndpointSupervisorDependencies = { @@ -20,7 +21,7 @@ export type MobileEndpointSupervisorDependencies = { saveHost: (host: HostProfile) => Promise now: () => number randomBytes: (length: number) => Uint8Array - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout onLog?: ConnectionLogSink } diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index e026ea26889..0ca61ab3337 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage' +import { defaultCancelTimer, defaultScheduleTimer } from './timer-scheduler' import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' @@ -216,8 +217,8 @@ export function dependencies( saveHost: vi.fn(async () => {}), now: Date.now, randomBytes: (length) => new Uint8Array(length).fill(1), - setTimer: setTimeout, - clearTimer: clearTimeout, + setTimer: defaultScheduleTimer, + clearTimer: defaultCancelTimer, ...overrides } } diff --git a/mobile/src/transport/mobile-relay-background-grace.test.ts b/mobile/src/transport/mobile-relay-background-grace.test.ts index 64e60b07b94..c1e2f12334f 100644 --- a/mobile/src/transport/mobile-relay-background-grace.test.ts +++ b/mobile/src/transport/mobile-relay-background-grace.test.ts @@ -11,7 +11,11 @@ describe('MobileRelayBackgroundGraceTimer', () => { vi.useFakeTimers() const onExpired = vi.fn() const timer = new MobileRelayBackgroundGraceTimer( - { now: Date.now, setTimer: setTimeout, clearTimer: clearTimeout }, + { + now: Date.now, + setTimer: (handler, ms) => setTimeout(handler, ms), + clearTimer: (handle) => clearTimeout(handle) + }, onExpired ) diff --git a/mobile/src/transport/mobile-relay-background-grace.ts b/mobile/src/transport/mobile-relay-background-grace.ts index cdea1374b4f..03990041f7b 100644 --- a/mobile/src/transport/mobile-relay-background-grace.ts +++ b/mobile/src/transport/mobile-relay-background-grace.ts @@ -1,12 +1,13 @@ import type { RelayReconnectController } from './mobile-relay-reconnect-controller' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ScheduleTimer } from './timer-scheduler' // Retain a healthy Relay briefly across routine app switches without waking the app. export const RELAY_BACKGROUND_GRACE_MS = 30_000 type RelayBackgroundGraceDependencies = { now: () => number - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout } diff --git a/mobile/src/transport/mobile-relay-direct-grace-timer.ts b/mobile/src/transport/mobile-relay-direct-grace-timer.ts index df3c1ba1428..1c6c26bc634 100644 --- a/mobile/src/transport/mobile-relay-direct-grace-timer.ts +++ b/mobile/src/transport/mobile-relay-direct-grace-timer.ts @@ -1,4 +1,5 @@ import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ScheduleTimer } from './timer-scheduler' // Why: on a black-holed LAN endpoint the direct dial sits in 'connecting' for the // whole 12s connect timeout (rpc-client CONNECT_TIMEOUT_MS), and relay recovery @@ -8,7 +9,7 @@ import type { StableLogicalRpcClient } from './stable-logical-rpc-client' const DIRECT_DIAL_GRACE_MS = 2500 type DirectGraceTimerDependencies = { - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout } diff --git a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts index 19aedfe8be1..924d8275eff 100644 --- a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts +++ b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts @@ -1,3 +1,5 @@ +import type { ScheduleTimer } from './timer-scheduler' + // Why: the relay resume lease expires; the phone must proactively re-resume a // little before the deadline (and retry shortly if a forced rotation didn't land) // so the session never lapses. Owns the single lease/rotation timer slot. @@ -12,7 +14,7 @@ const LEASE_ROTATION_MAX_DELAY_MS = 6 * 60 * 60 * 1000 export type RelayLeaseRotationDependencies = { now: () => number - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout } diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index ac6112f2c44..6432794cd17 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -393,8 +393,8 @@ function createController( { now: Date.now, randomBytes: () => new Uint8Array([128, 0]), - setTimer: setTimeout, - clearTimer: clearTimeout + setTimer: (handler, ms) => setTimeout(handler, ms), + clearTimer: (handle) => clearTimeout(handle) }, onRetry ) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index a606abbe9b4..ad8672ef69c 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -12,6 +12,7 @@ import { RelayCredentialEligibility } from './relay-credential-eligibility' import { RelayPairingRejectionLatch } from './relay-pairing-rejection-latch' import { RelayRecoveryFailureCount } from './relay-recovery-failure-count' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ScheduleTimer } from './timer-scheduler' import type { ConnectionState, ForegroundNudgeReason } from './types' type RelayCredentialLease = { expiresAt: number; version: number } @@ -19,7 +20,7 @@ type RelayCredentialLease = { expiresAt: number; version: number } export type RelayReconnectDependencies = { now: () => number randomBytes: (length: number) => Uint8Array - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout } diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index ce7cca3fd9f..7b3790a56c3 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -236,8 +236,8 @@ function dependencies( saveHost: vi.fn(async () => {}), now: Date.now, randomBytes: (length: number) => new Uint8Array(length), - setTimer: setTimeout, - clearTimer: clearTimeout, + setTimer: (handler, ms) => setTimeout(handler, ms), + clearTimer: (handle) => clearTimeout(handle), ...overrides } } diff --git a/mobile/src/transport/relay-host-signed-out-verdict.test.ts b/mobile/src/transport/relay-host-signed-out-verdict.test.ts index 2607b922b58..3d510793540 100644 --- a/mobile/src/transport/relay-host-signed-out-verdict.test.ts +++ b/mobile/src/transport/relay-host-signed-out-verdict.test.ts @@ -138,11 +138,11 @@ describe('RelayReconnectController cadence', () => { { now: () => 0, randomBytes: () => new Uint8Array([0, 0]), - setTimer: ((callback: () => void, delay: number) => { + setTimer: (callback, delay) => { delays.push(delay) return 1 as unknown as ReturnType - }) as unknown as typeof setTimeout, - clearTimer: (() => {}) as unknown as typeof clearTimeout + }, + clearTimer: () => {} }, vi.fn() ) diff --git a/mobile/src/transport/rpc-session-liveness-watchdog-default-timers.test.ts b/mobile/src/transport/rpc-session-liveness-watchdog-default-timers.test.ts new file mode 100644 index 00000000000..dd79fa5aa1d --- /dev/null +++ b/mobile/src/transport/rpc-session-liveness-watchdog-default-timers.test.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installIllegalInvocationTimerGuards } from './global-timer-receiver-test-fakes' +import { + LIVENESS_IDLE_MS, + LIVENESS_PROBE_TIMEOUT_MS, + RpcSessionLivenessWatchdog +} from './rpc-session-liveness-watchdog' + +describe('RpcSessionLivenessWatchdog default timers', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('schedules and clears with no injected timers when the global rejects a non-global receiver', async () => { + const timers = installIllegalInvocationTimerGuards() + const sendProbe = vi.fn(() => true) + const terminate = vi.fn() + const watchdog = new RpcSessionLivenessWatchdog({ transport: 'direct', sendProbe, terminate }) + const identity = {} + + watchdog.start(identity) + await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS) + expect(sendProbe).toHaveBeenCalledOnce() + + watchdog.stop(identity) + expect(timers.cleared).toHaveLength(1) + expect(timers.cleared[0]).toBe(timers.scheduled[1]) + await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS) + expect(terminate).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.ts b/mobile/src/transport/rpc-session-liveness-watchdog.ts index cbe891f810c..1b2251e1373 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.ts @@ -1,3 +1,5 @@ +import { defaultCancelTimer, defaultScheduleTimer, type ScheduleTimer } from './timer-scheduler' + export const LIVENESS_IDLE_MS = 20_000 export const LIVENESS_PROBE_TIMEOUT_MS = 8_000 export const MISSED_PROBE_LIMIT = 3 @@ -17,7 +19,7 @@ type WatchdogOptions = { missedProbeLimit?: number voluntaryProbeMinIntervalMs?: number now?: () => number - setTimer?: typeof setTimeout + setTimer?: ScheduleTimer clearTimer?: typeof clearTimeout } @@ -41,7 +43,7 @@ export class RpcSessionLivenessWatchdog { private readonly missedProbeLimit: number private readonly voluntaryProbeMinIntervalMs: number private readonly now: () => number - private readonly setTimer: typeof setTimeout + private readonly setTimer: ScheduleTimer private readonly clearTimer: typeof clearTimeout constructor(private readonly options: WatchdogOptions) { @@ -50,8 +52,8 @@ export class RpcSessionLivenessWatchdog { this.missedProbeLimit = options.missedProbeLimit ?? MISSED_PROBE_LIMIT this.voluntaryProbeMinIntervalMs = options.voluntaryProbeMinIntervalMs ?? 0 this.now = options.now ?? Date.now - this.setTimer = options.setTimer ?? setTimeout - this.clearTimer = options.clearTimer ?? clearTimeout + this.setTimer = options.setTimer ?? defaultScheduleTimer + this.clearTimer = options.clearTimer ?? defaultCancelTimer } start(identity: RpcSessionIdentity): void { diff --git a/mobile/src/transport/timer-receiver-census.test.ts b/mobile/src/transport/timer-receiver-census.test.ts new file mode 100644 index 00000000000..8ff818a139b --- /dev/null +++ b/mobile/src/transport/timer-receiver-census.test.ts @@ -0,0 +1,124 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const SOURCE_ROOT = fileURLToPath(new URL('..', import.meta.url)) +const TIMER_GLOBALS = new Set(['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']) +const GLOBAL_RECEIVERS = new Set(['global', 'globalThis', 'window']) +const SHARED_DEFAULTS = new Set(['defaultScheduleTimer', 'defaultCancelTimer']) + +// Sites that take their default from timer-scheduler; the census is meaningless if it +// cannot see them, so an empty or misdirected walk fails instead of passing vacuously. +const SHARED_DEFAULT_SITES = [ + 'files/mobile-file-preview-navigation.ts', + 'transport/host-open-retry-scheduler.ts', + 'transport/mobile-endpoint-lifecycle.ts', + 'transport/mobile-endpoint-supervisor-test-fakes.ts', + 'transport/rpc-session-liveness-watchdog.ts' +] + +const PARKING_OPERATORS = new Set([ + ts.SyntaxKind.QuestionQuestionToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.BarBarEqualsToken +]) + +type Census = { parked: string[]; shared: string[] } + +function productFiles(): string[] { + return readdirSync(SOURCE_ROOT, { recursive: true, encoding: 'utf8' }) + .filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$|\.generated\.ts$/.test(entry)) + .map((entry) => entry.replaceAll('\\', '/')) +} + +function timerName(node: ts.Node): string | null { + if (ts.isIdentifier(node) && TIMER_GLOBALS.has(node.text)) { + return node.text + } + if ( + ts.isPropertyAccessExpression(node) && + TIMER_GLOBALS.has(node.name.text) && + ts.isIdentifier(node.expression) && + GLOBAL_RECEIVERS.has(node.expression.text) + ) { + return node.name.text + } + return null +} + +// The receiver is only lost once the function is parked somewhere a later call reaches +// through: a nullish/logical default, an object literal member, or an assignment onto a +// property. A plain local capture stays legal: calling it bare leaves the receiver undefined. +function parkedTimer(node: ts.Node): ts.Node | null { + if (ts.isBinaryExpression(node)) { + const operator = node.operatorToken.kind + const parks = + PARKING_OPERATORS.has(operator) || + (operator === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left)) + return parks ? node.right : null + } + if (ts.isPropertyAssignment(node)) { + return node.initializer + } + if (ts.isShorthandPropertyAssignment(node)) { + return node.name + } + return null +} + +function scanSource(relativePath: string, text: string, census: Census): void { + const sourceFile = ts.createSourceFile(relativePath, text, ts.ScriptTarget.Latest, true) + const visit = (node: ts.Node): void => { + const candidate = parkedTimer(node) + const name = candidate === null ? null : timerName(candidate) + if (candidate !== null && name !== null) { + const line = sourceFile.getLineAndCharacterOfPosition(candidate.getStart(sourceFile)).line + 1 + census.parked.push(`${relativePath}:${line} ${name}`) + } + if (ts.isIdentifier(node) && SHARED_DEFAULTS.has(node.text)) { + census.shared.push(relativePath) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) +} + +function parkedIn(source: string): string[] { + const census: Census = { parked: [], shared: [] } + scanSource('fixture.ts', source, census) + return census.parked +} + +describe('global timer receiver census', () => { + const census: Census = { parked: [], shared: [] } + for (const relativePath of productFiles()) { + scanSource(relativePath, readFileSync(`${SOURCE_ROOT}${relativePath}`, 'utf8'), census) + } + + it('sees the shared receiver-free defaults, so an empty or misdirected walk cannot pass', () => { + expect(census.shared).toEqual(expect.arrayContaining(SHARED_DEFAULT_SITES)) + }) + + it('parks no bare global timer where a later call would supply a non-global receiver', () => { + expect(census.parked).toEqual([]) + }) + + it.each([ + ['a nullish default', 'const schedule = injected ?? setTimeout'], + ['a logical default', 'const schedule = injected || setTimeout'], + ['a nullish assignment default', 'schedule ??= setTimeout'], + ['a logical assignment default', 'schedule ||= setTimeout'], + ['an object literal member', 'const deps = { setTimer: setTimeout }'], + ['a shorthand object member', 'const deps = { setTimeout }'], + ['an assignment onto a property', 'this.setTimer = setTimeout'], + ['a qualified global read', 'const deps = { setTimer: globalThis.setTimeout }'] + ])('flags a global timer parked by %s', (_form, source) => { + expect(parkedIn(source)).toEqual(['fixture.ts:1 setTimeout']) + }) + + it('leaves a plain local capture alone, which a bare call invokes receiver-free', () => { + expect(parkedIn('const schedule = globalThis.setTimeout')).toEqual([]) + }) +}) diff --git a/mobile/src/transport/timer-scheduler.ts b/mobile/src/transport/timer-scheduler.ts new file mode 100644 index 00000000000..5e20f41a33e --- /dev/null +++ b/mobile/src/transport/timer-scheduler.ts @@ -0,0 +1,7 @@ +// The injected-timer seam's real contract: `typeof setTimeout` additionally demands +// Node's `__promisify__` member, which no injected timer (or safe wrapper) can supply. +export type ScheduleTimer = (handler: () => void, ms: number) => ReturnType + +// Why: browsers throw Illegal invocation when a global timer is called with a non-global receiver; Hermes does not. +export const defaultScheduleTimer: ScheduleTimer = (handler, ms) => setTimeout(handler, ms) +export const defaultCancelTimer: typeof clearTimeout = (handle) => clearTimeout(handle) diff --git a/mobile/tests-typecheck-baseline.txt b/mobile/tests-typecheck-baseline.txt index 0e2c3b3c0f7..34dd8f7cf66 100644 --- a/mobile/tests-typecheck-baseline.txt +++ b/mobile/tests-typecheck-baseline.txt @@ -103,7 +103,6 @@ src/transport/host-removal-lifecycle.test.ts src/transport/host-status-gates.test.ts src/transport/host-store.test.ts src/transport/mobile-endpoint-supervisor-nudge.test.ts -src/transport/mobile-relay-background-grace.test.ts src/transport/mobile-relay-background-lifecycle.test.ts src/transport/mobile-relay-direct-upgrade.test.ts src/transport/mobile-relay-e2ee-link.test.ts From b749091b67ecede30c202d394718914e4814a7e7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:03:30 -0400 Subject: [PATCH 017/224] feat(mobile): native shell view serving a mobile web generation from a private origin (OTA phase B, 3/4) (#21417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): declare the orca-mobile-web-shell TS surface Two props and one event: a generation directory the TypeScript store owns, a session id that scopes the private origin, and a load state. No module functions and no reload — a retry is a remount under a new React key, which rebuilds the WebView and reinstalls every fence. The native event body is a flat dictionary, so parseMobileWebShellLoadState rebuilds the union instead of asserting it and answers null for anything it does not recognise. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve a generation from a private origin on iOS A WKWebView behind a custom-scheme handler that answers only from a map built once from the generation's manifest, with the CSP as a response header on the document. The scheme handler reads on a serial background queue and keeps a live-task set that stop() removes from: an asset is up to 10 MiB, and delivering to a stopped task raises an Objective-C exception Swift cannot catch. Origin, request refusal, the manifest map and the policy header hold no WebKit type, so tests/MobileWebShellChecks.swift compiles and runs them with swiftc, no device. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): compare the iOS shell's applied props field by field One joined string could not tell a directory ending in the separator from a shorter one with a longer session id. Two fields have no separator to collide on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a string schemaVersion is not a manifest The contract declares a number. The Kotlin side read it with optInt, which coerces "1" to 1, so a manifest that widened the field would have been served; this check covers the same shape on both platforms. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve a generation from a private origin on Android A WebView behind shouldInterceptRequest, answering only from the same manifest-built map as iOS, with the CSP as a response header on the document. The origin host label is a slice of the session id's SHA-256, never of the session id: Chromium lowercases an https host and java.net.URI reads null for a label holding '_', which is how the reference 403'd every asset. A main-frame failure is reported from a post() because Chromium commits its own error document after onReceivedError returns. onRenderProcessGone destroys the dead WebView and does not rebuild it, so the retry policy stays in one place. clearCache(true) is never called: it is process-global and would wipe the terminal WebView's cache too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): untrack the shell module's gradle build output The previous commit staged 312 files from android/build. mobile/.gitignore anchors /android/ at the mobile root, so a module's own gradle output was never covered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): parse the shell load-state payload with a zod shape The anti-slop gate rejects an `object` parameter and `Reflect.get`. zod reads a shape key straight off the value, so the own-property strip stays. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the web shell one load-state machine per platform A failure is terminal, and a repeat says nothing. Chromium commits its error document after onReceivedError returns and a rule list compiles long after a generation was refused, so both platforms could report over a failure the caller had already acted on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the Android shell reporting ready over a failed document onPageFinished ran after reportDocumentFailure's post and both emitted `ready` and set the WebView visible again, putting Chromium's error page on screen. A prop change after the renderer died now reports instead of going silent. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): publish the Android shell's served generation atomically The map and the host it is keyed against were two plain fields written on the main thread and read on Chromium's, so an interceptor could see a stale null and 403 a good frame, or a new map against the previous host. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the iOS shell to one terminal load state A rule list that failed to compile after a generation was already refused emitted a second, contradictory reason. The document-failure flag it carried is now the state machine's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop serving the previous generation after a failed prop update Both platforms returned early with the old map still installed and the old page still on screen, so a caller told the shell had failed was looking at a working one from the generation before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve the shell document at "/" and nowhere else /index.html answered the same bytes without the policy header, which rides the document response alone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the shell's response headers as a pure predicate Which response carries the policy header was decided inside the two request handlers, where no test without a device can reach it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the shell's path-length edge and its charset casing Both limits were checked only from the rejecting side, so a one-off length and an uppercase charset passed unnoticed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): state the Android shell's file-URL settings and what B4 must check The two file-URL settings were left to their defaults, and the settings that only a device can prove named nobody to prove them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): drop the shell module's unresolved entry points Nothing imports the module by name, on either side; the TypeScript is reached by path, as the notification-dismissal module's is. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the iOS shell failing a document it cancelled itself stopLoading on a prop update and every navigation the policy delegate refuses reach the failure delegates as errors, so a healthy page reported `failed`, lost its `ready`, and sent the caller to delete a good cached generation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): answer when the iOS rule list store is missing Optional-chaining past a nil store ran no completion handler, so the view stayed at `loading` for good. The next prop update now reads the same terminal isolation failure a compile failure sets. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a manifest whose schemaVersion is true or 1.0 on iOS NSNumber bridges both to 1, so `as? Int` accepted a manifest Kotlin rejects. Verified against JSONSerialization: objCType is c for true, d for 1.0, q for 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an Android document failure the next load did not have The report is deferred past Chromium's error document, so a prop update could land between the decision and the report and fail the generation that had just replaced the one that actually failed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert each blocked global's descriptor whole contains("writable:false") passed on a WebSocket descriptor that had lost it, because the serviceWorker copy still carried one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the Android shell's navigation and refusal decisions Both lived inside the WebViewClient, which no suite compiles, so dropping the navigation guard or answering a refusal with 200 changed nothing anyone could see. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the domain a policy-cancelled frame load is reported under WKErrorDomain has no frame-load codes: WKErrorCode stops at the app-bound domain errors, and 102 belongs to the legacy WebKitErrorDomain. The iOS SDK exports no symbol for it, so the assert that pinned one is gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../orca-mobile-web-shell/android/.gitignore | 1 + .../android/build.gradle | 25 ++ .../android/src/main/AndroidManifest.xml | 1 + .../orcamobilewebshell/MobileWebShellCsp.kt | 29 ++ .../MobileWebShellGeneration.kt | 125 +++++++ .../MobileWebShellLoadState.kt | 65 ++++ .../MobileWebShellNavigationPolicy.kt | 18 + .../MobileWebShellNetworkApiBlocker.kt | 40 +++ .../MobileWebShellOrigin.kt | 70 ++++ .../MobileWebShellRefusal.kt | 18 + .../MobileWebShellResponseHeaders.kt | 20 ++ .../orcamobilewebshell/MobileWebShellView.kt | 305 ++++++++++++++++ .../OrcaMobileWebShellModule.kt | 30 ++ .../MobileWebShellCspTest.kt | 63 ++++ .../MobileWebShellGenerationTest.kt | 135 +++++++ .../MobileWebShellLoadStateTest.kt | 98 ++++++ .../MobileWebShellOriginTest.kt | 105 ++++++ .../MobileWebShellRequestPolicyTest.kt | 60 ++++ .../MobileWebShellResponseHeadersTest.kt | 32 ++ .../expo-module.config.json | 9 + .../ios/MobileWebShellCsp.swift | 26 ++ .../ios/MobileWebShellGeneration.swift | 138 ++++++++ .../ios/MobileWebShellLoadState.swift | 72 ++++ .../ios/MobileWebShellOrigin.swift | 118 +++++++ .../ios/MobileWebShellResponseHeaders.swift | 22 ++ .../ios/MobileWebShellView.swift | 333 ++++++++++++++++++ .../ios/OrcaMobileWebShell.podspec | 15 + .../ios/OrcaMobileWebShellModule.swift | 23 ++ .../orca-mobile-web-shell/package.json | 5 + .../orca-mobile-web-shell/src/index.ts | 32 ++ .../orca-mobile-web-shell/src/load-state.ts | 72 ++++ .../tests/MobileWebShellChecks.swift | 288 +++++++++++++++ .../mobile-web-shell/shell-load-state.test.ts | 47 +++ 33 files changed, 2440 insertions(+) create mode 100644 mobile/modules/orca-mobile-web-shell/android/.gitignore create mode 100644 mobile/modules/orca-mobile-web-shell/android/build.gradle create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/AndroidManifest.xml create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellGeneration.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellNavigationPolicy.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellNetworkApiBlocker.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellOrigin.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellRefusal.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellResponseHeaders.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellGenerationTest.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellOriginTest.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellRequestPolicyTest.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellResponseHeadersTest.kt create mode 100644 mobile/modules/orca-mobile-web-shell/expo-module.config.json create mode 100644 mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift create mode 100644 mobile/modules/orca-mobile-web-shell/ios/MobileWebShellGeneration.swift create mode 100644 mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift create mode 100644 mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift create mode 100644 mobile/modules/orca-mobile-web-shell/ios/MobileWebShellResponseHeaders.swift create mode 100644 mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift create mode 100644 mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShell.podspec create mode 100644 mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift create mode 100644 mobile/modules/orca-mobile-web-shell/package.json create mode 100644 mobile/modules/orca-mobile-web-shell/src/index.ts create mode 100644 mobile/modules/orca-mobile-web-shell/src/load-state.ts create mode 100644 mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift create mode 100644 mobile/src/mobile-web-shell/shell-load-state.test.ts diff --git a/mobile/modules/orca-mobile-web-shell/android/.gitignore b/mobile/modules/orca-mobile-web-shell/android/.gitignore new file mode 100644 index 00000000000..84c048a73cc --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/mobile/modules/orca-mobile-web-shell/android/build.gradle b/mobile/modules/orca-mobile-web-shell/android/build.gradle new file mode 100644 index 00000000000..1ca89303e9d --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/build.gradle @@ -0,0 +1,25 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.orcamobilewebshell' +version = '0.0.1' + +def expoModulesCorePlugin = new File(project(':expo-modules-core').projectDir.absolutePath, 'ExpoModulesCorePlugin.gradle') +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() +useDefaultAndroidSdkVersions() + +android { + namespace 'expo.modules.orcamobilewebshell' +} + +dependencies { + // Already on the APK classpath at this exact version via react-native-webview + // (node_modules/react-native-webview/android/gradle.properties), so this adds no artifact. + implementation 'androidx.webkit:webkit:1.14.0' + // The android.jar used by JVM unit tests stubs org.json, so the real parser has to be on the + // test classpath or every manifest check would read null. + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20240303' +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/AndroidManifest.xml b/mobile/modules/orca-mobile-web-shell/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..94cbbcfc396 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt new file mode 100644 index 00000000000..47abc1c1f98 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt @@ -0,0 +1,29 @@ +package expo.modules.orcamobilewebshell + +/** + * Sent as a response header on the document and nowhere else: a served document must never carry + * its own policy, so there is no meta tag to find and no bundle change that can relax it. Kept in + * step with the iOS copy. + */ +internal val MOBILE_WEB_SHELL_CSP = listOf( + "default-src 'none'", + "script-src 'self'", + // 'self' holds only while the bundle ships linked stylesheets. React Native Web emits runtime + // style elements, so Phase C has to revisit this openly rather than relax it quietly. + "style-src 'self'", + "img-src 'self'", + "font-src 'none'", + // The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the + // page cannot already read, and the bootstrap page reads ./manifest.json through it. This is the + // fence for fetch and XMLHttpRequest; the document-start script covers only the two things the + // native layer cannot see. + "connect-src 'self'", + "media-src 'none'", + "object-src 'none'", + "frame-src 'none'", + "child-src 'none'", + "worker-src 'none'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'" +).joinToString("; ") diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellGeneration.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellGeneration.kt new file mode 100644 index 00000000000..0e4730a0185 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellGeneration.kt @@ -0,0 +1,125 @@ +package expo.modules.orcamobilewebshell + +import java.io.File +import org.json.JSONArray +import org.json.JSONObject + +private const val MOBILE_WEB_SHELL_MANIFEST_NAME = "manifest.json" +private const val MOBILE_WEB_SHELL_MANIFEST_CONTENT_TYPE = "application/json" +private const val MOBILE_WEB_SHELL_SCHEMA_VERSION = 1 +private const val MOBILE_WEB_SHELL_ENTRYPOINT = "index.html" +private const val MOBILE_WEB_SHELL_MAX_ASSETS = 256 +private const val MOBILE_WEB_SHELL_MAX_ASSET_PATH_LENGTH = 255 +private const val MOBILE_WEB_SHELL_MAX_CONTENT_TYPE_LENGTH = 128 + +internal data class MobileWebShellAsset(val file: File, val contentType: String) + +/** + * The served surface of one activated generation: a request path to file map, built once from the + * manifest before anything loads. Serving is a lookup in this map and never a path join at request + * time, so "not in the manifest" is a refusal by construction rather than by sanitiser. + * + * Asset bytes are not re-hashed here. The TypeScript store verified every byte against the manifest + * before the activating rename, and the directory path is one the app owns and the page can never + * influence. + */ +internal class MobileWebShellGeneration private constructor( + val entries: Map +) { + companion object { + fun load(directoryPath: String): MobileWebShellGeneration? { + if (!directoryPath.startsWith("/")) return null + val directory = File(directoryPath) + val manifest = runCatching { + File(directory, MOBILE_WEB_SHELL_MANIFEST_NAME).readText(Charsets.UTF_8) + }.getOrNull() ?: return null + return make(manifest, directory) + } + + fun make(manifestJson: String, directory: File): MobileWebShellGeneration? { + val root = runCatching { JSONObject(manifestJson) }.getOrNull() ?: return null + // opt, not optInt: optInt coerces the string "1" to 1, and the contract pins a number. + if (root.opt("schemaVersion") != MOBILE_WEB_SHELL_SCHEMA_VERSION) return null + if (root.opt("entrypoint") != MOBILE_WEB_SHELL_ENTRYPOINT) return null + val assets = root.opt("assets") + if (assets !is JSONArray) return null + if (assets.length() == 0 || assets.length() > MOBILE_WEB_SHELL_MAX_ASSETS) return null + + val entries = mutableMapOf() + for (index in 0 until assets.length()) { + val asset = assets.opt(index) + if (asset !is JSONObject) return null + val path = asset.opt("path") + val contentType = asset.opt("contentType") + if (path !is String || !isServableAssetPath(path)) return null + if (contentType !is String || !isServableContentType(contentType)) return null + entries["/$path"] = MobileWebShellAsset(File(directory, path), contentType) + } + // Removed, not copied: the document answers at "/" and nowhere else, so the one response that + // carries the policy header is the only way to reach those bytes. + val document = entries.remove("/$MOBILE_WEB_SHELL_ENTRYPOINT") ?: return null + entries["/"] = document + // The manifest is written last and is not part of the content hash, so it is not in `assets`; + // the bootstrap page still reads it from its own origin. + entries["/$MOBILE_WEB_SHELL_MANIFEST_NAME"] = MobileWebShellAsset( + File(directory, MOBILE_WEB_SHELL_MANIFEST_NAME), + MOBILE_WEB_SHELL_MANIFEST_CONTENT_TYPE + ) + return MobileWebShellGeneration(entries) + } + + /** + * Re-checked here rather than trusted: the schema that pins this shape is on the other side of + * a file the native layer cannot see change. + */ + fun isServableAssetPath(path: String): Boolean { + if (path.isEmpty() || path.toByteArray(Charsets.UTF_8).size > MOBILE_WEB_SHELL_MAX_ASSET_PATH_LENGTH) { + return false + } + return path.split('/').all { segment -> + segment.isNotEmpty() && + segment != "." && + segment != ".." && + segment.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '.' || it == '_' || it == '-' } + } + } + + /** + * This value becomes a response header, so it must not be able to carry a second header or a + * parameter we did not intend. One lowercase type, one optional charset: the manifest + * contract's only accepted spelling. + */ + fun isServableContentType(contentType: String): Boolean { + if (contentType.isEmpty() || + contentType.toByteArray(Charsets.UTF_8).size > MOBILE_WEB_SHELL_MAX_CONTENT_TYPE_LENGTH + ) { + return false + } + var type = contentType + val separator = contentType.indexOf("; charset=") + if (separator >= 0) { + val charset = contentType.substring(separator + "; charset=".length) + if (charset.isEmpty()) return false + if (!charset.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }) return false + type = contentType.substring(0, separator) + } + val halves = type.split('/') + if (halves.size != 2) return false + return halves.all(::isMimeToken) + } + + private fun isMimeToken(token: String): Boolean { + val first = token.firstOrNull() ?: return false + if (!(first in 'a'..'z' || first in '0'..'9')) return false + return token.all { it in 'a'..'z' || it in '0'..'9' || it == '.' || it == '+' || it == '-' } + } + } +} + +/** `WebResourceResponse` takes the mime type and the encoding separately. */ +internal fun splitMobileWebShellContentType(contentType: String): Pair { + val separator = contentType.indexOf("; charset=") + if (separator < 0) return contentType to null + return contentType.substring(0, separator) to + contentType.substring(separator + "; charset=".length) +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt new file mode 100644 index 00000000000..6255a01ccd3 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt @@ -0,0 +1,65 @@ +package expo.modules.orcamobilewebshell + +/** The wire names the TypeScript parser accepts; a swap here is a silent change of meaning. */ +internal enum class MobileWebShellFailureReason(val wireName: String) { + GENERATION_UNREADABLE("generation-unreadable"), + ISOLATION_UNAVAILABLE("isolation-unavailable"), + DOCUMENT_LOAD_FAILED("document-load-failed"), + RENDER_PROCESS_GONE("render-process-gone") +} + +internal data class MobileWebShellLoadEmission(val state: String, val reason: String?) { + fun toPayload(): Map = if (reason == null) { + mapOf("state" to state) + } else { + mapOf("state" to state, "reason" to reason) + } +} + +/** + * What a mount is still allowed to report. A failure is terminal: Chromium commits its own error + * document after `onReceivedError` returns, and a rule list can fail to compile long after the + * generation was already refused, so without this a `ready` or a second reason lands on top of a + * failure the caller has already acted on. Consecutive duplicates are dropped as well. + * + * Pure, and the same rule on both platforms, so a JVM test and a `swiftc` check can hold it. + */ +internal class MobileWebShellLoadStateMachine { + private var terminal = false + private var last: MobileWebShellLoadEmission? = null + + /** Which load this machine is reporting on. Read before deferring work, checked on delivery. */ + var epoch: Int = 0 + private set + + /** A new prop pair. Nothing else reopens a terminal state: a retry is a remount. */ + fun reset() { + terminal = false + last = null + epoch += 1 + } + + fun started(): MobileWebShellLoadEmission? = emit(MobileWebShellLoadEmission("loading", null)) + + fun finished(): MobileWebShellLoadEmission? = emit(MobileWebShellLoadEmission("ready", null)) + + fun failed(reason: MobileWebShellFailureReason): MobileWebShellLoadEmission? { + val emission = emit(MobileWebShellLoadEmission("failed", reason.wireName)) + terminal = true + return emission + } + + /** + * A failure decided during one load and reported after the next one started belongs to neither: + * Android has to defer its report past Chromium's error document, and a prop update can land in + * between, which would fail the generation that just replaced the one that actually failed. + */ + fun failedDuring(epoch: Int, reason: MobileWebShellFailureReason): MobileWebShellLoadEmission? = + if (epoch != this.epoch) null else failed(reason) + + private fun emit(emission: MobileWebShellLoadEmission): MobileWebShellLoadEmission? { + if (terminal || emission == last) return null + last = emission + return emission + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellNavigationPolicy.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellNavigationPolicy.kt new file mode 100644 index 00000000000..d92bcaf3cd1 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellNavigationPolicy.kt @@ -0,0 +1,18 @@ +package expo.modules.orcamobilewebshell + +/** + * Whether a navigation is dropped. Only the document URL of the generation currently served is + * allowed to load: nothing in the bundle navigates, so anything that tries is either a link the + * page opened or a URL the page built, and neither is ours to follow. + * + * `true` means Chromium never starts the navigation. A serving host of null means no generation is + * applied, so there is no document to allow yet. + */ +internal fun mobileWebShellDropsNavigation( + parts: MobileWebShellRequestParts, + originHost: String?, + isForMainFrame: Boolean +): Boolean { + if (!isForMainFrame || originHost == null) return true + return resolveMobileWebShellRequestPath(parts, originHost) != "/" +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellNetworkApiBlocker.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellNetworkApiBlocker.kt new file mode 100644 index 00000000000..d103101e467 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellNetworkApiBlocker.kt @@ -0,0 +1,40 @@ +package expo.modules.orcamobilewebshell + +import android.webkit.WebView +import androidx.webkit.ScriptHandler +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature + +/** + * CSP is the fence for fetch and XMLHttpRequest. This script exists only for the two things the + * native layer is never shown: a WebSocket handshake, which neither `blockNetworkLoads` nor + * `shouldInterceptRequest` sees, and a service worker registration, whose only native control is + * process-global and would reconfigure the app's other WebViews. Kept in step with the iOS copy. + * `configurable: false` with `writable: false` is the only property shape the page cannot put back. + */ +internal val MOBILE_WEB_SHELL_NETWORK_API_BLOCKER = """ + (function(){ + var deny=function(){throw new TypeError('Network access is disabled')}; + try{Object.defineProperty(globalThis,'WebSocket',{value:deny,configurable:false,writable:false})}catch(_){} + try{Object.defineProperty(Navigator.prototype,'serviceWorker',{get:function(){return undefined},configurable:false})}catch(_){} + try{Object.defineProperty(navigator,'serviceWorker',{value:undefined,configurable:false,writable:false})}catch(_){} + })(); +""".trimIndent() + +/** + * Null when the WebView provider is older than the document-start script feature (Chromium 83). + * The feature query is the capability; a version string is not, so nothing here parses one. + */ +internal fun installMobileWebShellNetworkApiBlocker( + webView: WebView, + allowedOrigin: String +): ScriptHandler? { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null + return runCatching { + WebViewCompat.addDocumentStartJavaScript( + webView, + MOBILE_WEB_SHELL_NETWORK_API_BLOCKER, + setOf(allowedOrigin) + ) + }.getOrNull() +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellOrigin.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellOrigin.kt new file mode 100644 index 00000000000..ec6835309f8 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellOrigin.kt @@ -0,0 +1,70 @@ +package expo.modules.orcamobilewebshell + +import java.security.MessageDigest + +internal const val MOBILE_WEB_SHELL_SCHEME = "https" +internal const val MOBILE_WEB_SHELL_MAX_URL_LENGTH = 8 * 1024 +private const val MOBILE_WEB_SHELL_ORIGIN_SUFFIX = ".orca-mobile-web.invalid" +private const val MOBILE_WEB_SHELL_LABEL_LENGTH = 32 +private const val MOBILE_WEB_SHELL_MAX_SESSION_ID_LENGTH = 128 + +internal fun isMobileWebShellSessionId(sessionId: String): Boolean = + sessionId.isNotEmpty() && + sessionId.length <= MOBILE_WEB_SHELL_MAX_SESSION_ID_LENGTH && + sessionId.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '-' || it == '_' } + +/** + * The host label is a slice of the session id's digest, never a slice of the session id. + * + * Session ids are base64url, and `https` is a special scheme, so Chromium ASCII-lowercases every + * host it loads and reports back while `java.net.URI.getHost()` answers null for a label holding + * `_`. The host the interceptor compared against then never equalled the one it was handed, and + * every asset fell to the refusal branch as a 403. Lowercase hex is canonical under both parsers, + * 32 characters because a DNS label caps at 63 octets, and `.invalid` is reserved by RFC 2606 so it + * can never resolve. + */ +internal fun mobileWebShellOriginHost(sessionId: String): String? { + if (!isMobileWebShellSessionId(sessionId)) return null + val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray(Charsets.UTF_8)) + val label = digest.joinToString("") { byte -> "%02x".format(byte) } + .take(MOBILE_WEB_SHELL_LABEL_LENGTH) + return "$label$MOBILE_WEB_SHELL_ORIGIN_SUFFIX" +} + +internal fun mobileWebShellOrigin(sessionId: String): String? = + mobileWebShellOriginHost(sessionId)?.let { host -> "$MOBILE_WEB_SHELL_SCHEME://$host" } + +/** A request reduced to the components the predicate reads, so it needs no `android.net.Uri`. */ +internal data class MobileWebShellRequestParts( + val method: String, + val hasRangeHeader: Boolean, + val scheme: String?, + val host: String?, + val port: Int, + val userInfo: String?, + val query: String?, + val fragment: String?, + val encodedPath: String?, + val urlLength: Int +) + +/** + * The map key for a request we are willing to answer, or null to refuse. Every clause is an allow, + * so a component nobody anticipated falls to refusal rather than through it. + */ +internal fun resolveMobileWebShellRequestPath( + parts: MobileWebShellRequestParts, + originHost: String +): String? { + val path = parts.encodedPath ?: return null + if (parts.method != "GET" || parts.hasRangeHeader) return null + if (parts.scheme != MOBILE_WEB_SHELL_SCHEME) return null + // Hosts are case-insensitive, so a parser that canonicalised one must still bind to this session. + if (parts.host == null || !parts.host.equals(originHost, ignoreCase = true)) return null + if (parts.port != -1 || parts.userInfo != null) return null + if (parts.query != null || parts.fragment != null) return null + if (parts.urlLength > MOBILE_WEB_SHELL_MAX_URL_LENGTH || path.contains('%')) return null + if (path.isEmpty() || path == "/") return "/" + if (!path.startsWith("/")) return null + return path +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellRefusal.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellRefusal.kt new file mode 100644 index 00000000000..e5f39bd9360 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellRefusal.kt @@ -0,0 +1,18 @@ +package expo.modules.orcamobilewebshell + +/** + * What a request outside the manifest map is answered with. A refusal is a response, never a null: + * returning null from `shouldInterceptRequest` hands the request to Chromium's own loader, which is + * the one path out of this origin that the settings cannot close. + * + * The body is empty on purpose. There is nothing to say to a page that asked for something it was + * never given, and a body is one more thing an error page could render. + */ +internal const val MOBILE_WEB_SHELL_REFUSAL_STATUS = 403 +internal const val MOBILE_WEB_SHELL_REFUSAL_REASON = "Forbidden" +internal const val MOBILE_WEB_SHELL_REFUSAL_MIME_TYPE = "text/plain" +internal const val MOBILE_WEB_SHELL_REFUSAL_CHARSET = "utf-8" + +internal val MOBILE_WEB_SHELL_REFUSAL_HEADERS = mapOf("Cache-Control" to "no-store") + +internal fun mobileWebShellRefusalBody(): ByteArray = ByteArray(0) diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellResponseHeaders.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellResponseHeaders.kt new file mode 100644 index 00000000000..523a562f2db --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellResponseHeaders.kt @@ -0,0 +1,20 @@ +package expo.modules.orcamobilewebshell + +/** + * The headers one served asset answers with. Content-Type is not among them: `WebResourceResponse` + * takes the mime type and the encoding as separate arguments. + * + * The policy header rides the document and nothing else: on a script or a stylesheet response it is + * inert, and sending it everywhere would hide which response is the one that has to carry it. + */ +internal fun mobileWebShellResponseHeaders(path: String, byteCount: Int): Map { + val headers = mutableMapOf( + "Content-Length" to byteCount.toString(), + "Cache-Control" to "no-store", + "X-Content-Type-Options" to "nosniff" + ) + if (path == "/") { + headers["Content-Security-Policy"] = MOBILE_WEB_SHELL_CSP + } + return headers +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt new file mode 100644 index 00000000000..7dfaae4cb78 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt @@ -0,0 +1,305 @@ +package expo.modules.orcamobilewebshell + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.net.Uri +import android.os.Message +import android.view.View +import android.webkit.RenderProcessGoneDetail +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.webkit.ScriptHandler +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import java.io.ByteArrayInputStream + +/** + * What the interceptor is currently allowed to answer. One immutable value, because the map and the + * host it is keyed against are written on the main thread and read on Chromium's: two fields would + * let a request see a new generation against the old host, and a plain field would let it see a + * stale null and refuse a frame we had just served. + */ +private class MobileWebShellServed( + val generation: MobileWebShellGeneration, + val originHost: String +) + +@SuppressLint("ViewConstructor", "SetJavaScriptEnabled") +internal class OrcaMobileWebShellView( + context: Context, + appContext: AppContext +) : ExpoView(context, appContext) { + private val onLoadState by EventDispatcher>() + + private var generationDirectory = "" + private var sessionId = "" + private var appliedDirectory: String? = null + private var appliedSessionId: String? = null + private val loadState = MobileWebShellLoadStateMachine() + // Written on the main thread, read from onPageStarted/onPageFinished, which Chromium runs after + // the failure that hid the view; `shouldInterceptRequest` also runs off the main thread. + @Volatile private var documentFailed = false + @Volatile private var served: MobileWebShellServed? = null + private var blocker: ScriptHandler? = null + private var webView: WebView? = createWebView() + + init { + addView(webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + fun setGenerationDirectory(value: String) { + generationDirectory = value + } + + fun setSessionId(value: String) { + sessionId = value + } + + /** + * Props arrive in no defined order, so neither setter starts anything; this does, once both are + * in. A repeat of the same pair is not a retry: a retry is a remount under a new React key. + */ + fun propsDidUpdate() { + if (generationDirectory == appliedDirectory && sessionId == appliedSessionId) return + appliedDirectory = generationDirectory + appliedSessionId = sessionId + documentFailed = false + loadState.reset() + val view = webView + if (view == null) { + // onRenderProcessGone destroyed it. Recovery is a remount, so a new prop pair on the corpse + // is still a failure, and one that says so beats one that goes quiet forever. + emit(loadState.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE)) + return + } + view.stopLoading() + emit(loadState.started()) + + val origin = mobileWebShellOrigin(sessionId) + val host = mobileWebShellOriginHost(sessionId) + if (origin == null || host == null) { + // The private origin is the isolation primitive; a malformed session id leaves us without one. + failPropUpdate(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE) + return + } + val loaded = MobileWebShellGeneration.load(generationDirectory) + if (loaded == null) { + failPropUpdate(MobileWebShellFailureReason.GENERATION_UNREADABLE) + return + } + blocker?.remove() + blocker = installMobileWebShellNetworkApiBlocker(view, origin) + if (blocker == null) { + failPropUpdate(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE) + return + } + served = MobileWebShellServed(loaded, host) + view.visibility = View.VISIBLE + view.loadUrl("$origin/") + } + + /** + * The generation that failed to apply replaces whatever was on screen; leaving the previous one + * served and visible would show a page the caller has just been told is not loaded. + */ + private fun failPropUpdate(reason: MobileWebShellFailureReason) { + served = null + webView?.visibility = View.INVISIBLE + emit(loadState.failed(reason)) + } + + /** Expo calls this once React Native is done with the view, and onRenderProcessGone calls it. */ + fun destroyWebView() { + val view = webView ?: return + webView = null + blocker?.remove() + blocker = null + served = null + documentFailed = false + view.stopLoading() + removeView(view) + view.destroy() + } + + // databaseEnabled and the two file-URL settings are deprecated and inert on new WebViews, but + // the floor here is Chromium 83, and an invariant left to a default is one nobody can read. + // + // device-checked in B4: no setting below can be proven from a JVM test, and neither can + // shouldOverrideUrlLoading dropping a navigation. Confirm on a device that a page cannot reach + // the network (blockNetworkLoads), cannot keep state across a remount (domStorageEnabled, + // databaseEnabled, cacheMode), cannot read a file or a content provider (allowFileAccess, + // allowContentAccess, the two file-URL settings), cannot load http (mixedContentMode), and + // cannot navigate away from the document. + @Suppress("DEPRECATION") + private fun createWebView(): WebView { + val view = WebView(context) + view.setBackgroundColor(Color.TRANSPARENT) + view.settings.apply { + javaScriptEnabled = true + domStorageEnabled = false + databaseEnabled = false + allowFileAccess = false + allowFileAccessFromFileURLs = false + allowUniversalAccessFromFileURLs = false + allowContentAccess = false + javaScriptCanOpenWindowsAutomatically = false + setSupportMultipleWindows(false) + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + cacheMode = WebSettings.LOAD_NO_CACHE + blockNetworkLoads = true + mediaPlaybackRequiresUserGesture = true + setGeolocationEnabled(false) + } + // Never clearCache(true): that is process-global and would wipe the HTTP cache of every other + // WebView in the app, including the terminal's. LOAD_NO_CACHE plus no-store is per view. + view.webViewClient = ShellWebViewClient() + view.webChromeClient = object : WebChromeClient() { + override fun onCreateWindow( + view: WebView?, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message? + ): Boolean = false + } + view.setDownloadListener { _, _, _, _, _ -> } + return view + } + + private fun emit(emission: MobileWebShellLoadEmission?) { + if (emission != null) onLoadState(emission.toPayload()) + } + + /** + * Chromium commits its own error document after `onReceivedError` returns, so hiding the WebView + * synchronously is undone a moment later; posting is what keeps the shell's own state the only + * thing on screen. `shouldInterceptRequest` also runs off the main thread. + */ + private fun reportDocumentFailure() { + // Set before the post, not inside it: onPageFinished runs in between and would otherwise + // report `ready` over the failure and make the error page visible again. + documentFailed = true + val epoch = loadState.epoch + post { + if (!documentFailed) return@post + val emission = loadState.failedDuring( + epoch, + MobileWebShellFailureReason.DOCUMENT_LOAD_FAILED + ) ?: return@post + webView?.visibility = View.INVISIBLE + emit(emission) + } + } + + private fun isDocumentUrl(url: Uri): Boolean { + val host = served?.originHost ?: return false + return resolveMobileWebShellRequestPath(requestParts(url), host) == "/" + } + + private fun requestParts( + url: Uri, + method: String = "GET", + hasRangeHeader: Boolean = false + ): MobileWebShellRequestParts = MobileWebShellRequestParts( + method = method, + hasRangeHeader = hasRangeHeader, + scheme = url.scheme, + host = url.host, + port = url.port, + userInfo = url.userInfo, + query = url.query, + fragment = url.fragment, + encodedPath = url.encodedPath, + urlLength = url.toString().length + ) + + private fun serveRequest(request: WebResourceRequest): WebResourceResponse? { + val current = served ?: return null + val parts = requestParts( + request.url, + method = request.method, + hasRangeHeader = request.requestHeaders.keys.any { it.equals("Range", ignoreCase = true) } + ) + val path = resolveMobileWebShellRequestPath(parts, current.originHost) ?: return null + val asset = current.generation.entries[path] ?: return null + val bytes = runCatching { asset.file.readBytes() }.getOrNull() ?: return null + val headers = mobileWebShellResponseHeaders(path, bytes.size) + val (mimeType, charset) = splitMobileWebShellContentType(asset.contentType) + return WebResourceResponse(mimeType, charset, 200, "OK", headers, ByteArrayInputStream(bytes)) + } + + private fun refusedResponse(): WebResourceResponse = WebResourceResponse( + MOBILE_WEB_SHELL_REFUSAL_MIME_TYPE, + MOBILE_WEB_SHELL_REFUSAL_CHARSET, + MOBILE_WEB_SHELL_REFUSAL_STATUS, + MOBILE_WEB_SHELL_REFUSAL_REASON, + MOBILE_WEB_SHELL_REFUSAL_HEADERS, + ByteArrayInputStream(mobileWebShellRefusalBody()) + ) + + private inner class ShellWebViewClient : WebViewClient() { + /** Never null, so no request can fall through to the network. */ + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse { + val response = serveRequest(request) + if (response != null) return response + if (request.isForMainFrame) reportDocumentFailure() + return refusedResponse() + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = + mobileWebShellDropsNavigation( + requestParts(request.url), + served?.originHost, + request.isForMainFrame + ) + + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + if (documentFailed || !isDocumentUrl(Uri.parse(url))) return + emit(loadState.started()) + } + + override fun onPageFinished(view: WebView, url: String) { + if (documentFailed || !isDocumentUrl(Uri.parse(url))) return + view.visibility = View.VISIBLE + view.clearHistory() + emit(loadState.finished()) + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + if (request.isForMainFrame) reportDocumentFailure() + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse + ) { + if (request.isForMainFrame) reportDocumentFailure() + } + + /** + * Returning false would kill the app. The dead WebView is destroyed and not rebuilt: renderer + * memory pressure, a provider update and a bad bundle are indistinguishable here, so the retry + * policy is the caller's and lives in one place. + */ + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { + destroyWebView() + emit(loadState.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE)) + return true + } + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt new file mode 100644 index 00000000000..ecb410d23e7 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt @@ -0,0 +1,30 @@ +package expo.modules.orcamobilewebshell + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class OrcaMobileWebShellModule : Module() { + override fun definition() = ModuleDefinition { + Name("OrcaMobileWebShell") + + View(OrcaMobileWebShellView::class) { + Events("onLoadState") + + Prop("generationDirectory") { view: OrcaMobileWebShellView, value: String -> + view.setGenerationDirectory(value) + } + + Prop("sessionId") { view: OrcaMobileWebShellView, value: String -> + view.setSessionId(value) + } + + OnViewDidUpdateProps { view: OrcaMobileWebShellView -> + view.propsDidUpdate() + } + + OnViewDestroys { view: OrcaMobileWebShellView -> + view.destroyWebView() + } + } + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt new file mode 100644 index 00000000000..75006761d0d --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt @@ -0,0 +1,63 @@ +package expo.modules.orcamobilewebshell + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MobileWebShellCspTest { + @Test + fun `states every fetching directive so nothing falls back to the default`() { + val directives = MOBILE_WEB_SHELL_CSP.split("; ") + assertTrue(directives.contains("default-src 'none'")) + assertTrue(directives.contains("script-src 'self'")) + assertTrue(directives.contains("style-src 'self'")) + assertTrue(directives.contains("img-src 'self'")) + // The bootstrap page reads ./manifest.json from its own origin, which is one read-only + // directory behind the manifest map, so 'self' reaches nothing it cannot already read. + assertTrue(directives.contains("connect-src 'self'")) + assertTrue(directives.contains("worker-src 'none'")) + assertTrue(directives.contains("frame-src 'none'")) + assertTrue(directives.contains("child-src 'none'")) + assertTrue(directives.contains("object-src 'none'")) + assertTrue(directives.contains("base-uri 'none'")) + assertTrue(directives.contains("form-action 'none'")) + assertTrue(directives.contains("frame-ancestors 'none'")) + } + + @Test + fun `grants nothing the build rules say the bundle never needs`() { + assertFalse(MOBILE_WEB_SHELL_CSP.contains("unsafe-inline")) + assertFalse(MOBILE_WEB_SHELL_CSP.contains("unsafe-eval")) + assertFalse(MOBILE_WEB_SHELL_CSP.contains("data:")) + assertFalse(MOBILE_WEB_SHELL_CSP.contains("blob:")) + assertFalse(MOBILE_WEB_SHELL_CSP.contains("http")) + } + + @Test + fun `is a single header line`() { + assertFalse(MOBILE_WEB_SHELL_CSP.contains("\r")) + assertFalse(MOBILE_WEB_SHELL_CSP.contains("\n")) + } + + @Test + fun `denies only what the native layer cannot see, with a shape the page cannot restore`() { + val blocker = MOBILE_WEB_SHELL_NETWORK_API_BLOCKER + // Whole definitions, not `contains("writable:false")`: one property's descriptor could lose a + // flag and still match because another property still carries it. + assertTrue( + blocker.contains("globalThis,'WebSocket',{value:deny,configurable:false,writable:false}") + ) + assertTrue( + blocker.contains( + "Navigator.prototype,'serviceWorker',{get:function(){return undefined},configurable:false}" + ) + ) + assertTrue( + blocker.contains("navigator,'serviceWorker',{value:undefined,configurable:false,writable:false}") + ) + // CSP is the fence for fetch and XMLHttpRequest; a script that replaced them would put one + // policy in two places and hide which one is actually holding. + assertFalse(blocker.contains("fetch")) + assertFalse(blocker.contains("XMLHttpRequest")) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellGenerationTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellGenerationTest.kt new file mode 100644 index 00000000000..c7b6c18bebb --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellGenerationTest.kt @@ -0,0 +1,135 @@ +package expo.modules.orcamobilewebshell + +import java.io.File +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +private val DIRECTORY = File("/tmp/generation") + +private fun asset(path: Any, contentType: Any): JSONObject = + JSONObject().put("path", path).put("contentType", contentType) + +private fun manifest( + schemaVersion: Any = 1, + entrypoint: Any = "index.html", + assets: List = listOf( + asset("index.html", "text/html; charset=utf-8"), + asset("assets/aa.js", "text/javascript; charset=utf-8"), + asset("assets/bb.png", "image/png") + ) +): String = JSONObject() + .put("schemaVersion", schemaVersion) + .put("entrypoint", entrypoint) + .put("assets", JSONArray(assets)) + .toString() + +private fun make(json: String) = MobileWebShellGeneration.make(json, DIRECTORY) + +class MobileWebShellGenerationTest { + @Test + fun `maps the document, every declared asset and the manifest itself`() { + val generation = make(manifest()) + assertNotNull(generation) + val entries = generation!!.entries + assertEquals(4, entries.size) + assertEquals(File(DIRECTORY, "index.html"), entries["/"]!!.file) + assertEquals("text/html; charset=utf-8", entries["/"]!!.contentType) + // Only "/" reaches the document: a second URL for the same bytes would answer without the CSP + // header, which rides the document response alone. + assertNull(entries["/index.html"]) + assertEquals(File(DIRECTORY, "assets/bb.png"), entries["/assets/bb.png"]!!.file) + assertEquals("image/png", entries["/assets/bb.png"]!!.contentType) + // The manifest is written last and is not part of the content hash, so it is not in assets[]. + assertEquals("application/json", entries["/manifest.json"]!!.contentType) + assertNull(entries["/assets/cc.js"]) + } + + @Test + fun `refuses a manifest whose shape it does not recognise`() { + assertNull(make("not json")) + assertNull(make("[]")) + assertNull(make(manifest(schemaVersion = 2))) + assertNull(make(manifest(schemaVersion = "1"))) + assertNull(make(manifest(entrypoint = "start.html"))) + assertNull(make(manifest(assets = emptyList()))) + // Without the entrypoint among the assets, "/" would map to a file nobody declared. + assertNull(make(manifest(assets = listOf(asset("assets/aa.js", "text/javascript"))))) + assertNull(make(manifest(assets = (0..256).map { asset("assets/a$it.js", "text/javascript") }))) + assertNotNull(make(manifest(assets = listOf(asset("index.html", "text/html")) + + (0..254).map { asset("assets/a$it.js", "text/javascript") }))) + } + + @Test + fun `refuses a manifest that declares a path or a content type it will not serve`() { + assertNull(make(manifest(assets = listOf( + asset("index.html", "text/html"), + asset("../escape.js", "text/javascript") + )))) + assertNull(make(manifest(assets = listOf( + asset("index.html", "text/html"), + asset("assets/aa.js", "text/javascript\r\nX-Injected: 1") + )))) + assertNull(make(manifest(assets = listOf( + asset("index.html", "text/html"), + asset(7, "text/javascript") + )))) + assertNull(make(manifest(assets = listOf( + asset("index.html", "text/html"), + asset("assets/aa.js", 7) + )))) + } + + @Test + fun `accepts only portable relative asset paths`() { + assertTrue(MobileWebShellGeneration.isServableAssetPath("index.html")) + assertTrue(MobileWebShellGeneration.isServableAssetPath("assets/a-b_c.2.js")) + assertFalse(MobileWebShellGeneration.isServableAssetPath("")) + assertFalse(MobileWebShellGeneration.isServableAssetPath("/leading")) + assertFalse(MobileWebShellGeneration.isServableAssetPath("trailing/")) + assertFalse(MobileWebShellGeneration.isServableAssetPath("a//b")) + assertFalse(MobileWebShellGeneration.isServableAssetPath("../secret")) + assertFalse(MobileWebShellGeneration.isServableAssetPath("assets/../../secret")) + assertFalse(MobileWebShellGeneration.isServableAssetPath("assets/./a.js")) + assertFalse(MobileWebShellGeneration.isServableAssetPath("back\\slash")) + assertFalse(MobileWebShellGeneration.isServableAssetPath("has space.js")) + assertTrue(MobileWebShellGeneration.isServableAssetPath("a".repeat(255))) + assertFalse(MobileWebShellGeneration.isServableAssetPath("a".repeat(256))) + } + + @Test + fun `accepts only a content type that cannot carry a second header`() { + assertTrue(MobileWebShellGeneration.isServableContentType("image/png")) + assertTrue(MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8")) + assertTrue(MobileWebShellGeneration.isServableContentType("application/manifest+json")) + assertFalse(MobileWebShellGeneration.isServableContentType("")) + assertFalse(MobileWebShellGeneration.isServableContentType("text/html\r\nX-Injected: 1")) + assertFalse(MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8; x=1")) + assertFalse(MobileWebShellGeneration.isServableContentType("TEXT/HTML")) + // A header value we did not mint character for character is a value we did not check. + assertFalse(MobileWebShellGeneration.isServableContentType("text/html; charset=UTF-8")) + assertFalse(MobileWebShellGeneration.isServableContentType("text")) + assertFalse(MobileWebShellGeneration.isServableContentType("text/html/extra")) + assertFalse(MobileWebShellGeneration.isServableContentType("/html")) + assertFalse(MobileWebShellGeneration.isServableContentType("-text/html")) + assertFalse(MobileWebShellGeneration.isServableContentType("text/html; charset=")) + assertFalse(MobileWebShellGeneration.isServableContentType("a".repeat(130) + "/b")) + } + + @Test + fun `splits the content type the way WebResourceResponse wants it`() { + assertEquals("text/html" to "utf-8", splitMobileWebShellContentType("text/html; charset=utf-8")) + assertEquals("image/png" to null, splitMobileWebShellContentType("image/png")) + } + + @Test + fun `refuses a directory path that is not absolute`() { + assertNull(MobileWebShellGeneration.load("relative/generation")) + assertNull(MobileWebShellGeneration.load("")) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt new file mode 100644 index 00000000000..05785ad9293 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt @@ -0,0 +1,98 @@ +package expo.modules.orcamobilewebshell + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +private fun failure(reason: String) = MobileWebShellLoadEmission("failed", reason) + +class MobileWebShellLoadStateTest { + @Test + fun `spells each reason the way the TypeScript parser reads it`() { + assertEquals( + listOf( + "generation-unreadable", + "isolation-unavailable", + "document-load-failed", + "render-process-gone" + ), + MobileWebShellFailureReason.entries.map { it.wireName } + ) + } + + @Test + fun `reports a load in progress and then a load that finished`() { + val machine = MobileWebShellLoadStateMachine() + assertEquals(MobileWebShellLoadEmission("loading", null), machine.started()) + assertEquals(MobileWebShellLoadEmission("ready", null), machine.finished()) + } + + @Test + fun `says nothing twice in a row`() { + val machine = MobileWebShellLoadStateMachine() + assertNotNull(machine.started()) + assertNull(machine.started()) + assertNotNull(machine.finished()) + assertNull(machine.finished()) + } + + // Chromium commits its error document after onReceivedError returns, so onPageFinished arrives + // after the failure; reporting `ready` there would also un-hide the error page. + @Test + fun `a load that finished after a failure reports nothing`() { + val machine = MobileWebShellLoadStateMachine() + machine.started() + assertEquals( + failure("document-load-failed"), + machine.failed(MobileWebShellFailureReason.DOCUMENT_LOAD_FAILED) + ) + assertNull(machine.finished()) + assertNull(machine.started()) + } + + @Test + fun `a second failure reports nothing, whatever its reason`() { + val machine = MobileWebShellLoadStateMachine() + assertEquals( + failure("generation-unreadable"), + machine.failed(MobileWebShellFailureReason.GENERATION_UNREADABLE) + ) + assertNull(machine.failed(MobileWebShellFailureReason.GENERATION_UNREADABLE)) + assertNull(machine.failed(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE)) + assertNull(machine.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE)) + } + + // Android defers a document failure past Chromium's error document, so a prop update can land + // between the decision and the report; the failure belongs to the load that is already gone. + @Test + fun `a failure decided before a new prop pair reports nothing`() { + val machine = MobileWebShellLoadStateMachine() + machine.started() + val epoch = machine.epoch + machine.reset() + assertNull(machine.failedDuring(epoch, MobileWebShellFailureReason.DOCUMENT_LOAD_FAILED)) + assertEquals(MobileWebShellLoadEmission("ready", null), machine.finished()) + } + + @Test + fun `a failure decided during the current load still reports`() { + val machine = MobileWebShellLoadStateMachine() + machine.started() + assertEquals( + failure("document-load-failed"), + machine.failedDuring(machine.epoch, MobileWebShellFailureReason.DOCUMENT_LOAD_FAILED) + ) + } + + @Test + fun `a new prop pair may report again, including the same failure`() { + val machine = MobileWebShellLoadStateMachine() + machine.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE) + machine.reset() + assertEquals( + failure("render-process-gone"), + machine.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE) + ) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellOriginTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellOriginTest.kt new file mode 100644 index 00000000000..ba6288282fd --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellOriginTest.kt @@ -0,0 +1,105 @@ +package expo.modules.orcamobilewebshell + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val SESSION = "sess-01JN_aZ9" + +private fun parts( + path: String?, + method: String = "GET", + hasRangeHeader: Boolean = false, + scheme: String? = "https", + host: String? = mobileWebShellOriginHost(SESSION), + port: Int = -1, + userInfo: String? = null, + query: String? = null, + fragment: String? = null, + urlLength: Int = 64 +) = MobileWebShellRequestParts( + method = method, + hasRangeHeader = hasRangeHeader, + scheme = scheme, + host = host, + port = port, + userInfo = userInfo, + query = query, + fragment = fragment, + encodedPath = path, + urlLength = urlLength +) + +private fun resolve(request: MobileWebShellRequestParts): String? = + resolveMobileWebShellRequestPath(request, mobileWebShellOriginHost(SESSION)!!) + +class MobileWebShellOriginTest { + @Test + fun `accepts only base64url session ids within the length bound`() { + assertTrue(isMobileWebShellSessionId("aZ0-_")) + assertTrue(isMobileWebShellSessionId("a".repeat(128))) + assertFalse(isMobileWebShellSessionId("a".repeat(129))) + assertFalse(isMobileWebShellSessionId("")) + assertFalse(isMobileWebShellSessionId("has space")) + assertFalse(isMobileWebShellSessionId("dots.are.hosts.too")) + assertFalse(isMobileWebShellSessionId("sl/ash")) + assertFalse(isMobileWebShellSessionId("sessioñ")) + } + + @Test + fun `labels the origin with a hash of the session id, never a slice of it`() { + val host = mobileWebShellOriginHost(SESSION)!! + val label = host.substringBefore('.') + assertEquals(32, label.length) + assertTrue(label.all { it in '0'..'9' || it in 'a'..'f' }) + // The bug this replaces: a label sliced off the session id carried case and '_', which + // Chromium and java.net.URI canonicalise differently, so every asset 403'd. + assertFalse(label.startsWith(SESSION.take(8))) + assertEquals("$label.orca-mobile-web.invalid", host) + assertEquals("https://$host", mobileWebShellOrigin(SESSION)) + assertNull(mobileWebShellOriginHost("bad host")) + assertNull(mobileWebShellOrigin("bad host")) + } + + @Test + fun `derives a different label for every session and the same one for a repeat`() { + assertEquals(mobileWebShellOriginHost(SESSION), mobileWebShellOriginHost(SESSION)) + assertTrue(mobileWebShellOriginHost(SESSION) != mobileWebShellOriginHost("${SESSION}a")) + // Case matters to the derivation even though the host comparison ignores it. + assertTrue(mobileWebShellOriginHost(SESSION) != mobileWebShellOriginHost(SESSION.uppercase())) + } + + @Test + fun `serves the document and a declared asset path`() { + assertEquals("/", resolve(parts("/"))) + assertEquals("/", resolve(parts(""))) + assertEquals("/assets/aa.js", resolve(parts("/assets/aa.js"))) + } + + @Test + fun `binds a host the parser canonicalised`() { + assertEquals("/", resolve(parts("/", host = mobileWebShellOriginHost(SESSION)!!.uppercase()))) + } + + @Test + fun `refuses everything outside a plain GET on this origin`() { + assertNull(resolve(parts("/", method = "POST"))) + assertNull(resolve(parts("/", method = "HEAD"))) + assertNull(resolve(parts("/", hasRangeHeader = true))) + assertNull(resolve(parts("/", scheme = "http"))) + assertNull(resolve(parts("/", scheme = null))) + assertNull(resolve(parts("/", host = "other.orca-mobile-web.invalid"))) + assertNull(resolve(parts("/", host = null))) + assertNull(resolve(parts("/", port = 443))) + assertNull(resolve(parts("/", userInfo = "someone"))) + assertNull(resolve(parts("/", query = "v=1"))) + assertNull(resolve(parts("/", fragment = "frag"))) + assertNull(resolve(parts("/assets/%2e%2e/etc"))) + assertNull(resolve(parts("assets/aa.js"))) + assertNull(resolve(parts(null))) + assertEquals("/", resolve(parts("/", urlLength = 8 * 1024))) + assertNull(resolve(parts("/", urlLength = 8 * 1024 + 1))) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellRequestPolicyTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellRequestPolicyTest.kt new file mode 100644 index 00000000000..213c4638597 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellRequestPolicyTest.kt @@ -0,0 +1,60 @@ +package expo.modules.orcamobilewebshell + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val POLICY_SESSION = "sess-01JN_aZ9" +private val POLICY_HOST = mobileWebShellOriginHost(POLICY_SESSION)!! + +private fun navigation( + path: String?, + host: String? = POLICY_HOST, + scheme: String? = "https", + query: String? = null +) = MobileWebShellRequestParts( + method = "GET", + hasRangeHeader = false, + scheme = scheme, + host = host, + port = -1, + userInfo = null, + query = query, + fragment = null, + encodedPath = path, + urlLength = 64 +) + +class MobileWebShellRequestPolicyTest { + @Test + fun `lets the document of the served generation load`() { + assertFalse(mobileWebShellDropsNavigation(navigation("/"), POLICY_HOST, true)) + assertFalse(mobileWebShellDropsNavigation(navigation(""), POLICY_HOST, true)) + } + + @Test + fun `drops everything else, so nothing the page builds can navigate`() { + // A subresource path is servable but is not a document; a link out is neither. + assertTrue(mobileWebShellDropsNavigation(navigation("/assets/aa.js"), POLICY_HOST, true)) + assertTrue(mobileWebShellDropsNavigation(navigation("/", query = "v=1"), POLICY_HOST, true)) + assertTrue(mobileWebShellDropsNavigation(navigation("/", host = "example.com"), POLICY_HOST, true)) + assertTrue(mobileWebShellDropsNavigation(navigation("/", scheme = "http"), POLICY_HOST, true)) + assertTrue(mobileWebShellDropsNavigation(navigation("/", scheme = "file"), POLICY_HOST, true)) + assertTrue(mobileWebShellDropsNavigation(navigation("/", scheme = "intent"), POLICY_HOST, true)) + assertTrue(mobileWebShellDropsNavigation(navigation(null), POLICY_HOST, true)) + } + + @Test + fun `drops a subframe navigation and any navigation before a generation is served`() { + assertTrue(mobileWebShellDropsNavigation(navigation("/"), POLICY_HOST, false)) + assertTrue(mobileWebShellDropsNavigation(navigation("/"), null, true)) + } + + @Test + fun `refuses with an empty forbidden response`() { + assertEquals(403, MOBILE_WEB_SHELL_REFUSAL_STATUS) + assertEquals(0, mobileWebShellRefusalBody().size) + assertEquals("no-store", MOBILE_WEB_SHELL_REFUSAL_HEADERS["Cache-Control"]) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellResponseHeadersTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellResponseHeadersTest.kt new file mode 100644 index 00000000000..c793f5d9dc2 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellResponseHeadersTest.kt @@ -0,0 +1,32 @@ +package expo.modules.orcamobilewebshell + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MobileWebShellResponseHeadersTest { + @Test + fun `sends the policy on the document`() { + val headers = mobileWebShellResponseHeaders("/", 12) + assertEquals(MOBILE_WEB_SHELL_CSP, headers["Content-Security-Policy"]) + assertEquals("12", headers["Content-Length"]) + assertEquals("no-store", headers["Cache-Control"]) + assertEquals("nosniff", headers["X-Content-Type-Options"]) + } + + @Test + fun `sends the policy on nothing else`() { + for (path in listOf("/index.html", "/assets/aa.js", "/manifest.json", "/assets/bb.png")) { + assertNull(mobileWebShellResponseHeaders(path, 12)["Content-Security-Policy"]) + } + } + + @Test + fun `caches nothing, whatever the path`() { + val headers = mobileWebShellResponseHeaders("/assets/aa.js", 0) + assertEquals("no-store", headers["Cache-Control"]) + assertEquals("nosniff", headers["X-Content-Type-Options"]) + // WebResourceResponse takes the mime type and the encoding as arguments, not as a header. + assertNull(headers["Content-Type"]) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/expo-module.config.json b/mobile/modules/orca-mobile-web-shell/expo-module.config.json new file mode 100644 index 00000000000..aecf60116b6 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "android"], + "ios": { + "modules": ["OrcaMobileWebShellModule"] + }, + "android": { + "modules": ["expo.modules.orcamobilewebshell.OrcaMobileWebShellModule"] + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift new file mode 100644 index 00000000000..de76cc4613c --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift @@ -0,0 +1,26 @@ +enum MobileWebShellCsp { + /// Sent as a response header on the document and nowhere else: a served document must never + /// carry its own policy, so there is no meta tag to find and no bundle change that can relax it. + static let header = [ + "default-src 'none'", + "script-src 'self'", + // 'self' holds only while the bundle ships linked stylesheets. React Native Web emits runtime + // style elements, so Phase C has to revisit this openly rather than relax it quietly. + "style-src 'self'", + "img-src 'self'", + "font-src 'none'", + // The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the + // page cannot already read, and the bootstrap page reads ./manifest.json through it. This is + // the fence for fetch and XMLHttpRequest; the document-start script covers only the two things + // the native layer cannot see. + "connect-src 'self'", + "media-src 'none'", + "object-src 'none'", + "frame-src 'none'", + "child-src 'none'", + "worker-src 'none'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'" + ].joined(separator: "; ") +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellGeneration.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellGeneration.swift new file mode 100644 index 00000000000..9d99d3a581e --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellGeneration.swift @@ -0,0 +1,138 @@ +import Foundation + +struct MobileWebShellAsset { + let file: URL + let contentType: String +} + +enum MobileWebShellGenerationError: Error { + case unreadable +} + +/// The served surface of one activated generation: a request path to file map, built once from the +/// manifest before anything loads. Serving is a lookup in this map and never a path join at request +/// time, so "not in the manifest" is a refusal by construction rather than by sanitiser. +/// +/// Asset bytes are not re-hashed here. The TypeScript store verified every byte against the +/// manifest before the activating rename, and the directory path is one the app owns and the page +/// can never influence. Framework-free so `swiftc` can check it. +struct MobileWebShellGeneration { + static let manifestName = "manifest.json" + static let manifestContentType = "application/json" + static let schemaVersion = 1 + static let entrypoint = "index.html" + static let maxAssets = 256 + static let maxAssetPathLength = 255 + static let maxContentTypeLength = 128 + + let entries: [String: MobileWebShellAsset] + + static func load(directoryPath: String) throws -> MobileWebShellGeneration { + guard directoryPath.hasPrefix("/") else { throw MobileWebShellGenerationError.unreadable } + let directory = URL(fileURLWithPath: directoryPath, isDirectory: true) + guard + let data = try? Data(contentsOf: directory.appendingPathComponent(manifestName)) + else { throw MobileWebShellGenerationError.unreadable } + return try make(manifestData: data, directory: directory) + } + + static func make(manifestData: Data, directory: URL) throws -> MobileWebShellGeneration { + let parsed = try? JSONSerialization.jsonObject(with: manifestData) + guard + let root = parsed as? [String: Any], + isPinnedSchemaVersion(root["schemaVersion"]), + let declaredEntrypoint = root["entrypoint"] as? String, + declaredEntrypoint == entrypoint, + let assets = root["assets"] as? [[String: Any]], + !assets.isEmpty, + assets.count <= maxAssets + else { throw MobileWebShellGenerationError.unreadable } + + var entries: [String: MobileWebShellAsset] = [:] + for asset in assets { + guard + let path = asset["path"] as? String, + isServableAssetPath(path), + let contentType = asset["contentType"] as? String, + isServableContentType(contentType) + else { throw MobileWebShellGenerationError.unreadable } + entries["/\(path)"] = MobileWebShellAsset( + file: directory.appendingPathComponent(path, isDirectory: false), + contentType: contentType + ) + } + // Removed, not copied: the document answers at "/" and nowhere else, so the one response that + // carries the policy header is the only way to reach those bytes. + guard let document = entries.removeValue(forKey: "/\(entrypoint)") else { + throw MobileWebShellGenerationError.unreadable + } + entries["/"] = document + // The manifest is written last and is not part of the content hash, so it is not in `assets`; + // the bootstrap page still reads it from its own origin. + entries["/\(manifestName)"] = MobileWebShellAsset( + file: directory.appendingPathComponent(manifestName, isDirectory: false), + contentType: manifestContentType + ) + return MobileWebShellGeneration(entries: entries) + } + + /// `as? Int` is not this check: NSNumber bridges `true` and `1.0` to 1, and the contract pins the + /// integer 1. JSONSerialization keeps the written form, so the number's own type answers it. + static func isPinnedSchemaVersion(_ value: Any?) -> Bool { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { + return false + } + let numberType = String(cString: number.objCType) + guard numberType != "d", numberType != "f" else { return false } + return number.intValue == schemaVersion + } + + /// Re-checked here rather than trusted: the schema that pins this shape is on the other side of + /// a file the native layer cannot see change. + static func isServableAssetPath(_ path: String) -> Bool { + guard !path.isEmpty, path.utf8.count <= maxAssetPathLength else { return false } + for segment in path.split(separator: "/", omittingEmptySubsequences: false) { + guard !segment.isEmpty, segment != ".", segment != ".." else { return false } + let valid = segment.allSatisfy { character in + character.isASCII && + (character.isLetter || character.isNumber || character == "." || character == "_" || + character == "-") + } + guard valid else { return false } + } + return true + } + + /// This value becomes a response header, so it must not be able to carry a second header or a + /// parameter we did not intend. One lowercase type, one optional charset: the manifest + /// contract's only accepted spelling. + static func isServableContentType(_ contentType: String) -> Bool { + guard !contentType.isEmpty, contentType.utf8.count <= maxContentTypeLength else { return false } + var type = Substring(contentType) + if let separator = contentType.range(of: "; charset=") { + let charset = contentType[separator.upperBound...] + let validCharset = !charset.isEmpty && charset.allSatisfy { character in + character.isASCII && + (("a"..."z").contains(character) || ("0"..."9").contains(character) || character == "-") + } + guard validCharset else { return false } + type = contentType[contentType.startIndex.. Bool { + guard + let first = token.first, + first.isASCII, + ("a"..."z").contains(first) || ("0"..."9").contains(first) + else { return false } + return token.allSatisfy { character in + character.isASCII && + (("a"..."z").contains(character) || ("0"..."9").contains(character) || + character == "." || character == "+" || character == "-") + } + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift new file mode 100644 index 00000000000..37b7233b995 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift @@ -0,0 +1,72 @@ +import Foundation + +/// The wire names the TypeScript parser accepts; a swap here is a silent change of meaning. +enum MobileWebShellFailureReason: String { + case generationUnreadable = "generation-unreadable" + case isolationUnavailable = "isolation-unavailable" + case documentLoadFailed = "document-load-failed" + case renderProcessGone = "render-process-gone" +} + +struct MobileWebShellLoadEmission: Equatable { + let state: String + let reason: String? +} + +/// What a mount is still allowed to report. A failure is terminal: a rule list can fail to compile +/// long after the generation was already refused, and WebKit still reports a navigation outcome +/// after a response was cancelled, so without this a second reason or a `ready` lands on top of a +/// failure the caller has already acted on. Consecutive duplicates are dropped as well. +/// +/// Pure, and the same rule as the Kotlin copy, so `swiftc` can check it without a device. +final class MobileWebShellLoadStateMachine { + private var isTerminal = false + private var last: MobileWebShellLoadEmission? + + /// A new prop pair. Nothing else reopens a terminal state: a retry is a remount. + func reset() { + isTerminal = false + last = nil + } + + func started() -> MobileWebShellLoadEmission? { + emit(MobileWebShellLoadEmission(state: "loading", reason: nil)) + } + + func finished() -> MobileWebShellLoadEmission? { + emit(MobileWebShellLoadEmission(state: "ready", reason: nil)) + } + + func failed(_ reason: MobileWebShellFailureReason) -> MobileWebShellLoadEmission? { + let emission = emit(MobileWebShellLoadEmission(state: "failed", reason: reason.rawValue)) + isTerminal = true + return emission + } + + private func emit(_ emission: MobileWebShellLoadEmission) -> MobileWebShellLoadEmission? { + guard !isTerminal, emission != last else { return nil } + last = emission + return emission + } +} + +/// A navigation WebKit reports as failed but which is not a failure of the document. +/// +/// `stopLoading` on a prop update, and every navigation the policy delegate refuses, arrive at the +/// failure delegates as errors. Reporting those would fail a healthy page, swallow its `ready`, and +/// send the caller off to delete a cached generation that is fine. +/// +/// The WebKit constant is written out because the iOS SDK exports no symbol for it: `WKErrorCode` +/// stops at the content-rule-list and app-bound-domain errors, and the frame-load codes live in the +/// legacy `WebKitErrorDomain`, which WKWebView still reports a policy-cancelled frame load under. +enum MobileWebShellNavigationError { + static let webKitDomain = "WebKitErrorDomain" + static let frameLoadInterruptedByPolicyChange = 102 + + static func isIgnorable(domain: String, code: Int) -> Bool { + if domain == NSURLErrorDomain, code == NSURLErrorCancelled { + return true + } + return domain == webKitDomain && code == frameLoadInterruptedByPolicyChange + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift new file mode 100644 index 00000000000..904d66bdcb8 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift @@ -0,0 +1,118 @@ +import Foundation + +/// The private origin a generation is served from, and the predicate that guards it. +/// +/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc` +/// and checks it without a device or a simulator. +enum MobileWebShellOrigin { + /// A scheme WebKit has no handler for, so the origin shares no cookie jar, cache or storage with + /// anything else in the app. A custom scheme's host is opaque, so the session id is used verbatim. + static let scheme = "orca-mobile-web" + static let maxSessionIdLength = 128 + static let maxUrlByteCount = 8 * 1024 + + static func isValidSessionId(_ sessionId: String) -> Bool { + guard !sessionId.isEmpty, sessionId.count <= maxSessionIdLength else { return false } + return sessionId.allSatisfy { character in + character.isASCII && + (character.isLetter || character.isNumber || character == "-" || character == "_") + } + } + + static func documentUrl(sessionId: String) -> URL? { + guard isValidSessionId(sessionId) else { return nil } + return URL(string: "\(scheme)://\(sessionId)/") + } + + /// The map key for a request we are willing to answer, or nil to refuse. Every clause is an + /// allow, so a component nobody anticipated falls to refusal rather than through it. + static func resolveRequestPath( + _ parts: MobileWebShellRequestParts, + sessionId: String + ) -> String? { + guard + isValidSessionId(sessionId), + parts.method == "GET", + !parts.hasRangeHeader, + parts.scheme == scheme, + // Case-insensitive: a URL parser may canonicalise a host, and comparing against the exact + // spelling we minted is how the reference lost every asset to a 403. + let host = parts.host, + host.compare(sessionId, options: .caseInsensitive) == .orderedSame, + parts.port == nil, + parts.user == nil, + parts.query == nil, + parts.fragment == nil, + parts.urlByteCount <= maxUrlByteCount, + !parts.percentEncodedPath.contains("%") + else { return nil } + if parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/" { return "/" } + guard parts.percentEncodedPath.hasPrefix("/") else { return nil } + return parts.percentEncodedPath + } +} + +/// A request reduced to the components the predicate reads, so the predicate needs no WebKit type. +struct MobileWebShellRequestParts { + var method: String + var hasRangeHeader: Bool + var scheme: String? + var host: String? + var port: Int? + var user: String? + var query: String? + var fragment: String? + var percentEncodedPath: String + var urlByteCount: Int + + init( + method: String, + hasRangeHeader: Bool, + scheme: String?, + host: String?, + port: Int?, + user: String?, + query: String?, + fragment: String?, + percentEncodedPath: String, + urlByteCount: Int + ) { + self.method = method + self.hasRangeHeader = hasRangeHeader + self.scheme = scheme + self.host = host + self.port = port + self.user = user + self.query = query + self.fragment = fragment + self.percentEncodedPath = percentEncodedPath + self.urlByteCount = urlByteCount + } + + init?(url: URL, method: String = "GET", hasRangeHeader: Bool = false) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + self.init( + method: method, + hasRangeHeader: hasRangeHeader, + scheme: url.scheme, + host: url.host, + port: url.port, + user: url.user, + query: url.query, + fragment: url.fragment, + percentEncodedPath: components.percentEncodedPath, + urlByteCount: url.absoluteString.utf8.count + ) + } + + init?(request: URLRequest) { + guard let url = request.url else { return nil } + self.init( + url: url, + method: request.httpMethod ?? "GET", + hasRangeHeader: request.value(forHTTPHeaderField: "Range") != nil + ) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellResponseHeaders.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellResponseHeaders.swift new file mode 100644 index 00000000000..e10cad69956 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellResponseHeaders.swift @@ -0,0 +1,22 @@ +/// The headers one served asset answers with. +/// +/// The policy header rides the document and nothing else: on a script or a stylesheet response it +/// is inert, and sending it everywhere would hide which response is the one that has to carry it. +enum MobileWebShellResponseHeaders { + static func forPath( + _ path: String, + contentType: String, + byteCount: Int + ) -> [String: String] { + var headers = [ + "Content-Type": contentType, + "Content-Length": String(byteCount), + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff" + ] + if path == "/" { + headers["Content-Security-Policy"] = MobileWebShellCsp.header + } + return headers + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift new file mode 100644 index 00000000000..4b3fb940b32 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift @@ -0,0 +1,333 @@ +import ExpoModulesCore +import WebKit + +private let networkBlockIdentifier = "dev.orca.mobile-web-shell.network-block-v1" + +/// Blocks every http(s) and ws(s) load beneath CSP, at the network layer. A nil compile result is a +/// fence we could not install, which is terminal: nothing loads. +private let networkBlockRules = """ + [ + { "trigger": { "url-filter": "^https?://" }, "action": { "type": "block" } }, + { "trigger": { "url-filter": "^wss?://" }, "action": { "type": "block" } } + ] + """ + +/// CSP is the fence for fetch and XMLHttpRequest. This script exists only for the two things a +/// native layer is never shown: a WebSocket handshake, which no request interceptor sees, and a +/// service worker registration. Kept in step with the Android copy. `configurable: false` with +/// `writable: false` is the only property shape the page cannot put back. +private let networkApiBlocker = """ + (function(){ + var deny=function(){throw new TypeError('Network access is disabled')}; + try{Object.defineProperty(globalThis,'WebSocket',{value:deny,configurable:false,writable:false})}catch(_){} + try{Object.defineProperty(Navigator.prototype,'serviceWorker',{get:function(){return undefined},configurable:false})}catch(_){} + try{Object.defineProperty(navigator,'serviceWorker',{value:undefined,configurable:false,writable:false})}catch(_){} + })(); + """ + +private final class MobileWebShellSchemeHandler: NSObject, WKURLSchemeHandler { + /// An asset is up to 10 MiB, and WebKit starts and stops scheme tasks on the main thread, so the + /// read must not happen there. + private let readQueue = DispatchQueue(label: "dev.orca.mobile-web-shell.read") + /// Delivering to a task WebKit has already stopped raises an Objective-C exception Swift cannot + /// catch, so a task is only touched while it is in this set. Main thread only. + private var liveTasks: Set = [] + + var sessionId: String? + var generation: MobileWebShellGeneration? + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + let key = ObjectIdentifier(urlSchemeTask) + liveTasks.insert(key) + guard + let sessionId, + let generation, + let url = urlSchemeTask.request.url, + let parts = MobileWebShellRequestParts(request: urlSchemeTask.request), + let path = MobileWebShellOrigin.resolveRequestPath(parts, sessionId: sessionId), + let asset = generation.entries[path] + else { + fail(urlSchemeTask, key) + return + } + readQueue.async { [weak self] in + let data = try? Data(contentsOf: asset.file) + DispatchQueue.main.async { + guard let self, self.liveTasks.contains(key) else { return } + guard + let data, + let response = Self.makeResponse( + url: url, + asset: asset, + byteCount: data.count, + path: path + ) + else { + self.fail(urlSchemeTask, key) + return + } + self.liveTasks.remove(key) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + liveTasks.remove(ObjectIdentifier(urlSchemeTask)) + } + + private func fail(_ urlSchemeTask: WKURLSchemeTask, _ key: ObjectIdentifier) { + guard liveTasks.remove(key) != nil else { return } + urlSchemeTask.didFailWithError(URLError(.resourceUnavailable)) + } + + private static func makeResponse( + url: URL, + asset: MobileWebShellAsset, + byteCount: Int, + path: String + ) -> HTTPURLResponse? { + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: MobileWebShellResponseHeaders.forPath( + path, + contentType: asset.contentType, + byteCount: byteCount + ) + ) + } +} + +final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate { + let onLoadState = EventDispatcher() + + private let schemeHandler = MobileWebShellSchemeHandler() + private var webView: WKWebView! + private var generationDirectory = "" + private var sessionId = "" + private var appliedDirectory: String? + private var appliedSessionId: String? + private var pendingDocumentUrl: URL? + private var isolationReady = false + private var isolationFailed = false + private let loadState = MobileWebShellLoadStateMachine() + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + let configuration = WKWebViewConfiguration() + // DOM storage and databases cannot be switched off on WebKit. A non-persistent store plus a + // per-session origin plus destruction on unmount is the whole mitigation, and no isolation + // claim here rests on them being absent. + configuration.websiteDataStore = .nonPersistent() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + configuration.setURLSchemeHandler(schemeHandler, forURLScheme: MobileWebShellOrigin.scheme) + configuration.userContentController.addUserScript( + WKUserScript( + source: networkApiBlocker, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) + webView = WKWebView(frame: bounds, configuration: configuration) + webView.navigationDelegate = self + webView.uiDelegate = self + webView.allowsBackForwardNavigationGestures = false + webView.scrollView.contentInsetAdjustmentBehavior = .never + webView.translatesAutoresizingMaskIntoConstraints = false + addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: topAnchor), + webView.bottomAnchor.constraint(equalTo: bottomAnchor), + webView.leadingAnchor.constraint(equalTo: leadingAnchor), + webView.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + installNetworkBlock(into: configuration.userContentController) + } + + func setGenerationDirectory(_ value: String) { + generationDirectory = value + } + + func setSessionId(_ value: String) { + sessionId = value + } + + /// Props arrive in no defined order, so neither setter starts anything; this does, once both are + /// in. A repeat of the same pair is not a retry: a retry is a remount under a new React key. + func propsDidUpdate() { + guard generationDirectory != appliedDirectory || sessionId != appliedSessionId else { return } + appliedDirectory = generationDirectory + appliedSessionId = sessionId + loadState.reset() + pendingDocumentUrl = nil + webView.stopLoading() + webView.isHidden = false + emit(loadState.started()) + guard + MobileWebShellOrigin.isValidSessionId(sessionId), + let documentUrl = MobileWebShellOrigin.documentUrl(sessionId: sessionId) + else { + // The private origin is the isolation primitive; a malformed session id leaves us without one. + failPropUpdate(.isolationUnavailable) + return + } + guard + let generation = try? MobileWebShellGeneration.load(directoryPath: generationDirectory) + else { + failPropUpdate(.generationUnreadable) + return + } + schemeHandler.sessionId = sessionId + schemeHandler.generation = generation + if isolationFailed { + failPropUpdate(.isolationUnavailable) + return + } + pendingDocumentUrl = documentUrl + loadWhenIsolated() + } + + /// The generation that failed to apply replaces whatever was on screen; leaving the previous one + /// served and visible would show a page the caller has just been told is not loaded. + private func failPropUpdate(_ reason: MobileWebShellFailureReason) { + schemeHandler.sessionId = nil + schemeHandler.generation = nil + pendingDocumentUrl = nil + webView.stopLoading() + webView.isHidden = true + emit(loadState.failed(reason)) + } + + private func installNetworkBlock(into controller: WKUserContentController) { + guard let store = WKContentRuleListStore.default() else { + // Optional-chaining past this ran no completion handler at all, so the view sat at `loading` + // for the rest of its life. No store is no fence, which is the same terminal answer. + isolationFailed = true + pendingDocumentUrl = nil + return + } + store.compileContentRuleList( + forIdentifier: networkBlockIdentifier, + encodedContentRuleList: networkBlockRules + ) { [weak self] ruleList, _ in + DispatchQueue.main.async { + guard let self else { return } + guard let ruleList else { + self.isolationFailed = true + self.pendingDocumentUrl = nil + // Compiling is asynchronous, so this can land after the generation was already refused; + // the state machine is what keeps that from being a second terminal reason. + if self.appliedSessionId != nil { + self.failPropUpdate(.isolationUnavailable) + } + return + } + controller.add(ruleList) + self.isolationReady = true + self.loadWhenIsolated() + } + } + } + + private func loadWhenIsolated() { + guard isolationReady, let url = pendingDocumentUrl else { return } + pendingDocumentUrl = nil + webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)) + } + + private func emit(_ emission: MobileWebShellLoadEmission?) { + guard let emission else { return } + var payload: [String: Any] = ["state": emission.state] + if let reason = emission.reason { + payload["reason"] = reason + } + onLoadState(payload) + } + + private func reportDocumentFailure() { + emit(loadState.failed(.documentLoadFailed)) + } + + /// A cancelled navigation is our own doing, not the document's; see MobileWebShellNavigationError. + private func reportNavigationFailure(_ error: Error) { + let error = error as NSError + guard !MobileWebShellNavigationError.isIgnorable(domain: error.domain, code: error.code) else { + return + } + reportDocumentFailure() + } + + private func isDocumentUrl(_ url: URL?) -> Bool { + guard let url, let parts = MobileWebShellRequestParts(url: url) else { return false } + return MobileWebShellOrigin.resolveRequestPath(parts, sessionId: sessionId) == "/" + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if #available(iOS 14.5, *), navigationAction.shouldPerformDownload { + decisionHandler(.cancel) + return + } + let allowed = navigationAction.targetFrame?.isMainFrame == true && + isDocumentUrl(navigationAction.request.url) + decisionHandler(allowed ? .allow : .cancel) + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + let allowed = navigationResponse.isForMainFrame && + navigationResponse.canShowMIMEType && + isDocumentUrl(navigationResponse.response.url) + if !allowed { + reportDocumentFailure() + } + decisionHandler(allowed ? .allow : .cancel) + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + guard appliedSessionId != nil else { return } + emit(loadState.started()) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard isDocumentUrl(webView.url) else { return } + emit(loadState.finished()) + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + reportNavigationFailure(error) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + reportNavigationFailure(error) + } + + /// Reported, never recovered from here. Renderer memory pressure and a WebView provider update + /// look identical at this point, so the retry policy is the caller's and lives in one place. + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + emit(loadState.failed(.renderProcessGone)) + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + nil + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShell.podspec b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShell.podspec new file mode 100644 index 00000000000..a60a36eb6aa --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShell.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'OrcaMobileWebShell' + s.version = '0.0.1' + s.summary = 'WebView shell that serves one generation directory from a private origin' + s.description = s.summary + s.license = { :type => 'MIT' } + s.author = 'Orca' + s.homepage = 'https://onorca.dev' + s.source = { :git => 'https://github.com/stablyai/orca.git' } + s.platforms = { :ios => '15.1' } + s.swift_version = '5.9' + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = '**/*.swift' +end diff --git a/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift new file mode 100644 index 00000000000..9596f54c7fa --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift @@ -0,0 +1,23 @@ +import ExpoModulesCore + +public class OrcaMobileWebShellModule: Module { + public func definition() -> ModuleDefinition { + Name("OrcaMobileWebShell") + + View(OrcaMobileWebShellView.self) { + Events("onLoadState") + + Prop("generationDirectory") { (view: OrcaMobileWebShellView, value: String) in + view.setGenerationDirectory(value) + } + + Prop("sessionId") { (view: OrcaMobileWebShellView, value: String) in + view.setSessionId(value) + } + + OnViewDidUpdateProps { (view: OrcaMobileWebShellView) in + view.propsDidUpdate() + } + } + } +} diff --git a/mobile/modules/orca-mobile-web-shell/package.json b/mobile/modules/orca-mobile-web-shell/package.json new file mode 100644 index 00000000000..76411f1ef27 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/package.json @@ -0,0 +1,5 @@ +{ + "name": "orca-mobile-web-shell", + "version": "0.0.1", + "private": true +} diff --git a/mobile/modules/orca-mobile-web-shell/src/index.ts b/mobile/modules/orca-mobile-web-shell/src/index.ts new file mode 100644 index 00000000000..1b838c55410 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/src/index.ts @@ -0,0 +1,32 @@ +import { requireNativeViewManager } from 'expo-modules-core' +import type { ComponentType } from 'react' +import type { NativeSyntheticEvent, ViewProps } from 'react-native' +import type { MobileWebShellLoadStatePayload } from './load-state' + +export type OrcaMobileWebShellViewProps = ViewProps & { + /** + * Absolute path of an activated generation directory: `index.html`, `manifest.json`, and + * `assets/.`. The TypeScript store owns it and has already verified every byte; + * the view only reads, and never from a path the page can influence. + */ + generationDirectory: string + /** `[A-Za-z0-9_-]{1,128}`. Scopes the private origin, so every mount must mint a fresh one. */ + sessionId: string + onLoadState?: (event: NativeSyntheticEvent) => void +} + +/** + * Renders one generation directory in a WebView served from a private origin. There is no reload + * and no imperative surface: a retry is a remount under a new React key, which rebuilds the + * WebView and reinstalls every fence. + */ +export const OrcaMobileWebShellView: ComponentType = + requireNativeViewManager('OrcaMobileWebShell') + +export { + MOBILE_WEB_SHELL_FAILURE_REASONS, + parseMobileWebShellLoadState, + type MobileWebShellFailureReason, + type MobileWebShellLoadState, + type MobileWebShellLoadStatePayload +} from './load-state' diff --git a/mobile/modules/orca-mobile-web-shell/src/load-state.ts b/mobile/modules/orca-mobile-web-shell/src/load-state.ts new file mode 100644 index 00000000000..2e9b6a3d9e1 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/src/load-state.ts @@ -0,0 +1,72 @@ +import { z } from 'zod' + +/** + * The load state the native shell view reports, and the parser that rebuilds the union from the + * flat dictionary a native event carries. + * + * Recovery is the caller's, never the view's: the view retries nothing and reloads nothing. + * `generation-unreadable` and `document-load-failed` mean the cached generation is suspect, so the + * caller deletes that host's cache and downloads once. `render-process-gone` remounts once and + * never deletes, because renderer memory pressure and a WebView provider update are + * indistinguishable here from a bad bundle. + */ +export const MOBILE_WEB_SHELL_FAILURE_REASONS = [ + /** The generation directory has no readable manifest, or declares an asset we refuse to map. */ + 'generation-unreadable', + /** A fence we could not install, so nothing was loaded. Terminal. */ + 'isolation-unavailable', + /** The main frame failed to load, or its response was refused. */ + 'document-load-failed', + /** The WebView content process died. */ + 'render-process-gone' +] as const + +export type MobileWebShellFailureReason = (typeof MOBILE_WEB_SHELL_FAILURE_REASONS)[number] + +export type MobileWebShellLoadState = + | { state: 'loading' } + | { state: 'ready' } + | { state: 'failed'; reason: MobileWebShellFailureReason } + +/** + * What the native event body actually is; the union above is derived from it, never asserted. + * Own-property parse: zod reads a shape key straight off the value, so an inherited `reason` would + * otherwise count as one the shell sent. + */ +const loadStatePayloadSchema = z.object({ + state: z.string(), + reason: z.string().optional() +}) + +export type MobileWebShellLoadStatePayload = z.infer + +function isFailureReason(value: string | undefined): value is MobileWebShellFailureReason { + return MOBILE_WEB_SHELL_FAILURE_REASONS.some((reason) => reason === value) +} + +function ownEnumerableFields(payload: unknown): Record | null { + if (typeof payload !== 'object' || payload === null) { + return null + } + return Object.fromEntries(Object.entries(payload)) +} + +/** Answers null for anything it does not recognise; a caller drops those rather than guessing. */ +export function parseMobileWebShellLoadState(payload: unknown): MobileWebShellLoadState | null { + const fields = ownEnumerableFields(payload) + if (fields === null) { + return null + } + const parsed = loadStatePayloadSchema.safeParse(fields) + if (!parsed.success) { + return null + } + const { state, reason } = parsed.data + if (state === 'loading' || state === 'ready') { + return { state } + } + if (state !== 'failed') { + return null + } + return isFailureReason(reason) ? { state: 'failed', reason } : null +} diff --git a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift new file mode 100644 index 00000000000..ef1193c3001 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift @@ -0,0 +1,288 @@ +import Foundation + +// Everything the shell decides before WebKit is involved: the session id it will accept, the +// requests it will answer, the map it builds from a manifest, and the policy header. Compiled and +// run without a device: +// +// swiftc -O -o /tmp/mobile-web-shell-checks \ +// ios/MobileWebShellOrigin.swift ios/MobileWebShellGeneration.swift ios/MobileWebShellCsp.swift \ +// ios/MobileWebShellLoadState.swift ios/MobileWebShellResponseHeaders.swift \ +// tests/MobileWebShellChecks.swift && /tmp/mobile-web-shell-checks +@main struct MobileWebShellChecks { + static let session = "sess-01JN_aZ9" + + static func parts( + path: String, + method: String = "GET", + hasRangeHeader: Bool = false, + scheme: String? = MobileWebShellOrigin.scheme, + host: String? = session, + port: Int? = nil, + user: String? = nil, + query: String? = nil, + fragment: String? = nil, + urlByteCount: Int = 64 + ) -> MobileWebShellRequestParts { + MobileWebShellRequestParts( + method: method, + hasRangeHeader: hasRangeHeader, + scheme: scheme, + host: host, + port: port, + user: user, + query: query, + fragment: fragment, + percentEncodedPath: path, + urlByteCount: urlByteCount + ) + } + + static func resolve(_ request: MobileWebShellRequestParts) -> String? { + MobileWebShellOrigin.resolveRequestPath(request, sessionId: session) + } + + static func manifest( + schemaVersion: Int = 1, + entrypoint: String = "index.html", + assets: [[String: Any]] = [ + ["path": "index.html", "contentType": "text/html; charset=utf-8"], + ["path": "assets/aa.js", "contentType": "text/javascript; charset=utf-8"], + ["path": "assets/bb.png", "contentType": "image/png"] + ] + ) -> Data { + let root: [String: Any] = [ + "schemaVersion": schemaVersion, + "entrypoint": entrypoint, + "assets": assets + ] + return try! JSONSerialization.data(withJSONObject: root) + } + + static func generation(_ data: Data) -> MobileWebShellGeneration? { + try? MobileWebShellGeneration.make( + manifestData: data, + directory: URL(fileURLWithPath: "/tmp/generation", isDirectory: true) + ) + } + + static func checkSessionIds() { + precondition(MobileWebShellOrigin.isValidSessionId("aZ0-_")) + precondition(MobileWebShellOrigin.isValidSessionId(String(repeating: "a", count: 128))) + precondition(!MobileWebShellOrigin.isValidSessionId(String(repeating: "a", count: 129))) + precondition(!MobileWebShellOrigin.isValidSessionId("")) + precondition(!MobileWebShellOrigin.isValidSessionId("has space")) + precondition(!MobileWebShellOrigin.isValidSessionId("dots.are.hosts.too")) + precondition(!MobileWebShellOrigin.isValidSessionId("sl/ash")) + // Non-ASCII letters and digits satisfy Character.isLetter/isNumber, so the ASCII gate is load + // bearing: an IDNA-mapped host would not be the origin we minted. + precondition(!MobileWebShellOrigin.isValidSessionId("sessioñ")) + precondition(!MobileWebShellOrigin.isValidSessionId("session٣")) + precondition(MobileWebShellOrigin.documentUrl(sessionId: session)?.absoluteString == + "orca-mobile-web://\(session)/") + precondition(MobileWebShellOrigin.documentUrl(sessionId: "bad host") == nil) + } + + static func checkRequestResolution() { + precondition(resolve(parts(path: "/")) == "/") + precondition(resolve(parts(path: "")) == "/") + precondition(resolve(parts(path: "/assets/aa.js")) == "/assets/aa.js") + // A host a parser canonicalised must still bind to this session. + precondition(resolve(parts(path: "/", host: session.uppercased())) == "/") + + precondition(resolve(parts(path: "/", method: "POST")) == nil) + precondition(resolve(parts(path: "/", method: "HEAD")) == nil) + precondition(resolve(parts(path: "/", hasRangeHeader: true)) == nil) + precondition(resolve(parts(path: "/", scheme: "https")) == nil) + precondition(resolve(parts(path: "/", scheme: nil)) == nil) + precondition(resolve(parts(path: "/", host: "other-session")) == nil) + precondition(resolve(parts(path: "/", host: nil)) == nil) + precondition(resolve(parts(path: "/", port: 443)) == nil) + precondition(resolve(parts(path: "/", user: "someone")) == nil) + precondition(resolve(parts(path: "/", query: "v=1")) == nil) + precondition(resolve(parts(path: "/", fragment: "frag")) == nil) + precondition(resolve(parts(path: "/assets/%2e%2e/etc")) == nil) + precondition(resolve(parts(path: "assets/aa.js")) == nil) + precondition(resolve(parts(path: "/", urlByteCount: 8 * 1024)) == "/") + precondition(resolve(parts(path: "/", urlByteCount: 8 * 1024 + 1)) == nil) + precondition(MobileWebShellOrigin.resolveRequestPath(parts(path: "/"), sessionId: "") == nil) + } + + static func checkAssetPaths() { + precondition(MobileWebShellGeneration.isServableAssetPath("index.html")) + precondition(MobileWebShellGeneration.isServableAssetPath("assets/a-b_c.2.js")) + precondition(!MobileWebShellGeneration.isServableAssetPath("")) + precondition(!MobileWebShellGeneration.isServableAssetPath("/leading")) + precondition(!MobileWebShellGeneration.isServableAssetPath("trailing/")) + precondition(!MobileWebShellGeneration.isServableAssetPath("a//b")) + precondition(!MobileWebShellGeneration.isServableAssetPath("../secret")) + precondition(!MobileWebShellGeneration.isServableAssetPath("assets/../../secret")) + precondition(!MobileWebShellGeneration.isServableAssetPath("assets/./a.js")) + precondition(!MobileWebShellGeneration.isServableAssetPath("back\\slash")) + precondition(!MobileWebShellGeneration.isServableAssetPath("has space.js")) + precondition(MobileWebShellGeneration.isServableAssetPath(String(repeating: "a", count: 255))) + precondition(!MobileWebShellGeneration.isServableAssetPath(String(repeating: "a", count: 256))) + } + + static func checkContentTypes() { + precondition(MobileWebShellGeneration.isServableContentType("image/png")) + precondition(MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8")) + precondition(MobileWebShellGeneration.isServableContentType("application/manifest+json")) + precondition(!MobileWebShellGeneration.isServableContentType("")) + precondition(!MobileWebShellGeneration.isServableContentType("text/html" + + "\r\nX-Injected: 1")) + precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8; x=1")) + precondition(!MobileWebShellGeneration.isServableContentType("TEXT/HTML")) + // A header value we did not mint character for character is a value we did not check. + precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset=UTF-8")) + precondition(!MobileWebShellGeneration.isServableContentType("text")) + precondition(!MobileWebShellGeneration.isServableContentType("text/html/extra")) + precondition(!MobileWebShellGeneration.isServableContentType("/html")) + precondition(!MobileWebShellGeneration.isServableContentType("-text/html")) + precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset=")) + precondition(!MobileWebShellGeneration.isServableContentType( + String(repeating: "a", count: 130) + "/b")) + } + + static func checkGenerationMap() { + guard let built = generation(manifest()) else { preconditionFailure("manifest rejected") } + precondition(built.entries.count == 4) + precondition(built.entries["/"]?.file.path == "/tmp/generation/index.html") + precondition(built.entries["/"]?.contentType == "text/html; charset=utf-8") + // Only "/" reaches the document: a second URL for the same bytes would answer without the CSP + // header, which rides the document response alone. + precondition(built.entries["/index.html"] == nil) + precondition(built.entries["/assets/aa.js"]?.contentType == "text/javascript; charset=utf-8") + precondition(built.entries["/assets/bb.png"]?.file.path == "/tmp/generation/assets/bb.png") + precondition(built.entries["/manifest.json"]?.contentType == "application/json") + precondition(built.entries["/assets/cc.js"] == nil) + precondition(built.entries["/../secret"] == nil) + + precondition(generation(manifest(schemaVersion: 2)) == nil) + precondition(generation(manifest(entrypoint: "start.html")) == nil) + precondition(generation(manifest(assets: [])) == nil) + // The entrypoint must be one of the assets, or "/" would map to a file nobody declared. + precondition(generation(manifest(assets: [ + ["path": "assets/aa.js", "contentType": "text/javascript; charset=utf-8"] + ])) == nil) + precondition(generation(manifest(assets: [ + ["path": "index.html", "contentType": "text/html; charset=utf-8"], + ["path": "../escape.js", "contentType": "text/javascript; charset=utf-8"] + ])) == nil) + precondition(generation(manifest(assets: [ + ["path": "index.html", "contentType": "text/html; charset=utf-8"], + ["path": "assets/aa.js", "contentType": "text/javascript\r\nX-Injected: 1"] + ])) == nil) + precondition(generation(manifest(assets: [ + ["path": "index.html", "contentType": "text/html; charset=utf-8"], + ["path": 7, "contentType": "text/javascript; charset=utf-8"] + ])) == nil) + let tooMany = (0..<257).map { index in + ["path": "assets/a\(index).js", "contentType": "text/javascript; charset=utf-8"] + } + precondition(generation(manifest(assets: tooMany)) == nil) + // A JSON string is not a JSON number, and true and 1.0 are not the integer 1, though NSNumber + // bridges all three to something `as? Int` accepts. + precondition(generation(Data(#"{"schemaVersion":true,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil) + precondition(generation(Data(#"{"schemaVersion":1.0,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil) + precondition(generation(Data(#"{"schemaVersion":1,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) != nil) + precondition(generation(Data(#"{"schemaVersion":"1","entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil) + precondition(generation(Data("not json".utf8)) == nil) + precondition(generation(Data("[]".utf8)) == nil) + } + + static func checkCsp() { + let header = MobileWebShellCsp.header + let directives = header.components(separatedBy: "; ") + precondition(directives.contains("default-src 'none'")) + precondition(directives.contains("script-src 'self'")) + precondition(directives.contains("connect-src 'self'")) + precondition(directives.contains("worker-src 'none'")) + precondition(directives.contains("frame-src 'none'")) + precondition(directives.contains("base-uri 'none'")) + precondition(directives.contains("form-action 'none'")) + precondition(directives.contains("frame-ancestors 'none'")) + // An inline script or an eval would make the no-inline-script build rule unenforced. + precondition(!header.contains("unsafe-inline")) + precondition(!header.contains("unsafe-eval")) + precondition(!header.contains("data:")) + precondition(!header.contains("blob:")) + precondition(!header.contains("\r") && !header.contains("\n")) + } + + static func checkLoadStateMachine() { + precondition(MobileWebShellFailureReason.generationUnreadable.rawValue == "generation-unreadable") + precondition(MobileWebShellFailureReason.isolationUnavailable.rawValue == "isolation-unavailable") + precondition(MobileWebShellFailureReason.documentLoadFailed.rawValue == "document-load-failed") + precondition(MobileWebShellFailureReason.renderProcessGone.rawValue == "render-process-gone") + + let progress = MobileWebShellLoadStateMachine() + precondition(progress.started()?.state == "loading") + precondition(progress.started() == nil) + precondition(progress.finished()?.state == "ready") + precondition(progress.finished() == nil) + + // A rule list compiles asynchronously, so it can fail after the generation was already refused. + let refused = MobileWebShellLoadStateMachine() + precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable") + precondition(refused.failed(.isolationUnavailable) == nil) + precondition(refused.failed(.renderProcessGone) == nil) + precondition(refused.finished() == nil) + precondition(refused.started() == nil) + + refused.reset() + precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable") + } + + static func checkResponseHeaders() { + let document = MobileWebShellResponseHeaders.forPath( + "/", + contentType: "text/html; charset=utf-8", + byteCount: 12 + ) + precondition(document["Content-Security-Policy"] == MobileWebShellCsp.header) + precondition(document["Content-Type"] == "text/html; charset=utf-8") + precondition(document["Content-Length"] == "12") + precondition(document["Cache-Control"] == "no-store") + precondition(document["X-Content-Type-Options"] == "nosniff") + + // The policy rides the document alone; on a subresource response it is inert. + for path in ["/index.html", "/assets/aa.js", "/manifest.json", "/assets/bb.png"] { + let headers = MobileWebShellResponseHeaders.forPath( + path, + contentType: "text/javascript; charset=utf-8", + byteCount: 0 + ) + precondition(headers["Content-Security-Policy"] == nil) + precondition(headers["Cache-Control"] == "no-store") + precondition(headers["X-Content-Type-Options"] == "nosniff") + } + } + + static func checkNavigationErrors() { + let ignorable = MobileWebShellNavigationError.isIgnorable + // Our own stopLoading on a prop update, and every navigation the policy delegate refuses. + precondition(ignorable(NSURLErrorDomain, NSURLErrorCancelled)) + precondition(ignorable("WebKitErrorDomain", 102)) + // Anything else is the document failing to load, which is the caller's cue to redownload. + precondition(!ignorable(NSURLErrorDomain, NSURLErrorNetworkConnectionLost)) + precondition(!ignorable(NSURLErrorDomain, NSURLErrorResourceUnavailable)) + precondition(!ignorable("WebKitErrorDomain", 101)) + precondition(!ignorable("WebKitErrorDomain", NSURLErrorCancelled)) + // WKErrorDomain has no frame-load codes at all, so 102 there is some other error. + precondition(!ignorable("WKErrorDomain", 102)) + precondition(!ignorable("SomeOtherDomain", 102)) + } + + static func main() { + checkSessionIds() + checkRequestResolution() + checkAssetPaths() + checkContentTypes() + checkGenerationMap() + checkCsp() + checkLoadStateMachine() + checkResponseHeaders() + checkNavigationErrors() + print("mobile web shell checks OK") + } +} diff --git a/mobile/src/mobile-web-shell/shell-load-state.test.ts b/mobile/src/mobile-web-shell/shell-load-state.test.ts new file mode 100644 index 00000000000..1f3cf12eafd --- /dev/null +++ b/mobile/src/mobile-web-shell/shell-load-state.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + MOBILE_WEB_SHELL_FAILURE_REASONS, + parseMobileWebShellLoadState +} from '../../modules/orca-mobile-web-shell/src/load-state' + +describe('parseMobileWebShellLoadState', () => { + it('accepts the two states that carry no reason', () => { + expect(parseMobileWebShellLoadState({ state: 'loading' })).toEqual({ state: 'loading' }) + expect(parseMobileWebShellLoadState({ state: 'ready' })).toEqual({ state: 'ready' }) + }) + + it('ignores a reason on a non-failure state', () => { + expect(parseMobileWebShellLoadState({ state: 'ready', reason: 'render-process-gone' })).toEqual( + { + state: 'ready' + } + ) + }) + + it('accepts every declared failure reason and nothing else', () => { + for (const reason of MOBILE_WEB_SHELL_FAILURE_REASONS) { + expect(parseMobileWebShellLoadState({ state: 'failed', reason })).toEqual({ + state: 'failed', + reason + }) + } + expect(parseMobileWebShellLoadState({ state: 'failed', reason: 'boom' })).toBeNull() + expect(parseMobileWebShellLoadState({ state: 'failed' })).toBeNull() + }) + + // A native layer that learns a fifth state must not be read as one of the four. + it('rejects an unknown state, a non-string state, and a non-object payload', () => { + expect(parseMobileWebShellLoadState({ state: 'loaded' })).toBeNull() + expect(parseMobileWebShellLoadState({ state: 3 })).toBeNull() + expect(parseMobileWebShellLoadState({})).toBeNull() + expect(parseMobileWebShellLoadState(null)).toBeNull() + expect(parseMobileWebShellLoadState('ready')).toBeNull() + expect(parseMobileWebShellLoadState(undefined)).toBeNull() + }) + + it('does not inherit a reason from the prototype chain', () => { + const inherited: Record = Object.create({ reason: 'render-process-gone' }) + inherited.state = 'failed' + expect(parseMobileWebShellLoadState(inherited)).toBeNull() + }) +}) From 46d7ecf4d161288bac3960a66b2cef21f84f4678 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:13:03 -0700 Subject: [PATCH 018/224] test(runtime): pin the merged-predecessor lockout the receipt ledger allowed (#20725) The production change this branch carried - asking the receipt ledger the same lineage-aware "is it retired" question as the recovery gate - landed in the base branch (#19860) as part of "give 'same publisher' one answer across the epoch fences". Rebasing onto that base leaves the regression case, which is the part the base does not have. `fences a merged predecessor at the recovery gate as well` stops one frame early: it asserts the merged frame loses and never asks whether the live successor still gets in afterwards. This case asks, for both the bare and the merged shape. The bare shape passes without the ledger fix and is the control. Mutation: restoring `history?.retired.includes(publicationEpoch)` in `recordReceivedWebSessionTabsSnapshot` fails only the merged-shape case. --- ...bs-late-merged-predecessor-lockout.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/renderer/src/runtime/web-session-tabs-late-merged-predecessor-lockout.test.ts diff --git a/src/renderer/src/runtime/web-session-tabs-late-merged-predecessor-lockout.test.ts b/src/renderer/src/runtime/web-session-tabs-late-merged-predecessor-lockout.test.ts new file mode 100644 index 00000000000..f6f67d8ed5c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-late-merged-predecessor-lockout.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { decideWebSessionTabsSnapshot } from './web-session-tabs-sync' +import { + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './web-session-tabs-sync/tracking' +import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) +vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn() +})) + +/** + * "Is this epoch retired" had two answers one file apart. The recovery gate + * (`isRetiredSessionTabsPublicationEpoch`) answers by lineage; the receipt ledger + * (`recordReceivedWebSessionTabsSnapshot`) still matched the string exactly. A late + * `:headless-merge:` frame from a superseded generation was therefore rejected at the gate but + * had already passed the ledger's check, noted itself current, and pushed the live successor onto + * `retired`. The successor's next frame was then rejected: a publisher that never stopped running + * was locked out of its worktree. + * + * `fences a merged predecessor at the recovery gate as well` stops one frame early — it asserts + * the merged frame is rejected and never asks whether the successor still gets in afterwards. + */ +const ENV = 'remote-runtime' +const WORKTREE = 'repo::/worktree' +const GEN_1 = 'renderer-generation-1' +const GEN_2 = 'renderer-generation-2' +const MERGED_GEN_1 = `${GEN_1}:headless-merge:abc` + +function frame(publicationEpoch: string, snapshotVersion: number): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE, + publicationEpoch, + snapshotVersion, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } +} + +/** The composed gate every production apply path runs. */ +function admits(snapshot: RuntimeMobileSessionTabsResult, receivedFrame: number): boolean { + return ( + shouldApplyRecoveredWebSessionTabsSnapshot(ENV, snapshot, receivedFrame) && + decideWebSessionTabsSnapshot(snapshot, ENV).apply + ) +} + +describe('a late frame from a retired generation must not retire the live successor', () => { + beforeEach(() => { + resetWebSessionTabsSyncTestState() + }) + + for (const [label, epoch] of [ + ['bare', GEN_1], + ['headless-merge', MERGED_GEN_1] + ] as const) { + it(`keeps admitting the successor after a ${label} predecessor frame is rejected`, () => { + const firstReceived = recordReceivedWebSessionTabsSnapshot(ENV, frame(GEN_1, 5)) + expect(admits(frame(GEN_1, 5), firstReceived)).toBe(true) + + const successorReceived = recordReceivedWebSessionTabsSnapshot(ENV, frame(GEN_2, 1)) + expect(admits(frame(GEN_2, 1), successorReceived)).toBe(true) + + // Late enough to win on delivery order; retired by lineage, so it must lose... + const late = frame(epoch, 9) + const lateReceived = recordReceivedWebSessionTabsSnapshot(ENV, late) + expect(admits(late, lateReceived)).toBe(false) + + // ...and losing must cost it nothing more than that frame. The successor is still publishing. + const next = frame(GEN_2, 2) + const nextReceived = recordReceivedWebSessionTabsSnapshot(ENV, next) + expect(admits(next, nextReceived)).toBe(true) + }) + } +}) From 593141590e73bd971c1678777bed56f48dbbf6f4 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 18 Sep 2026 02:13:11 -0700 Subject: [PATCH 019/224] fix(terminal): retire captured remote handles when pending panes close (#21005) * fix(terminal): retire captured remote handles when pending panes close A restored pane can hold a scoped `remote:@@` layout binding while `remote.attach()` is still waiting for `terminal.resolvePane`. The transport's `getPtyId()` is null, so an explicit split close passed null to `closeWebRuntimeTerminal`, dropped the binding and destroyed only the viewer. The host terminal stayed connected. Only an exact scoped handle whose environment matches the owning workspace's runtime authorizes the close. The provider helper captures the pairing revision, runs its existing compatibility check, then rechecks pairing and ownership immediately before dispatch. Rebased onto main after #21001 was squash-merged. The previous head was a merge commit that carried its own conflict-resolution content -- the runtime branch in `terminal-pane-close-admission.ts` and the restored `it.each([false, true])` parameter -- which a plain rebase drops along with the merge. Rebuilt from the recorded net diff instead and verified byte-identical at 15 files, 906 insertions, 41 deletions. * test(memory): rebase the pending runtime-close proof onto the squashed base `fix.patch` recorded a baseline taken against #21001's pre-squash branch tip. Squash-merging #21001 replaced that tip with a single commit, so the recorded hunks no longer reverse-applied and `reproduce.mjs` aborted with `Source changed: use-terminal-pane-close-actions.ts` -- confirmed by running it before regenerating rather than assuming the rebase alone would fix it. Regenerated against `main` and re-run: 5 pass / 10 fail before, 15 pass / 0 fail after, exit 0, and every `results.json` hash recomputed from the run rather than hand-edited. --------- Co-authored-by: m4air Co-authored-by: Neil --- .../pending-runtime-pane-close/README.md | 38 ++++ .../pending-runtime-pane-close/fix.patch | 179 ++++++++++++++++++ .../host-handle-proof.test.ts | 70 +++++++ .../pending-runtime-pane-close/reproduce.mjs | 153 +++++++++++++++ .../pending-runtime-pane-close/results.json | 60 ++++++ .../pending-pane-close-confirmation.test.ts | 47 ++++- ...pending-runtime-pane-close-test-fixture.ts | 78 ++++++++ .../pending-runtime-pane-close.test.ts | 122 ++++++++++++ .../retire-unbound-ipc-terminal-pane.ts | 34 +--- .../retire-unbound-runtime-terminal-pane.ts | 60 ++++++ .../terminal-pane-close-admission.ts | 10 +- .../terminal-pane-retirement-ownership.ts | 33 ++++ .../use-terminal-pane-close-actions.ts | 8 + .../src/runtime/runtime-rpc-client.ts | 2 +- .../terminals/terminal-tab-close-providers.ts | 53 +++++- 15 files changed, 906 insertions(+), 41 deletions(-) create mode 100644 docs/audits/pending-runtime-pane-close/README.md create mode 100644 docs/audits/pending-runtime-pane-close/fix.patch create mode 100644 docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts create mode 100644 docs/audits/pending-runtime-pane-close/reproduce.mjs create mode 100644 docs/audits/pending-runtime-pane-close/results.json create mode 100644 src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture.ts create mode 100644 src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts create mode 100644 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-retirement-ownership.ts diff --git a/docs/audits/pending-runtime-pane-close/README.md b/docs/audits/pending-runtime-pane-close/README.md new file mode 100644 index 00000000000..91dfd11e839 --- /dev/null +++ b/docs/audits/pending-runtime-pane-close/README.md @@ -0,0 +1,38 @@ +# Closing an unbound paired-runtime pane with a captured handle + +A restored pane can already hold a scoped `remote:@@` layout binding while `remote.attach()` waits for `terminal.resolvePane`. The transport's `getPtyId()` is still null. An explicit split close therefore passed null to `closeWebRuntimeTerminal`, removed the layout binding, and destroyed only the viewer. The host terminal stayed connected. This attachment/teardown behavior exists in `v1.4.198`. + +This is a specific retained host-terminal mechanism. The change is stacked on the local/direct-SSH pending-close fix in [#21001](https://github.com/stablyai/orca/pull/21001) and reuses its current-owner query. It does not prove the incident frequency in [#15210](https://github.com/stablyai/orca/issues/15210), Linux Electron-main growth, or [#19831](https://github.com/stablyai/orca/issues/19831)'s memory slope. + +## Scope and authority + +Only an exact scoped handle whose environment matches the owning workspace's runtime authorizes this fix. Existing retirement planning and the shared current-owner query protect other tabs, sibling aliases, and bound transports. The provider helper captures the pairing revision, performs its existing compatibility check, then checks pairing and current ownership again immediately before dispatch. The second call skips only the check that just completed. It sends the existing `terminal.close` request for the captured handle. + +The actual host fixture verifies that re-registering the same PTY ID with a new incarnation allocates a new handle. A close addressed to the old handle rejects and never invokes the controller's kill operation. Client same-leaf adoption, a replaced transport map, and changed worktree/pairing ownership also suppress the queued request. Ordinary detach remains viewer-only. + +Native host PTY hints, legacy handles without an explicit environment, and returned different handles are outside this fix. Current client snapshot registries retain freshness/frame identity rather than a live terminal-row incarnation. Inferring destructive authority from a late native-hint resolution could stop a replacement. The separate read-only native-hint and pending web-activation reproduction remains in `notes/paired-pending-split-close`; it establishes omitted requests, with no claim that these excluded cases are fixed. No parent-tab close, local fallback, new wire field, or capability is introduced. A request is not confirmation of process death; provider failures retain their existing handling. + +## Close-confirmation review correction + +The public split-close callback now probes the captured scoped handle before authorizing retirement, including while `terminal.resolvePane` remains pending. Live or unverified pending work opens the existing confirmation dialog. Cancel keeps the host terminal; Confirm rechecks the captured tab, pane, transport, handle, host, and pairing revision. A replacement or a split that became the only pane invalidates the old decision. The host's existing handle-incarnation fence and the compatibility-dispatch checks remain in force. + +`pending-pane-close-confirmation.test.ts` adds public-callback controls for both local/direct-SSH and paired pending panes. The comparative counts below remain the original proof snapshot, which called the post-confirmation `executeClosePane` callback directly. + +## Reproduce + +Run in the repository root with existing dependencies: + +```sh +ORCA_BACKGROUND_LAUNCH=1 node docs/audits/pending-runtime-pane-close/reproduce.mjs +``` + +The script runs 13 tests using the actual split-close hook and remote transport, plus two tests delivering the close RPC into an actual `OrcaRuntimeService` with a fake PTY controller. React registration and unrelated presentation callbacks are mocked. No Electron window, host process inventory, or real PTY child is used. + +The temporary Vite transform reverses only `fix.patch`; the baseline includes the IPC fix from #21001. Working sources remain untouched. The script uses the shared cross-platform process runner and records source hashes and exact cases in `results.json`. + +| Version | Passed | Failed | +| ------------------------ | -----: | -----: | +| Before scoped-handle fix | 5 | 10 | +| With scoped-handle fix | 15 | 0 | + +The baseline failure count includes new eager-request/compatibility assertions, not ten independent leaks. Additional validation: 255 tests in 21 selected renderer suites, full renderer typecheck, direct lint, and the changed-code quality gate pass. The original 24-case IPC proof still runs after the shared ownership extraction; its committed results remain a snapshot of the published IPC source. diff --git a/docs/audits/pending-runtime-pane-close/fix.patch b/docs/audits/pending-runtime-pane-close/fix.patch new file mode 100644 index 00000000000..b3c53432eaa --- /dev/null +++ b/docs/audits/pending-runtime-pane-close/fix.patch @@ -0,0 +1,179 @@ +diff --git a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts +index 081a33fc895..a3235fea66a 100644 +--- a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts ++++ b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts +@@ -1,21 +1,16 @@ +-import type { AppState } from '@/store/types' + import { + buildTerminalTabRetirementPlan, +- getTerminalPtyOwnershipIdentity, +- hasTerminalPtyOwnerOutsidePane ++ getTerminalPtyOwnershipIdentity + } from '@/store/slices/terminal-tab-retirement' + import { startTerminalTabProviderRetirement } from '@/store/terminals/terminal-tab-close-providers' +-import type { PtyTransport } from './pty-transport-types' ++import { ++ terminalPaneHasOtherOwner, ++ type UnboundTerminalPaneRetirement ++} from './terminal-pane-retirement-ownership' + + /** Capture explicit split-close intent before the durable leaf binding is removed. */ +-export function retireUnboundIpcTerminalPane(args: { +- getState: () => AppState +- tabId: string +- leafId: string +- transport: PtyTransport | undefined +- getTransports: () => ReadonlyMap +-}): void { +- const { getState, tabId, leafId, transport, getTransports } = args ++export function retireUnboundIpcTerminalPane(args: UnboundTerminalPaneRetirement): void { ++ const { getState, tabId, leafId, transport } = args + if (!transport || transport.getPtyId()) { + return + } +@@ -33,19 +28,8 @@ export function retireUnboundIpcTerminalPane(args: { + if (!ptyId) { + return + } +- const hasOtherOwner = (excludedLeafId?: string): boolean => { +- const current = getState() +- return ( +- hasTerminalPtyOwnerOutsidePane(current, identity, tabId, excludedLeafId) || +- [...getTransports().values()].some((candidate) => { +- const boundId = candidate.getPtyId() +- return ( +- boundId !== null && +- getTerminalPtyOwnershipIdentity(current, boundId, plan.worktreeId) === identity +- ) +- }) +- ) +- } ++ const hasOtherOwner = (excludedLeafId?: string): boolean => ++ terminalPaneHasOtherOwner(args, identity, plan.worktreeId, excludedLeafId) + if (hasOtherOwner(leafId)) { + return + } +diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +index ea85e929e81..3e8dd463a30 100644 +--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts ++++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +@@ -1,5 +1,6 @@ + import { useCallback, useImperativeHandle, useRef } from 'react' + import { useAppStore } from '../../store' ++import { retireUnboundRuntimeTerminalPane } from './retire-unbound-runtime-terminal-pane' + import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' + import { makePaneKey } from '../../../../shared/stable-pane-id' + import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session' +@@ -61,6 +62,13 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr + } + setTerminalErrorsByPaneId((current) => clearPaneTerminalError(current, paneId)) + if (leafId) { ++ retireUnboundRuntimeTerminalPane({ ++ getState: useAppStore.getState, ++ tabId, ++ leafId, ++ transport: paneTransportsRef.current.get(paneId), ++ getTransports: () => paneTransportsRef.current ++ }) + syncPanePtyLayoutBindingForLeaf?.(leafId, null, paneId) + } else { + syncPanePtyLayoutBinding(paneId, null) +diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts +index eb04233cc91..719e54eac89 100644 +--- a/src/renderer/src/runtime/runtime-rpc-client.ts ++++ b/src/renderer/src/runtime/runtime-rpc-client.ts +@@ -95,7 +95,7 @@ export async function callRuntimeRpc( + return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + } + +-async function ensureRuntimeEnvironmentCompatible( ++export async function ensureRuntimeEnvironmentCompatible( + environmentId: string, + options: { + timeoutMs?: number +diff --git a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts +index 322d4106c7e..4ba425d95d2 100644 +--- a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts ++++ b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts +@@ -1,5 +1,9 @@ ++import { ++ captureRuntimeEnvironmentRequestRevision, ++ getRuntimeEnvironmentRevision ++} from '@/runtime/runtime-environment-revision' + import type { AppState } from '../types' +-import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' ++import { callRuntimeRpc, ensureRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client' + import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' + import { + classifyTerminalRetirementWorktree, +@@ -11,13 +15,15 @@ export function startTerminalTabProviderRetirement({ + remoteCloseOwnedByHost, + retirementPlan, + state, +- tabId ++ tabId, ++ canRetireRuntimeTerminal + }: { + localPtyTeardownOwnedExternally: boolean + remoteCloseOwnedByHost: boolean + retirementPlan: TerminalTabRetirementPlan + state: AppState + tabId: string ++ canRetireRuntimeTerminal?: () => boolean + }): void { + const fallbackWorktreeRoute = retirementPlan.worktreeId + ? resolveTerminalWorktreeRoute(state, retirementPlan.worktreeId) +@@ -33,11 +39,7 @@ export function startTerminalTabProviderRetirement({ + } + const environmentId = terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId + retirementTasks.push( +- callRuntimeRpc( +- environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, +- 'terminal.close', +- { terminal: terminal.handle } +- ) ++ retireRuntimeTerminal(environmentId, terminal.handle, canRetireRuntimeTerminal) + ) + } + } +@@ -66,3 +68,40 @@ export function startTerminalTabProviderRetirement({ + } + }) + } ++ ++async function retireRuntimeTerminal( ++ environmentId: string | null | undefined, ++ handle: string, ++ canRetire?: () => boolean ++): Promise { ++ const target = environmentId ++ ? { kind: 'environment' as const, environmentId } ++ : { kind: 'local' as const } ++ if (!canRetire) { ++ return callRuntimeRpc(target, 'terminal.close', { terminal: handle }) ++ } ++ const revision = environmentId ++ ? captureRuntimeEnvironmentRequestRevision(environmentId) ++ : undefined ++ if (environmentId) { ++ await ensureRuntimeEnvironmentCompatible(environmentId, { ++ expectedEnvironmentPairingRevision: revision ++ }) ++ } ++ if ( ++ (environmentId && getRuntimeEnvironmentRevision(environmentId) !== revision) || ++ !canRetire() ++ ) { ++ return ++ } ++ // Compatibility was checked above; recheck pane ownership at the actual dispatch boundary. ++ return callRuntimeRpc( ++ target, ++ 'terminal.close', ++ { terminal: handle }, ++ { ++ skipCompatibilityCheck: true, ++ expectedEnvironmentPairingRevision: revision ++ } ++ ) ++} diff --git a/docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts b/docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts new file mode 100644 index 00000000000..55d028de7b5 --- /dev/null +++ b/docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts @@ -0,0 +1,70 @@ +import { expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' +import { preparePendingRuntimeClose } from '../../../src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture' + +it.each([false, true])( + 'actual close RPC addresses only the captured host incarnation: replacement=%s', + async (replacement) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the only store method used by the exercised register/resolve/close path. + const store = { getRepos: () => [] } as unknown as ConstructorParameters< + typeof OrcaRuntimeService + >[0] + const runtime = new OrcaRuntimeService(store) + const kill = vi.fn((id: string) => { + runtime.onPtyExit(id, 0) + return true + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The real close method uses the supplied kill operation; this fixture launches no subprocess. + runtime.setPtyController({ kill } as Parameters[0]) + const binding = { + tabId: 'tab-parent', + leafId: '11111111-1111-4111-8111-111111111111', + incarnationId: '11111111-1111-4111-8111-111111111111' + } + runtime.registerPty('host-pty', 'workspace', null, binding) + const paneKey = `${binding.tabId}:${binding.leafId}` + const original = runtime.resolveTerminalPane(paneKey, 'workspace') + const p = await preparePendingRuntimeClose(`remote:env-1@@${original.handle}`) + const beforeCall = p.runtimeCall.getMockImplementation()! + p.runtimeCall.mockImplementation(async (request) => { + if (request.method !== 'terminal.close') { + return beforeCall(request) + } + const params = request.params + if ( + !params || + typeof params !== 'object' || + !('terminal' in params) || + typeof params.terminal !== 'string' + ) { + throw new Error('expected captured terminal handle') + } + return { ok: true, result: { close: await runtime.closeTerminal(params.terminal) } } + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + p.actions.executeClosePane(1) + if (replacement) { + runtime.registerPty('host-pty', 'workspace', null, { + ...binding, + incarnationId: '22222222-2222-4222-8222-222222222222' + }) + expect(runtime.resolveTerminalPane(paneKey, 'workspace').handle).not.toBe(original.handle) + } + p.acceptCompatibility() + await p.settle(original.handle) + expect(p.runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.close', params: { terminal: original.handle } }) + ) + if (replacement) { + expect(kill).not.toHaveBeenCalled() + expect(runtime.resolveTerminalPane(paneKey, 'workspace').connected).toBe(true) + } else { + expect(kill).toHaveBeenCalledExactlyOnceWith('host-pty') + } + expect(window.api.pty.kill).not.toHaveBeenCalled() + } finally { + runtime.onPtyExit('host-pty', 0) + } + } +) diff --git a/docs/audits/pending-runtime-pane-close/reproduce.mjs b/docs/audits/pending-runtime-pane-close/reproduce.mjs new file mode 100644 index 00000000000..52038bd2aa5 --- /dev/null +++ b/docs/audits/pending-runtime-pane-close/reproduce.mjs @@ -0,0 +1,153 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { applyPatch, parsePatch, reversePatch } from 'diff' +import { build } from 'esbuild' + +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.') +} + +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8') +const beforeSources = {} +const sourceHashes = {} +for (const parsed of parsePatch(patch)) { + const path = parsed.newFileName.replace(/^b\//, '') + const absolute = resolve(root, path) + const current = await readFile(absolute, 'utf8') + const before = applyPatch(current, reversePatch(parsed)) + if (before === false) { + throw new Error(`Source changed; review the proof patch: ${path}`) + } + beforeSources[absolute.replaceAll('\\', '/')] = before + sourceHashes[path] = { + before: createHash('sha256').update(before).digest('hex'), + after: createHash('sha256').update(current).digest('hex') + } +} + +for (const path of [ + 'src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts', + 'src/renderer/src/components/terminal-pane/terminal-pane-retirement-ownership.ts', + 'src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture.ts', + 'src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts', + 'docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts' +]) { + sourceHashes[path] = { + current: createHash('sha256') + .update(await readFile(resolve(root, path))) + .digest('hex') + } +} + +const scratch = await mkdtemp(join(tmpdir(), 'orca-pending-runtime-close-')) +const require = createRequire(import.meta.url) +let runnerModuleId +try { + const runnerPath = join(scratch, 'run-process.cjs') + await build({ + absWorkingDir: root, + entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')], + outfile: runnerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + runnerModuleId = require.resolve(runnerPath) + const { runProcess } = require(runnerModuleId) + const baselineConfig = join(scratch, 'before.config.mjs') + const fixedConfig = join(scratch, 'after.config.mjs') + const includes = [ + 'src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts', + 'docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts' + ] + const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href) + await writeFile( + baselineConfig, + `import base from ${configImport}; +const beforeSources = ${JSON.stringify(beforeSources)}; +export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{ + name: 'pending-runtime-close-before-fix', enforce: 'pre', + transform(_code, id) { + const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]]; + return before === undefined ? null : {code: before, map: null}; + } +}]};\n` + ) + + await writeFile( + fixedConfig, + `import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n` + ) + + async function run(label, config) { + const report = join(scratch, `${label}.json`) + const result = await runProcess({ + program: process.execPath, + args: [ + resolve(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--config', + config, + '--reporter=json', + `--outputFile=${report}` + ], + cwd: root, + env: process.env, + timeoutMs: 90_000, + maxOutputBytes: 4 * 1024 * 1024 + }) + let parsed + try { + parsed = JSON.parse(await readFile(report, 'utf8')) + } catch (error) { + throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error }) + } + return { + exitCode: result.code, + passed: parsed.numPassedTests, + failed: parsed.numFailedTests, + failedCases: parsed.testResults.flatMap((suite) => + suite.assertionResults + .filter((test) => test.status === 'failed') + .map((test) => test.fullName) + ) + } + } + + const before = await run('before', baselineConfig) + const after = await run('after', fixedConfig) + const passed = + before.failed === 10 && + before.passed === 5 && + before.passed + before.failed === 15 && + after.passed === 15 && + after.failed === 0 + console.log( + JSON.stringify( + { + comparison: + 'Actual split close/IPC transport and actual host handle-close tests; before reverses only fix.patch in a temporary Vite transform', + sourceHashes, + before, + after, + passed + }, + null, + 2 + ) + ) + if (!passed) { + process.exitCode = 1 + } +} finally { + if (runnerModuleId) { + delete require.cache[runnerModuleId] + } + await rm(scratch, { recursive: true, force: true }) +} diff --git a/docs/audits/pending-runtime-pane-close/results.json b/docs/audits/pending-runtime-pane-close/results.json new file mode 100644 index 00000000000..c8c14337687 --- /dev/null +++ b/docs/audits/pending-runtime-pane-close/results.json @@ -0,0 +1,60 @@ +{ + "comparison": "Actual split close/IPC transport and actual host handle-close tests; before reverses only fix.patch in a temporary Vite transform", + "sourceHashes": { + "src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts": { + "before": "3194229bdd3c992e8459cdad727a3931653953b204854486b8aaf4d129152d24", + "after": "f52f4b50d94e71506921788e3b49db547dbd879569d80151429adaeaa5865571" + }, + "src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts": { + "before": "bbfeb2385fd120a7a1407d043011fb689b5c2b652c31150de44061f1edb76a7b", + "after": "d9f0c08d82e70180f4e3c28ca33815314c580ba59c65cb3c5004079b56c3f227" + }, + "src/renderer/src/runtime/runtime-rpc-client.ts": { + "before": "d0ae689153ba0d972ba7a10484c2024bf9fd08c8628bd43009396aa0f653058e", + "after": "37912cebd375be8378a8a882cf5677663d435414cb81bec5180637df7165f2b9" + }, + "src/renderer/src/store/terminals/terminal-tab-close-providers.ts": { + "before": "87b666644adc3b3295b848b424f44b5834980c7871d7df55bbda6fabeb4c7c7b", + "after": "66f5853c4dc1a14bee7b630a2d868ae3432a8d4870b3e5022a4a7138469d6579" + }, + "src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts": { + "current": "9e7fe08d0d32e75b9d01cb56c6fcffef48443e57b1d8d183377120ce944a1ae7" + }, + "src/renderer/src/components/terminal-pane/terminal-pane-retirement-ownership.ts": { + "current": "a7aa072930b2e293ca49701df355b60e52ad8b8bb1c8449239e0777f8d8c3020" + }, + "src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture.ts": { + "current": "2e08dec20d64d2f21962fb6a04ee0783d49cf2db1c0bc3d407e761a7f5515c7a" + }, + "src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts": { + "current": "f1477f53e086330c7587c2e0c5f13070917bb84b4b249de1d31045c3f03fb2f3" + }, + "docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts": { + "current": "becc10e549a97e4a45d03f93de726ee7989f11fc6cb118bc362c7eab4809d8c7" + } + }, + "before": { + "exitCode": 1, + "passed": 5, + "failed": 10, + "failedCases": [ + "actual close RPC addresses only the captured host incarnation: replacement=false", + "actual close RPC addresses only the captured host incarnation: replacement=true", + "closes the captured scoped handle while actual remote attach is still unbound", + "rechecks same-leaf ownership after compatibility settles", + "rechecks other-tab ownership after compatibility settles", + "rechecks bound-transport ownership after compatibility settles", + "rechecks worktree-owner ownership after compatibility settles", + "rechecks pairing ownership after compatibility settles", + "does not bypass a failed compatibility check", + "never turns a late different resolved handle into close authority" + ] + }, + "after": { + "exitCode": 0, + "passed": 15, + "failed": 0, + "failedCases": [] + }, + "passed": true +} diff --git a/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts b/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts index 03f8887564d..3fd342378bd 100644 --- a/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts +++ b/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { preparePendingSplitClose } from './pending-split-close-test-fixture' +import { preparePendingRuntimeClose } from './pending-runtime-pane-close-test-fixture' import { flushPtySideEffects } from './pty-transport-test-harness' import type { PtyRunningWorkProbe } from '../terminal/pty-running-work-probe' @@ -7,7 +8,8 @@ beforeEach(() => vi.clearAllMocks()) afterEach(() => vi.useRealTimers()) async function prepare(remote = false, requestedPtyId?: string) { - const p = await preparePendingSplitClose(requestedPtyId) + const paired = remote ? await preparePendingRuntimeClose() : undefined + const p = paired ?? (await preparePendingSplitClose(requestedPtyId)) Object.assign(p.state, { settings: { skipCloseTerminalWithRunningProcessConfirm: false } }) const { probePtyRunningWork } = await import('../terminal/pty-running-work-probe') const { useTerminalPaneCloseActions } = await import('./use-terminal-pane-close-actions') @@ -21,16 +23,24 @@ async function prepare(remote = false, requestedPtyId?: string) { vi.mocked(probePtyRunningWork).mockReturnValueOnce(reply.promise) const verdict = (value: PtyRunningWorkProbe['verdict']) => reply.resolve([{ ptyId: 'captured', verdict: value, timedOut: false, remote }]) - const closed = () => vi.mocked(window.api.pty.kill).mock.calls.length > 0 + const closed = () => + paired + ? paired.runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.close') + : vi.mocked(window.api.pty.kill).mock.calls.length > 0 const settle = async () => { - p.spawn.resolve({ id: requestedPtyId ?? 'pty-restored', isReattach: true }) - await p.connecting + if (paired) { + paired.acceptCompatibility() + await paired.settle() + } else { + p.spawn.resolve({ id: requestedPtyId ?? 'pty-restored', isReattach: true }) + await p.connecting + } await flushPtySideEffects() } - return { ...p, actions, probePtyRunningWork, verdict, reply, closed, settle } + return { ...p, paired, actions, probePtyRunningWork, verdict, reply, closed, settle } } -it.each([false])('requires confirmation for pending live work, paired=%s', async (remote) => { +it.each([false, true])('requires confirmation for pending live work, paired=%s', async (remote) => { const p = await prepare(remote) p.actions.handleRequestClosePane(1) expect(p.probePtyRunningWork).toHaveBeenCalledWith( @@ -50,7 +60,7 @@ it.each([false])('requires confirmation for pending live work, paired=%s', async expect(p.closed()).toBe(true) }) -it.each([false])('Cancel preserves pending work, paired=%s', async (remote) => { +it.each([false, true])('Cancel preserves pending work, paired=%s', async (remote) => { const p = await prepare(remote) p.actions.handleRequestClosePane(1) p.verdict('live') @@ -190,3 +200,26 @@ it.each(['tab', 'generation', 'leaf', 'transport', 'manager', 'whole-tab', 'bind p.transport.detach?.({ preserveExitObserver: false }) } ) + +it.each(['probe', 'dialog'] as const)( + 'does not close a re-paired host after %s starts', + async (phase) => { + const p = await prepare(true) + if (!p.paired) { + throw new Error('paired fixture required') + } + p.actions.handleRequestClosePane(1) + if (phase === 'dialog') { + p.verdict('live') + await flushPtySideEffects() + } + p.paired.replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 2, pairingRevision: 2 }]) + p.verdict('live') + await flushPtySideEffects() + if (phase === 'dialog') { + p.actions.handleConfirmClose(false) + } + await p.settle() + expect(p.closed()).toBe(false) + } +) diff --git a/src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture.ts b/src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture.ts new file mode 100644 index 00000000000..e6bba20cce3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture.ts @@ -0,0 +1,78 @@ +import { vi } from 'vitest' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import { preparePendingSplitClose } from './pending-split-close-test-fixture' +import { flushPtySideEffects } from './pty-transport-test-harness' + +export async function preparePendingRuntimeClose(id = 'remote:env-1@@term_original') { + const p = await preparePendingSplitClose(id) + p.transport.detach?.({ preserveExitObserver: false }) + p.spawn.resolve({ id, isReattach: true }) + await p.connecting + p.state.worktreesByRepo = { + repo: [{ id: 'workspace', repoId: 'repo', runtimeOwnerEnvironmentId: 'env-1' }] + } + const resolvePane = Promise.withResolvers() + const compatibility = Promise.withResolvers() + const runtimeCall = vi.fn( + (request: { + method: string + params?: unknown + expectedEnvironmentPairingRevision?: number + }): Promise => { + if (request.method === 'terminal.resolvePane') { + return resolvePane.promise + } + if (request.method === 'status.get') { + return compatibility.promise + } + return Promise.resolve({ ok: true, result: {} }) + } + ) + Object.assign(window.api, { runtimeEnvironments: { call: runtimeCall } }) + const { replaceRuntimeEnvironmentRevisions } = + await import('../../runtime/runtime-environment-revision') + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 1 }]) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const remote = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'workspace', + tabId: p.tabId, + leafId: p.leafId + }) + p.transports.set(1, remote) + remote.attach({ existingPtyId: id, callbacks: {} }) + const acceptCompatibility = (): void => + compatibility.resolve({ + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + }) + return { + ...p, + remote, + runtimeCall, + compatibility, + acceptCompatibility, + replaceRuntimeEnvironmentRevisions, + async settle(handle = 'term_original') { + resolvePane.resolve({ + ok: true, + result: { + terminal: { + handle, + tabId: p.tabId, + leafId: p.leafId, + worktreeId: 'workspace', + ptyId: 'host-pty' + } + } + }) + await flushPtySideEffects() + remote.destroy?.() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts b/src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts new file mode 100644 index 00000000000..166062fbfff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts @@ -0,0 +1,122 @@ +import { expect, it, vi } from 'vitest' +import { preparePendingRuntimeClose } from './pending-runtime-pane-close-test-fixture' +import { makeCloseTestTab } from './pending-split-close-test-fixture' + +it('closes the captured scoped handle while actual remote attach is still unbound', async () => { + const p = await preparePendingRuntimeClose() + expect(p.runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.resolvePane' }) + ) + expect(p.remote.getPtyId()).toBeNull() + p.actions.executeClosePane(1) + expect(p.runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'status.get' })) + p.acceptCompatibility() + await p.settle() + expect(p.runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.close', + params: { terminal: 'term_original' }, + expectedEnvironmentPairingRevision: 1 + }) + ) + expect(window.api.pty.kill).not.toHaveBeenCalled() +}) + +it('normal remount keeps the captured remote handle', async () => { + const p = await preparePendingRuntimeClose() + p.remote.detach?.() + p.acceptCompatibility() + await p.settle() + expect(p.runtimeCall.mock.calls.map(([request]) => request.method)).not.toContain( + 'terminal.close' + ) + expect(p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId?.[p.leafId]).toBe( + 'remote:env-1@@term_original' + ) +}) + +it.each(['same-leaf', 'other-tab', 'bound-transport', 'worktree-owner', 'pairing'] as const)( + 'rechecks %s ownership after compatibility settles', + async (owner) => { + const p = await preparePendingRuntimeClose() + p.actions.executeClosePane(1) + expect(p.runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'status.get' })) + const id = 'remote:env-1@@term_original' + const { createIpcPtyTransport } = await import('./pty-transport') + const survivor = createIpcPtyTransport({}) + if (owner === 'same-leaf') { + p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId = { [p.leafId]: id } + } else if (owner === 'other-tab') { + p.state.tabsByWorktree.workspace.push(makeCloseTestTab('replacement', 'remote:term_original')) + } else if (owner === 'bound-transport') { + survivor.attach({ existingPtyId: id, callbacks: {} }) + p.controller.paneTransportsRef.current = new Map([[1, survivor]]) + } else if (owner === 'worktree-owner') { + p.state.worktreesByRepo = { + repo: [{ id: 'workspace', repoId: 'repo', runtimeOwnerEnvironmentId: 'env-2' }] + } + } else { + p.replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 2, pairingRevision: 2 }]) + } + p.acceptCompatibility() + await p.settle() + expect(p.runtimeCall.mock.calls.map(([request]) => request.method)).not.toContain( + 'terminal.close' + ) + expect(window.api.pty.kill).not.toHaveBeenCalled() + survivor.detach?.({ preserveExitObserver: false }) + } +) + +it('protects a sibling legacy alias before issuing a compatibility request', async () => { + const p = await preparePendingRuntimeClose() + p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId = { + [p.leafId]: 'remote:env-1@@term_original', + [p.siblingLeafId]: 'remote:term_original' + } + p.actions.executeClosePane(1) + p.acceptCompatibility() + await p.settle() + expect(p.runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'terminal.resolvePane' + ]) +}) + +it.each(['ssh:host@@native-hint', 'remote:term_original', 'remote:env-2@@term_original'])( + 'refuses to infer close authority from %s', + async (id) => { + const p = await preparePendingRuntimeClose(id) + p.actions.executeClosePane(1) + p.acceptCompatibility() + await p.settle() + expect(p.runtimeCall.mock.calls.map(([request]) => request.method)).not.toContain( + 'terminal.close' + ) + expect(window.api.pty.kill).not.toHaveBeenCalled() + } +) + +it('does not bypass a failed compatibility check', async () => { + const p = await preparePendingRuntimeClose() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + p.actions.executeClosePane(1) + p.compatibility.reject(new Error('incompatible runtime')) + await p.settle() + expect(p.runtimeCall.mock.calls.map(([request]) => request.method)).not.toContain( + 'terminal.close' + ) + expect(warn).toHaveBeenCalledWith( + '[terminal-retirement] provider teardown failed', + expect.objectContaining({ runtimeFailures: 1 }) + ) +}) + +it('never turns a late different resolved handle into close authority', async () => { + const p = await preparePendingRuntimeClose() + p.actions.executeClosePane(1) + p.acceptCompatibility() + await p.settle('term_replacement') + expect( + p.runtimeCall.mock.calls.filter(([request]) => request.method === 'terminal.close') + ).toEqual([[expect.objectContaining({ params: { terminal: 'term_original' } })]]) +}) diff --git a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts index 081a33fc895..a3235fea66a 100644 --- a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts +++ b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts @@ -1,21 +1,16 @@ -import type { AppState } from '@/store/types' import { buildTerminalTabRetirementPlan, - getTerminalPtyOwnershipIdentity, - hasTerminalPtyOwnerOutsidePane + getTerminalPtyOwnershipIdentity } from '@/store/slices/terminal-tab-retirement' import { startTerminalTabProviderRetirement } from '@/store/terminals/terminal-tab-close-providers' -import type { PtyTransport } from './pty-transport-types' +import { + terminalPaneHasOtherOwner, + type UnboundTerminalPaneRetirement +} from './terminal-pane-retirement-ownership' /** Capture explicit split-close intent before the durable leaf binding is removed. */ -export function retireUnboundIpcTerminalPane(args: { - getState: () => AppState - tabId: string - leafId: string - transport: PtyTransport | undefined - getTransports: () => ReadonlyMap -}): void { - const { getState, tabId, leafId, transport, getTransports } = args +export function retireUnboundIpcTerminalPane(args: UnboundTerminalPaneRetirement): void { + const { getState, tabId, leafId, transport } = args if (!transport || transport.getPtyId()) { return } @@ -33,19 +28,8 @@ export function retireUnboundIpcTerminalPane(args: { if (!ptyId) { return } - const hasOtherOwner = (excludedLeafId?: string): boolean => { - const current = getState() - return ( - hasTerminalPtyOwnerOutsidePane(current, identity, tabId, excludedLeafId) || - [...getTransports().values()].some((candidate) => { - const boundId = candidate.getPtyId() - return ( - boundId !== null && - getTerminalPtyOwnershipIdentity(current, boundId, plan.worktreeId) === identity - ) - }) - ) - } + const hasOtherOwner = (excludedLeafId?: string): boolean => + terminalPaneHasOtherOwner(args, identity, plan.worktreeId, excludedLeafId) if (hasOtherOwner(leafId)) { return } diff --git a/src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts b/src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts new file mode 100644 index 00000000000..9c0b5b304a4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts @@ -0,0 +1,60 @@ +import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { + buildTerminalTabRetirementPlan, + getTerminalPtyOwnershipIdentity +} from '@/store/slices/terminal-tab-retirement' +import { startTerminalTabProviderRetirement } from '@/store/terminals/terminal-tab-close-providers' +import { + terminalPaneHasOtherOwner, + type UnboundTerminalPaneRetirement +} from './terminal-pane-retirement-ownership' + +/** An exact scoped handle authorizes close; a native hint cannot name its incarnation. */ +export function retireUnboundRuntimeTerminalPane(args: UnboundTerminalPaneRetirement): void { + const { getState, tabId, leafId, transport } = args + if (!transport || transport.getPtyId()) { + return + } + const state = getState() + const requestedPtyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + const remote = requestedPtyId ? parseRemoteRuntimePtyId(requestedPtyId) : null + const environmentId = remote?.environmentId?.trim() + if (!requestedPtyId || !remote?.handle || !environmentId) { + return + } + const plan = buildTerminalTabRetirementPlan(state, tabId) + const identity = getTerminalPtyOwnershipIdentity(state, requestedPtyId, plan.worktreeId) + const terminal = plan.runtimeTerminals.find( + (candidate) => + getTerminalPtyOwnershipIdentity(state, candidate.ptyId, plan.worktreeId) === identity + ) + const ownerIsCurrent = (): boolean => { + const owner = resolveTerminalHostOwnership(getState(), plan.worktreeId, 'teardown') + return owner.kind === 'runtime' && owner.runtimeEnvironmentId === environmentId + } + if ( + !terminal || + !ownerIsCurrent() || + terminalPaneHasOtherOwner(args, identity, plan.worktreeId, leafId) + ) { + return + } + startTerminalTabProviderRetirement({ + localPtyTeardownOwnedExternally: false, + remoteCloseOwnedByHost: false, + retirementPlan: { + ...plan, + ptyIds: [requestedPtyId], + localOrSshPtyIds: [], + runtimeTerminals: [{ ...terminal, environmentId }], + cleanupOnlyPtyIds: [], + sharedPtyIds: [], + unroutablePtyIds: [] + }, + state, + tabId, + canRetireRuntimeTerminal: () => + ownerIsCurrent() && !terminalPaneHasOtherOwner(args, identity, plan.worktreeId) + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts index 19ee58355c3..2dc8ce69a00 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts @@ -30,7 +30,15 @@ export function capturePendingTerminalPaneClose( const identity = getTerminalPtyOwnershipIdentity(state, ptyId, plan.worktreeId) const isIdentity = (id: string): boolean => getTerminalPtyOwnershipIdentity(state, id, plan.worktreeId) === identity - if (!plan.localOrSshPtyIds.some(isIdentity)) { + if ( + !plan.localOrSshPtyIds.some(isIdentity) && + !( + environmentId && + owner.kind === 'runtime' && + owner.runtimeEnvironmentId === environmentId && + plan.runtimeTerminals.some((terminal) => isIdentity(terminal.ptyId)) + ) + ) { return undefined } const originalTab = locateTerminalTab(state.tabsByWorktree, tabId)?.tab diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-retirement-ownership.ts b/src/renderer/src/components/terminal-pane/terminal-pane-retirement-ownership.ts new file mode 100644 index 00000000000..21d71d1a8fe --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-retirement-ownership.ts @@ -0,0 +1,33 @@ +import type { AppState } from '@/store/types' +import { + getTerminalPtyOwnershipIdentity, + hasTerminalPtyOwnerOutsidePane +} from '@/store/slices/terminal-tab-retirement' +import type { PtyTransport } from './pty-transport-types' + +export type UnboundTerminalPaneRetirement = { + getState: () => AppState + tabId: string + leafId: string + transport: PtyTransport | undefined + getTransports: () => ReadonlyMap +} + +export function terminalPaneHasOtherOwner( + args: Pick, + identity: string, + worktreeId: string | null, + excludedLeafId?: string +): boolean { + const current = args.getState() + return ( + hasTerminalPtyOwnerOutsidePane(current, identity, args.tabId, excludedLeafId) || + [...args.getTransports().values()].some((candidate) => { + const boundId = candidate.getPtyId() + return ( + boundId !== null && + getTerminalPtyOwnershipIdentity(current, boundId, worktreeId) === identity + ) + }) + ) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index ea85e929e81..3e8dd463a30 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -1,5 +1,6 @@ import { useCallback, useImperativeHandle, useRef } from 'react' import { useAppStore } from '../../store' +import { retireUnboundRuntimeTerminalPane } from './retire-unbound-runtime-terminal-pane' import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' import { makePaneKey } from '../../../../shared/stable-pane-id' import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session' @@ -61,6 +62,13 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr } setTerminalErrorsByPaneId((current) => clearPaneTerminalError(current, paneId)) if (leafId) { + retireUnboundRuntimeTerminalPane({ + getState: useAppStore.getState, + tabId, + leafId, + transport: paneTransportsRef.current.get(paneId), + getTransports: () => paneTransportsRef.current + }) syncPanePtyLayoutBindingForLeaf?.(leafId, null, paneId) } else { syncPanePtyLayoutBinding(paneId, null) diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index eb04233cc91..719e54eac89 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -95,7 +95,7 @@ export async function callRuntimeRpc( return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) } -async function ensureRuntimeEnvironmentCompatible( +export async function ensureRuntimeEnvironmentCompatible( environmentId: string, options: { timeoutMs?: number diff --git a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts index 322d4106c7e..4ba425d95d2 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts @@ -1,5 +1,9 @@ +import { + captureRuntimeEnvironmentRequestRevision, + getRuntimeEnvironmentRevision +} from '@/runtime/runtime-environment-revision' import type { AppState } from '../types' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, ensureRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import { classifyTerminalRetirementWorktree, @@ -11,13 +15,15 @@ export function startTerminalTabProviderRetirement({ remoteCloseOwnedByHost, retirementPlan, state, - tabId + tabId, + canRetireRuntimeTerminal }: { localPtyTeardownOwnedExternally: boolean remoteCloseOwnedByHost: boolean retirementPlan: TerminalTabRetirementPlan state: AppState tabId: string + canRetireRuntimeTerminal?: () => boolean }): void { const fallbackWorktreeRoute = retirementPlan.worktreeId ? resolveTerminalWorktreeRoute(state, retirementPlan.worktreeId) @@ -33,11 +39,7 @@ export function startTerminalTabProviderRetirement({ } const environmentId = terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId retirementTasks.push( - callRuntimeRpc( - environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, - 'terminal.close', - { terminal: terminal.handle } - ) + retireRuntimeTerminal(environmentId, terminal.handle, canRetireRuntimeTerminal) ) } } @@ -66,3 +68,40 @@ export function startTerminalTabProviderRetirement({ } }) } + +async function retireRuntimeTerminal( + environmentId: string | null | undefined, + handle: string, + canRetire?: () => boolean +): Promise { + const target = environmentId + ? { kind: 'environment' as const, environmentId } + : { kind: 'local' as const } + if (!canRetire) { + return callRuntimeRpc(target, 'terminal.close', { terminal: handle }) + } + const revision = environmentId + ? captureRuntimeEnvironmentRequestRevision(environmentId) + : undefined + if (environmentId) { + await ensureRuntimeEnvironmentCompatible(environmentId, { + expectedEnvironmentPairingRevision: revision + }) + } + if ( + (environmentId && getRuntimeEnvironmentRevision(environmentId) !== revision) || + !canRetire() + ) { + return + } + // Compatibility was checked above; recheck pane ownership at the actual dispatch boundary. + return callRuntimeRpc( + target, + 'terminal.close', + { terminal: handle }, + { + skipCompatibilityCheck: true, + expectedEnvironmentPairingRevision: revision + } + ) +} From ca2ae890115faf66ee97e7335caeac061a350524 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:44:11 -0400 Subject: [PATCH 020/224] ci: install mobile dependencies in every desktop packaging job, pin mobile page source to LF (OTA phase C, C0.6) (#21425) * chore(mobile): pin mobile page source to LF so a Windows checkout keeps buildId The Phase C web bundle hashes every text byte under mobile/src and mobile/app into its asset digests and from there into buildId. There is no global text=auto, so a CRLF checkout on Windows would give the Windows release a different buildId for identical source, the same failure the src/mobile-web pin above exists for. All 1924 tracked files in those directories are already LF in the index, so the pin renormalises nothing. mobile/web-entry does not exist yet; the pin is forward-looking for the Phase C entry point. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: install mobile dependencies in every desktop packaging job Ten workflows reach build:release/build:desktop and none of them installs mobile/node_modules. Root has no react-native, react-native-web or expo, so once the mobile web bundle builds from mobile/ its packaging jobs would fail at electron-builder's beforePack with an unresolvable import. Extract the frozen mobile install that pr.yml's static analysis job already ran inline into .github/actions/install-mobile-dependencies, and invoke it from every job the packaging census enumerates, after the root install and before the build. Same --frozen-lockfile, same lockfile-drift guard, and still no --ignore-scripts: mobile's postinstall generates the gitignored webview engine modules that tracked source imports. pr.yml now uses the action too, so there is one definition. Where a packaging job's setup-node caches the pnpm store, mobile/pnpm-lock.yaml joins cache-dependency-path so a mobile lockfile change invalidates it. Two jobs (daemon-relocation-spike, win-update-survival-e2e) do not cache at all and are left alone. The census test grows a per-job assertion that the action is present, so a new packaging job has to add the install deliberately rather than discover it at beforePack. release-cut's composite-action restore is no longer Windows-only: every platform consumes this action now, so any of them can be the leg whose cut ref predates it. No job builds anything different; this only makes mobile/node_modules present. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(ci): assert the mobile install contract on the shared action pr.yml's static analysis job no longer carries the install inline, so the scope test's findIndex by step name resolved to -1. Match the step by the action it uses, and read working-directory and --frozen-lockfile off the action itself so the job cannot keep the step while the action stops installing anything. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): exempt binary asset types from the mobile LF pin /mobile/{src,app,web-entry}/** text eol=lf would mark a future PNG or font as text and rewrite its bytes on a Windows checkout. Exempt the asset types an RN page carries, the same way src/mobile-web exempts its PNG. -text after text eol=lf wins: probed a CRLF-bearing .png under the pin, it stays i/crlf attr/-text while a sibling .ts still normalises to i/lf. No tracked file changes classification; the 1924 files under mobile/src and mobile/app stay i/lf. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: gate the mobile install with the build it feeds in the cached lanes win-crash-survival, win-update-survival and daemon-relocation-spike all skip electron-builder on an installer/unpacked cache hit, so an unconditional mobile install spent time on node_modules nothing then consumed. Move each `uses:` below its cache step and carry the same cache-hit condition as the build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .gitattributes | 32 +++++++++++++++++ .../install-mobile-dependencies/action.yml | 22 ++++++++++++ .github/workflows/adhoc-mac-build.yml | 7 ++++ .github/workflows/daemon-relocation-spike.yml | 6 ++++ .github/workflows/daily-mac-build.yml | 8 +++++ .github/workflows/dev-channel-win-build.yml | 7 ++++ .github/workflows/hourly-mac-build.yml | 7 ++++ .github/workflows/pr.yml | 35 ++++++++++--------- .github/workflows/release-cut.yml | 28 ++++++++++++--- .github/workflows/release-mac-build.yml | 7 ++++ .github/workflows/win-crash-survival-e2e.yml | 9 +++++ .github/workflows/win-update-survival-e2e.yml | 6 ++++ .../workflows/windows-signing-rehearsal.yml | 7 ++++ ...undle-packaging-workflow-contract.test.mjs | 9 +++++ config/scripts/pr-code-change-scope.test.mjs | 17 +++++++-- 15 files changed, 182 insertions(+), 25 deletions(-) create mode 100644 .github/actions/install-mobile-dependencies/action.yml diff --git a/.gitattributes b/.gitattributes index 145c06043bd..1f031677aab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -48,3 +48,35 @@ /src/mobile-web/src/*.ts text eol=lf /src/mobile-web/src/*.css text eol=lf /src/mobile-web/src/*.png -text +# Mobile web page source. Same buildId hazard as src/mobile-web above: these bytes are +# hashed into the Phase C bundle, so a CRLF Windows checkout would ship a different +# buildId for identical source. web-entry/ does not exist yet; the pin lands ahead of it. +/mobile/src/** text eol=lf +/mobile/app/** text eol=lf +/mobile/web-entry/** text eol=lf +# The blanket pin above would mark a future binary as text; exempt the asset types an +# RN page actually carries, the same way src/mobile-web exempts its PNG. +/mobile/src/**/*.png -text +/mobile/src/**/*.jpg -text +/mobile/src/**/*.jpeg -text +/mobile/src/**/*.webp -text +/mobile/src/**/*.ttf -text +/mobile/src/**/*.otf -text +/mobile/src/**/*.woff -text +/mobile/src/**/*.woff2 -text +/mobile/app/**/*.png -text +/mobile/app/**/*.jpg -text +/mobile/app/**/*.jpeg -text +/mobile/app/**/*.webp -text +/mobile/app/**/*.ttf -text +/mobile/app/**/*.otf -text +/mobile/app/**/*.woff -text +/mobile/app/**/*.woff2 -text +/mobile/web-entry/**/*.png -text +/mobile/web-entry/**/*.jpg -text +/mobile/web-entry/**/*.jpeg -text +/mobile/web-entry/**/*.webp -text +/mobile/web-entry/**/*.ttf -text +/mobile/web-entry/**/*.otf -text +/mobile/web-entry/**/*.woff -text +/mobile/web-entry/**/*.woff2 -text diff --git a/.github/actions/install-mobile-dependencies/action.yml b/.github/actions/install-mobile-dependencies/action.yml new file mode 100644 index 00000000000..0ed2b45bb9a --- /dev/null +++ b/.github/actions/install-mobile-dependencies/action.yml @@ -0,0 +1,22 @@ +name: Install mobile dependencies +description: Frozen pnpm install for the mobile/ project, whose node_modules the mobile web bundle build and the mobile-aware lint passes resolve React Native and Expo from. + +runs: + using: composite + steps: + # Why a separate install: mobile is its own pnpm project, so the root install leaves + # mobile/node_modules empty and every mobile import resolves to nothing. + # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates the + # gitignored terminal/mermaid webview engine modules that tracked source imports. + # The drift guard mirrors the root install so a stale mobile lockfile fails by name -- + # mobile's lockfile carries patchedDependencies that a silent rewrite would drop. + - name: Install mobile dependencies + shell: bash + working-directory: mobile + run: | + pnpm install --frozen-lockfile + # Job containers can run composite steps from a source mirror without .git. + if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then + git -C "$GITHUB_WORKSPACE" diff --exit-code -- \ + mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml + fi diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 4f667bf2778..c081831c28b 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -184,6 +184,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads uses: actions/cache@v5 @@ -205,6 +208,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: signing is what makes an adhoc build installable over an existing # Orca, so a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/daemon-relocation-spike.yml b/.github/workflows/daemon-relocation-spike.yml index 2ffd4a58661..bbca7e7a044 100644 --- a/.github/workflows/daemon-relocation-spike.yml +++ b/.github/workflows/daemon-relocation-spike.yml @@ -59,6 +59,12 @@ jobs: path: dist/win-unpacked key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. Gated with the + # build it feeds, so a cache hit does not pay for an install nothing consumes. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-unpacked.outputs.cache-hit != 'true' + - name: Build unpacked app if: steps.cache-unpacked.outputs.cache-hit != 'true' run: pnpm run build:unpack diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 41b87526fea..4e89b1f5a4b 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -156,6 +156,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads if: steps.freshness.outputs.should_build == 'true' @@ -179,6 +182,11 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.freshness.outputs.should_build == 'true' + # Why: signing is what makes a daily installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml index 7913e7e6e80..3f8e162e073 100644 --- a/.github/workflows/dev-channel-win-build.yml +++ b/.github/workflows/dev-channel-win-build.yml @@ -203,6 +203,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # Caches the Electron binary and electron-builder's tool downloads (nsis, # winCodeSign). Same key shape as release-cut's Windows leg. @@ -229,6 +232,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why the packaging check runs before the 20-minute build: it only needs # node_modules, and a stale config should cost seconds rather than a build. - name: Verify dev-channel packaging identity diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index 1aed485a666..8e061b83563 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -164,6 +164,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads uses: actions/cache@v5 @@ -185,6 +188,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: signing is what makes an hourly installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fc064fc5c99..c857b8df1f0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -142,24 +142,11 @@ jobs: - name: Enforce type-aware code-quality baseline run: pnpm run audit:code-quality:type-aware - # Why: the changed-code gate lints mobile files too, and its type-aware pass - # resolves types from mobile/node_modules. Mobile is a separate pnpm project, - # so the root install above leaves it empty and every mobile type degrades to - # an `error` type — reported as phantom findings against the changed lines. - # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates - # the gitignored terminal/mermaid webview engine modules that tracked source imports, - # and skipping it degrades those very types the step exists to resolve. The drift - # guard mirrors the root install so a stale mobile lockfile fails by name — mobile's - # lockfile carries patchedDependencies that a silent rewrite would drop. - - name: Install mobile dependencies + # Why here: the changed-code gate lints mobile files too, and its type-aware pass + # resolves types from mobile/node_modules. Without the install every mobile type + # degrades to an `error` type — reported as phantom findings against the changed lines. + - uses: ./.github/actions/install-mobile-dependencies if: needs.code_paths.outputs.mobile_dependencies == 'true' - working-directory: mobile - run: | - pnpm install --frozen-lockfile - if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then - git -C "$GITHUB_WORKSPACE" diff --exit-code -- \ - mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml - fi - name: Enforce changed-code quality run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" @@ -748,6 +735,13 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: electron + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml + + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies # Why --no-file-parallelism: every file here launches a full Electron stack twice, and each # probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other @@ -861,6 +855,13 @@ jobs: with: native-runtime: node persist-native-cache: 'false' + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml + + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies - name: Save compiled Node native modules if: steps.deps.outputs.native-cache-hit != 'true' diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index eb80d20af72..cd940482917 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1219,22 +1219,33 @@ jobs: # ref, so cutting from an older/off-main ref whose tree predates a composite # action would fail the step with "Can't find 'action.yml'". Restore the # actions directory from the commit this workflow file itself came from. + # Not Windows-only: every platform now consumes install-mobile-dependencies, so + # any of them can be the one whose cut ref predates the action. - name: Restore composite actions from the workflow ref - if: matrix.platform == 'win' && github.run_attempt == 1 shell: bash env: WORKFLOW_SHA: ${{ github.workflow_sha }} + PLATFORM: ${{ matrix.platform }} run: | set -euo pipefail - action_path=".github/actions/install-signpath-module/action.yml" - if [ -f "$action_path" ]; then + required=(.github/actions/install-mobile-dependencies/action.yml) + if [ "$PLATFORM" = win ] && [ "$GITHUB_RUN_ATTEMPT" = 1 ]; then + required+=(.github/actions/install-signpath-module/action.yml) + fi + missing=() + for action_path in "${required[@]}"; do + [ -f "$action_path" ] || missing+=("$action_path") + done + if [ "${#missing[@]}" -eq 0 ]; then echo "Composite actions already present at the cut ref." exit 0 fi - echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA." + echo "Cut ref predates ${missing[*]}; restoring from $WORKFLOW_SHA." git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" git checkout "$WORKFLOW_SHA" -- .github/actions - test -f "$action_path" + for action_path in "${required[@]}"; do + test -f "$action_path" + done # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - name: Setup pnpm @@ -1247,6 +1258,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # Why: release builds hit the same native-module postinstall path as # PR CI, so keep the pinned node-gyp override here too instead of @@ -1287,6 +1301,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: `pnpm build:release` verifies the Linux computer-use provider by # importing AT-SPI bindings, which are runtime package deps but are not # present on stock GitHub Ubuntu release runners. diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 3d7e4dd05bf..45193dfe1ae 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -47,6 +47,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # Cache the Electron binary + electron-builder tool downloads (notarytool, # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. @@ -74,6 +77,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + - name: Verify macOS signing environment run: node config/scripts/verify-macos-release-env.mjs env: diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index f3d22cc1227..1e0efae0a23 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -55,6 +55,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -101,6 +104,12 @@ jobs: restore-keys: | crash-survival-electron-builder- + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. Gated with the + # build it feeds, so a cache hit does not pay for an install nothing consumes. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-installer.outputs.cache-hit != 'true' + - name: Build Windows installer (unsigned) if: steps.cache-installer.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml index e7ed41125e9..50c38f2e3ad 100644 --- a/.github/workflows/win-update-survival-e2e.yml +++ b/.github/workflows/win-update-survival-e2e.yml @@ -75,6 +75,12 @@ jobs: path: dist/orca-windows-setup.exe key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }} + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. Gated with the + # build it feeds, so a cache hit does not pay for an install nothing consumes. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-installer.outputs.cache-hit != 'true' + - name: Build Windows installer (unsigned) if: steps.cache-installer.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 244ee4d3e08..0fa2a31cd97 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -57,6 +57,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads uses: actions/cache@v5 @@ -78,6 +81,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: rehearsal builds are never published, so the official-build # secrets (telemetry key, diagnostics URL) are intentionally omitted. - name: Build app diff --git a/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs b/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs index 203bea1d87e..f719784d115 100644 --- a/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs +++ b/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs @@ -142,6 +142,15 @@ describe('mobile web bundle packaging coverage', () => { expect(job.text).toMatch(BUNDLE_PRODUCER) } ) + + it.each(packagingJobs().map((job) => [job.label, job]))( + 'installs mobile/node_modules before electron-builder packs: %s', + (_label, job) => { + // mobile is a separate pnpm project, so the root install leaves it empty and the bundle + // build cannot resolve React Native or Expo. One definition, so no job hand-rolls it. + expect(job.text).toContain('uses: ./.github/actions/install-mobile-dependencies') + } + ) }) describe('the build scripts the census trusts', () => { diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 6e39bd9b20a..92c71a7809b 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -456,13 +456,24 @@ describe('PR Checks skip wiring', () => { '${{ steps.filter.outputs.mobile_dependencies }}' ) const steps = prWorkflow.jobs.static_analysis.steps - const install = steps.findIndex((step) => step.name === 'Install mobile dependencies') + const install = steps.findIndex( + (step) => step.uses === './.github/actions/install-mobile-dependencies' + ) const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') expect(install).toBeGreaterThan(-1) expect(install).toBeLessThan(gate) expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") - expect(steps[install]['working-directory']).toBe('mobile') - expect(steps[install].run).toContain('--frozen-lockfile') + // The install itself moved into the action the packaging jobs share; assert it there so + // this job cannot keep the step while the action stops installing anything. + const action = parse( + readFileSync( + join(projectDir, '.github/actions/install-mobile-dependencies/action.yml'), + 'utf8' + ) + ) + const [installStep] = action.runs.steps + expect(installStep['working-directory']).toBe('mobile') + expect(installStep.run).toContain('--frozen-lockfile') }) it('keeps the cheap root-directory guard on docs-only PRs', () => { From 8f9a55ef8a3d5c6bed2914d95952903a13361bed Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:36:15 -0700 Subject: [PATCH 021/224] fix(editor): restore editability after View Log (#21424) --- .../src/components/editor/MonacoEditor.tsx | 7 +++++-- .../src/store/slices/editor-read-only-tabs.test.ts | 5 +++-- .../store/slices/editor/actions/open-file-apply.ts | 14 +++++++++++--- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 31c473bf29f..4c583765581 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -163,9 +163,12 @@ export default function MonacoEditor({ editorRef.current.updateOptions({ fontSize: editorFontSize, fontFamily: editorFontFamily, - ...buildFileEditorWordWrapOptions(editorWordWrap) + ...buildFileEditorWordWrapOptions(editorWordWrap), + // Keep a retained Monaco instance aligned when a tab changes between + // a read-only surface and a normal editable file. + readOnly }) - }, [editorFontFamily, editorFontSize, editorWordWrap]) + }, [editorFontFamily, editorFontSize, editorWordWrap, readOnly]) const decorations = useMonacoEditorDecorations({ editorRef, diff --git a/src/renderer/src/store/slices/editor-read-only-tabs.test.ts b/src/renderer/src/store/slices/editor-read-only-tabs.test.ts index 3bd2a0314ec..e76a4b8df8a 100644 --- a/src/renderer/src/store/slices/editor-read-only-tabs.test.ts +++ b/src/renderer/src/store/slices/editor-read-only-tabs.test.ts @@ -63,7 +63,7 @@ describe('read-only editor tabs (AI Vault View Log)', () => { expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBe(1) }) - it('keeps read-only sticky when the same path is opened writable (no silent upgrade)', () => { + it('restores editability when the same path is explicitly opened writable', () => { const store = createEditorStore() openReadOnlyLog(store) @@ -77,7 +77,8 @@ describe('read-only editor tabs (AI Vault View Log)', () => { }) expect(store.getState().openFiles).toHaveLength(1) - expect(store.getState().openFiles[0]?.readOnly).toBe(true) + expect(store.getState().openFiles[0]?.readOnly).toBeUndefined() + expect(store.getState().openFiles[0]?.liveTail).toBeUndefined() }) it('never flips an existing writable tab to read-only on View Log', () => { diff --git a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts index c161390dd03..2adea4df540 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts @@ -108,6 +108,11 @@ export function applyOpenFileToState( ) ? (existing.fileContentReloadNonce ?? 0) + 1 : existing.fileContentReloadNonce + // View Log is the only read-only open path. A normal open of the same path + // is an explicit request to edit it, so drop the log-only restrictions while + // keeping View Log from downgrading an already writable tab. + const nextReadOnly = existing.readOnly === true && file.readOnly === true ? true : undefined + const nextLiveTail = file.liveTail === true && nextReadOnly === true ? true : undefined const needsExistingUpdate = existing.mode !== file.mode || existing.diffSource !== file.diffSource || @@ -124,11 +129,12 @@ export function applyOpenFileToState( existing.runtimeEnvironmentId !== runtimeEnvironmentId || existing.externalSshTargetId !== nextExternalSshTargetId || refreshExternalSshProvenance || - existing.fileContentReloadNonce !== fileContentReloadNonce + existing.fileContentReloadNonce !== fileContentReloadNonce || + existing.readOnly !== nextReadOnly || + existing.liveTail !== nextLiveTail if (!needsExistingUpdate) { return activeResult } - // Why: `readOnly` is intentionally NOT in this override map — it's sticky, so `...f` preserves the tab's own read-only state. return { openFiles: s.openFiles.map((f) => f.id === id @@ -154,7 +160,9 @@ export function applyOpenFileToState( skippedConflicts: file.skippedConflicts, conflictReview: file.conflictReview, isPreview: updatedPreview, - fileContentReloadNonce + fileContentReloadNonce, + readOnly: nextReadOnly, + liveTail: nextLiveTail } : f ), From 7909dad7bad2baeeb2ff58999fd6b98a58a7a599 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:42:48 -0700 Subject: [PATCH 022/224] fix(ci): keep mobile patches LF so Windows can parse them (#21439) A Windows checkout CRLF-converted mobile/patches/*.patch because no gitattributes rule covered them, and pnpm rejected the result with ERR_PNPM_INVALID_PATCH, failing package (windows) and the verify aggregate. config/patches/*.patch has been pinned -text for this exact reason; mobile/patches/ was added later and never got the same rule. git ls-files --eol showed all three mobile patches with an empty attr against attr/-text on every config patch. --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitattributes b/.gitattributes index 1f031677aab..2f291d4d627 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,6 +23,9 @@ # runs `git apply` on one must force `-c core.autocrlf=input` rather than trust # the host's setting. See config/scripts/windows-process-tree-gyp-rebuild.mjs. /config/patches/*.patch -text +# Same reason, and pnpm parses these too: a CRLF checkout makes the mobile +# patches unparseable, so Windows packaging dies on ERR_PNPM_INVALID_PATCH. +/mobile/patches/*.patch -text # The xterm bundle hunks also make a diff nobody can read; review the hand-written # source patch under xterm-src/ instead. The sibling patches stay diffable. /config/patches/@xterm__xterm@*.patch -diff From 01beadbcf0ed6370b10823fa472cb1fa1db19e5b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:05:23 -0700 Subject: [PATCH 023/224] fix(explorer): make filename search find all workspace files (#21423) * fix(explorer): search file names through runtime * fix(explorer): keep filename search results complete * fix(explorer): narrow runtime search change * test(explorer): remove unsupported local search assertion * fix(explorer): fence filename search results --- .../src/components/quick-open-file-list.ts | 13 +++++- .../use-file-explorer-name-filter.test.ts | 45 +++++++++++++++++++ .../use-file-explorer-name-filter.ts | 14 ++++-- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 7f605ce2e41..250f6da7b33 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -26,6 +26,8 @@ export type RuntimeFileListState = { loading: boolean loadError: string | null truncated?: boolean + /** Query that produced `files`; null means a request is still settling. */ + resolvedQuery?: string | null operationOwner?: FileExplorerOperationOwner } @@ -145,6 +147,7 @@ export function useRuntimeFileListForWorktree({ const [loading, setLoading] = useState(false) const [loadError, setLoadError] = useState(null) const [truncated, setTruncated] = useState(false) + const [resolvedQuery, setResolvedQuery] = useState(undefined) const [listedOperationOwner, setListedOperationOwner] = useState({ kind: 'unresolved' }) @@ -205,7 +208,7 @@ export function useRuntimeFileListForWorktree({ useEffect(() => { if (!enabled) { setLoading(false) - setTruncated(false) + setResolvedQuery(null) setListedOperationOwner({ kind: 'unresolved' }) return } @@ -213,9 +216,10 @@ export function useRuntimeFileListForWorktree({ if (!target.canList || !worktreeId || !worktreePath || !operationRouteAvailable) { setFiles([]) setListedOperationOwner({ kind: 'unresolved' }) - setLoadError(operationRouteAvailable ? null : getFileExplorerOwnerUnresolvedMessage()) + setLoadError(!operationRouteAvailable ? getFileExplorerOwnerUnresolvedMessage() : null) setLoading(false) setTruncated(false) + setResolvedQuery(null) return } @@ -223,6 +227,7 @@ export function useRuntimeFileListForWorktree({ const requestKeyChanged = lastRequestKeyRef.current !== requestKey if (requestKeyChanged) { setFiles([]) + setResolvedQuery(null) } lastRequestKeyRef.current = requestKey setLoadError(null) @@ -231,6 +236,7 @@ export function useRuntimeFileListForWorktree({ if (usesRuntimePathSearch && (remoteQuery.length === 0 || remoteQueryTooLarge)) { setFiles([]) setLoading(false) + setResolvedQuery(remoteQuery) setListedOperationOwner(operationOwnerRef.current) return } @@ -277,6 +283,7 @@ export function useRuntimeFileListForWorktree({ if (!cancelled) { setFiles(result.files) setTruncated(result.truncated) + setResolvedQuery(usesRuntimePathSearch ? remoteQuery : undefined) setListedOperationOwner(requestOperationOwner) } }) @@ -284,6 +291,7 @@ export function useRuntimeFileListForWorktree({ if (!cancelled) { setFiles([]) setTruncated(false) + setResolvedQuery(usesRuntimePathSearch ? remoteQuery : null) setLoadError(cleanRuntimeFileListError(error)) } }) @@ -323,6 +331,7 @@ export function useRuntimeFileListForWorktree({ loading: loading || connectionPending, loadError, truncated, + resolvedQuery, operationOwner: listedOperationOwner } } diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts new file mode 100644 index 00000000000..72717f0cf49 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { RuntimeFileListState } from '@/components/quick-open-file-list' +import { useFileExplorerNameFilter } from './use-file-explorer-name-filter' + +const useRuntimeFileListForWorktreeMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/components/quick-open-file-list', () => ({ + useRuntimeFileListForWorktree: useRuntimeFileListForWorktreeMock +})) + +const emptyState: RuntimeFileListState = { + files: [], + loading: false, + loadError: null +} + +describe('useFileExplorerNameFilter', () => { + beforeEach(() => { + useRuntimeFileListForWorktreeMock.mockReset().mockReturnValue(emptyState) + useAppStore.setState({ activeWorktreeId: 'worktree-1' }) + }) + + afterEach(() => { + cleanup() + }) + + it('passes the active filename query to the runtime path search', () => { + const { result } = renderHook(() => + useFileExplorerNameFilter({ isFilesViewActive: true, activeWorktreeId: 'worktree-1' }) + ) + + act(() => result.current.setNameFilterQuery('AppDelegate.swift')) + + expect(useRuntimeFileListForWorktreeMock).toHaveBeenLastCalledWith({ + enabled: true, + worktreeId: 'worktree-1', + query: 'AppDelegate.swift' + }) + expect(result.current.nameFilterSource?.query).toBe('AppDelegate.swift') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts index 8e271008abf..883ca986abf 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts @@ -45,7 +45,8 @@ export function useFileExplorerNameFilter({ }, [hasNameFilter]) const nameFilterFiles = useRuntimeFileListForWorktree({ enabled: hasNameFilter && !nameFilterQueryTooLarge, - worktreeId: activeWorktreeId + worktreeId: activeWorktreeId, + query: nameFilterQuery }) const nameFilterSource = useMemo( () => @@ -55,9 +56,13 @@ export function useFileExplorerNameFilter({ operationOwner: nameFilterFiles.operationOwner, relativePaths: nameFilterQueryTooLarge ? [] - : nameFilterFiles.loading && nameFilterFiles.files.length === 0 - ? null - : nameFilterFiles.files + : nameFilterFiles.resolvedQuery === nameFilterQuery.trim() + ? nameFilterFiles.loading + ? null + : nameFilterFiles.files + : nameFilterFiles.loading + ? null + : [] } : null, [ @@ -65,6 +70,7 @@ export function useFileExplorerNameFilter({ nameFilterFiles.files, nameFilterFiles.loading, nameFilterFiles.operationOwner, + nameFilterFiles.resolvedQuery, nameFilterQuery, nameFilterQueryTooLarge ] From 8fc81182ab332555f343df64009f94291b4d86ab Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:21:31 -0400 Subject: [PATCH 024/224] feat(mobile): envelope contract for the web shell bridge (OTA phase C, C0.1) (#21432) * feat(mobile): bound a web-shell bridge frame at one enforcement point The page and the shell exchange frames over a native channel that will happily carry whatever either side hands it. `parseBridgeMessage` is the only place the byte, depth and node caps are checked, and the byte cap is checked against the raw string so it protects `JSON.parse` rather than trusting it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): carry a bridge rejection without losing its delivery-unknown mark A host `RpcFailure` is data and rides in the reply untouched; a rejection of `sendRequest` is the other path and needs rebuilding page-side. The mark that says the request may already have run is a `WeakSet` on object identity, so it cannot survive serialization and has to be re-applied, and the recorder reads `error.constructor.name`, so the rebuilt error is named rather than plain. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): declare every message the web shell bridge carries One schema per message in both directions, with `v` gating envelope shape and `init.grants` gating capability. Unknown keys are dropped rather than refused: the page bundle ships from a desktop that updates independently of the installed shell. A reply payload is read through loose objects so a field a newer host adds reaches the page unaltered, which is what the goldens record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): split an oversized bridge reply instead of refusing it The native screens have no reply byte cap, so refusing one at the frame cap would invent a failure the phone does not have; source control's diffs would be first to hit it. Frames are measured after serialization and only then accepted, so an escaped control character or a surrogate pair cut across the boundary cannot push one over. The absolute ceiling aborts the request rather than truncating a reply. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the bridge numbers rather than deriving every fixture A test that builds its fixture from the constant it is checking moves with that constant: widening the frame cap, the depth, the node count or the reply ceiling left every boundary case passing. These numbers are wire between a released shell and a page served by a desktop, so they are pinned as literals; the in-flight and subscription caps had nothing holding them at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the page's frames, not the desktop's answers The depth and node caps exist to bound the cost of walking a hostile frame, and only one direction is hostile. A 5 000-row listing reply carries 25 000 values, so holding the shell's answers to the same 20 000 node cap would refuse ordinary data. `parseBridgeMessage` now takes the direction and walks `page-to-shell` only; both directions keep the frame byte cap, and a chunked reply keeps the 8 MiB ceiling as its single bound. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): carry a decoded screencast frame, not just its bytes The binary `event` carried `b64` alone, but a binary listener is handed an already-decoded `BrowserScreencastFrame`: format, metadata and the screencast's own frame counter would all have been lost, and the envelope's `seq` is the backpressure counter, not that one. The frame's fields now ride beside the base64, mirrored field for field, with a compile-time pin that nothing but the image is missing. C6 writes the encoder. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fail to compile when the sender grows an option The options pin only proved the schema accepts what the sender declares today. A `Record` makes the other direction a compile error, so a new option cannot ship past the bridge unnoticed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): carry an error code of any shape, as the recorder does The capture narrowed `code` to a string or a number, but the recorder records whatever code it finds. A structured code would have crossed the bridge as an absent field and moved a golden the day C0.5 replays through it. Absent still means absent. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): let the schema state the part bound on its own The splitter's parts-ceiling branch could not fire: the largest reply the ceiling admits, with every character re-escaping, splits into 26 parts against a cap of 27. A branch no input reaches is a second statement of a bound that drifts from the first. The derivation is pinned by a test now, and `replyPartSchema` is the only place the bound is written. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound the ids a reply assembler holds at once Nothing expired a half-assembled reply, so a host that sent a first part and never a last one grew the map for the life of the page. A reply exists only for a request the page made, so the in-flight cap is the right bound, and the new id is the one refused. C0.4 owes the assembler a discard for every request it settles. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refuse a screencast metadata field that is not a number Only the compile-time pin stood between a metadata field and `z.unknown()`. Every one of the nine is now exercised, so widening any of them fails a test rather than only a typecheck. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): survive an error whose own getter throws Reading `code` and `cause` runs whatever getter defined them, and both were read in one parse, so a getter that throws took the capture with it: the rejection path would have thrown where it had to produce an envelope. Each field is read on its own now, and a throwing getter costs that field only. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a surrogate pair whole across a chunk boundary A pair cut in half encodes as two replacements, three bytes each, where the pair whole is four. The sender cut by code unit and the assembler summed the parts, so a reply within two bytes per boundary of the ceiling was refused `reply-too-large` for bytes it never had, and each half-pair frame was not well-formed UTF-8 for the native bridge to carry. The cut backs up one unit, and the ceiling is measured once on the joined text. Code units still bound what is held, since a reply is never fewer bytes than code units. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): always produce a capture, whatever the error does when read `message` and `constructor` can be getters, and `String(value)` runs a `toString` the thrower wrote, so reading an error is running someone else's code. A throw there left the rejection with no frame at all and a promise that never settles. The whole capture is guarded now, and the fallback still carries the delivery-unknown mark, which is a `WeakSet` lookup. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make an error frame sendable by construction A megabyte message or code is not a protocol error, it is a big string, and it produced a frame the receiver refuses as oversized: a rejection the page never hears. A cyclic code took JSON.stringify down with the whole frame. Messages are truncated to 16 KiB and marked, a code is dropped when it will not serialize or is past 4 KiB, and the worst chain the budgets allow now measures 512 KiB against the 640 KiB frame cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a refused reply refused, and bound them together Every failure dropped the id, so the next part opened a fresh accumulator: a duplicate part then a whole set completed, and one id could feed 67 MB through an 8 MiB ceiling one refusal at a time. A refusal is remembered now and answers every later part, until the page discards the id. The bytes held across all ids gain a ceiling of their own, since 64 replies at the per-reply ceiling is half a gigabyte of parts that never complete. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say that a new enum member is not an additive field The version rule read as though anything additive was safe. A value outside a closed list is refused whole by the older side, so `end.reason`, `binary.format`, `connection.state` and the foreground reasons are negotiated, not appended. The byte-cap comment had its inequality the wrong way round while I was there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin what the guards keep, not only what they drop Two mutants lived: clearing the assembler could have kept its tombstones, and the guard around a cyclic code was hidden by the outer guard added for a throwing getter. The capture is now asserted whole, so dropping the code has to leave the message and the cause behind, and teardown has to forget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): carry the code that was measured, not the one that made it A stateful `toJSON` answers the budget check and the frame serializer differently, so the snapshot is what crosses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../bridge/bridge-caps.test.ts | 226 +++++++++ .../mobile-web-shell/bridge/bridge-caps.ts | 162 +++++++ .../bridge/bridge-envelope.test.ts | 443 +++++++++++++++++ .../bridge/bridge-envelope.ts | 287 +++++++++++ .../bridge/bridge-error-capture.test.ts | 386 +++++++++++++++ .../bridge/bridge-error-capture.ts | 198 ++++++++ .../bridge/bridge-reply-chunking.test.ts | 450 ++++++++++++++++++ .../bridge/bridge-reply-chunking.ts | 244 ++++++++++ 8 files changed, 2396 insertions(+) create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-caps.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-caps.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-envelope.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-error-capture.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-error-capture.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.ts diff --git a/mobile/src/mobile-web-shell/bridge/bridge-caps.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-caps.test.ts new file mode 100644 index 00000000000..2010c980387 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-caps.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest' +import { + BRIDGE_MAX_DEPTH, + BRIDGE_MAX_MESSAGE_BYTES, + BRIDGE_MAX_METHOD_CHARS, + BRIDGE_MAX_NODES, + BRIDGE_MAX_PENDING_REQUESTS, + BRIDGE_MAX_REPLY_BYTES, + BRIDGE_MAX_REPLY_PARTS, + BRIDGE_DIRECTIONS, + BRIDGE_MAX_SUBSCRIPTIONS, + parseBridgeMessage, + utf8ByteLength +} from './bridge-caps' + +/** A JSON document of exactly `bytes` UTF-8 bytes: a quoted run of ASCII. */ +function jsonStringOfBytes(bytes: number): string { + return `"${'x'.repeat(bytes - 2)}"` +} + +/** A scalar nested inside `levels - 1` arrays, so the scalar itself sits at `levels`. */ +function nestedArrays(levels: number): string { + return `${'['.repeat(levels - 1)}0${']'.repeat(levels - 1)}` +} + +/** An array holding `nodes - 1` scalars, so the array and its values total `nodes`. */ +function arrayOfNodes(nodes: number): string { + return `[${Array.from({ length: nodes - 1 }, () => '0').join(',')}]` +} + +describe('utf8ByteLength', () => { + it('agrees with TextEncoder across the encoding widths', () => { + const encoder = new TextEncoder() + for (const sample of ['', 'plain ascii', 'é', 'ünïcodé', '中文', '😀', 'a😀b中é']) { + expect(utf8ByteLength(sample)).toBe(encoder.encode(sample).length) + } + }) + + it('counts a lone surrogate as its replacement, like TextEncoder does', () => { + const loneHigh = '\ud83d' + const loneLow = '\ude00' + expect(utf8ByteLength(loneHigh)).toBe(new TextEncoder().encode(loneHigh).length) + expect(utf8ByteLength(`a${loneLow}b`)).toBe(new TextEncoder().encode(`a${loneLow}b`).length) + }) + + it('counts a surrogate pair once, not twice', () => { + expect(utf8ByteLength('😀')).toBe(4) + expect(utf8ByteLength('😀😀')).toBe(8) + }) +}) + +/** A listing reply of the shape the node cap would refuse: `rows` records of four fields each. */ +function listingReply(rows: number): string { + const records = Array.from({ length: rows }, (_, index) => ({ + id: index, + name: `worktree-${index}`, + branch: 'main', + dirty: false + })) + return JSON.stringify({ + v: 1, + type: 'reply', + id: 'a'.repeat(22), + payload: { ok: true, result: records } + }) +} + +describe('parseBridgeMessage byte cap', () => { + it('accepts a frame of exactly the cap', () => { + const raw = jsonStringOfBytes(BRIDGE_MAX_MESSAGE_BYTES) + expect(utf8ByteLength(raw)).toBe(BRIDGE_MAX_MESSAGE_BYTES) + expect(parseBridgeMessage(raw, 'page-to-shell').ok).toBe(true) + }) + + it('refuses a frame one byte over the cap', () => { + const raw = jsonStringOfBytes(BRIDGE_MAX_MESSAGE_BYTES + 1) + expect(parseBridgeMessage(raw, 'page-to-shell')).toEqual({ ok: false, refusal: 'oversized' }) + }) + + it('measures bytes, not code units, so multi-byte text cannot slip past', () => { + // Half the cap in code units, every one of them two bytes: under the length guard, over the cap. + const body = 'é'.repeat(BRIDGE_MAX_MESSAGE_BYTES / 2) + const raw = `"${body}"` + expect(raw.length).toBeLessThan(BRIDGE_MAX_MESSAGE_BYTES) + expect(parseBridgeMessage(raw, 'page-to-shell')).toEqual({ ok: false, refusal: 'oversized' }) + }) +}) + +describe('parseBridgeMessage document caps', () => { + it('refuses text that is not JSON', () => { + expect(parseBridgeMessage('{', 'page-to-shell')).toEqual({ + ok: false, + refusal: 'malformed-json' + }) + expect(parseBridgeMessage('', 'page-to-shell')).toEqual({ + ok: false, + refusal: 'malformed-json' + }) + }) + + it('accepts nesting of exactly the depth cap', () => { + expect(parseBridgeMessage(nestedArrays(BRIDGE_MAX_DEPTH), 'page-to-shell').ok).toBe(true) + }) + + it('refuses nesting one level past the depth cap', () => { + expect(parseBridgeMessage(nestedArrays(BRIDGE_MAX_DEPTH + 1), 'page-to-shell')).toEqual({ + ok: false, + refusal: 'too-deep' + }) + }) + + it('counts object nesting the same as array nesting', () => { + const deep = `${'{"a":'.repeat(BRIDGE_MAX_DEPTH)}0${'}'.repeat(BRIDGE_MAX_DEPTH)}` + expect(parseBridgeMessage(deep, 'page-to-shell')).toEqual({ ok: false, refusal: 'too-deep' }) + }) + + it('accepts exactly the node cap', () => { + expect(parseBridgeMessage(arrayOfNodes(BRIDGE_MAX_NODES), 'page-to-shell').ok).toBe(true) + }) + + it('refuses one node past the cap', () => { + expect(parseBridgeMessage(arrayOfNodes(BRIDGE_MAX_NODES + 1), 'page-to-shell')).toEqual({ + ok: false, + refusal: 'too-many-nodes' + }) + }) + + it('counts object values as nodes too', () => { + const entries = Array.from({ length: BRIDGE_MAX_NODES }, (_, index) => `"k${index}":0`) + expect(parseBridgeMessage(`{${entries.join(',')}}`, 'page-to-shell')).toEqual({ + ok: false, + refusal: 'too-many-nodes' + }) + }) + + it('returns the parsed document when every cap holds', () => { + expect(parseBridgeMessage('{"v":1,"type":"ready"}', 'page-to-shell')).toEqual({ + ok: true, + message: { v: 1, type: 'ready' } + }) + }) +}) + +describe('the agreed numbers', () => { + it('pins what a released shell and a served page believe about each other', () => { + // These are wire, not tuning: the page bundle and the installed shell agree on them without + // ever negotiating, so a change here is a change both sides have to ship for. + expect({ + messageBytes: BRIDGE_MAX_MESSAGE_BYTES, + depth: BRIDGE_MAX_DEPTH, + nodes: BRIDGE_MAX_NODES, + methodChars: BRIDGE_MAX_METHOD_CHARS, + pendingRequests: BRIDGE_MAX_PENDING_REQUESTS, + subscriptions: BRIDGE_MAX_SUBSCRIPTIONS, + replyBytes: BRIDGE_MAX_REPLY_BYTES, + replyParts: BRIDGE_MAX_REPLY_PARTS + }).toEqual({ + messageBytes: 655_360, + depth: 16, + nodes: 20_000, + methodChars: 64, + pendingRequests: 64, + subscriptions: 32, + replyBytes: 8_388_608, + replyParts: 27 + }) + }) +}) + +describe('derived caps', () => { + it('allows enough parts for a ceiling-sized reply whose every byte re-escapes', () => { + // A chunk is JSON text inside a JSON string, so re-escaping it at worst doubles it. + const worstCaseFrames = Math.ceil((BRIDGE_MAX_REPLY_BYTES * 2) / BRIDGE_MAX_MESSAGE_BYTES) + expect(BRIDGE_MAX_REPLY_PARTS).toBeGreaterThan(worstCaseFrames) + }) +}) + +describe('parseBridgeMessage direction', () => { + it('names both directions and nothing else', () => { + expect(BRIDGE_DIRECTIONS).toEqual(['page-to-shell', 'shell-to-page']) + }) + + it('lets a reply past the node cap through, and refuses the same document from the page', () => { + const raw = listingReply(5_000) + expect(utf8ByteLength(raw)).toBeLessThan(BRIDGE_MAX_MESSAGE_BYTES) + expect(parseBridgeMessage(raw, 'page-to-shell')).toEqual({ + ok: false, + refusal: 'too-many-nodes' + }) + expect(parseBridgeMessage(raw, 'shell-to-page').ok).toBe(true) + }) + + it('accepts exactly the node count the design note called out', () => { + // 5 000 records x 4 fields, plus the records and the array: past 20 000 either way you count. + expect(parseBridgeMessage(arrayOfNodes(25_000), 'shell-to-page').ok).toBe(true) + expect(parseBridgeMessage(arrayOfNodes(25_000), 'page-to-shell')).toEqual({ + ok: false, + refusal: 'too-many-nodes' + }) + }) + + it('lets a reply nest past the depth cap, and refuses the same nesting from the page', () => { + const raw = nestedArrays(BRIDGE_MAX_DEPTH + 1) + expect(parseBridgeMessage(raw, 'shell-to-page').ok).toBe(true) + expect(parseBridgeMessage(raw, 'page-to-shell')).toEqual({ ok: false, refusal: 'too-deep' }) + }) + + it('holds a reply to the frame byte cap all the same', () => { + expect( + parseBridgeMessage(jsonStringOfBytes(BRIDGE_MAX_MESSAGE_BYTES), 'shell-to-page').ok + ).toBe(true) + expect( + parseBridgeMessage(jsonStringOfBytes(BRIDGE_MAX_MESSAGE_BYTES + 1), 'shell-to-page') + ).toEqual({ + ok: false, + refusal: 'oversized' + }) + }) + + it('holds a reply to being JSON at all', () => { + expect(parseBridgeMessage('{', 'shell-to-page')).toEqual({ + ok: false, + refusal: 'malformed-json' + }) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-caps.ts b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts new file mode 100644 index 00000000000..652a5154d42 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts @@ -0,0 +1,162 @@ +/** + * The bridge's caps, its refusal vocabulary, and the single point that enforces them. + * + * Every frame crossing the page <-> shell boundary is read through `parseBridgeMessage`. It is the + * only place these bounds are checked: a second check drifts from the first, and a check placed + * after `JSON.parse` cannot protect the parse itself. + * + * The two directions are not symmetric. What the shell reads from the page is attacker-shaped, so + * it is walked for depth and node count. What the page reads from the shell is whatever the desktop + * answered, where a listing of a few thousand rows is an ordinary reply: a node cap there would + * refuse real data, so the frame byte cap and the reply ceiling are that direction's only bounds. + */ + +/** Which side sent the frame. The document caps below bound `page-to-shell` only. */ +export const BRIDGE_DIRECTIONS = ['page-to-shell', 'shell-to-page'] as const + +export type BridgeDirection = (typeof BRIDGE_DIRECTIONS)[number] + +/** Frame ceiling, in UTF-8 bytes of the raw string, checked before `JSON.parse` sees it. */ +export const BRIDGE_MAX_MESSAGE_BYTES = 640 * 1024 + +/** Nesting levels a page-to-shell frame may carry, counting the frame object itself as one. */ +export const BRIDGE_MAX_DEPTH = 16 + +/** Values a page-to-shell frame may carry, containers and scalars alike. */ +export const BRIDGE_MAX_NODES = 20_000 + +/** Longest method name accepted. The desktop's mobile-scope allowlist owns which names exist. */ +export const BRIDGE_MAX_METHOD_CHARS = 64 + +/** + * In-flight bounds. The RN host is authoritative for both; the page holds the same numbers only to + * refuse at the call site instead of after a round trip. + */ +export const BRIDGE_MAX_PENDING_REQUESTS = 64 +export const BRIDGE_MAX_SUBSCRIPTIONS = 32 + +/** + * A reply above this aborts its request rather than being chunked further. The frame cap is a + * transport bound; this is the policy. The native screens have no reply byte cap at all, so a + * smaller number here would invent a refusal that source control's diffs would be the first to hit. + */ +export const BRIDGE_MAX_REPLY_BYTES = 8 * 1024 * 1024 + +/** + * Parts a chunked reply may be split into. A chunk is a slice of JSON text carried inside a JSON + * string, and re-escaping such a slice at worst doubles it: every character it holds is already + * printable, so only a quote or a backslash grows, and each of those grows by one byte. The extra + * part covers each frame's own envelope. + */ +export const BRIDGE_MAX_REPLY_PARTS = + Math.ceil((BRIDGE_MAX_REPLY_BYTES * 2) / BRIDGE_MAX_MESSAGE_BYTES) + 1 + +/** Why a frame was dropped. Both sides log this name; none of them is recoverable in place. */ +export const BRIDGE_REFUSALS = [ + /** Over the frame cap. */ + 'oversized', + /** Not JSON, or nested past what `JSON.parse` itself will walk. */ + 'malformed-json', + /** Nested past `BRIDGE_MAX_DEPTH`, which only `page-to-shell` is held to. */ + 'too-deep', + /** More values than `BRIDGE_MAX_NODES`, which only `page-to-shell` is held to. */ + 'too-many-nodes', + /** Valid JSON that is not a message this protocol version declares. */ + 'unrecognised-message', + /** A reply body over `BRIDGE_MAX_REPLY_BYTES`, refused by the sender and by the assembler. */ + 'reply-too-large', + /** A reply part that disagrees with the parts already held for its id. */ + 'inconsistent-part', + /** A reply part index that arrived twice. */ + 'duplicate-part', + /** A part for a new id while `BRIDGE_MAX_PENDING_REQUESTS` replies are already half-assembled. */ + 'too-many-pending' +] as const + +export type BridgeRefusal = (typeof BRIDGE_REFUSALS)[number] + +export type BridgeRead = + | { ok: true; message: TMessage } + | { ok: false; refusal: BridgeRefusal } + +/** Exact UTF-8 length; a lone surrogate counts as the three bytes its replacement encodes to. */ +export function utf8ByteLength(value: string): number { + let bytes = 0 + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index) + if (unit < 0x80) { + bytes += 1 + } else if (unit < 0x800) { + bytes += 2 + } else if ( + unit >= 0xd800 && + unit <= 0xdbff && + (value.charCodeAt(index + 1) & 0xfc00) === 0xdc00 + ) { + bytes += 4 + index += 1 + } else { + bytes += 3 + } + } + return bytes +} + +type DocumentRefusal = Extract + +function childrenOf(value: unknown): unknown[] | null { + if (Array.isArray(value)) { + return value + } + return typeof value === 'object' && value !== null ? Object.values(value) : null +} + +/** + * Depth-first with an explicit stack, counting children as they are pushed so a wide container is + * refused before its values are queued. + */ +function inspectDocument(root: unknown): DocumentRefusal | null { + const pending: { value: unknown; depth: number }[] = [{ value: root, depth: 1 }] + let nodes = 1 + for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) { + if (entry.depth > BRIDGE_MAX_DEPTH) { + return 'too-deep' + } + const children = childrenOf(entry.value) + if (children === null) { + continue + } + nodes += children.length + if (nodes > BRIDGE_MAX_NODES) { + return 'too-many-nodes' + } + for (const child of children) { + pending.push({ value: child, depth: entry.depth + 1 }) + } + } + return null +} + +/** + * Parses a frame far enough to hand it to a schema, and no further. `direction` has no default: a + * new call site has to say which bounds it is asking for. + */ +export function parseBridgeMessage(raw: string, direction: BridgeDirection): BridgeRead { + // A code unit never encodes to fewer than one byte, so a string longer than the cap in units is + // over it in bytes too: the hostile case is refused without walking it. + if (raw.length > BRIDGE_MAX_MESSAGE_BYTES || utf8ByteLength(raw) > BRIDGE_MAX_MESSAGE_BYTES) { + return { ok: false, refusal: 'oversized' } + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + // A nesting bomb that overflows `JSON.parse`'s own recursion lands here rather than below. + return { ok: false, refusal: 'malformed-json' } + } + if (direction === 'shell-to-page') { + return { ok: true, message: parsed } + } + const refusal = inspectDocument(parsed) + return refusal === null ? { ok: true, message: parsed } : { ok: false, refusal } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts new file mode 100644 index 00000000000..3c6b1ee13bf --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts @@ -0,0 +1,443 @@ +import { describe, expect, it } from 'vitest' +import { + BrowserScreencastOpcode, + type BrowserScreencastFormat, + type BrowserScreencastFrame +} from '../../transport/browser-screencast-protocol' +import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from '../../transport/types' +import type { SendRequestOptions } from '../../transport/unvalidated-rpc-request-port' +import { + BRIDGE_MAX_MESSAGE_BYTES, + BRIDGE_MAX_METHOD_CHARS, + BRIDGE_MAX_REPLY_PARTS +} from './bridge-caps' +import { + BRIDGE_BINARY_FORMATS, + BRIDGE_CONNECTION_STATES, + BRIDGE_FOREGROUND_NUDGE_REASONS, + BRIDGE_PROTOCOL_VERSION, + readBridgeClientMessage, + readBridgeHostMessage, + type BridgeHostMessage, + type BridgeReplyPayload +} from './bridge-envelope' + +const ID = 'AAAAAAAAAAAAAAAAAAAAAA' +const CONNECTION = { + state: 'connected', + reconnectAttempt: 0, + lastConnectedAt: 1_700_000_000_000, + lastInboundAt: null, + generation: 2 +} +const GRANTS = { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] } +const SUCCESS_PAYLOAD = { + id: 'r1', + ok: true, + result: { worktrees: [] }, + _meta: { runtimeId: 'runtime-a' } +} + +const BINARY_FRAME = { + b64: 'AAAA', + format: 'jpeg', + frameSeq: 7, + metadata: { imageWidth: 390, imageHeight: 844, timestamp: 1_700_000_000.5 } +} + +type BridgeBinaryEvent = Extract + +/** Compile-time pin: everything a decoded frame holds but its bytes crosses as a field. */ +function asDecodedFrameFields( + binary: BridgeBinaryEvent['binary'] +): Omit { + return { + opcode: BrowserScreencastOpcode.Frame, + seq: binary.frameSeq, + format: binary.format, + metadata: binary.metadata + } +} + +function readClient(message: unknown): ReturnType { + return readBridgeClientMessage(JSON.stringify(message)) +} + +function readHost(message: unknown): ReturnType { + return readBridgeHostMessage(JSON.stringify(message)) +} + +function client(fields: Record): Record { + return { v: BRIDGE_PROTOCOL_VERSION, ...fields } +} + +describe('client messages', () => { + const accepted = [ + ['ready', { type: 'ready' }], + ['request without params', { type: 'request', id: ID, method: 'status.get' }], + ['request with params', { type: 'request', id: ID, method: 'status.get', params: { a: 1 } }], + [ + 'request with options', + { + type: 'request', + id: ID, + method: 'status.get', + options: { timeoutMs: 5000, budgetSpansConnect: true, failWhenDisconnected: false } + } + ], + ['subscribe', { type: 'subscribe', id: ID, method: 'terminal.subscribe', params: { t: 'x' } }], + [ + 'subscribe wanting binary', + { type: 'subscribe', id: ID, method: 'browser.screencast', params: {}, wantsBinary: true } + ], + ['cancel of a request', { type: 'cancel', id: ID, target: 'request' }], + ['cancel of a subscription', { type: 'cancel', id: ID, target: 'subscription' }], + ['ack', { type: 'ack', id: ID, seq: 0 }], + ['foreground notify', { type: 'notify', name: 'foreground' }], + ['foreground notify with a reason', { type: 'notify', name: 'foreground', reason: 'focus' }], + [ + 'terminal viewport notify', + { type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 80, rows: 24 } + ], + ['close', { type: 'close' }] + ] as const + + for (const [name, fields] of accepted) { + it(`accepts ${name}`, () => { + expect(readClient(client(fields)).ok).toBe(true) + }) + } + + const refused = [ + ['a version this shell does not speak', { ...client({ type: 'ready' }), v: 2 }], + ['a missing version', { type: 'ready' }], + ['an unknown type', client({ type: 'hello' })], + ['an id of the wrong length', client({ type: 'cancel', id: 'short', target: 'request' })], + [ + 'a method over the cap', + client({ type: 'request', id: ID, method: 'm'.repeat(BRIDGE_MAX_METHOD_CHARS + 1) }) + ], + ['an empty method', client({ type: 'request', id: ID, method: '' })], + ['an unknown cancel target', client({ type: 'cancel', id: ID, target: 'stream' })], + ['a negative ack sequence', client({ type: 'ack', id: ID, seq: -1 })], + ['a fractional ack sequence', client({ type: 'ack', id: ID, seq: 1.5 })], + ['an unknown notify name', client({ type: 'notify', name: 'battery' })], + ['an unknown foreground reason', client({ type: 'notify', name: 'foreground', reason: 'tap' })], + [ + 'a viewport of zero columns', + client({ type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 0, rows: 24 }) + ], + ['a bare array', []], + ['a bare string', 'ready'] + ] as const + + for (const [name, message] of refused) { + it(`refuses ${name}`, () => { + expect(readClient(message)).toEqual({ ok: false, refusal: 'unrecognised-message' }) + }) + } + + it('accepts a method of exactly the cap', () => { + const method = 'm'.repeat(BRIDGE_MAX_METHOD_CHARS) + expect(readClient(client({ type: 'request', id: ID, method })).ok).toBe(true) + }) + + it('keeps an absent params absent, so the host replays the arity the page used', () => { + const read = readClient(client({ type: 'request', id: ID, method: 'status.get' })) + expect(read.ok && read.message.type === 'request' && 'params' in read.message).toBe(false) + }) + + it('keeps an explicit null params, which is not the same call', () => { + const read = readClient(client({ type: 'request', id: ID, method: 'status.get', params: null })) + expect(read.ok && read.message.type === 'request' && read.message.params).toBeNull() + }) + + it('drops a field it does not know rather than refusing the frame', () => { + const read = readClient(client({ type: 'ready', sentAt: 5 })) + expect(read).toEqual({ ok: true, message: { v: BRIDGE_PROTOCOL_VERSION, type: 'ready' } }) + }) + + it('carries the frame refusal through rather than relabelling it', () => { + expect(readBridgeClientMessage('{')).toEqual({ ok: false, refusal: 'malformed-json' }) + }) +}) + +describe('host messages', () => { + const accepted = [ + [ + 'init', + { type: 'init', sessionId: 's1', buildId: 'b1', connection: CONNECTION, grants: GRANTS } + ], + ['state', { type: 'state', connection: CONNECTION }], + ['a whole reply', { type: 'reply', id: ID, payload: SUCCESS_PAYLOAD }], + [ + 'a failure reply, which is data and not a rejection', + { + type: 'reply', + id: ID, + payload: { + id: 'r1', + ok: false, + error: { code: 'forbidden', message: 'no', data: { scope: 'mobile' } }, + _meta: { runtimeId: 'runtime-a' } + } + } + ], + ['a reply part', { type: 'reply', id: ID, part: { i: 0, of: 2 }, chunk: '{"id"' }], + ['an event', { type: 'event', id: ID, seq: 0, payload: { type: 'data' } }], + ['a binary event', { type: 'event', id: ID, seq: 1, binary: BINARY_FRAME }], + ['an unsubscribed end', { type: 'end', id: ID, reason: 'unsubscribed' }], + ['a closed end', { type: 'end', id: ID, reason: 'closed' }], + ['an overflow end', { type: 'end', id: ID, reason: 'overflow' }], + [ + 'an error', + { + type: 'error', + id: ID, + error: { category: 'Error', message: 'x', isRpcDeliveryUnknown: true } + } + ], + [ + // The recorder records every code it finds, whatever its shape, so refusing one here would + // move a golden. + 'an error whose code is an object', + { + type: 'error', + id: ID, + error: { category: 'Error', message: 'x', isRpcDeliveryUnknown: false, code: { n: 1 } } + } + ] + ] as const + + for (const [name, fields] of accepted) { + it(`accepts ${name}`, () => { + expect(readHost(client(fields)).ok).toBe(true) + }) + } + + const refused = [ + [ + 'an init without a build id', + client({ type: 'init', sessionId: 's1', buildId: '', connection: CONNECTION, grants: GRANTS }) + ], + [ + 'a connection state the transport does not have', + client({ type: 'state', connection: { ...CONNECTION, state: 'idle' } }) + ], + [ + 'a connection snapshot missing its generation', + client({ + type: 'state', + connection: { + state: 'connected', + reconnectAttempt: 0, + lastConnectedAt: null, + lastInboundAt: null + } + }) + ], + [ + 'a reply whose payload is not an envelope', + client({ type: 'reply', id: ID, payload: { ok: true } }) + ], + [ + 'a part index past the part cap', + client({ + type: 'reply', + id: ID, + part: { i: BRIDGE_MAX_REPLY_PARTS, of: BRIDGE_MAX_REPLY_PARTS }, + chunk: 'x' + }) + ], + ['a part count of zero', client({ type: 'reply', id: ID, part: { i: 0, of: 0 }, chunk: 'x' })], + [ + 'a binary event carrying only its bytes', + client({ type: 'event', id: ID, seq: 1, binary: { b64: 'AAAA' } }) + ], + [ + 'a binary event without the screencast frame seq', + client({ + type: 'event', + id: ID, + seq: 1, + binary: { b64: 'AAAA', format: 'jpeg', metadata: {} } + }) + ], + [ + 'a binary event in a format the screencast cannot produce', + client({ type: 'event', id: ID, seq: 1, binary: { ...BINARY_FRAME, format: 'webp' } }) + ], + [ + 'a binary event whose metadata is not an object', + client({ type: 'event', id: ID, seq: 1, binary: { ...BINARY_FRAME, metadata: 7 } }) + ], + + [ + 'an end for a reason that is not one of the three', + client({ type: 'end', id: ID, reason: 'done' }) + ] + ] as const + + for (const [name, message] of refused) { + it(`refuses ${name}`, () => { + expect(readHost(message)).toEqual({ ok: false, refusal: 'unrecognised-message' }) + }) + } + + it('accepts a part index of exactly one below the part cap', () => { + const part = { i: BRIDGE_MAX_REPLY_PARTS - 1, of: BRIDGE_MAX_REPLY_PARTS } + expect(readHost(client({ type: 'reply', id: ID, part, chunk: 'x' })).ok).toBe(true) + }) + + it('passes a reply payload through verbatim, including fields it does not know', () => { + const payload = { + ...SUCCESS_PAYLOAD, + streaming: true, + _meta: { runtimeId: 'runtime-a', hostVersion: '9.9.9' }, + hint: 'from a newer host' + } + const read = readHost(client({ type: 'reply', id: ID, payload })) + expect( + read.ok && read.message.type === 'reply' && 'payload' in read.message && read.message.payload + ).toEqual(payload) + }) +}) + +describe('type pins', () => { + it('pins the protocol version both sides send', () => { + expect(BRIDGE_PROTOCOL_VERSION).toBe(1) + }) + + it('closes the connection states over the transport union', () => { + const asTransport = (value: (typeof BRIDGE_CONNECTION_STATES)[number]): ConnectionState => value + const asBridge = (value: ConnectionState): (typeof BRIDGE_CONNECTION_STATES)[number] => value + expect(BRIDGE_CONNECTION_STATES.map(asTransport).map(asBridge)).toEqual([ + ...BRIDGE_CONNECTION_STATES + ]) + }) + + it('closes the foreground reasons over the transport union', () => { + const asTransport = ( + value: (typeof BRIDGE_FOREGROUND_NUDGE_REASONS)[number] + ): ForegroundNudgeReason => value + const asBridge = ( + value: ForegroundNudgeReason + ): (typeof BRIDGE_FOREGROUND_NUDGE_REASONS)[number] => value + expect(BRIDGE_FOREGROUND_NUDGE_REASONS.map(asTransport).map(asBridge)).toEqual([ + ...BRIDGE_FOREGROUND_NUDGE_REASONS + ]) + }) + + it('resolves a reply payload to the transport envelope the page hands its callers', () => { + const asRpcResponse = (value: BridgeReplyPayload): RpcResponse => value + const read = readHost(client({ type: 'reply', id: ID, payload: SUCCESS_PAYLOAD })) + const payload = + read.ok && read.message.type === 'reply' && 'payload' in read.message + ? asRpcResponse(read.message.payload) + : null + expect(payload).toEqual(SUCCESS_PAYLOAD) + }) + + it('accepts every option the raw sender declares', () => { + // Both directions: the literal has to satisfy the type, and the type has to have no key the + // literal is missing, so a new option fails to compile until the schema learns it. + const optionKeys: Record = { + timeoutMs: true, + budgetSpansConnect: true, + failWhenDisconnected: true + } + const options: SendRequestOptions = { + timeoutMs: 1000, + budgetSpansConnect: true, + failWhenDisconnected: true + } + expect(Object.keys(optionKeys).toSorted()).toEqual(Object.keys(options).toSorted()) + expect(readClient(client({ type: 'request', id: ID, method: 'm', options })).ok).toBe(true) + }) + + it('closes the binary formats over the screencast protocol', () => { + const asProtocol = (value: (typeof BRIDGE_BINARY_FORMATS)[number]): BrowserScreencastFormat => + value + const asBridge = (value: BrowserScreencastFormat): (typeof BRIDGE_BINARY_FORMATS)[number] => + value + expect(BRIDGE_BINARY_FORMATS.map(asProtocol).map(asBridge)).toEqual([...BRIDGE_BINARY_FORMATS]) + }) + + it('refuses a screencast metadata field that is not a finite number', () => { + const keys = [ + 'offsetTop', + 'pageScaleFactor', + 'deviceWidth', + 'deviceHeight', + 'imageWidth', + 'imageHeight', + 'scrollOffsetX', + 'scrollOffsetY', + 'timestamp' + ] + for (const key of keys) { + const binary = { ...BINARY_FRAME, metadata: { [key]: '390' } } + expect([key, readHost(client({ type: 'event', id: ID, seq: 1, binary })).ok]).toEqual([ + key, + false + ]) + } + }) + + it('carries a decoded screencast frame whole, minus its bytes', () => { + const read = readHost(client({ type: 'event', id: ID, seq: 1, binary: BINARY_FRAME })) + const binary = + read.ok && read.message.type === 'event' && 'binary' in read.message + ? read.message.binary + : null + expect(binary).toEqual(BINARY_FRAME) + expect(binary === null ? null : asDecodedFrameFields(binary)).toEqual({ + opcode: BrowserScreencastOpcode.Frame, + seq: BINARY_FRAME.frameSeq, + format: BINARY_FRAME.format, + metadata: BINARY_FRAME.metadata + }) + }) +}) + +describe('the readers bound their two directions differently', () => { + const records = Array.from({ length: 5_000 }, (_, index) => ({ + id: index, + name: `worktree-${index}`, + branch: 'main', + dirty: false + })) + + it('accepts a reply carrying more values than the page-to-shell node cap', () => { + const read = readHost({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id: ID, + payload: { ...SUCCESS_PAYLOAD, result: records } + }) + expect( + read.ok && read.message.type === 'reply' && 'payload' in read.message && read.message.payload + ).toEqual({ + ...SUCCESS_PAYLOAD, + result: records + }) + }) + + it('refuses the page sending that many values back the other way', () => { + expect( + readClient(client({ type: 'request', id: ID, method: 'worktree.list', params: { records } })) + ).toEqual({ ok: false, refusal: 'too-many-nodes' }) + }) + + it('still refuses a host frame one byte over the frame cap', () => { + const padding = 'x'.repeat(BRIDGE_MAX_MESSAGE_BYTES) + const raw = JSON.stringify({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id: ID, + payload: { ...SUCCESS_PAYLOAD, result: padding } + }) + expect(raw.length).toBeGreaterThan(BRIDGE_MAX_MESSAGE_BYTES) + expect(readBridgeHostMessage(raw)).toEqual({ ok: false, refusal: 'oversized' }) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts new file mode 100644 index 00000000000..d3d034f4d9c --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts @@ -0,0 +1,287 @@ +import { z } from 'zod' +import { BridgeErrorCaptureSchema } from './bridge-error-capture' +import { + BRIDGE_MAX_METHOD_CHARS, + BRIDGE_MAX_REPLY_PARTS, + parseBridgeMessage, + type BridgeDirection, + type BridgeRead +} from './bridge-caps' + +/** + * Every message the page and the shell exchange, in both directions. + * + * `v` gates envelope shape and nothing else: capability is gated by `init.grants`, so a shell that + * learns a new native grant never bumps it. Unknown keys are dropped rather than refused, because + * the page bundle is served by a desktop that updates independently of the installed shell, and an + * additive field must not take a working pair offline. The rule, in one line: `v` gates + * incompatible shape; additive fields never bump `v`. + * + * A new member of a closed list is NOT an additive field. `end.reason`, `binary.format`, + * `connection.state` and the foreground reasons are enumerated here, so a value outside the list + * takes the whole frame down as `unrecognised-message` on the older side. Adding one is a + * compatibility change: it has to be negotiated, the way a new opcode is, not shipped on the + * strength of the reader dropping what it does not know. + * + * The two readers differ in more than their schema: the page's traffic is held to the document + * caps, the shell's answers are not. `parseBridgeMessage` documents why. + */ +export const BRIDGE_PROTOCOL_VERSION = 1 + +/** Correlation ids are minted by whichever side opens the exchange; 22 chars is 128 bits of base64url. */ +export const BRIDGE_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/ + +const versionSchema = z.literal(BRIDGE_PROTOCOL_VERSION) +const idSchema = z.string().regex(BRIDGE_ID_PATTERN) +// Length only: the desktop's mobile-scope allowlist decides which names exist, and a charset guess +// here would refuse a method that allowlist already permits. +const methodSchema = z.string().min(1).max(BRIDGE_MAX_METHOD_CHARS) + +/** Closed against `ConnectionState`; the pin lives in this module's test. */ +export const BRIDGE_CONNECTION_STATES = [ + 'connecting', + 'handshaking', + 'connected', + 'disconnected', + 'reconnecting', + 'auth-failed' +] as const + +/** Closed against `BrowserScreencastFormat`; the pin lives in this module's test. */ +export const BRIDGE_BINARY_FORMATS = ['jpeg', 'png'] as const + +/** Closed against `ForegroundNudgeReason`; the pin lives in this module's test. */ +export const BRIDGE_FOREGROUND_NUDGE_REASONS = ['focus', 'app-resume', 'network-change'] as const + +/** + * What the page's synchronous `RpcClient` getters read. It travels whole rather than as deltas so a + * dropped frame cannot leave the cache half-applied, and `generation` is what lets the page notice + * it missed one. + */ +export const BridgeConnectionSnapshotSchema = z.object({ + state: z.enum(BRIDGE_CONNECTION_STATES), + reconnectAttempt: z.number().int().nonnegative(), + lastConnectedAt: z.number().nullable(), + // Null also covers the client not implementing the optional getter at all. + lastInboundAt: z.number().nullable(), + generation: z.number().int().nonnegative().nullable() +}) + +export type BridgeConnectionSnapshot = z.infer + +/** `native` is a list of grant names, empty in C0. Adding one is never a version bump. */ +export const BridgeGrantsSchema = z.object({ + rpc: z.object({ + maxPendingRequests: z.number().int().positive(), + maxSubscriptions: z.number().int().positive() + }), + native: z.array(z.string().min(1).max(64)) +}) + +export type BridgeGrants = z.infer + +/** Pinned against `SendRequestOptions` in this module's test. */ +export const BridgeSendRequestOptionsSchema = z.object({ + timeoutMs: z.number().int().positive().optional(), + budgetSpansConnect: z.boolean().optional(), + failWhenDisconnected: z.boolean().optional() +}) + +const rpcMetaSchema = z.looseObject({ runtimeId: z.string() }) + +/** + * A host `RpcFailure` is data, not a rejection: it rides in `reply` exactly as it arrived, `_meta` + * and `error.data` included, because the page reads it and the goldens record it. Loose objects all + * the way down for the same reason — a field a newer host adds must reach the page unaltered. + */ +export const BridgeReplyPayloadSchema = z.union([ + z.looseObject({ + id: z.string(), + ok: z.literal(true), + result: z.unknown(), + streaming: z.literal(true).optional(), + _meta: rpcMetaSchema + }), + z.looseObject({ + id: z.string(), + ok: z.literal(false), + error: z.looseObject({ + code: z.string(), + message: z.string(), + data: z.unknown().optional() + }), + _meta: rpcMetaSchema + }) +]) + +/** + * `BrowserScreencastFrameMetadata` field for field, loose so a field a newer host adds still reaches + * the page. Every value is a finite number there, which is what `z.number()` accepts. + */ +const screencastMetadataSchema = z.looseObject({ + offsetTop: z.number().optional(), + pageScaleFactor: z.number().optional(), + deviceWidth: z.number().optional(), + deviceHeight: z.number().optional(), + imageWidth: z.number().optional(), + imageHeight: z.number().optional(), + scrollOffsetX: z.number().optional(), + scrollOffsetY: z.number().optional(), + timestamp: z.number().optional() +}) + +const replyPartSchema = z.object({ + i: z + .number() + .int() + .nonnegative() + .max(BRIDGE_MAX_REPLY_PARTS - 1), + of: z.number().int().positive().max(BRIDGE_MAX_REPLY_PARTS) +}) + +const BridgeClientMessageSchema = z.discriminatedUnion('type', [ + z.object({ v: versionSchema, type: z.literal('ready') }), + z.object({ + v: versionSchema, + type: z.literal('request'), + id: idSchema, + method: methodSchema, + // Absent stays absent: `sendRequest(method)` and `sendRequest(method, undefined)` are different + // calls to the recorder, so the host replays the arity the page used. + params: z.unknown().optional(), + options: BridgeSendRequestOptionsSchema.optional() + }), + z.object({ + v: versionSchema, + type: z.literal('subscribe'), + id: idSchema, + method: methodSchema, + params: z.unknown(), + wantsBinary: z.boolean().optional() + }), + z.object({ + v: versionSchema, + type: z.literal('cancel'), + id: idSchema, + target: z.enum(['request', 'subscription']) + }), + z.object({ + v: versionSchema, + type: z.literal('ack'), + id: idSchema, + seq: z.number().int().nonnegative() + }), + z.discriminatedUnion('name', [ + z.object({ + v: versionSchema, + type: z.literal('notify'), + name: z.literal('foreground'), + reason: z.enum(BRIDGE_FOREGROUND_NUDGE_REASONS).optional() + }), + z.object({ + v: versionSchema, + type: z.literal('notify'), + name: z.literal('terminalViewport'), + terminal: z.string().min(1), + cols: z.number().int().positive(), + rows: z.number().int().positive() + }) + ]), + z.object({ v: versionSchema, type: z.literal('close') }) +]) + +export type BridgeClientMessage = z.infer + +// Not a discriminated union: `reply` and `event` each have two shapes under one `type`, which zod's +// discriminator cannot express. Hot frames come first so the common case matches on the first try. +const BridgeHostMessageSchema = z.union([ + z.object({ + v: versionSchema, + type: z.literal('event'), + id: idSchema, + seq: z.number().int().nonnegative(), + payload: z.unknown() + }), + z.object({ + v: versionSchema, + type: z.literal('event'), + id: idSchema, + seq: z.number().int().nonnegative(), + // A binary listener is handed a decoded `BrowserScreencastFrame`, never bytes, so every field + // but the image crosses beside the base64. `seq` is the bridge's backpressure counter; + // `frameSeq` is the screencast's own, and conflating them loses one of the two. + binary: z.object({ + b64: z.string(), + format: z.enum(BRIDGE_BINARY_FORMATS), + frameSeq: z.number().int().nonnegative(), + metadata: screencastMetadataSchema + }) + }), + z.object({ + v: versionSchema, + type: z.literal('reply'), + id: idSchema, + payload: BridgeReplyPayloadSchema + }), + z.object({ + v: versionSchema, + type: z.literal('reply'), + id: idSchema, + part: replyPartSchema, + chunk: z.string() + }), + z.object({ + v: versionSchema, + type: z.literal('state'), + connection: BridgeConnectionSnapshotSchema + }), + z.object({ + v: versionSchema, + type: z.literal('end'), + id: idSchema, + reason: z.enum(['unsubscribed', 'closed', 'overflow']) + }), + z.object({ + v: versionSchema, + type: z.literal('error'), + id: idSchema, + error: BridgeErrorCaptureSchema + }), + z.object({ + v: versionSchema, + type: z.literal('init'), + sessionId: z.string().min(1), + buildId: z.string().min(1), + connection: BridgeConnectionSnapshotSchema, + grants: BridgeGrantsSchema + }) +]) + +export type BridgeHostMessage = z.infer +export type BridgeReplyMessage = Extract +export type BridgeReplyPayload = z.infer + +/** What the RN host accepts from the page. */ +export function readBridgeClientMessage(raw: string): BridgeRead { + return readMessage(raw, BridgeClientMessageSchema, 'page-to-shell') +} + +/** What the page accepts from the RN host. */ +export function readBridgeHostMessage(raw: string): BridgeRead { + return readMessage(raw, BridgeHostMessageSchema, 'shell-to-page') +} + +function readMessage( + raw: string, + schema: z.ZodType, + direction: BridgeDirection +): BridgeRead { + const framed = parseBridgeMessage(raw, direction) + if (!framed.ok) { + return framed + } + const parsed = schema.safeParse(framed.message) + return parsed.success + ? { ok: true, message: parsed.data } + : { ok: false, refusal: 'unrecognised-message' } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-error-capture.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-error-capture.test.ts new file mode 100644 index 00000000000..087f0f43607 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-error-capture.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it } from 'vitest' +import { + isRpcDeliveryUnknown, + markRpcDeliveryUnknown +} from '../../transport/rpc-delivery-ambiguity' +import { BRIDGE_MAX_MESSAGE_BYTES, utf8ByteLength } from './bridge-caps' +import { BRIDGE_PROTOCOL_VERSION, readBridgeHostMessage } from './bridge-envelope' +import { + BRIDGE_MAX_CAUSE_DEPTH, + BRIDGE_MAX_ERROR_CODE_CHARS, + BRIDGE_MAX_ERROR_MESSAGE_CHARS, + BRIDGE_TRUNCATION_MARK, + BRIDGE_UNREADABLE_ERROR_MESSAGE, + BridgeErrorCaptureSchema, + captureBridgeError, + reconstructBridgeError, + type BridgeErrorCapture +} from './bridge-error-capture' + +class RpcTimeoutError extends Error { + constructor( + message: string, + readonly code: string + ) { + super(message) + } +} + +/** What the wire actually does to a capture, so nothing in these tests is proved in memory. */ +function overTheWire(capture: BridgeErrorCapture): BridgeErrorCapture { + const parsed = BridgeErrorCaptureSchema.safeParse(JSON.parse(JSON.stringify(capture))) + if (!parsed.success) { + throw new Error(`capture did not survive its own schema: ${parsed.error.message}`) + } + return parsed.data +} + +function causeChain(depth: number): Error { + let error = new Error('root') + for (let level = depth; level > 0; level -= 1) { + error = new Error(`level-${level}`, { cause: error }) + } + return error +} + +describe('captureBridgeError', () => { + it('captures three fields for an error carrying nothing else', () => { + const capture = captureBridgeError(new Error('boom')) + expect(capture).toEqual({ category: 'Error', message: 'boom', isRpcDeliveryUnknown: false }) + expect(Object.keys(capture).sort()).toEqual(['category', 'isRpcDeliveryUnknown', 'message']) + }) + + it('never carries a stack', () => { + expect(JSON.stringify(captureBridgeError(new Error('boom')))).not.toContain('stack') + }) + + it('keeps the subclass name, which is what the recorder reads', () => { + expect(captureBridgeError(new RpcTimeoutError('late', 'timeout')).category).toBe( + 'RpcTimeoutError' + ) + }) + + it('keeps a string code and a numeric code', () => { + expect(captureBridgeError(new RpcTimeoutError('late', 'timeout')).code).toBe('timeout') + const numbered = Object.assign(new Error('closed'), { code: 1006 }) + expect(captureBridgeError(numbered).code).toBe(1006) + }) + + it('keeps a code the transport does not narrow, so the recorder sees the same field', () => { + const structured = Object.assign(new Error('closed'), { code: { status: 500 } }) + expect(captureBridgeError(structured).code).toEqual({ status: 500 }) + }) + + it('keeps an absent code absent rather than sending an undefined one', () => { + expect('code' in captureBridgeError(new Error('bare'))).toBe(false) + expect('code' in captureBridgeError(Object.assign(new Error('x'), { code: undefined }))).toBe( + false + ) + }) + + it('still captures the rejection when a getter throws', () => { + const throwingCode = new Error('outer') + Object.defineProperty(throwingCode, 'code', { + get: () => { + throw new Error('code getter') + }, + enumerable: true + }) + Object.defineProperty(throwingCode, 'cause', { value: new Error('inner'), enumerable: true }) + const captured = captureBridgeError(throwingCode) + expect('code' in captured).toBe(false) + expect(captured.cause?.message).toBe('inner') + + const throwingCause = Object.assign(new Error('outer'), { code: 'timeout' }) + Object.defineProperty(throwingCause, 'cause', { + get: () => { + throw new Error('cause getter') + }, + enumerable: true + }) + const second = captureBridgeError(throwingCause) + expect(second).toEqual({ + category: 'Error', + message: 'outer', + isRpcDeliveryUnknown: false, + code: 'timeout' + }) + }) + + it('captures something for an error whose message getter throws', () => { + const error = new Error('outer') + Object.defineProperty(error, 'message', { + get: () => { + throw new Error('message getter') + } + }) + markRpcDeliveryUnknown(error) + expect(captureBridgeError(error)).toEqual({ + category: 'Error', + message: BRIDGE_UNREADABLE_ERROR_MESSAGE, + isRpcDeliveryUnknown: true + }) + }) + + it('captures something for a thrown value whose toString throws', () => { + const thrown = { + toString: () => { + throw new Error('toString') + } + } + expect(captureBridgeError(thrown)).toEqual({ + category: 'Error', + message: BRIDGE_UNREADABLE_ERROR_MESSAGE, + isRpcDeliveryUnknown: false + }) + }) + + it('captures something when the cause chain throws partway down', () => { + const inner = new Error('inner') + Object.defineProperty(inner, 'message', { + get: () => { + throw new Error('message getter') + } + }) + const outer = new Error('outer', { cause: inner }) + expect(captureBridgeError(outer).cause).toEqual({ + category: 'Error', + message: BRIDGE_UNREADABLE_ERROR_MESSAGE, + isRpcDeliveryUnknown: false + }) + }) + + it('captures something for a proxy whose prototype cannot be read', () => { + const unreadable = { + category: 'Error', + message: BRIDGE_UNREADABLE_ERROR_MESSAGE, + isRpcDeliveryUnknown: false + } + const revocable = Proxy.revocable(new Error('gone'), {}) + revocable.revoke() + expect(captureBridgeError(revocable.proxy)).toEqual(unreadable) + const trapped = new Proxy(new Error('trapped'), { + getPrototypeOf: () => { + throw new Error('getPrototypeOf') + } + }) + expect(captureBridgeError(trapped)).toEqual(unreadable) + }) + + it('reads a code defined as a getter', () => { + const error = new Error('closed') + Object.defineProperty(error, 'code', { get: () => 'from-getter', enumerable: true }) + expect(captureBridgeError(error).code).toBe('from-getter') + }) + + it('describes a thrown value that is not an error', () => { + expect(captureBridgeError('nope')).toEqual({ + category: 'string', + message: 'nope', + isRpcDeliveryUnknown: false + }) + }) + + it('follows the cause chain exactly as deep as the recorder does', () => { + const capture = captureBridgeError(causeChain(BRIDGE_MAX_CAUSE_DEPTH + 2)) + let level = 0 + let node: BridgeErrorCapture | undefined = capture.cause + while (node !== undefined) { + level += 1 + node = node.cause + } + expect(level).toBe(BRIDGE_MAX_CAUSE_DEPTH) + }) + + it('captures a cause that is not an error', () => { + expect(captureBridgeError(new Error('outer', { cause: 42 })).cause).toEqual({ + category: 'number', + message: '42', + isRpcDeliveryUnknown: false + }) + }) +}) + +describe('BridgeErrorCaptureSchema', () => { + it('accepts a chain of exactly the cause depth', () => { + expect(BridgeErrorCaptureSchema.safeParse(captureBridgeError(causeChain(20))).success).toBe( + true + ) + }) + + it('truncates a chain deeper than the capture can produce rather than losing the error', () => { + const deepest = captureBridgeError(causeChain(20)) + let node: BridgeErrorCapture = deepest + let depth = 0 + while (node.cause !== undefined) { + node = node.cause + depth += 1 + } + node.cause = { category: 'Error', message: 'too deep', isRpcDeliveryUnknown: false } + const parsed = BridgeErrorCaptureSchema.safeParse(deepest) + expect(depth).toBe(BRIDGE_MAX_CAUSE_DEPTH) + expect(parsed.success && parsed.data.cause?.cause?.cause?.cause?.cause).toBeUndefined() + expect(parsed.success && parsed.data.cause?.cause?.cause?.cause?.message).toBe('level-5') + }) +}) + +describe('reconstructBridgeError', () => { + it('re-applies the delivery-unknown mark, which cannot survive serialization', () => { + const original = markRpcDeliveryUnknown(new Error('socket closed mid-request')) + expect(isRpcDeliveryUnknown(original)).toBe(true) + + const capture = overTheWire(captureBridgeError(original)) + expect(isRpcDeliveryUnknown(capture)).toBe(false) + + const rebuilt = reconstructBridgeError(capture) + expect(isRpcDeliveryUnknown(rebuilt)).toBe(true) + expect(rebuilt.message).toBe('socket closed mid-request') + }) + + it('leaves an unmarked error unmarked', () => { + const rebuilt = reconstructBridgeError(overTheWire(captureBridgeError(new Error('plain')))) + expect(isRpcDeliveryUnknown(rebuilt)).toBe(false) + }) + + it('reports the same constructor name, so a recorded rejection does not move', () => { + const original = new RpcTimeoutError('late', 'timeout') + const rebuilt = reconstructBridgeError(overTheWire(captureBridgeError(original))) + expect(rebuilt.constructor.name).toBe('RpcTimeoutError') + expect(rebuilt.name).toBe('RpcTimeoutError') + expect(rebuilt).toBeInstanceOf(Error) + }) + + it('round-trips to the same capture the host made', () => { + const original = markRpcDeliveryUnknown(new RpcTimeoutError('late', 'timeout')) + const captured = overTheWire(captureBridgeError(original)) + expect(captureBridgeError(reconstructBridgeError(captured))).toEqual(captured) + }) + + it('rebuilds the cause chain as errors, not as plain data', () => { + const original = new Error('outer', { cause: new RpcTimeoutError('inner', 'timeout') }) + const rebuilt = reconstructBridgeError(overTheWire(captureBridgeError(original))) + expect(rebuilt.cause).toBeInstanceOf(Error) + expect(rebuilt.cause).toMatchObject({ message: 'inner', code: 'timeout' }) + }) + + it('reuses one class per category rather than minting one per error', () => { + const first = reconstructBridgeError({ + category: 'RpcTimeoutError', + message: 'a', + isRpcDeliveryUnknown: false + }) + const second = reconstructBridgeError({ + category: 'RpcTimeoutError', + message: 'b', + isRpcDeliveryUnknown: false + }) + expect(first.constructor).toBe(second.constructor) + }) + + it('still names a category it has never seen before', () => { + const rebuilt = reconstructBridgeError({ + category: `Novel${Math.random().toString(36).slice(2, 8)}Error`, + message: 'x', + isRpcDeliveryUnknown: true + }) + expect(rebuilt.constructor.name).toBe(rebuilt.name) + expect(isRpcDeliveryUnknown(rebuilt)).toBe(true) + }) +}) + +describe('captureBridgeError budgets', () => { + /** One character to six bytes escaped, which is the most a JSON string can cost. */ + const CONTROL = String.fromCharCode(1) + + it('truncates a message past its budget and says so', () => { + const captured = captureBridgeError(new Error('x'.repeat(1024 * 1024))) + expect(captured.message.length).toBe( + BRIDGE_MAX_ERROR_MESSAGE_CHARS + BRIDGE_TRUNCATION_MARK.length + ) + expect(captured.message.endsWith(BRIDGE_TRUNCATION_MARK)).toBe(true) + }) + + it('leaves a message of exactly the budget alone', () => { + const message = 'x'.repeat(BRIDGE_MAX_ERROR_MESSAGE_CHARS) + expect(captureBridgeError(new Error(message)).message).toBe(message) + }) + + it('truncates what a thrown non-error stringifies to', () => { + expect(captureBridgeError('x'.repeat(1024 * 1024)).message.length).toBe( + BRIDGE_MAX_ERROR_MESSAGE_CHARS + BRIDGE_TRUNCATION_MARK.length + ) + }) + + it('drops a code that cannot be serialized and keeps the rest of the error', () => { + const cyclic: Record = {} + cyclic.self = cyclic + const error = Object.assign(new Error('outer', { cause: new Error('inner') }), { code: cyclic }) + expect(captureBridgeError(error)).toEqual({ + category: 'Error', + message: 'outer', + isRpcDeliveryUnknown: false, + cause: { category: 'Error', message: 'inner', isRpcDeliveryUnknown: false } + }) + const unserializable = Object.assign(new Error('x'), { code: () => undefined }) + expect(captureBridgeError(unserializable)).toEqual({ + category: 'Error', + message: 'x', + isRpcDeliveryUnknown: false + }) + }) + + it('drops a code past its budget and keeps one at it', () => { + const atBudget = 'x'.repeat(BRIDGE_MAX_ERROR_CODE_CHARS - 2) + expect(captureBridgeError(Object.assign(new Error('x'), { code: atBudget })).code).toBe( + atBudget + ) + const overBudget = 'x'.repeat(BRIDGE_MAX_ERROR_CODE_CHARS - 1) + expect('code' in captureBridgeError(Object.assign(new Error('x'), { code: overBudget }))).toBe( + false + ) + }) + + it('carries the code it measured, not what a second serialization would produce', () => { + let reads = 0 + const growing = { + toJSON: () => { + reads += 1 + return reads === 1 ? 'small' : 'x'.repeat(BRIDGE_MAX_ERROR_CODE_CHARS * 2) + } + } + const captured = captureBridgeError(Object.assign(new Error('x'), { code: growing })) + expect(JSON.stringify(captured)).toContain('"code":"small"') + expect(reads).toBe(1) + let thrown = 0 + const poisoned = { + toJSON: () => { + thrown += 1 + if (thrown > 1) { + throw new Error('second read') + } + return 'once' + } + } + const second = captureBridgeError(Object.assign(new Error('x'), { code: poisoned })) + expect(JSON.stringify(second)).toContain('"code":"once"') + }) + + it('keeps the worst error frame the budgets allow inside the frame cap', () => { + let error = new Error('root') + for (let level = 0; level <= BRIDGE_MAX_CAUSE_DEPTH; level += 1) { + error = new Error(CONTROL.repeat(BRIDGE_MAX_ERROR_MESSAGE_CHARS * 2), { cause: error }) + Object.assign(error, { code: CONTROL.repeat(BRIDGE_MAX_ERROR_CODE_CHARS / 6 - 1) }) + } + const captured = captureBridgeError(error) + expect(captured.cause?.cause?.cause?.cause).toBeDefined() + expect(captured.cause?.cause?.cause?.cause?.cause).toBeUndefined() + const frame = JSON.stringify({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'error', + id: 'A'.repeat(22), + error: captured + }) + expect(utf8ByteLength(frame)).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) + expect(readBridgeHostMessage(frame).ok).toBe(true) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-error-capture.ts b/mobile/src/mobile-web-shell/bridge/bridge-error-capture.ts new file mode 100644 index 00000000000..7155354ecfb --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-error-capture.ts @@ -0,0 +1,198 @@ +import { z } from 'zod' +import { + isRpcDeliveryUnknown, + markRpcDeliveryUnknown +} from '../../transport/rpc-delivery-ambiguity' + +/** + * A rejection of `sendRequest` crossing the bridge, and the error the page raises from it. + * + * A host `RpcFailure` is not this: that is data and rides in `reply` untouched. This is the other + * path, the one where the promise rejects, and it carries exactly the five fields the golden + * recorder reads off an error. No stack, ever. + */ +export type BridgeErrorCapture = { + category: string + message: string + isRpcDeliveryUnknown: boolean + code?: unknown + cause?: BridgeErrorCapture +} + +/** + * Matches the recorder's own cause depth, so a chain it would record is a chain that crosses. A + * deeper chain is truncated at this level rather than refused: losing the error entirely because + * its fifth cause was one too many is the worse of the two failures. + */ +export const BRIDGE_MAX_CAUSE_DEPTH = 4 + +/** + * Budgets that make an error frame sendable by construction. A frame the receiver refuses as + * `oversized` is a rejection the page never hears, and a `message` or a `code` is whatever the host + * put there: a megabyte of either is not a protocol error, it is a big string. Five levels at six + * bytes a character is the worst an escape can make of these, and this module's test holds that + * worst case against the frame cap. + */ +export const BRIDGE_MAX_ERROR_MESSAGE_CHARS = 16 * 1024 +export const BRIDGE_MAX_ERROR_CODE_CHARS = 4 * 1024 + +/** Says the message was cut, so the page shows a short message rather than a wrong one. */ +export const BRIDGE_TRUNCATION_MARK = ' [truncated]' + +function boundMessage(message: string): string { + return message.length > BRIDGE_MAX_ERROR_MESSAGE_CHARS + ? `${message.slice(0, BRIDGE_MAX_ERROR_MESSAGE_CHARS)}${BRIDGE_TRUNCATION_MARK}` + : message +} + +/** + * A code is dropped rather than truncated: half a code is not a smaller code, it is a different + * one, and a cyclic or unserializable code would take `JSON.stringify` down with the whole frame. + * What is carried is the snapshot that was measured, not the value it came from: a stateful + * `toJSON` runs again when the frame is serialized, and the second answer is nobody's budget. + */ +function boundCode(code: unknown): { code?: unknown } { + if (code === undefined) { + return {} + } + try { + const serialized = JSON.stringify(code) + if (serialized === undefined || serialized.length > BRIDGE_MAX_ERROR_CODE_CHARS) { + return {} + } + return { code: JSON.parse(serialized) } + } catch { + return {} + } +} + +function errorCaptureSchema(remainingCauses: number): z.ZodType { + const fields = { + category: z.string(), + message: z.string(), + isRpcDeliveryUnknown: z.boolean(), + // Whatever shape the code has: the recorder records every present code, so narrowing here + // would drop a field from a rejection the goldens already hold. + code: z.unknown().optional() + } + return remainingCauses === 0 + ? z.object(fields) + : z.object({ ...fields, cause: errorCaptureSchema(remainingCauses - 1).optional() }) +} + +export const BridgeErrorCaptureSchema = errorCaptureSchema(BRIDGE_MAX_CAUSE_DEPTH) + +// Read through a schema rather than an assertion: `code` and `cause` are not on `Error`, and a +// getter that defines one is still worth reading. One schema each, because reading either property +// runs whatever getter defined it, and a getter that throws must not cost the other field. +const errorCodeSchema = z.object({ code: z.unknown().optional() }) +const errorCauseSchema = z.object({ cause: z.unknown().optional() }) + +/** + * A rejection is the one thing that always has to produce a capture: an error thrown while reading + * an error leaves the page with no envelope at all, so a throwing getter costs its own field only. + */ +function readDetail(error: Error, schema: z.ZodType): TDetail | undefined { + try { + const parsed = schema.safeParse(error) + return parsed.success ? parsed.data : undefined + } catch { + return undefined + } +} + +/** What crosses when the error cannot be read at all. The mark is a `WeakSet` lookup, so it holds. */ +export const BRIDGE_UNREADABLE_ERROR_MESSAGE = 'error could not be read' + +/** + * `message` and `constructor` can be getters too, and `String(value)` runs a `toString` the thrower + * wrote. Every read here is someone else's code, so the whole capture is guarded: a rejection that + * produced no envelope at all would leave the page with a promise that never settles. + */ +export function captureBridgeError(error: unknown, depth = 0): BridgeErrorCapture { + try { + return capture(error, depth) + } catch { + return { + category: 'Error', + message: BRIDGE_UNREADABLE_ERROR_MESSAGE, + isRpcDeliveryUnknown: readDeliveryUnknownMark(error) + } + } +} + +/** The mark is read through `instanceof`, which is a trap: a revoked proxy throws in the fallback too. */ +function readDeliveryUnknownMark(error: unknown): boolean { + try { + return isRpcDeliveryUnknown(error) + } catch { + return false + } +} + +function capture(error: unknown, depth: number): BridgeErrorCapture { + if (!(error instanceof Error)) { + return { + category: typeof error, + message: boundMessage(String(error)), + isRpcDeliveryUnknown: false + } + } + const code = readDetail(error, errorCodeSchema)?.code + const cause = readDetail(error, errorCauseSchema)?.cause + return { + category: error.constructor.name, + message: boundMessage(error.message), + isRpcDeliveryUnknown: isRpcDeliveryUnknown(error), + ...boundCode(code), + ...(cause !== undefined && depth < BRIDGE_MAX_CAUSE_DEPTH + ? { cause: captureBridgeError(cause, depth + 1) } + : {}) + } +} + +class BridgeReconstructedError extends Error { + code?: unknown +} + +type ReconstructedErrorClass = new (message: string) => BridgeReconstructedError + +const reconstructedClasses = new Map() + +/** Bounds a map keyed by a name that arrives over the wire; past it, classes are built per error. */ +const RECONSTRUCTED_CLASS_LIMIT = 64 + +/** + * The recorder reads `error.constructor.name`, so reconstructing every rejection as a plain `Error` + * would move every golden that records one. The class is renamed rather than the instance for that + * reason. + */ +function errorClassFor(category: string): ReconstructedErrorClass { + const cached = reconstructedClasses.get(category) + if (cached !== undefined) { + return cached + } + const created = class extends BridgeReconstructedError {} + Object.defineProperty(created, 'name', { value: category }) + if (reconstructedClasses.size < RECONSTRUCTED_CLASS_LIMIT) { + reconstructedClasses.set(category, created) + } + return created +} + +/** + * Re-applying the delivery-unknown mark is the whole reason this is a function and not a `new + * Error`: the mark is a `WeakSet` on object identity, so it cannot survive serialization, and a + * caller that reads it as a definite send failure will offer to retry something the host already ran. + */ +export function reconstructBridgeError(capture: BridgeErrorCapture): Error { + const created = new (errorClassFor(capture.category))(capture.message) + created.name = capture.category + if (capture.code !== undefined) { + created.code = capture.code + } + if (capture.cause !== undefined) { + created.cause = reconstructBridgeError(capture.cause) + } + return capture.isRpcDeliveryUnknown ? markRpcDeliveryUnknown(created) : created +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.test.ts new file mode 100644 index 00000000000..cb4e5d88082 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it } from 'vitest' +import { + BRIDGE_MAX_MESSAGE_BYTES, + BRIDGE_MAX_PENDING_REQUESTS, + BRIDGE_MAX_REPLY_BYTES, + BRIDGE_MAX_REPLY_PARTS, + utf8ByteLength +} from './bridge-caps' +import { + BRIDGE_PROTOCOL_VERSION, + readBridgeHostMessage, + type BridgeReplyMessage, + type BridgeReplyPayload +} from './bridge-envelope' +import { + BridgeReplyAssembler, + splitBridgeReply, + type BridgeReplySplit +} from './bridge-reply-chunking' + +const ID = 'AAAAAAAAAAAAAAAAAAAAAA' +const OTHER_ID = 'BBBBBBBBBBBBBBBBBBBBBB' +/** A control character is the worst a JSON string literal can do to a byte: one becomes six. */ +const WORST_ESCAPING_CHARACTER = String.fromCharCode(1) + +function payloadOf(result: unknown): BridgeReplyPayload { + return { id: 'r1', ok: true, result, _meta: { runtimeId: 'runtime-a' } } +} + +function part(i: number, of: number, chunk: string, id = ID): BridgeReplyMessage { + return { v: BRIDGE_PROTOCOL_VERSION, type: 'reply', id, part: { i, of }, chunk } +} + +function framesOf(split: BridgeReplySplit): BridgeReplyMessage[] { + if (!split.ok) { + throw new Error(`expected a split, got ${split.refusal}`) + } + return split.frames +} + +const ASTRAL = String.fromCodePoint(0x1f600) +const LONE_HIGH_SURROGATE = String.fromCharCode(0xd800) + +/** A payload whose serialized form is exactly the ceiling, `ASTRAL` all the way to the last bytes. */ +function ceilingPayload(): BridgeReplyPayload { + const overhead = JSON.stringify(payloadOf('')).length + const pairs = Math.floor((BRIDGE_MAX_REPLY_BYTES - overhead) / 4) + const padding = BRIDGE_MAX_REPLY_BYTES - overhead - pairs * 4 + return payloadOf(ASTRAL.repeat(pairs) + 'x'.repeat(padding)) +} + +/** Feeds frames in the given order and returns the assembler's answer to the last one. */ +function assemble(frames: BridgeReplyMessage[]): ReturnType { + const assembler = new BridgeReplyAssembler() + let answer: ReturnType = { status: 'pending' } + for (const frame of frames) { + answer = assembler.accept(frame) + } + return answer +} + +describe('splitBridgeReply', () => { + it('leaves a reply that fits in one frame unchunked', () => { + const payload = payloadOf({ worktrees: ['a', 'b'] }) + const frames = framesOf(splitBridgeReply(ID, payload)) + expect(frames).toEqual([{ v: BRIDGE_PROTOCOL_VERSION, type: 'reply', id: ID, payload }]) + }) + + it('chunks a reply over the frame cap', () => { + const frames = framesOf(splitBridgeReply(ID, payloadOf('x'.repeat(1_500_000)))) + expect(frames.length).toBeGreaterThan(2) + expect(frames.map((frame) => ('part' in frame ? frame.part.i : -1))).toEqual( + frames.map((_, index) => index) + ) + }) + + it('ships only frames the receiving side will accept', () => { + for (const frame of framesOf(splitBridgeReply(ID, payloadOf('x'.repeat(1_500_000))))) { + const raw = JSON.stringify(frame) + expect(utf8ByteLength(raw)).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) + expect(readBridgeHostMessage(raw).ok).toBe(true) + } + }) + + it('splits the worst reply the ceiling admits into fewer parts than the schema allows', () => { + // Every character re-escapes, which is the most a chunk can grow by, at the largest reply that + // can be sent at all. If this count ever reaches the part cap, the cap is the wrong number. + const empty = payloadOf('') + const backslashes = Math.floor((BRIDGE_MAX_REPLY_BYTES - JSON.stringify(empty).length) / 2) + const payload = payloadOf('\\'.repeat(backslashes)) + expect(utf8ByteLength(JSON.stringify(payload))).toBeLessThanOrEqual(BRIDGE_MAX_REPLY_BYTES) + expect(utf8ByteLength(JSON.stringify(payload))).toBeGreaterThan(BRIDGE_MAX_REPLY_BYTES - 4) + const frames = framesOf(splitBridgeReply(ID, payload)) + expect(frames.length).toBe(26) + expect(BRIDGE_MAX_REPLY_PARTS).toBeGreaterThan(frames.length) + for (const frame of frames) { + expect(utf8ByteLength(JSON.stringify(frame))).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) + expect(readBridgeHostMessage(JSON.stringify(frame)).ok).toBe(true) + } + }) + + it('never cuts a frame inside a surrogate pair, at any cut parity', () => { + for (let padding = 0; padding < 4; padding += 1) { + const payload = payloadOf(`${'x'.repeat(padding)}${ASTRAL.repeat(1_000_000)}`) + const frames = framesOf(splitBridgeReply(ID, payload)) + expect(frames.length).toBeGreaterThan(2) + for (const frame of frames) { + const chunk = 'part' in frame ? frame.chunk : '' + const first = chunk.charCodeAt(0) + const last = chunk.charCodeAt(chunk.length - 1) + expect([ + padding, + first >= 0xdc00 && first <= 0xdfff, + last >= 0xd800 && last <= 0xdbff + ]).toEqual([padding, false, false]) + } + } + }) + + it('round-trips a reply of exactly the ceiling, cuts and all', () => { + const payload = ceilingPayload() + expect(assemble(framesOf(splitBridgeReply(ID, payload)))).toEqual({ + status: 'complete', + payload + }) + }) + + it('refuses a lone-surrogate reply over the ceiling rather than splitting it', () => { + // A lone surrogate is escaped to six characters, so this is past the ceiling six times over. + const payload = payloadOf(LONE_HIGH_SURROGATE.repeat(BRIDGE_MAX_REPLY_BYTES / 6)) + expect(splitBridgeReply(ID, payload)).toEqual({ ok: false, refusal: 'reply-too-large' }) + }) + + it('round-trips lone surrogates that fit', () => { + const payload = payloadOf(LONE_HIGH_SURROGATE.repeat(200_000)) + expect(assemble(framesOf(splitBridgeReply(ID, payload)))).toEqual({ + status: 'complete', + payload + }) + }) + + it('stays under the frame cap when every byte escapes to six', () => { + const payload = payloadOf(WORST_ESCAPING_CHARACTER.repeat(1_300_000)) + const frames = framesOf(splitBridgeReply(ID, payload)) + expect(frames.length).toBeLessThanOrEqual(BRIDGE_MAX_REPLY_PARTS) + for (const frame of frames) { + expect(utf8ByteLength(JSON.stringify(frame))).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) + } + }) + + it('refuses a reply over the ceiling instead of chunking it forever', () => { + const oversized = payloadOf('x'.repeat(BRIDGE_MAX_REPLY_BYTES + 1)) + expect(splitBridgeReply(ID, oversized)).toEqual({ ok: false, refusal: 'reply-too-large' }) + }) + + it('chunks a reply of just under the ceiling', () => { + const atCeiling = payloadOf('x'.repeat(BRIDGE_MAX_REPLY_BYTES - 200)) + expect(utf8ByteLength(JSON.stringify(atCeiling))).toBeLessThanOrEqual(BRIDGE_MAX_REPLY_BYTES) + expect(splitBridgeReply(ID, atCeiling).ok).toBe(true) + }) +}) + +describe('round trip', () => { + const payloads: [string, BridgeReplyPayload][] = [ + ['a small reply', payloadOf({ ok: 1 })], + ['a reply spanning several frames', payloadOf('x'.repeat(1_500_000))], + ['a reply of astral characters', payloadOf('\u{1f600}'.repeat(400_000))], + ['a reply of control characters', payloadOf(WORST_ESCAPING_CHARACTER.repeat(1_300_000))], + ['a reply of mixed widths', payloadOf(`${'é'.repeat(300_000)}${'中'.repeat(300_000)}`)] + ] + + for (const [name, payload] of payloads) { + it(`reassembles ${name} byte for byte`, () => { + expect(assemble(framesOf(splitBridgeReply(ID, payload)))).toEqual({ + status: 'complete', + payload + }) + }) + } + + it('restores a surrogate pair that was cut in half between two frames', () => { + // Each half is a lone surrogate, which `JSON.stringify` escapes rather than corrupting, so the + // pair comes back whole once the halves are joined. + const head = '{"id":"r1","ok":true,"result":"\ud83d' + const tail = '\ude00","_meta":{"runtimeId":"runtime-a"}}' + expect(JSON.parse(JSON.stringify(head))).toBe(head) + expect(assemble([part(0, 2, head), part(1, 2, tail)])).toEqual({ + status: 'complete', + payload: payloadOf('\u{1f600}') + }) + }) + + it('round-trips an astral payload at every cut parity', () => { + for (let padding = 0; padding < 4; padding += 1) { + const payload = payloadOf(`${'x'.repeat(padding)}${'\u{1f600}'.repeat(400_000)}`) + expect(assemble(framesOf(splitBridgeReply(ID, payload)))).toEqual({ + status: 'complete', + payload + }) + } + }) + + it('reassembles frames that arrive out of order', () => { + const payload = payloadOf('x'.repeat(1_500_000)) + const frames = framesOf(splitBridgeReply(ID, payload)) + expect(assemble(frames.toReversed())).toEqual({ status: 'complete', payload }) + }) + + it('keeps two replies apart while both are in flight', () => { + const first = payloadOf('x'.repeat(1_500_000)) + const second = payloadOf('y'.repeat(1_500_000)) + const firstFrames = framesOf(splitBridgeReply(ID, first)) + const secondFrames = framesOf(splitBridgeReply(OTHER_ID, second)) + const assembler = new BridgeReplyAssembler() + for (const frame of [...firstFrames.slice(0, -1), ...secondFrames.slice(0, -1)]) { + expect(assembler.accept(frame)).toEqual({ status: 'pending' }) + } + expect(assembler.accept(secondFrames[secondFrames.length - 1] ?? part(0, 1, ''))).toEqual({ + status: 'complete', + payload: second + }) + expect(assembler.accept(firstFrames[firstFrames.length - 1] ?? part(0, 1, ''))).toEqual({ + status: 'complete', + payload: first + }) + }) +}) + +describe('BridgeReplyAssembler refusals', () => { + it('stays pending while a part is missing', () => { + const assembler = new BridgeReplyAssembler() + expect(assembler.accept(part(0, 3, '{"id"'))).toEqual({ status: 'pending' }) + expect(assembler.accept(part(2, 3, '}'))).toEqual({ status: 'pending' }) + }) + + it('refuses a part index that arrived already, and drops what it held', () => { + const assembler = new BridgeReplyAssembler() + expect(assembler.accept(part(0, 2, 'a'))).toEqual({ status: 'pending' }) + expect(assembler.accept(part(0, 2, 'a'))).toEqual({ + status: 'failed', + refusal: 'duplicate-part' + }) + }) + + it('keeps a refused id refused, so a sender cannot start over on the next part', () => { + const assembler = new BridgeReplyAssembler() + assembler.accept(part(0, 3, '{"id"')) + expect(assembler.accept(part(0, 3, '{"id"'))).toEqual({ + status: 'failed', + refusal: 'duplicate-part' + }) + // A whole set for the same id would otherwise complete, the refusal forgotten. + const payload = payloadOf('small') + const serialized = JSON.stringify(payload) + for (const index of [0, 1, 2]) { + expect(assembler.accept(part(index, 3, serialized.slice(index * 5, index * 5 + 5)))).toEqual({ + status: 'failed', + refusal: 'duplicate-part' + }) + } + expect( + assembler.accept({ v: BRIDGE_PROTOCOL_VERSION, type: 'reply', id: ID, payload }) + ).toEqual({ status: 'failed', refusal: 'duplicate-part' }) + assembler.discard(ID) + expect(assembler.accept(part(0, 3, '{"id"'))).toEqual({ status: 'pending' }) + }) + + it('refuses every later part of a reply that went past the ceiling', () => { + const assembler = new BridgeReplyAssembler() + const full = 'x'.repeat(BRIDGE_MAX_MESSAGE_BYTES) + let answer: ReturnType = { status: 'pending' } + for (let index = 0; index < 20; index += 1) { + answer = assembler.accept(part(index, 20, full)) + } + // Thirteen full parts pass the ceiling; without the tombstone the rest would keep arriving. + expect(answer).toEqual({ status: 'failed', refusal: 'reply-too-large' }) + }) + + it('refuses a part whose count disagrees with the parts already held', () => { + const assembler = new BridgeReplyAssembler() + expect(assembler.accept(part(0, 2, 'a'))).toEqual({ status: 'pending' }) + expect(assembler.accept(part(1, 3, 'b'))).toEqual({ + status: 'failed', + refusal: 'inconsistent-part' + }) + }) + + it('refuses a part index that is not inside its own count', () => { + expect(new BridgeReplyAssembler().accept(part(2, 2, 'a'))).toEqual({ + status: 'failed', + refusal: 'inconsistent-part' + }) + }) + + it('holds no more half-assembled replies than there can be requests in flight', () => { + const assembler = new BridgeReplyAssembler() + const idOf = (index: number): string => `id${String(index).padStart(20, '0')}` + for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) { + expect(assembler.accept(part(0, 2, 'a', idOf(index)))).toEqual({ status: 'pending' }) + } + const overflowing = idOf(BRIDGE_MAX_PENDING_REQUESTS) + expect(assembler.accept(part(0, 2, 'a', overflowing))).toEqual({ + status: 'failed', + refusal: 'too-many-pending' + }) + // A part for an id already held still lands: the bound is on ids, not on parts. + expect(assembler.accept(part(1, 2, 'b', idOf(0)))).toEqual({ + status: 'failed', + refusal: 'malformed-json' + }) + assembler.discard(overflowing) + expect(assembler.accept(part(0, 2, 'a', overflowing))).toEqual({ status: 'pending' }) + }) + + it('refuses the part that would push every reply in flight past the aggregate', () => { + const assembler = new BridgeReplyAssembler() + const idOf = (index: number): string => `id${String(index).padStart(20, '0')}` + const full = 'x'.repeat(BRIDGE_MAX_MESSAGE_BYTES) + const remainder = 'x'.repeat(BRIDGE_MAX_REPLY_BYTES - 12 * BRIDGE_MAX_MESSAGE_BYTES) + // Four replies each held to exactly the per-reply ceiling is exactly the aggregate. + for (let id = 0; id < 4; id += 1) { + for (let index = 0; index < 12; index += 1) { + expect(assembler.accept(part(index, 20, full, idOf(id)))).toEqual({ status: 'pending' }) + } + expect(assembler.accept(part(12, 20, remainder, idOf(id)))).toEqual({ status: 'pending' }) + } + expect(assembler.accept(part(0, 20, 'x', idOf(4)))).toEqual({ + status: 'failed', + refusal: 'too-many-pending' + }) + expect(assembler.accept(part(13, 20, 'x', idOf(0)))).toEqual({ + status: 'failed', + refusal: 'too-many-pending' + }) + }) + + it('forgets every refusal when it is cleared for teardown', () => { + const assembler = new BridgeReplyAssembler() + assembler.accept(part(0, 2, 'a')) + expect(assembler.accept(part(0, 2, 'a'))).toEqual({ + status: 'failed', + refusal: 'duplicate-part' + }) + assembler.clear() + expect(assembler.accept(part(0, 2, 'a'))).toEqual({ status: 'pending' }) + }) + + it('frees a slot when the page discards an id it abandoned', () => { + const assembler = new BridgeReplyAssembler() + const idOf = (index: number): string => `id${String(index).padStart(20, '0')}` + for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) { + assembler.accept(part(0, 2, 'a', idOf(index))) + } + assembler.discard(idOf(3)) + expect(assembler.accept(part(0, 2, 'a', idOf(BRIDGE_MAX_PENDING_REQUESTS)))).toEqual({ + status: 'pending' + }) + }) + + it('measures the joined reply, so a pair split across two parts is not counted twice', () => { + const payload = ceilingPayload() + const serialized = JSON.stringify(payload) + // One code unit into the first pair: each half would encode as three bytes instead of the four + // the pair costs whole, which is two bytes of headroom this reply does not have. + const cut = serialized.indexOf(ASTRAL) + 1 + expect( + assemble([part(0, 2, serialized.slice(0, cut)), part(1, 2, serialized.slice(cut))]) + ).toEqual({ status: 'complete', payload }) + }) + + it('refuses a joined reply past the ceiling whose code units still fit', () => { + // Astral text is two code units to four bytes, so counting units alone would let this through. + const overhead = JSON.stringify(payloadOf('')).length + const pairs = Math.floor((BRIDGE_MAX_REPLY_BYTES - overhead) / 4) + 1 + const serialized = JSON.stringify(payloadOf(ASTRAL.repeat(pairs))) + expect(utf8ByteLength(serialized)).toBeGreaterThan(BRIDGE_MAX_REPLY_BYTES) + expect(serialized.length).toBeLessThanOrEqual(BRIDGE_MAX_REPLY_BYTES) + const cut = serialized.indexOf(ASTRAL) + 1 + expect( + assemble([part(0, 2, serialized.slice(0, cut)), part(1, 2, serialized.slice(cut))]) + ).toEqual({ status: 'failed', refusal: 'reply-too-large' }) + }) + + it('accepts parts summing to exactly the ceiling', () => { + const assembler = new BridgeReplyAssembler() + const full = 'x'.repeat(BRIDGE_MAX_MESSAGE_BYTES) + for (let index = 0; index < 12; index += 1) { + expect(assembler.accept(part(index, 14, full))).toEqual({ status: 'pending' }) + } + const remaining = BRIDGE_MAX_REPLY_BYTES - 12 * BRIDGE_MAX_MESSAGE_BYTES + expect(assembler.accept(part(12, 14, 'x'.repeat(remaining)))).toEqual({ status: 'pending' }) + }) + + it('aborts one byte past the ceiling', () => { + const assembler = new BridgeReplyAssembler() + const full = 'x'.repeat(BRIDGE_MAX_MESSAGE_BYTES) + for (let index = 0; index < 12; index += 1) { + assembler.accept(part(index, 14, full)) + } + const remaining = BRIDGE_MAX_REPLY_BYTES - 12 * BRIDGE_MAX_MESSAGE_BYTES + expect(assembler.accept(part(12, 14, 'x'.repeat(remaining + 1)))).toEqual({ + status: 'failed', + refusal: 'reply-too-large' + }) + }) + + it('refuses parts that do not reassemble into JSON', () => { + const assembler = new BridgeReplyAssembler() + assembler.accept(part(0, 2, '{"id":')) + expect(assembler.accept(part(1, 2, 'not json'))).toEqual({ + status: 'failed', + refusal: 'malformed-json' + }) + }) + + it('refuses parts that reassemble into something that is not a reply', () => { + const assembler = new BridgeReplyAssembler() + assembler.accept(part(0, 2, '{"id":"r1",')) + expect(assembler.accept(part(1, 2, '"ok":true}'))).toEqual({ + status: 'failed', + refusal: 'unrecognised-message' + }) + }) + + it('drops a half-assembled reply when the whole one arrives instead', () => { + const assembler = new BridgeReplyAssembler() + const payload = payloadOf({ ok: 1 }) + assembler.accept(part(0, 2, '{"id":')) + expect( + assembler.accept({ v: BRIDGE_PROTOCOL_VERSION, type: 'reply', id: ID, payload }) + ).toEqual({ status: 'complete', payload }) + expect(assembler.accept(part(0, 2, '{"id":'))).toEqual({ status: 'pending' }) + }) + + it('forgets a reply the page abandoned', () => { + const assembler = new BridgeReplyAssembler() + assembler.accept(part(0, 2, 'a')) + assembler.discard(ID) + expect(assembler.accept(part(0, 2, 'a'))).toEqual({ status: 'pending' }) + }) + + it('forgets every reply on teardown', () => { + const assembler = new BridgeReplyAssembler() + assembler.accept(part(0, 2, 'a')) + assembler.accept(part(0, 2, 'a', OTHER_ID)) + assembler.clear() + expect(assembler.accept(part(0, 2, 'a'))).toEqual({ status: 'pending' }) + expect(assembler.accept(part(0, 2, 'a', OTHER_ID))).toEqual({ status: 'pending' }) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.ts b/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.ts new file mode 100644 index 00000000000..5545030ad65 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.ts @@ -0,0 +1,244 @@ +import { + BRIDGE_MAX_MESSAGE_BYTES, + BRIDGE_MAX_PENDING_REQUESTS, + BRIDGE_MAX_REPLY_BYTES, + BRIDGE_MAX_REPLY_PARTS, + utf8ByteLength, + type BridgeRefusal +} from './bridge-caps' +import { + BRIDGE_PROTOCOL_VERSION, + BridgeReplyPayloadSchema, + type BridgeReplyMessage, + type BridgeReplyPayload +} from './bridge-envelope' + +/** + * Replies too big for one frame, split and put back together. + * + * A reply is never refused for being over the frame cap: the native screens have no reply byte cap, + * so refusing one would invent a failure the phone does not have today. It is refused only over the + * absolute ceiling, which aborts the request rather than truncating an answer the caller will read. + */ +export type BridgeReplySplit = + | { ok: true; frames: BridgeReplyMessage[] } + | { ok: false; refusal: BridgeRefusal } + +export type BridgeReplyAssembly = + | { status: 'pending' } + | { status: 'complete'; payload: BridgeReplyPayload } + | { status: 'failed'; refusal: BridgeRefusal } + +/** + * `of` is unknown until the split finishes, so a candidate frame is measured with the widest part + * numbers the schema allows. A chunk that fits under that bound fits under the real one. + */ +function partFrameBytes(id: string, chunk: string): number { + return utf8ByteLength( + JSON.stringify({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id, + part: { i: BRIDGE_MAX_REPLY_PARTS, of: BRIDGE_MAX_REPLY_PARTS }, + chunk + }) + ) +} + +/** + * Measure, then accept: the frame that ships is the one that was weighed, so escaping a control + * character or a surrogate split across the cut cannot push it over. A single code unit always + * fits, since the envelope is under a hundred bytes against a 640 KiB frame. + */ +function chunkEnd(id: string, serialized: string, start: number): number { + let end = Math.min(serialized.length, start + BRIDGE_MAX_MESSAGE_BYTES) + while (end - start > 1) { + const bytes = partFrameBytes(id, serialized.slice(start, end)) + if (bytes <= BRIDGE_MAX_MESSAGE_BYTES) { + break + } + const scaled = Math.floor((end - start) * (BRIDGE_MAX_MESSAGE_BYTES / bytes)) + end = start + Math.max(1, Math.min(scaled, end - start - 1)) + } + return end - start > 1 && splitsASurrogatePair(serialized, end) ? end - 1 : end +} + +/** + * A pair cut in half encodes as two replacements, three bytes each, where the pair is four: the + * halves would disagree with the whole about the reply's size, and neither frame would be + * well-formed UTF-8 for the native bridge to carry. Backing the cut up one unit costs one code unit + * of a frame, and shrinking a frame that already fits keeps it fitting. + */ +function splitsASurrogatePair(serialized: string, end: number): boolean { + if (end >= serialized.length) { + return false + } + const last = serialized.charCodeAt(end - 1) + const next = serialized.charCodeAt(end) + return last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff +} + +/** + * A reply at the ceiling splits into fewer parts than the schema admits, because a chunk is JSON + * text re-escaped inside a JSON string and that at worst doubles it. The part cap is stated once, + * by `replyPartSchema`; the derivation is pinned by this module's test. + */ +export function splitBridgeReply(id: string, payload: BridgeReplyPayload): BridgeReplySplit { + let serialized: string + try { + serialized = JSON.stringify(payload) + } catch { + return { ok: false, refusal: 'malformed-json' } + } + if (utf8ByteLength(serialized) > BRIDGE_MAX_REPLY_BYTES) { + return { ok: false, refusal: 'reply-too-large' } + } + const whole: BridgeReplyMessage = { v: BRIDGE_PROTOCOL_VERSION, type: 'reply', id, payload } + if (utf8ByteLength(JSON.stringify(whole)) <= BRIDGE_MAX_MESSAGE_BYTES) { + return { ok: true, frames: [whole] } + } + const chunks: string[] = [] + for (let start = 0; start < serialized.length;) { + const end = chunkEnd(id, serialized, start) + chunks.push(serialized.slice(start, end)) + start = end + } + return { + ok: true, + frames: chunks.map((chunk, index) => ({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id, + part: { i: index, of: chunks.length }, + chunk + })) + } +} + +type PendingReply = { of: number; chunks: Map; units: number } + +/** + * Every half-assembled reply together. Without it, the per-reply ceiling times the in-flight cap is + * half a gigabyte of parts that never complete. Four whole replies at once is more than the page + * asks for and far less than the phone can lose. + */ +const BRIDGE_MAX_ASSEMBLING_BYTES = BRIDGE_MAX_REPLY_BYTES * 4 + +/** + * Parts may arrive in any order, so they are held by index rather than appended. + * + * A failed id stays failed. Dropping it and starting over on the next part is what lets a sender + * walk past the ceiling one refusal at a time, so the refusal is remembered and every later part + * for that id gets the same answer. `discard` is how the page says the id is finished with, which + * is also how it becomes usable again. + * + * The number of ids held at once is bounded by the in-flight request cap, since a reply only exists + * for a request the page made, and their bytes together by `BRIDGE_MAX_ASSEMBLING_BYTES`. Nothing + * here expires an id on its own, so C0.4 has to `discard` the id of every request it settles or + * abandons, or a lost final part holds a slot until teardown. + */ +export class BridgeReplyAssembler { + private readonly pending = new Map() + private readonly refused = new Map() + + accept(message: BridgeReplyMessage): BridgeReplyAssembly { + const refusal = this.refused.get(message.id) + if (refusal !== undefined) { + return { status: 'failed', refusal } + } + if (!('part' in message)) { + this.pending.delete(message.id) + return { status: 'complete', payload: message.payload } + } + const { id, part, chunk } = message + const held = this.pending.get(id) + if (part.i >= part.of || (held !== undefined && held.of !== part.of)) { + return this.fail(id, 'inconsistent-part') + } + if (held === undefined && this.pending.size >= BRIDGE_MAX_PENDING_REQUESTS) { + return this.fail(id, 'too-many-pending') + } + const entry = held ?? { of: part.of, chunks: new Map(), units: 0 } + if (entry.chunks.has(part.i)) { + return this.fail(id, 'duplicate-part') + } + if (this.assemblingUnits() + chunk.length > BRIDGE_MAX_ASSEMBLING_BYTES) { + return this.fail(id, 'too-many-pending') + } + // Code units, not bytes: a reply is never fewer bytes than code units, so this bounds what is + // held without refusing a reply the joined measurement would accept. The ceiling itself is + // checked once, on the joined text, because a pair split across two parts is four bytes whole + // and six counted half by half. + const units = entry.units + chunk.length + if (units > BRIDGE_MAX_REPLY_BYTES) { + return this.fail(id, 'reply-too-large') + } + entry.chunks.set(part.i, chunk) + entry.units = units + this.pending.set(id, entry) + if (entry.chunks.size < entry.of) { + return { status: 'pending' } + } + this.pending.delete(id) + return readAssembledPayload(entry) + } + + /** For a request the page abandoned, and for teardown. Also how a refused id is reopened. */ + discard(id: string): void { + this.pending.delete(id) + this.refused.delete(id) + } + + clear(): void { + this.pending.clear() + this.refused.clear() + } + + /** Code units, for the same reason the per-reply bound counts them: never more than the bytes. */ + private assemblingUnits(): number { + let units = 0 + for (const entry of this.pending.values()) { + units += entry.units + } + return units + } + + private fail(id: string, refusal: BridgeRefusal): BridgeReplyAssembly { + this.pending.delete(id) + // The oldest refusal goes rather than the map growing: an id the page has not discarded in 64 + // refusals is one it is no longer waiting on. + if (this.refused.size >= BRIDGE_MAX_PENDING_REQUESTS) { + const oldest = this.refused.keys().next() + if (!oldest.done) { + this.refused.delete(oldest.value) + } + } + this.refused.set(id, refusal) + return { status: 'failed', refusal } + } +} + +/** + * The reassembled body is checked as a reply payload and against the reply ceiling, which the + * assembler already applied, and against nothing else: the document caps bound the page's traffic, + * not the desktop's answers. + */ +function readAssembledPayload(entry: PendingReply): BridgeReplyAssembly { + const joined = [...entry.chunks.entries()] + .sort(([left], [right]) => left - right) + .map(([, chunk]) => chunk) + .join('') + if (utf8ByteLength(joined) > BRIDGE_MAX_REPLY_BYTES) { + return { status: 'failed', refusal: 'reply-too-large' } + } + let parsed: unknown + try { + parsed = JSON.parse(joined) + } catch { + return { status: 'failed', refusal: 'malformed-json' } + } + const payload = BridgeReplyPayloadSchema.safeParse(parsed) + return payload.success + ? { status: 'complete', payload: payload.data } + : { status: 'failed', refusal: 'unrecognised-message' } +} From 3aefee4a13ee34309acd45e8816e1e47ce9842d3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:38:47 -0400 Subject: [PATCH 025/224] feat(mobile): native page-shell bridge in orca-mobile-web-shell (OTA phase C, C0.2) (#21434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): native page↔shell bridge in orca-mobile-web-shell (OTA phase C, C0.2) Adds one prop, one event and one view function to the shell view, off unless asked for: with `bridgeEnabled` false nothing is registered on either platform, so Phase B's behaviour is byte-identical. iOS accepts a `WKScriptMessageHandler` message only from our own WebView, the main frame, the `orca-mobile-web` scheme and the session we loaded under, and replies through `callAsyncJavaScript` with the payload bound as a real JS value. Android registers a `WebMessageListener` gated on a `WEB_MESSAGE_LISTENER` feature query (Chromium 88; unsupported is `isolation-unavailable`, and only when the bridge was asked for) and replies through the reply proxy. Simulator-measured before any acceptance logic was written: WKFrameInfo's securityOrigin does populate for the custom scheme, but WebKit ASCII-lowercases the host, so `orca-mobile-web://sess-01JN_aZ9/` reports `sess-01jn_az9`. Exact equality would refuse every message from a mixed-case session id. Folding is ASCII-only rather than caseInsensitiveCompare, because U+212A KELVIN SIGN folds to `k` under Unicode and would match a host nobody minted. The 640 KiB cap is measured on the raw UTF-8 string. Inbound it is a silent, counted refusal; outbound `postBridgeMessage` throws, because its only caller is the host and a dropped reply is a request that never settles. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pick the completion-handler callAsyncJavaScript overload The trailing closure resolved to the `async` overload, which the compiler read as an extra trailing closure. The label is `in contentWorld:`, and naming the completion handler is what selects the synchronous one. Restates the two exception classes' inherited Sendable conformance, which Swift 6 warns on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fold the request host ASCII-only, shared with the bridge `resolveRequestPath` compared the request host with `caseInsensitiveCompare`, which folds U+212A KELVIN SIGN to `k`, so a host nobody minted could match a session id containing `k` and be served every asset. Both predicates now use one `MobileWebShellOrigin.asciiLowercased`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): converge the shell load guard on applied props, not install success The re-entry guard compared `bridgeEnabled` with `bridgeInstalled`, which is written only where the install succeeds. With the prop true, every early return — malformed session id, unreadable generation, a WebView with no WEB_MESSAGE_LISTENER — left the two unequal, so the next prop commit re-entered, reset the state machine and re-emitted loading then failed, forever. Both platforms now record the prop triple and compare it field by field in one pure `MobileWebShellAppliedProps.matches`, checked by swiftc and JUnit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): settle postBridgeMessage on delivery and bind it to the frame that spoke postBridgeMessage resolved whatever happened: the completion handler was nil, and `bridgeInstalled` stayed true after the renderer died and after a failed prop update, so the host's request never settled. It also posted with `in: nil`, which means the current main frame, while page to native binds to the applied session. Both ends now use the frame the last accepted message came from, checked against the applied session id with the same ASCII fold, and the promise is rejected when there is nowhere to post or when WebKit reports the delivery failed. Android drops its reply proxy on the same three events for parity. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the bridge delivery script throw when the page has no bridge `if (bridge) { bridge.__deliver(m) }` made a page the installer never ran in indistinguishable from a delivered message: the script completed, so callAsyncJavaScript succeeded, so the host's promise resolved on a message nobody received. Unguarded, the missing global throws and the promise rejects. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the applied-props record to the fields it compares Nothing failed if a fourth prop joined the record and no comparison mentioned it — the prop would simply never reload. Both suites now assert the record's stored fields by name, so adding one without deciding whether it re-enters is red rather than silent. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): import assertEquals for the applied-props field pin Belongs with the previous commit, which left the import behind; no amend. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse and unbind the document a prop update replaced Two ways the previous document kept speaking for the load that replaced it. On Android a failed prop update nulled `served` and the reply proxy but left the web message listener installed, so a page still alive after `stopLoading` posted through a listener bound to the origin this mount had stopped serving, and re-armed the proxy doing it. Every disable path now goes through one removal. On both platforms that document is same-origin whenever only the directory or the bridge prop changed, so it passed acceptance between `stopLoading` and the next commit and emitted after the host was told `loading`. Acceptance is now armed at navigation commit — `didCommit` on iOS, `onPageStarted` on Android — and disarmed by a new prop triple, a failure, and a renderer that died. The state lives in the load-state machine and the arming clause is a field of the pure accept predicate, so both are checked by swiftc and JUnit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the bridge post target only for the document that armed it `WKFrameInfo` outlives the frame it describes, so the held target has to be cleared at the commit that re-opens arming as well as at the provisional start, and a post in flight between the two has no document to go to. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): publish the Android bridge state written off the main thread `reportDocumentFailure` runs from `shouldInterceptRequest`, so the reply proxy it drops and the commit flag it clears are written off the UI thread that reads them. Same reason `documentFailed` and `served` already carry it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what a resolved postBridgeMessage does not prove Android's reply proxy is void with no acknowledgement, so resolve there means enqueued. The shared handle promised delivery, which is only ever an iOS answer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../MobileWebShellAppliedProps.kt | 25 ++ .../MobileWebShellBridge.kt | 72 ++++++ .../MobileWebShellLoadState.kt | 27 ++- .../orcamobilewebshell/MobileWebShellView.kt | 125 +++++++++- .../OrcaMobileWebShellModule.kt | 10 +- .../MobileWebShellAppliedPropsTest.kt | 45 ++++ .../MobileWebShellBridgeTest.kt | 87 +++++++ .../MobileWebShellLoadStateTest.kt | 27 +++ .../ios/MobileWebShellAppliedProps.swift | 25 ++ .../ios/MobileWebShellBridge.swift | 108 +++++++++ .../ios/MobileWebShellLoadState.swift | 18 ++ .../ios/MobileWebShellOrigin.swift | 20 +- .../ios/MobileWebShellView.swift | 223 +++++++++++++++++- .../ios/OrcaMobileWebShellModule.swift | 11 +- .../orca-mobile-web-shell/src/index.ts | 48 +++- .../tests/MobileWebShellChecks.swift | 215 +++++++++++++++++ 16 files changed, 1055 insertions(+), 31 deletions(-) create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedProps.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellBridge.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedPropsTest.kt create mode 100644 mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellBridgeTest.kt create mode 100644 mobile/modules/orca-mobile-web-shell/ios/MobileWebShellAppliedProps.swift create mode 100644 mobile/modules/orca-mobile-web-shell/ios/MobileWebShellBridge.swift diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedProps.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedProps.kt new file mode 100644 index 00000000000..b4c96c9053a --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedProps.kt @@ -0,0 +1,25 @@ +package expo.modules.orcamobilewebshell + +/** + * The prop triple a load was started for, and the only thing that decides whether the next prop + * commit re-enters. The same rule as the Swift copy. + * + * Recording the props rather than the outcome is what makes a failure converge. A guard that reads + * whether the bridge actually installed never agrees with a prop that is true but could not be + * honoured — a malformed session id, an unreadable generation, a WebView too old for the listener — + * so every later commit re-enters, resets the machine, and re-emits loading then failed forever. + */ +internal class MobileWebShellAppliedProps( + private val generationDirectory: String, + val sessionId: String, + private val bridgeEnabled: Boolean +) { + /** + * Field by field rather than a data class: a generated `equals` would grow with any field added + * to the record, which is how a prop nobody meant to be a reload becomes one. + */ + fun matches(other: MobileWebShellAppliedProps): Boolean = + generationDirectory == other.generationDirectory && + sessionId == other.sessionId && + bridgeEnabled == other.bridgeEnabled +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellBridge.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellBridge.kt new file mode 100644 index 00000000000..e2323044994 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellBridge.kt @@ -0,0 +1,72 @@ +package expo.modules.orcamobilewebshell + +/** + * The `WebMessageListener` name, which is also the global Chromium injects into the page. iOS + * installs a global of the same name, so one page reaches both shells. + */ +internal const val MOBILE_WEB_SHELL_BRIDGE_OBJECT = "orcaBridge" + +/** + * Measured on the raw JSON string in UTF-8, before anything parses it. The TypeScript contract holds + * the same ceiling; native is the one that cannot be talked out of it. + */ +internal const val MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES = 640 * 1024 + +internal fun acceptsMobileWebShellBridgeByteCount(byteCount: Int): Boolean = + byteCount <= MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES + +/** + * Chromium enforces the allowed-origin set before the listener runs, so the origin is not re-checked + * here; what is left is the frame. CSP already says `frame-src 'none'`, but the injected object + * reaches every same-origin frame, so the shell states the main-frame rule itself rather than + * inheriting it from a header a future bundle could need relaxed. + * + * The document the current props replaced is same-origin whenever only the directory or the bridge + * prop changed, and it is alive until the next one commits, so it has to be refused by when it + * spoke rather than by where it spoke from. + */ +internal fun acceptsMobileWebShellBridgeFrame( + isMainFrame: Boolean, + isStringMessage: Boolean, + hasCommittedDocument: Boolean +): Boolean = isMainFrame && isStringMessage && hasCommittedDocument + +/** + * Refusal is silent: the shell exposes no new state and tells the page nothing, because a page that + * learns which messages were dropped learns the cap. The tally is what a test can hold the cap to. + */ +internal class MobileWebShellBridgeGate { + var refusedCount = 0 + private set + + fun accepts(byteCount: Int): Boolean { + if (!acceptsMobileWebShellBridgeByteCount(byteCount)) { + refusedCount += 1 + return false + } + return true + } +} + +/** What a prop update should do about the listener, decided before any WebView call. */ +internal enum class MobileWebShellBridgeInstall { + /** The prop is false, so nothing is registered and Phase B behaviour is byte-identical. */ + SKIP, + INSTALL, + /** The WebView provider is older than `WEB_MESSAGE_LISTENER` (Chromium 88). Terminal. */ + UNAVAILABLE +} + +/** + * The floor is asked as a feature query and never as a version string: the query is the capability. + * An unsupported provider only matters when the bridge was asked for, so the enabled check comes + * first — with the prop false the shell must load on a WebView the bridge could not run on. + */ +internal fun mobileWebShellBridgeInstall( + bridgeEnabled: Boolean, + isListenerSupported: Boolean +): MobileWebShellBridgeInstall = when { + !bridgeEnabled -> MobileWebShellBridgeInstall.SKIP + isListenerSupported -> MobileWebShellBridgeInstall.INSTALL + else -> MobileWebShellBridgeInstall.UNAVAILABLE +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt index 6255a01ccd3..7836824a87c 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt @@ -22,21 +22,45 @@ internal data class MobileWebShellLoadEmission(val state: String, val reason: St * generation was already refused, so without this a `ready` or a second reason lands on top of a * failure the caller has already acted on. Consecutive duplicates are dropped as well. * - * Pure, and the same rule on both platforms, so a JVM test and a `swiftc` check can hold it. + * Pure, and the same rule on both platforms, so a JVM test and a `swiftc` check can hold it. The + * two fields a caller reads directly are volatile: Android decides a document failure from + * `shouldInterceptRequest`, which Chromium does not run on the UI thread. */ internal class MobileWebShellLoadStateMachine { private var terminal = false private var last: MobileWebShellLoadEmission? = null /** Which load this machine is reporting on. Read before deferring work, checked on delivery. */ + @Volatile var epoch: Int = 0 private set + /** + * Whether a document under the current prop triple has committed. The document a load replaces + * stays alive between `stopLoading` and the next commit, and it is same-origin whenever only the + * directory or the bridge prop changed, so without this it passes every origin check and speaks + * for a load the caller has already been told is `loading`. + */ + @Volatile + var hasCommittedDocument = false + private set + /** A new prop pair. Nothing else reopens a terminal state: a retry is a remount. */ fun reset() { terminal = false last = null epoch += 1 + documentEnded() + } + + fun committed() { + if (terminal) return + hasCommittedDocument = true + } + + /** The committed document is gone: a new load, a failure, or a renderer that died. */ + fun documentEnded() { + hasCommittedDocument = false } fun started(): MobileWebShellLoadEmission? = emit(MobileWebShellLoadEmission("loading", null)) @@ -46,6 +70,7 @@ internal class MobileWebShellLoadStateMachine { fun failed(reason: MobileWebShellFailureReason): MobileWebShellLoadEmission? { val emission = emit(MobileWebShellLoadEmission("failed", reason.wireName)) terminal = true + documentEnded() return emission } diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt index 7dfaae4cb78..866d05cf77d 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt @@ -15,8 +15,13 @@ import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ScriptHandler +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import expo.modules.kotlin.AppContext +import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView import java.io.ByteArrayInputStream @@ -38,11 +43,19 @@ internal class OrcaMobileWebShellView( appContext: AppContext ) : ExpoView(context, appContext) { private val onLoadState by EventDispatcher>() + private val onBridgeMessage by EventDispatcher>() private var generationDirectory = "" private var sessionId = "" - private var appliedDirectory: String? = null - private var appliedSessionId: String? = null + private var bridgeEnabled = false + private var bridgeInstalled = false + private val bridgeGate = MobileWebShellBridgeGate() + // Chromium hands a reply proxy to the listener, so native cannot speak first. The envelope has + // the page send `ready` before anything is delivered, so there is nothing to speak first about. + // Volatile for the same reason as `documentFailed`: `reportDocumentFailure` drops the proxy from + // whichever thread `shouldInterceptRequest` ran on, and the listener reads it on the UI thread. + @Volatile private var replyProxy: JavaScriptReplyProxy? = null + private var applied: MobileWebShellAppliedProps? = null private val loadState = MobileWebShellLoadStateMachine() // Written on the main thread, read from onPageStarted/onPageFinished, which Chromium runs after // the failure that hid the view; `shouldInterceptRequest` also runs off the main thread. @@ -63,14 +76,18 @@ internal class OrcaMobileWebShellView( sessionId = value } + fun setBridgeEnabled(value: Boolean) { + bridgeEnabled = value + } + /** * Props arrive in no defined order, so neither setter starts anything; this does, once both are - * in. A repeat of the same pair is not a retry: a retry is a remount under a new React key. + * in. A repeat of the same triple is not a retry: a retry is a remount under a new React key. */ fun propsDidUpdate() { - if (generationDirectory == appliedDirectory && sessionId == appliedSessionId) return - appliedDirectory = generationDirectory - appliedSessionId = sessionId + val next = MobileWebShellAppliedProps(generationDirectory, sessionId, bridgeEnabled) + if (applied?.matches(next) == true) return + applied = next documentFailed = false loadState.reset() val view = webView @@ -101,6 +118,10 @@ internal class OrcaMobileWebShellView( failPropUpdate(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE) return } + if (!applyBridgeListener(view, origin)) { + failPropUpdate(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE) + return + } served = MobileWebShellServed(loaded, host) view.visibility = View.VISIBLE view.loadUrl("$origin/") @@ -111,14 +132,87 @@ internal class OrcaMobileWebShellView( * served and visible would show a page the caller has just been told is not loaded. */ private fun failPropUpdate(reason: MobileWebShellFailureReason) { + // The listener outlives the props it was installed under, and the document it was installed + // for is still alive after `stopLoading`: left in place it would keep posting through an + // origin this mount has just stopped serving, and re-arm the reply proxy doing it. + removeBridgeListener() served = null webView?.visibility = View.INVISIBLE emit(loadState.failed(reason)) } + /** + * `addWebMessageListener` is the whole install: Chromium injects an `orcaBridge` object of the + * agreed shape before any page script runs, and enforces the allowed origin itself, which is why + * the listener needs no origin check of its own. Answers false only for a provider too old to + * offer the listener at all. + */ + private fun applyBridgeListener(view: WebView, origin: String): Boolean { + removeBridgeListener() + val outcome = mobileWebShellBridgeInstall( + bridgeEnabled, + WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) + ) + if (outcome != MobileWebShellBridgeInstall.INSTALL) { + return outcome == MobileWebShellBridgeInstall.SKIP + } + return runCatching { + WebViewCompat.addWebMessageListener( + view, + MOBILE_WEB_SHELL_BRIDGE_OBJECT, + setOf(origin), + bridgeListener + ) + bridgeInstalled = true + }.isSuccess + } + + /** The one way the bridge goes away, so no disable path can leave a listener behind. */ + private fun removeBridgeListener() { + val view = webView + if (bridgeInstalled && view != null) { + WebViewCompat.removeWebMessageListener(view, MOBILE_WEB_SHELL_BRIDGE_OBJECT) + } + bridgeInstalled = false + replyProxy = null + } + + /** Chromium calls this on the UI thread, which is also the only thread that may reply. */ + private val bridgeListener = WebViewCompat.WebMessageListener { + _, message, _, isMainFrame, proxy -> + val isStringMessage = message.type == WebMessageCompat.TYPE_STRING + val json = if (isStringMessage) message.data else null + if ( + acceptsMobileWebShellBridgeFrame( + isMainFrame, + isStringMessage, + loadState.hasCommittedDocument + ) && json != null && + bridgeGate.accepts(json.toByteArray(Charsets.UTF_8).size) + ) { + replyProxy = proxy + onBridgeMessage(mapOf("json" to json)) + } + } + + /** + * Thrown rather than dropped: the only caller is the React Native host, and a silent drop would + * turn a chunking bug there into a request that never settles. + */ + fun postBridgeMessage(json: String) { + val proxy = replyProxy ?: throw MobileWebShellBridgeUnavailableException() + val byteCount = json.toByteArray(Charsets.UTF_8).size + if (!acceptsMobileWebShellBridgeByteCount(byteCount)) { + throw MobileWebShellBridgeMessageTooLargeException(byteCount) + } + proxy.postMessage(json) + } + /** Expo calls this once React Native is done with the view, and onRenderProcessGone calls it. */ fun destroyWebView() { val view = webView ?: return + removeBridgeListener() + loadState.documentEnded() webView = null blocker?.remove() blocker = null @@ -183,6 +277,10 @@ internal class OrcaMobileWebShellView( * thing on screen. `shouldInterceptRequest` also runs off the main thread. */ private fun reportDocumentFailure() { + replyProxy = null + // Synchronously, unlike the emission: the error document commits before the post runs, and a + // page that failed is not one to hear from in the meantime. + loadState.documentEnded() // Set before the post, not inside it: onPageFinished runs in between and would otherwise // report `ready` over the failure and make the error page visible again. documentFailed = true @@ -264,7 +362,14 @@ internal class OrcaMobileWebShellView( ) override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + // The document that spoke is being replaced, so its proxy stops being somewhere to post: the + // next one has to say `ready` first, which is what the envelope has it do. + replyProxy = null + loadState.documentEnded() if (documentFailed || !isDocumentUrl(Uri.parse(url))) return + // The load the caller was told about is the one now on screen, so this is where the page + // becomes something to hear. Chromium runs page script after this. + loadState.committed() emit(loadState.started()) } @@ -303,3 +408,11 @@ internal class OrcaMobileWebShellView( } } } + +internal class MobileWebShellBridgeUnavailableException : + CodedException("The mobile web shell bridge is not installed on this view") + +internal class MobileWebShellBridgeMessageTooLargeException(byteCount: Int) : CodedException( + "A bridge message of $byteCount bytes exceeds the " + + "$MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES byte cap" +) diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt index ecb410d23e7..042f25e9f31 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt @@ -8,7 +8,7 @@ class OrcaMobileWebShellModule : Module() { Name("OrcaMobileWebShell") View(OrcaMobileWebShellView::class) { - Events("onLoadState") + Events("onLoadState", "onBridgeMessage") Prop("generationDirectory") { view: OrcaMobileWebShellView, value: String -> view.setGenerationDirectory(value) @@ -18,6 +18,14 @@ class OrcaMobileWebShellModule : Module() { view.setSessionId(value) } + Prop("bridgeEnabled") { view: OrcaMobileWebShellView, value: Boolean -> + view.setBridgeEnabled(value) + } + + AsyncFunction("postBridgeMessage") { view: OrcaMobileWebShellView, json: String -> + view.postBridgeMessage(json) + } + OnViewDidUpdateProps { view: OrcaMobileWebShellView -> view.propsDidUpdate() } diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedPropsTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedPropsTest.kt new file mode 100644 index 00000000000..d9bbd327f52 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedPropsTest.kt @@ -0,0 +1,45 @@ +package expo.modules.orcamobilewebshell + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MobileWebShellAppliedPropsTest { + private fun props( + generationDirectory: String = "/gen/aa", + sessionId: String = "sess-01JN_aZ9", + bridgeEnabled: Boolean = true + ) = MobileWebShellAppliedProps(generationDirectory, sessionId, bridgeEnabled) + + @Test + fun `the same triple does not re-enter`() { + assertTrue(props().matches(props())) + } + + @Test + fun `every field re-enters on its own`() { + assertFalse(props().matches(props(generationDirectory = "/gen/ab"))) + assertFalse(props().matches(props(sessionId = "sess-01JN_aZ8"))) + assertFalse(props().matches(props(bridgeEnabled = false))) + } + + @Test + fun `compares every stored field`() { + // A fourth prop that nobody compared is a prop that silently never reloads, so the record's + // shape is pinned here rather than left to whoever adds the field. + val fields = MobileWebShellAppliedProps::class.java.declaredFields + .filterNot { it.isSynthetic } + .map { it.name } + .sorted() + assertEquals(listOf("bridgeEnabled", "generationDirectory", "sessionId"), fields) + } + + @Test + fun `a triple that failed to apply is still applied`() { + // The prop pair that could not install the listener is compared like any other: the caller sees + // isolation-unavailable once, not on every commit for the life of the mount. + val failed = props(generationDirectory = "/gen/corrupt") + assertTrue(failed.matches(props(generationDirectory = "/gen/corrupt"))) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellBridgeTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellBridgeTest.kt new file mode 100644 index 00000000000..672e4ca8488 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellBridgeTest.kt @@ -0,0 +1,87 @@ +package expo.modules.orcamobilewebshell + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MobileWebShellBridgeTest { + @Test + fun `caps a message at 640 KiB of raw bytes`() { + val cap = MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES + assertEquals(640 * 1024, cap) + assertTrue(acceptsMobileWebShellBridgeByteCount(0)) + assertTrue(acceptsMobileWebShellBridgeByteCount(cap - 1)) + assertTrue(acceptsMobileWebShellBridgeByteCount(cap)) + assertFalse(acceptsMobileWebShellBridgeByteCount(cap + 1)) + } + + @Test + fun `measures the cap in UTF-8 bytes, not characters`() { + // A multi-byte payload must not buy extra room; the view measures the same way. + val wide = "😀".repeat(4) + assertEquals(8, wide.length) + assertEquals(16, wide.toByteArray(Charsets.UTF_8).size) + } + + @Test + fun `counts every refusal and lets nothing under the cap through uncounted`() { + val cap = MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES + val gate = MobileWebShellBridgeGate() + assertEquals(0, gate.refusedCount) + assertTrue(gate.accepts(cap)) + assertEquals(0, gate.refusedCount) + assertFalse(gate.accepts(cap + 1)) + assertFalse(gate.accepts(cap * 2)) + assertEquals(2, gate.refusedCount) + } + + @Test + fun `hears only a string message from the main frame of a committed document`() { + assertTrue(frame()) + // CSP says frame-src 'none', but Chromium injects the object into every same-origin frame, so + // the shell states the rule itself rather than inheriting it from a header C0.7 has to relax. + assertFalse(frame(isMainFrame = false)) + // An ArrayBuffer message: getData() throws on one, and base64 in JSON is the only binary lane. + assertFalse(frame(isStringMessage = false)) + assertFalse(frame(isMainFrame = false, isStringMessage = false)) + // The document the current props replaced, still alive and still same-origin, speaking for a + // load the caller has already been told is `loading`. + assertFalse(frame(hasCommittedDocument = false)) + } + + private fun frame( + isMainFrame: Boolean = true, + isStringMessage: Boolean = true, + hasCommittedDocument: Boolean = true + ) = acceptsMobileWebShellBridgeFrame(isMainFrame, isStringMessage, hasCommittedDocument) + + @Test + fun `asks for the listener only when the bridge was asked for`() { + // The floor is a feature query, never a version string. With the prop false the shell must + // still load on a provider that could not have run the bridge at all. + assertEquals( + MobileWebShellBridgeInstall.SKIP, + mobileWebShellBridgeInstall(bridgeEnabled = false, isListenerSupported = false) + ) + assertEquals( + MobileWebShellBridgeInstall.SKIP, + mobileWebShellBridgeInstall(bridgeEnabled = false, isListenerSupported = true) + ) + assertEquals( + MobileWebShellBridgeInstall.INSTALL, + mobileWebShellBridgeInstall(bridgeEnabled = true, isListenerSupported = true) + ) + assertEquals( + MobileWebShellBridgeInstall.UNAVAILABLE, + mobileWebShellBridgeInstall(bridgeEnabled = true, isListenerSupported = false) + ) + } + + @Test + fun `names the injected object the same thing on both platforms`() { + // iOS installs a global of this name from its document-start script; a swap here is a page that + // reaches one shell and not the other. + assertEquals("orcaBridge", MOBILE_WEB_SHELL_BRIDGE_OBJECT) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt index 05785ad9293..71188f49657 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt @@ -1,8 +1,10 @@ package expo.modules.orcamobilewebshell import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test private fun failure(reason: String) = MobileWebShellLoadEmission("failed", reason) @@ -21,6 +23,31 @@ class MobileWebShellLoadStateTest { ) } + @Test + fun `hears a document only between its commit and the end of that load`() { + val machine = MobileWebShellLoadStateMachine() + assertFalse(machine.hasCommittedDocument) + machine.started() + // The previous document is alive and same-origin until the next one commits. + assertFalse(machine.hasCommittedDocument) + machine.committed() + assertTrue(machine.hasCommittedDocument) + + // A new prop triple: the committed document is the one being replaced. + machine.reset() + assertFalse(machine.hasCommittedDocument) + machine.committed() + machine.documentEnded() + assertFalse(machine.hasCommittedDocument) + + // A failure ends the document, and nothing after it re-arms: a retry is a remount. + machine.committed() + machine.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE) + assertFalse(machine.hasCommittedDocument) + machine.committed() + assertFalse(machine.hasCommittedDocument) + } + @Test fun `reports a load in progress and then a load that finished`() { val machine = MobileWebShellLoadStateMachine() diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellAppliedProps.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellAppliedProps.swift new file mode 100644 index 00000000000..8180f5cbf41 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellAppliedProps.swift @@ -0,0 +1,25 @@ +import Foundation + +/// The prop triple a load was started for, and the only thing that decides whether the next prop +/// commit re-enters. +/// +/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc` +/// and checks it without a device or a simulator. +/// +/// Recording the props rather than the outcome is what makes a failure converge. A guard that reads +/// whether the bridge actually installed never agrees with a prop that is true but could not be +/// honoured — a malformed session id, an unreadable generation, a WebView too old for the listener +/// — so every later commit re-enters, resets the machine, and re-emits loading then failed forever. +struct MobileWebShellAppliedProps { + var generationDirectory: String + var sessionId: String + var bridgeEnabled: Bool + + /// Field by field rather than `Equatable`: a synthesized `==` would grow with any field added to + /// the record, which is how a prop nobody meant to be a reload becomes one. + func matches(_ other: MobileWebShellAppliedProps) -> Bool { + generationDirectory == other.generationDirectory + && sessionId == other.sessionId + && bridgeEnabled == other.bridgeEnabled + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellBridge.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellBridge.swift new file mode 100644 index 00000000000..61629a6cf96 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellBridge.swift @@ -0,0 +1,108 @@ +import Foundation + +/// The page ↔ native message channel: what it is called, how big a message may be, and the +/// predicate that decides whether a script message came from the document we served. +/// +/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc` +/// and checks it without a device or a simulator. +enum MobileWebShellBridge { + /// The `WKScriptMessageHandler` name and the global the document-start script installs. Android + /// uses the same name for its `WebMessageListener`, so one page reaches both shells. + static let handlerName = "orcaBridge" + + /// Measured on the raw JSON string in UTF-8, before anything parses it. The TypeScript contract + /// holds the same ceiling; native is the one that cannot be talked out of it. + static let maxMessageByteCount = 640 * 1024 + + /// Every clause is an allow, so a message shape nobody anticipated is refused rather than passed. + /// + /// Simulator-verified 2026-09-18: `WKFrameInfo.securityOrigin` does populate for a custom scheme, + /// but WebKit ASCII-lowercases the host, so `orca-mobile-web://sess-01JN_aZ9/` reports host + /// `sess-01jn_az9`. Session ids are base64url and mixed case, so exact equality would refuse every + /// message; the fold is `MobileWebShellOrigin.asciiLowercased`, shared with the request predicate. + static func accepts(_ source: MobileWebShellBridgeSource, sessionId: String) -> Bool { + guard + source.isOurWebView, + source.isMainFrame, + source.hasCommittedDocument, + source.originProtocol == MobileWebShellOrigin.scheme, + MobileWebShellOrigin.isValidSessionId(sessionId), + MobileWebShellOrigin.asciiLowercased(source.originHost) + == MobileWebShellOrigin.asciiLowercased(sessionId) + else { return false } + return true + } + + /// WebKit hands the handler no reply proxy, so a native → page post has to name a frame itself. + /// The frame is the one the last accepted message came from, and nil is the whole answer for a + /// page that has never spoken, a load that failed and a renderer that died: a post with nowhere + /// proven to go is refused, never delivered to whatever frame happens to be current. + /// + /// `hasCommittedDocument` is the same arming acceptance reads. Between a new provisional + /// navigation and its commit there is no document the held frame belongs to, and `WKFrameInfo` is + /// a snapshot that outlives the frame it describes, so it cannot be asked. + static func canPost( + toFrameOriginHost host: String?, + sessionId: String, + hasCommittedDocument: Bool + ) -> Bool { + guard + hasCommittedDocument, + let host, + MobileWebShellOrigin.isValidSessionId(sessionId), + MobileWebShellOrigin.asciiLowercased(host) + == MobileWebShellOrigin.asciiLowercased(sessionId) + else { return false } + return true + } + + static func acceptsByteCount(_ byteCount: Int) -> Bool { + byteCount <= maxMessageByteCount + } +} + +/// Where a native post may go: the frame of the last accepted message and the host that frame +/// reported when it spoke. One value, so the frame and the host it is checked against can never be +/// from different documents, and generic over the frame so the rule needs no WebKit type. +/// +/// Held for the document that armed it and no longer. Every boundary that ends that document clears +/// it — a new provisional navigation, the commit that replaces it, a load failure, a dead renderer, +/// a prop update — so the document now on screen has to speak before anything is posted to it. +struct MobileWebShellBridgeTarget { + private var armed: (frame: Frame, originHost: String)? + + var frame: Frame? { armed?.frame } + var originHost: String? { armed?.originHost } + + mutating func arm(frame: Frame, originHost: String) { + armed = (frame: frame, originHost: originHost) + } + + mutating func clear() { + armed = nil + } +} + +/// A script message reduced to what the predicate reads, so the predicate needs no WebKit type. +struct MobileWebShellBridgeSource { + var isOurWebView: Bool + var isMainFrame: Bool + /// Whether a document has committed under the props this message is being judged against. + var hasCommittedDocument: Bool + var originProtocol: String + var originHost: String +} + +/// Refusal is silent: the shell exposes no new state and tells the page nothing, because a page that +/// learns which messages were dropped learns the cap. The tally is what a test can hold the cap to. +final class MobileWebShellBridgeGate { + private(set) var refusedCount = 0 + + func accepts(byteCount: Int) -> Bool { + guard MobileWebShellBridge.acceptsByteCount(byteCount) else { + refusedCount += 1 + return false + } + return true + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift index 37b7233b995..200b5330c00 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift @@ -23,10 +23,27 @@ final class MobileWebShellLoadStateMachine { private var isTerminal = false private var last: MobileWebShellLoadEmission? + /// Whether a document under the current prop triple has committed. The document a load replaces + /// stays alive between `stopLoading` and the next commit, and it is same-origin whenever only the + /// directory or the bridge prop changed, so without this it passes every origin check and speaks + /// for a load the caller has already been told is `loading`. + private(set) var hasCommittedDocument = false + /// A new prop pair. Nothing else reopens a terminal state: a retry is a remount. func reset() { isTerminal = false last = nil + documentEnded() + } + + func committed() { + guard !isTerminal else { return } + hasCommittedDocument = true + } + + /// The committed document is gone: a new load, a failure, or a renderer that died. + func documentEnded() { + hasCommittedDocument = false } func started() -> MobileWebShellLoadEmission? { @@ -40,6 +57,7 @@ final class MobileWebShellLoadStateMachine { func failed(_ reason: MobileWebShellFailureReason) -> MobileWebShellLoadEmission? { let emission = emit(MobileWebShellLoadEmission(state: "failed", reason: reason.rawValue)) isTerminal = true + documentEnded() return emission } diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift index 904d66bdcb8..745d757fe0f 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift @@ -19,6 +19,22 @@ enum MobileWebShellOrigin { } } + /// Host comparison folds case, because a URL parser canonicalises a host and comparing against + /// the exact spelling we minted is how the reference lost every asset to a 403. ASCII-only and + /// never Unicode: U+212A KELVIN SIGN folds to `k` under `NSString.caseInsensitiveCompare`, which + /// would match a host nobody minted against a session id containing `k`. + static func asciiLowercased(_ value: String) -> String { + var scalars = String.UnicodeScalarView() + for scalar in value.unicodeScalars { + guard (65...90).contains(scalar.value), let lowered = Unicode.Scalar(scalar.value + 32) else { + scalars.append(scalar) + continue + } + scalars.append(lowered) + } + return String(scalars) + } + static func documentUrl(sessionId: String) -> URL? { guard isValidSessionId(sessionId) else { return nil } return URL(string: "\(scheme)://\(sessionId)/") @@ -35,10 +51,8 @@ enum MobileWebShellOrigin { parts.method == "GET", !parts.hasRangeHeader, parts.scheme == scheme, - // Case-insensitive: a URL parser may canonicalise a host, and comparing against the exact - // spelling we minted is how the reference lost every asset to a 403. let host = parts.host, - host.compare(sessionId, options: .caseInsensitive) == .orderedSame, + asciiLowercased(host) == asciiLowercased(sessionId), parts.port == nil, parts.user == nil, parts.query == nil, diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift index 4b3fb940b32..c6ad6891ffa 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift @@ -25,6 +25,39 @@ private let networkApiBlocker = """ })(); """ +/// Installs `window.orcaBridge`, the whole page-facing surface: `postMessage(json)` and an +/// `onmessage` assignment. Android needs no counterpart because `addWebMessageListener` injects an +/// object of the same name and shape, so the contract is the intersection of the two. +/// +/// CSP is untouched and the network blocker still runs: this is a second document-start script, not +/// a replacement. The sink is captured at install time so a page that deletes `window.webkit` +/// cannot take the channel with it, and every property is non-configurable and non-writable, the +/// only shape the page cannot put back. +private let bridgeInstaller = """ + (function(){ + var sink=window.webkit.messageHandlers.orcaBridge; + var handler=null; + var bridge={}; + Object.defineProperty(bridge,'postMessage',{value:function(json){ + if(typeof json!=='string'){throw new TypeError('orcaBridge.postMessage expects a string')} + sink.postMessage(json)},configurable:false,writable:false,enumerable:true}); + Object.defineProperty(bridge,'onmessage',{get:function(){return handler}, + set:function(value){handler=typeof value==='function'?value:null},configurable:false,enumerable:true}); + Object.defineProperty(bridge,'__deliver',{value:function(json){if(handler){handler({data:json})}}, + configurable:false,writable:false,enumerable:false}); + Object.defineProperty(globalThis,'orcaBridge',{value:bridge,configurable:false,writable:false,enumerable:true}); + })(); + """ + +/// The body of a `callAsyncJavaScript` call, with the payload bound to `m` as a real JS value, so no +/// reply content is ever parsed as script text. +/// +/// Unguarded on purpose: a missing global is a page the installer never ran in, and throwing is what +/// rejects the host's promise. Checking for it would resolve a message nobody received. +private let bridgeDeliver = """ + globalThis.orcaBridge.__deliver(m) + """ + private final class MobileWebShellSchemeHandler: NSObject, WKURLSchemeHandler { /// An asset is up to 10 MiB, and WebKit starts and stops scheme tasks on the main thread, so the /// read must not happen there. @@ -102,15 +135,58 @@ private final class MobileWebShellSchemeHandler: NSObject, WKURLSchemeHandler { } } +/// `WKUserContentController` retains its message handlers, so the back-reference has to be weak or +/// the view outlives the React element that owned it. +private final class MobileWebShellBridgeReceiver: NSObject, WKScriptMessageHandler { + weak var view: OrcaMobileWebShellView? + + func userContentController( + _ controller: WKUserContentController, + didReceive message: WKScriptMessage + ) { + view?.receiveBridgeMessage(message) + } +} + +/// The RN host sees this, never the page: it is the difference between a request that failed and +/// one that never settles. +internal final class MobileWebShellBridgeDeliveryFailedException: GenericException, + @unchecked Sendable { + override var reason: String { + "The mobile web shell bridge could not deliver a message: \(param)" + } +} + +internal final class MobileWebShellBridgeUnavailableException: Exception, @unchecked Sendable { + override var reason: String { + "The mobile web shell bridge is not installed on this view" + } +} + +/// Thrown rather than dropped: the only caller is the React Native host, and a silent drop would +/// turn a chunking bug there into a request that never settles. +internal final class MobileWebShellBridgeMessageTooLargeException: GenericException, + @unchecked Sendable { + override var reason: String { + "A bridge message of \(param) bytes exceeds the \(MobileWebShellBridge.maxMessageByteCount) byte cap" + } +} + final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate { let onLoadState = EventDispatcher() + let onBridgeMessage = EventDispatcher() private let schemeHandler = MobileWebShellSchemeHandler() + private let bridgeReceiver = MobileWebShellBridgeReceiver() + private let bridgeGate = MobileWebShellBridgeGate() + private var bridgeEnabled = false + private var bridgeInstalled = false + private var bridgeTarget = MobileWebShellBridgeTarget() private var webView: WKWebView! private var generationDirectory = "" private var sessionId = "" - private var appliedDirectory: String? - private var appliedSessionId: String? + private var applied: MobileWebShellAppliedProps? + private var appliedSessionId: String? { applied?.sessionId } private var pendingDocumentUrl: URL? private var isolationReady = false private var isolationFailed = false @@ -125,13 +201,8 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate configuration.websiteDataStore = .nonPersistent() configuration.preferences.javaScriptCanOpenWindowsAutomatically = false configuration.setURLSchemeHandler(schemeHandler, forURLScheme: MobileWebShellOrigin.scheme) - configuration.userContentController.addUserScript( - WKUserScript( - source: networkApiBlocker, - injectionTime: .atDocumentStart, - forMainFrameOnly: false - ) - ) + configuration.userContentController.addUserScript(Self.makeBlockerScript()) + bridgeReceiver.view = self webView = WKWebView(frame: bounds, configuration: configuration) webView.navigationDelegate = self webView.uiDelegate = self @@ -156,12 +227,23 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate sessionId = value } + func setBridgeEnabled(_ value: Bool) { + bridgeEnabled = value + } + /// Props arrive in no defined order, so neither setter starts anything; this does, once both are - /// in. A repeat of the same pair is not a retry: a retry is a remount under a new React key. + /// in. A repeat of the same triple is not a retry: a retry is a remount under a new React key. + /// `bridgeEnabled` is in the record because a document-start script only takes effect at the next + /// document start: toggling it has to reload, or the prop would silently do nothing. func propsDidUpdate() { - guard generationDirectory != appliedDirectory || sessionId != appliedSessionId else { return } - appliedDirectory = generationDirectory - appliedSessionId = sessionId + let next = MobileWebShellAppliedProps( + generationDirectory: generationDirectory, + sessionId: sessionId, + bridgeEnabled: bridgeEnabled + ) + guard applied?.matches(next) != true else { return } + applied = next + clearBridgeTarget() loadState.reset() pendingDocumentUrl = nil webView.stopLoading() @@ -183,6 +265,7 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate } schemeHandler.sessionId = sessionId schemeHandler.generation = generation + applyBridgeInstallation() if isolationFailed { failPropUpdate(.isolationUnavailable) return @@ -194,6 +277,7 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate /// The generation that failed to apply replaces whatever was on screen; leaving the previous one /// served and visible would show a page the caller has just been told is not loaded. private func failPropUpdate(_ reason: MobileWebShellFailureReason) { + clearBridgeTarget() schemeHandler.sessionId = nil schemeHandler.generation = nil pendingDocumentUrl = nil @@ -202,6 +286,102 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate emit(loadState.failed(reason)) } + /// Rebuilt per install rather than stored: `removeAllUserScripts` is the only removal WebKit has, + /// so uninstalling the bridge means re-adding the blocker. + private static func makeBlockerScript() -> WKUserScript { + WKUserScript( + source: networkApiBlocker, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + } + + /// Nothing here runs while the prop stays false, which is what keeps Phase B byte-identical. + private func applyBridgeInstallation() { + guard bridgeEnabled != bridgeInstalled else { return } + clearBridgeTarget() + let controller = webView.configuration.userContentController + if bridgeEnabled { + controller.add(bridgeReceiver, name: MobileWebShellBridge.handlerName) + controller.addUserScript( + WKUserScript( + source: bridgeInstaller, + injectionTime: .atDocumentStart, + // A convenience, not the fence: a subframe can reach a handler this never ran in, and + // `accepts` is what refuses it. + forMainFrameOnly: true + ) + ) + } else { + controller.removeScriptMessageHandler(forName: MobileWebShellBridge.handlerName) + controller.removeAllUserScripts() + controller.addUserScript(Self.makeBlockerScript()) + } + bridgeInstalled = bridgeEnabled + } + + /// The session the page was loaded under, not the latest prop: a document served under the + /// previous one is still alive until the next load commits, and it must not be heard. + fileprivate func receiveBridgeMessage(_ message: WKScriptMessage) { + guard bridgeInstalled, let json = message.body as? String else { return } + let origin = message.frameInfo.securityOrigin + let source = MobileWebShellBridgeSource( + isOurWebView: message.webView === webView, + isMainFrame: message.frameInfo.isMainFrame, + hasCommittedDocument: loadState.hasCommittedDocument, + originProtocol: origin.`protocol`, + originHost: origin.host + ) + guard + MobileWebShellBridge.accepts(source, sessionId: appliedSessionId ?? ""), + bridgeGate.accepts(byteCount: json.utf8.count) + else { return } + bridgeTarget.arm(frame: message.frameInfo, originHost: origin.host) + onBridgeMessage(["json": json]) + } + + /// Anything that ends the document the page spoke from ends the only target native has. + private func clearBridgeTarget() { + bridgeTarget.clear() + } + + /// Settles on what WebKit did, not on what we handed it: a post into a dead renderer, a document + /// that failed to load, a navigation still in flight or a page that has never spoken rejects here, + /// and the delivery itself resolves only once the page has run it. Resolving any of those + /// optimistically turns a request the RN host is waiting on into one that never settles. + func postBridgeMessage(_ json: String, promise: Promise) throws { + guard + MobileWebShellBridge.canPost( + toFrameOriginHost: bridgeTarget.originHost, + sessionId: appliedSessionId ?? "", + hasCommittedDocument: loadState.hasCommittedDocument + ), + let frame = bridgeTarget.frame + else { + throw MobileWebShellBridgeUnavailableException() + } + let byteCount = json.utf8.count + guard MobileWebShellBridge.acceptsByteCount(byteCount) else { + throw MobileWebShellBridgeMessageTooLargeException(byteCount) + } + // Two `in:` labels is the real signature: `in frame:` and `in contentWorld:`. Naming the + // completion handler is what picks it over the `async` overload. The frame is the one that + // spoke, so the reply goes where the request came from rather than to the current main frame. + webView.callAsyncJavaScript( + bridgeDeliver, + arguments: ["m": json], + in: frame, + in: .page + ) { result in + switch result { + case .success: + promise.resolve() + case .failure(let error): + promise.reject(MobileWebShellBridgeDeliveryFailedException(error.localizedDescription)) + } + } + } + private func installNetworkBlock(into controller: WKUserContentController) { guard let store = WKContentRuleListStore.default() else { // Optional-chaining past this ran no completion handler at all, so the view sat at `loading` @@ -249,6 +429,7 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate } private func reportDocumentFailure() { + clearBridgeTarget() emit(loadState.failed(.documentLoadFailed)) } @@ -295,10 +476,25 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + // The document that spoke is being replaced, so it stops being somewhere to post and stops + // being someone to hear: the next one has to commit, then say `ready`, which is what the + // envelope has it do. + clearBridgeTarget() + loadState.documentEnded() guard appliedSessionId != nil else { return } emit(loadState.started()) } + /// The load the caller was told about is the one now on screen, so this is where the page becomes + /// something to hear. Earlier than `didFinish`, because the page speaks at document start. + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + guard isDocumentUrl(webView.url) else { return } + // Cleared here too, not only at the provisional start: arming is what this re-opens, so the + // frame the replaced document spoke from must not be inheritable by the one replacing it. + clearBridgeTarget() + loadState.committed() + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { guard isDocumentUrl(webView.url) else { return } emit(loadState.finished()) @@ -319,6 +515,7 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate /// Reported, never recovered from here. Renderer memory pressure and a WebView provider update /// look identical at this point, so the retry policy is the caller's and lives in one place. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + clearBridgeTarget() emit(loadState.failed(.renderProcessGone)) } diff --git a/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift index 9596f54c7fa..cc1b3ef6d24 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift @@ -5,7 +5,7 @@ public class OrcaMobileWebShellModule: Module { Name("OrcaMobileWebShell") View(OrcaMobileWebShellView.self) { - Events("onLoadState") + Events("onLoadState", "onBridgeMessage") Prop("generationDirectory") { (view: OrcaMobileWebShellView, value: String) in view.setGenerationDirectory(value) @@ -15,6 +15,15 @@ public class OrcaMobileWebShellModule: Module { view.setSessionId(value) } + Prop("bridgeEnabled") { (view: OrcaMobileWebShellView, value: Bool) in + view.setBridgeEnabled(value) + } + + AsyncFunction("postBridgeMessage") { + (view: OrcaMobileWebShellView, json: String, promise: Promise) in + try view.postBridgeMessage(json, promise: promise) + } + OnViewDidUpdateProps { (view: OrcaMobileWebShellView) in view.propsDidUpdate() } diff --git a/mobile/modules/orca-mobile-web-shell/src/index.ts b/mobile/modules/orca-mobile-web-shell/src/index.ts index 1b838c55410..4b4490328ee 100644 --- a/mobile/modules/orca-mobile-web-shell/src/index.ts +++ b/mobile/modules/orca-mobile-web-shell/src/index.ts @@ -1,8 +1,11 @@ import { requireNativeViewManager } from 'expo-modules-core' -import type { ComponentType } from 'react' +import type { ComponentType, RefAttributes } from 'react' import type { NativeSyntheticEvent, ViewProps } from 'react-native' import type { MobileWebShellLoadStatePayload } from './load-state' +/** One raw JSON envelope, exactly as the page posted it. Parsing is the caller's. */ +export type MobileWebShellBridgeMessagePayload = { json: string } + export type OrcaMobileWebShellViewProps = ViewProps & { /** * Absolute path of an activated generation directory: `index.html`, `manifest.json`, and @@ -12,16 +15,49 @@ export type OrcaMobileWebShellViewProps = ViewProps & { generationDirectory: string /** `[A-Za-z0-9_-]{1,128}`. Scopes the private origin, so every mount must mint a fresh one. */ sessionId: string + /** + * Off unless asked for: with it false nothing is registered on either platform, so the view + * behaves exactly as it did before the bridge existed. On Android a provider older than + * `WEB_MESSAGE_LISTENER` (Chromium 88) reports `isolation-unavailable` rather than loading + * without a channel, and only when this is true. + */ + bridgeEnabled?: boolean onLoadState?: (event: NativeSyntheticEvent) => void + /** + * The page posted `json` through `window.orcaBridge`. Native has already refused anything from + * another origin, another frame or another WebView, and anything over the 640 KiB cap + * (`MobileWebShellBridge.maxMessageByteCount`); a refusal is silent and reaches no event. + */ + onBridgeMessage?: (event: NativeSyntheticEvent) => void +} + +/** What a ref on the view carries. Expo puts the view's functions on the component prototype. */ +export type OrcaMobileWebShellViewHandle = { + /** + * Delivers one raw JSON envelope to the page. Rejects when the message is over the cap, and when + * there is nowhere to post: no page has spoken since the last load, a navigation is in flight, + * the load failed, or the renderer is gone. The caller is the host, so a silent drop is a request + * that never settles. + * + * Delivery is never proven by resolve. iOS rejects the failures it is told about, because + * `callAsyncJavaScript` reports whether the page ran the delivery; Android cannot, because + * `JavaScriptReplyProxy.postMessage` is void and has no acknowledgement, so resolve there means + * enqueued rather than delivered. Anything that must know the page received a message has to + * hear that from the page. + */ + postBridgeMessage: (json: string) => Promise } /** - * Renders one generation directory in a WebView served from a private origin. There is no reload - * and no imperative surface: a retry is a remount under a new React key, which rebuilds the - * WebView and reinstalls every fence. + * Renders one generation directory in a WebView served from a private origin. There is no reload: + * a retry is a remount under a new React key, which rebuilds the WebView and reinstalls every + * fence. The only imperative call is `postBridgeMessage`, and it can say nothing about the load. */ -export const OrcaMobileWebShellView: ComponentType = - requireNativeViewManager('OrcaMobileWebShell') +export const OrcaMobileWebShellView: ComponentType< + OrcaMobileWebShellViewProps & RefAttributes +> = requireNativeViewManager< + OrcaMobileWebShellViewProps & RefAttributes +>('OrcaMobileWebShell') export { MOBILE_WEB_SHELL_FAILURE_REASONS, diff --git a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift index ef1193c3001..b84dc374bcf 100644 --- a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift +++ b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift @@ -7,6 +7,7 @@ import Foundation // swiftc -O -o /tmp/mobile-web-shell-checks \ // ios/MobileWebShellOrigin.swift ios/MobileWebShellGeneration.swift ios/MobileWebShellCsp.swift \ // ios/MobileWebShellLoadState.swift ios/MobileWebShellResponseHeaders.swift \ +// ios/MobileWebShellBridge.swift ios/MobileWebShellAppliedProps.swift \ // tests/MobileWebShellChecks.swift && /tmp/mobile-web-shell-checks @main struct MobileWebShellChecks { static let session = "sess-01JN_aZ9" @@ -94,6 +95,16 @@ import Foundation precondition(resolve(parts(path: "/", hasRangeHeader: true)) == nil) precondition(resolve(parts(path: "/", scheme: "https")) == nil) precondition(resolve(parts(path: "/", scheme: nil)) == nil) + // The same ASCII-only fold as the bridge: a Kelvin-sign host is a host nobody minted, and a + // caseInsensitiveCompare here would serve it every asset. + precondition(MobileWebShellOrigin.resolveRequestPath( + parts(path: "/", host: "\u{212A}ey"), + sessionId: "key" + ) == nil) + precondition(MobileWebShellOrigin.resolveRequestPath( + parts(path: "/", host: "KEY"), + sessionId: "key" + ) == "/") precondition(resolve(parts(path: "/", host: "other-session")) == nil) precondition(resolve(parts(path: "/", host: nil)) == nil) precondition(resolve(parts(path: "/", port: 443)) == nil) @@ -231,6 +242,29 @@ import Foundation refused.reset() precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable") + + // A document is heard only between its own commit and the end of that load. + let arming = MobileWebShellLoadStateMachine() + precondition(!arming.hasCommittedDocument) + _ = arming.started() + // The previous document is alive and same-origin until the next one commits. + precondition(!arming.hasCommittedDocument) + arming.committed() + precondition(arming.hasCommittedDocument) + + // A new prop triple: the committed document is the one being replaced. + arming.reset() + precondition(!arming.hasCommittedDocument) + arming.committed() + arming.documentEnded() + precondition(!arming.hasCommittedDocument) + + // A failure ends the document, and nothing after it re-arms: a retry is a remount. + arming.committed() + _ = arming.failed(.renderProcessGone) + precondition(!arming.hasCommittedDocument) + arming.committed() + precondition(!arming.hasCommittedDocument) } static func checkResponseHeaders() { @@ -273,6 +307,182 @@ import Foundation precondition(!ignorable("SomeOtherDomain", 102)) } + static func bridgeSource( + isOurWebView: Bool = true, + isMainFrame: Bool = true, + hasCommittedDocument: Bool = true, + originProtocol: String = MobileWebShellOrigin.scheme, + originHost: String = session + ) -> MobileWebShellBridgeSource { + MobileWebShellBridgeSource( + isOurWebView: isOurWebView, + isMainFrame: isMainFrame, + hasCommittedDocument: hasCommittedDocument, + originProtocol: originProtocol, + originHost: originHost + ) + } + + static func acceptsBridge(_ source: MobileWebShellBridgeSource) -> Bool { + MobileWebShellBridge.accepts(source, sessionId: session) + } + + static func checkAppliedProps() { + func props( + directory: String = "/gen/aa", + session: String = session, + bridge: Bool = true + ) -> MobileWebShellAppliedProps { + MobileWebShellAppliedProps( + generationDirectory: directory, + sessionId: session, + bridgeEnabled: bridge + ) + } + + precondition(props().matches(props())) + precondition(!props().matches(props(directory: "/gen/ab"))) + precondition(!props().matches(props(session: "sess-01JN_aZ8"))) + precondition(!props().matches(props(bridge: false))) + // A triple that could not be honoured is still applied: re-entry reads the props, never whether + // the install succeeded, so a corrupt generation reports its failure once rather than on every + // commit for the life of the mount. + precondition(props(directory: "/gen/corrupt").matches(props(directory: "/gen/corrupt"))) + + // A fourth prop that nobody compared is a prop that silently never reloads, so the record's + // shape is pinned here rather than left to whoever adds the field. + let fields = Mirror(reflecting: props()).children.compactMap(\.label).sorted() + precondition(fields == ["bridgeEnabled", "generationDirectory", "sessionId"]) + } + + static func checkBridgeAcceptance() { + precondition(acceptsBridge(bridgeSource())) + // Simulator-measured: WebKit reports the custom scheme's host ASCII-lowercased, so the session + // we minted never equals the host verbatim. Exact equality here refuses every message. + precondition(acceptsBridge(bridgeSource(originHost: "sess-01jn_az9"))) + precondition(acceptsBridge(bridgeSource(originHost: "SESS-01JN_AZ9"))) + + // A frame we did not serve. + precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ8"))) + precondition(!acceptsBridge(bridgeSource(originHost: ""))) + precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ9.evil"))) + // ASCII folding only: U+212A KELVIN SIGN lowercases to "k" under Unicode case folding, so a + // caseInsensitiveCompare would accept a host nobody minted. + precondition(!MobileWebShellBridge.accepts( + bridgeSource(originHost: "\u{212A}ey"), + sessionId: "key" + )) + precondition(MobileWebShellOrigin.asciiLowercased("\u{212A}EY") == "\u{212A}ey") + + // Another scheme reaching the same handler. + precondition(!acceptsBridge(bridgeSource(originProtocol: "https"))) + precondition(!acceptsBridge(bridgeSource(originProtocol: ""))) + precondition(!acceptsBridge(bridgeSource(originProtocol: "orca-mobile-web "))) + + // A subframe, and a message routed to a WebView that is not ours. + precondition(!acceptsBridge(bridgeSource(isMainFrame: false))) + precondition(!acceptsBridge(bridgeSource(isOurWebView: false))) + + // The document the current props replaced: same session, same origin, still alive between + // `stopLoading` and the next commit, speaking for a load already reported as `loading`. + precondition(!acceptsBridge(bridgeSource(hasCommittedDocument: false))) + + // No applied session is not an empty one: nothing may be accepted before a load. + precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: ""), sessionId: "")) + precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: "a b"), sessionId: "a b")) + } + + static func checkBridgePostTarget() { + func canPost( + _ host: String?, + _ sessionId: String = session, + committed: Bool = true + ) -> Bool { + MobileWebShellBridge.canPost( + toFrameOriginHost: host, + sessionId: sessionId, + hasCommittedDocument: committed + ) + } + + precondition(canPost(session)) + // The same ASCII fold as acceptance: WebKit reports the host lowercased. + precondition(canPost("sess-01jn_az9")) + + // Nowhere to post, all four for the same reason: no frame has been accepted. A page that has + // never spoken, a document whose load failed, a renderer that died, a bridge not installed. + precondition(!canPost(nil)) + + // A frame from another document, and a frame under no session at all. + precondition(!canPost("sess-01JN_aZ8")) + precondition(!canPost("\u{212A}ey", "key")) + precondition(!canPost(session, "")) + precondition(!canPost("", "")) + + // In flight: a navigation has started and not committed, so there is no document to post into + // even while a frame from the one being replaced is still held. + precondition(!canPost(session, committed: false)) + } + + /// The target across one document replacing another, in the order the navigation delegate runs: + /// a frame armed by document A is never what a post to document B goes to. + static func checkBridgeTargetLifecycle() { + func canPost(_ target: MobileWebShellBridgeTarget, committed: Bool) -> Bool { + MobileWebShellBridge.canPost( + toFrameOriginHost: target.originHost, + sessionId: session, + hasCommittedDocument: committed + ) + } + + var target = MobileWebShellBridgeTarget() + precondition(target.frame == nil && target.originHost == nil) + precondition(!canPost(target, committed: true)) + + // didCommit for document A, then A's first accepted message. + target.clear() + target.arm(frame: "frame-a", originHost: session) + precondition(target.frame == "frame-a") + precondition(canPost(target, committed: true)) + + // didStartProvisionalNavigation for document B. Refused twice over: nothing armed, and nothing + // committed to post into. + target.clear() + precondition(target.frame == nil) + precondition(!canPost(target, committed: false)) + + // didCommit for document B. Arming re-opens, so the clear has to happen here as well or A's + // frame becomes postable again as B's. + target.clear() + precondition(!canPost(target, committed: true)) + + // B speaks for itself, and that is the only way a post reaches it. + target.arm(frame: "frame-b", originHost: session) + precondition(target.frame == "frame-b") + precondition(canPost(target, committed: true)) + } + + static func checkBridgeByteCap() { + let cap = MobileWebShellBridge.maxMessageByteCount + precondition(cap == 640 * 1024) + precondition(MobileWebShellBridge.acceptsByteCount(0)) + precondition(MobileWebShellBridge.acceptsByteCount(cap - 1)) + precondition(MobileWebShellBridge.acceptsByteCount(cap)) + precondition(!MobileWebShellBridge.acceptsByteCount(cap + 1)) + + // The cap is on UTF-8 bytes, not characters: a multi-byte payload must not buy extra room. + let wide = String(repeating: "\u{1F600}", count: 4) + precondition(wide.count == 4 && wide.utf8.count == 16) + + let gate = MobileWebShellBridgeGate() + precondition(gate.refusedCount == 0) + precondition(gate.accepts(byteCount: cap)) + precondition(gate.refusedCount == 0) + precondition(!gate.accepts(byteCount: cap + 1)) + precondition(!gate.accepts(byteCount: cap * 2)) + precondition(gate.refusedCount == 2) + } + static func main() { checkSessionIds() checkRequestResolution() @@ -283,6 +493,11 @@ import Foundation checkLoadStateMachine() checkResponseHeaders() checkNavigationErrors() + checkAppliedProps() + checkBridgeAcceptance() + checkBridgePostTarget() + checkBridgeTargetLifecycle() + checkBridgeByteCap() print("mobile web shell checks OK") } } From 47d107cf2ea3f5e856dd328f5c90643a2e81c990 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:22:25 -0400 Subject: [PATCH 026/224] feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(mobile): say whether a host status was readable, and carry its protocol window `useHostStatusGates` settled the same closed gates for a host that answered `status.get` with no capabilities and for one whose status nobody could read: both paths produced an empty capability list and an `ok` verdict. A caller that walls on a missing capability cannot tell those apart, and the mobile web bundle's wall is terminal, so it must never fire for the second. `statusReadable` distinguishes them. `hostProtocolWindow` exposes the two protocol numbers the hook already read for `evaluateCompat`, as the reply's own fields, so the bundle wall can evaluate its own window without a second `status.get`. Both are additive; no existing consumer changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): add the hybrid shell flag and the generation path both loaders demand `orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same shape as the terminal autocomplete flag. `generationDirectoryPath` converts the store's `file://` uri to the absolute path the native shell view requires: both `MobileWebShellGeneration.load` implementations refuse anything without a leading slash, and `expo-file-system` only ever hands out uris. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell session as a pure reducer Every decision the route makes, as `(session, event) -> (state, effects)`: the capability wall, the lazy sweep and cache read, the manifest check, the cached build-id hit that skips paging, the offline open with no compat check, and the three recovery rules the native shell view's contract states. Pure, so the rules are table tests rather than a simulator run. Two latches sit beside the state because both outlive it: `retriedOnce` spans the delete and refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced by a `ready` under a new session id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) Wires the four Phase B and A pieces together and adds no decision of its own. `h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native `desktop-too-old` wall still applies above it. With the flag off — every store build, since the only writer is a `__DEV__` Troubleshoot toggle — the route redirects to `h/[hostId]` and the screen is never constructed, so nothing is fetched, written or swept. The runner owns only the impure edges and checks an epoch before every dispatch, so an unmount, a host change or a retry abandons work in flight and aborts a download that would otherwise hold four of the host's read slots. The native view is keyed on the session id, which is what makes the reducer's remount a rebuilt WebView with every fence reinstalled. A census test pins who touches the flag: the route reads it, the developer row reads and writes it, and the key itself lives in one module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the hoisted test doubles instead of asserting them The changed-code casting gate refuses `as` in new code, and these three were only widening an empty literal. An annotated `vi.hoisted` factory does the same job under a check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress Found on a simulator with the paired desktop stopped: the client never settles on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting` -> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping `reconnecting` to "still connecting" left a phone holding a verified cached generation on `checking` forever instead of opening it, which is the one case the offline rule exists for. `connecting` and `handshaking` are the first dial and still wait; everything else is unreachable. The mapping moves next to the reducer it feeds, because it is a decision and the runner is supposed to hold none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build the reachability stub instead of asserting it Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the shell session vocabulary into its own module Pure move, no behaviour: the states, events, effects and gates the reducer and its runner share now sit beside the reducer rather than inside it, so the transition rules have room to grow under the file's line budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop a shell effect result whose flow has been superseded Every restart of the flow bumps a number the effects of that run are stamped with, and a result echoes it back: a manifest read still in flight when the socket drops used to reject after the offline path had already opened the cached generation, replacing a displayed workspace with a download failure, and a status refetch arriving mid-check used to run the cache read and the download twice. The gates restart no longer clears the remount latch either; only the retry button does, so a reconnect cannot grant a second remount. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the flag across modules, not just src and app The native view tree was outside the scan, so a reader added there would have passed an assertion that reads as exhaustive. Proven by adding one to the shell view module: the census fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let only the state that mounted the view hear the view A native batch reports two failures in a row, and the reducer applied both: document-load-failed started the delete-and-refetch, render-process-gone then made it terminal without a new flow, and the cache read the recovery had already asked for dragged the session back to checking behind a failure screen. A report arriving outside `ready` is from a view that is no longer on screen, so it changes nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say a host status could not be read instead of spinning on it A transient status.get failure settles the gate unreadable and nothing probes it again, so the route sat on "Checking host" for as long as anyone left it there and Try again re-read the same settled answer. It now says what happened and offers no retry, and a status that does become readable picks the flow back up on its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restart the flow on the verdict that changed, not on every gates object A reconnect cycle rebuilds the gates several times a second with the same answer in them, and each one re-swept the staging tree and flipped an offline screen to a spinner and back. Only a changed verdict restarts now, which is also why the gates effect has to depend on the host id: two hosts whose gates read identically would otherwise leave the second session in `checking`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover what only the shell runner can get wrong Three cancellations had no test: the epoch that stops a result reaching a session that is gone, the unmount cleanup that aborts the download, and the retry that does both before starting over. Each is now red under its own mutant. The download also re-checks the abort before it writes, since an abort landing between the fetch's last read and the commit would otherwise still put a generation on disk for a screen nobody is on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the shell runner's refs after the commit, not during render React can replay or discard a render, so a handle written during one can run effects for a session that never existed. The client and the host cache key stop being refs at all; the effect handle is committed in an effect above every effect that dispatches. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the hybrid shell flag unreadable outside a development build Development and release share a bundle id, and the iOS data container survives an install-over, so a flag a developer toggled on would follow the store build in and mount the shell on a deep link. The release read never reaches storage. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive the route's flag read as each build kind reads it The route test exercises the real preference read, so it has to say which build it is. A store build whose container kept a development toggle redirects. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a cache read that lands mid-dial wait for the compat check A connection still being made is not a host that cannot be reached. Opening the cached generation there skips the compat check the landing connection is what makes answerable, so only `unreachable` takes the offline path now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the developer toggle only after its write lands The route reads the flag back from storage, so a switch that moved on the tap let the open button race the value that was being persisted. The switch and the button both stay put until the write settles, and a failed write keeps the previous position. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): say which build kind a test runs as without asserting on globalThis Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): undo a staged generation the abort reached before the commit The commit is the write staging cannot take back: it renames into the active slot and moves the host index. An abort landing while the bytes were being staged now removes the staged tree instead of activating it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): open the cached workspace when the link, not the bundle, cut a read short An RPC rejection can reach the reducer before the reachability change does, so the offline gate never fires and a phone holding a valid generation reads that the workspace could not be downloaded. A read that failed on the link now opens what is on disk, the same path offline takes; a verdict about the bundle, from the host or from the bytes, still fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send a hybrid shell recovery through the same gate a start takes A view failure deleted the host cache and went straight back to the manifest check on whatever gates the ready session happened to be holding. Gates that arrive while a generation is on screen are stored without restarting, so after a reconnect whose status probe failed a ready session carried statusReadable false and an empty capability list, and the recovery's manifest check walled the host as bundle-unavailable: terminal, no retry, about a host that never answered. The gate is now one verdict both entries read, and recovery passes its delete through it, so an unreadable status lands on the status-unreadable message that re-arms when a readable gate arrives, and only a readable refusal still walls. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- mobile/app/h/[hostId]/web.tsx | 56 ++ mobile/app/h/_layout.tsx | 2 + mobile/app/troubleshoot.tsx | 10 +- .../mobile-web-shell-dev-row.test.tsx | 121 +++ .../diagnostics/mobile-web-shell-dev-row.tsx | 84 ++ .../MobileWebShellScreen.test.tsx | 234 ++++++ .../mobile-web-shell/MobileWebShellScreen.tsx | 229 ++++++ .../generation-store-file-system.test.ts | 32 + .../generation-store-file-system.ts | 17 + .../mobile-web-shell-flag-census.test.ts | 74 ++ .../mobile-web-shell-reachability.test.ts | 53 ++ .../mobile-web-shell-route.test.tsx | 112 +++ .../mobile-web-shell-session-contract.ts | 177 +++++ .../mobile-web-shell-session.test.ts | 717 ++++++++++++++++++ .../mobile-web-shell-session.ts | 385 ++++++++++ .../use-mobile-web-shell-session.test.ts | 346 +++++++++ .../use-mobile-web-shell-session.ts | 335 ++++++++ mobile/src/storage/preferences.test.ts | 38 + mobile/src/storage/preferences.ts | 24 + mobile/src/transport/host-status-gates.ts | 44 +- .../transport/mobile-web-bundle-operations.ts | 15 + .../mobile-web-bundle-reply-schemas.test.ts | 29 + 22 files changed, 3129 insertions(+), 5 deletions(-) create mode 100644 mobile/app/h/[hostId]/web.tsx create mode 100644 mobile/src/diagnostics/mobile-web-shell-dev-row.test.tsx create mode 100644 mobile/src/diagnostics/mobile-web-shell-dev-row.tsx create mode 100644 mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx create mode 100644 mobile/src/mobile-web-shell/MobileWebShellScreen.tsx create mode 100644 mobile/src/mobile-web-shell/generation-store-file-system.test.ts create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-reachability.test.ts create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-route.test.tsx create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-session.ts create mode 100644 mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts create mode 100644 mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts diff --git a/mobile/app/h/[hostId]/web.tsx b/mobile/app/h/[hostId]/web.tsx new file mode 100644 index 00000000000..2425b1e00f1 --- /dev/null +++ b/mobile/app/h/[hostId]/web.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { ActivityIndicator, StyleSheet, View } from 'react-native' +import { Redirect, useLocalSearchParams } from 'expo-router' +import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen' +import { loadMobileWebShellEnabled } from '../../../src/storage/preferences' +import { colors } from '../../../src/theme/mobile-theme' + +/** + * The hybrid shell route, dark behind a development-only flag. + * + * The only caller of `loadMobileWebShellEnabled`. With the flag off — which is every store build, + * since the only writer is the `__DEV__` Troubleshoot toggle — this redirects and the screen is + * never constructed, so nothing is fetched, written or swept. It sits under `app/h/[hostId]` so + * `HostProtocolGate` in that group's layout still owns the `desktop-too-old` wall above it. + * + * Reachable by deep link and from the developer row only; no screen links here. + */ +export default function MobileWebShellRoute() { + const { hostId } = useLocalSearchParams<{ hostId: string }>() + const [enabled, setEnabled] = useState(null) + + useEffect(() => { + let stale = false + void loadMobileWebShellEnabled().then((value) => { + if (!stale) { + setEnabled(value) + } + }) + return () => { + stale = true + } + }, []) + + if (enabled === null) { + // A redirect fired before the read settles would bounce a flag that is on, and a screen mounted + // before it settles would fetch on a flag that is off. Neither, until it is known. + return ( + + + + ) + } + if (!enabled || !hostId) { + return + } + return +} + +const styles = StyleSheet.create({ + pending: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgBase + } +}) diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index 7f77c33b648..d98c457f2a0 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -54,6 +54,8 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) { /> + {/* Dev-flag only: redirects to the host screen unless the hybrid shell flag is on. */} + ) } diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index d64b238b577..ffa9031e00a 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -1,5 +1,6 @@ import { useRouter } from 'expo-router' import { MobileWebBundleProbeRow } from '../src/diagnostics/mobile-web-bundle-probe-row' +import { MobileWebShellDevRow } from '../src/diagnostics/mobile-web-shell-dev-row' import { TroubleshootView } from '../src/diagnostics/troubleshoot-view' import { useTroubleshootDiagnostics } from '../src/diagnostics/use-troubleshoot-diagnostics' @@ -21,7 +22,14 @@ export default function NativeTroubleshootRoute() { runDiagnostics={() => void runDiagnostics()} onBack={() => router.back()} onConnectionLog={() => router.push('/connection-log')} - developerRow={isDevelopmentBuild ? : null} + developerRow={ + isDevelopmentBuild ? ( + <> + + + + ) : null + } /> ) } diff --git a/mobile/src/diagnostics/mobile-web-shell-dev-row.test.tsx b/mobile/src/diagnostics/mobile-web-shell-dev-row.test.tsx new file mode 100644 index 00000000000..26147feb778 --- /dev/null +++ b/mobile/src/diagnostics/mobile-web-shell-dev-row.test.tsx @@ -0,0 +1,121 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * The developer toggle is the only writer of the hybrid shell flag, and the route it opens reads + * that flag back from storage rather than from this screen. So what the switch shows and what the + * open button permits must both follow the write, not the tap. + */ +type Doubles = { + stored: boolean + saves: { next: boolean; settle: () => void; fail: () => void }[] + pushes: string[] +} + +const doubles = vi.hoisted((): Doubles => ({ stored: false, saves: [], pushes: [] })) + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + Switch: 'Switch', + Text: 'Text', + View: 'View' +})) +vi.mock('expo-router', () => ({ + useRouter: () => ({ + push: (href: string) => { + doubles.pushes.push(href) + } + }) +})) +vi.mock('lucide-react-native', () => ({ LayoutTemplate: 'LayoutTemplate' })) +vi.mock('../transport/host-store', () => ({ loadHosts: async () => [{ id: 'host-1' }] })) +vi.mock('../storage/preferences', () => ({ + loadMobileWebShellEnabled: async () => doubles.stored, + saveMobileWebShellEnabled: (next: boolean) => + new Promise((resolve, reject) => { + doubles.saves.push({ + next, + settle: () => { + doubles.stored = next + resolve() + }, + fail: () => reject(new Error('storage unavailable')) + }) + }) +})) +vi.mock('./troubleshoot-screen-styles', () => ({ troubleshootScreenStyles: {} })) + +import { MobileWebShellDevRow } from './mobile-web-shell-dev-row' + +function only(tree: ReactTestRenderer, testID: string): ReactTestInstance { + const found = tree.root.findAll((node) => node.props.testID === testID) + const node = found[0] + if (node === undefined || found.length !== 1) { + throw new Error(`expected one ${testID}, found ${found.length}`) + } + return node +} + +async function mountRow(): Promise { + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(MobileWebShellDevRow)) + }) + const tree = rendered.tree + if (tree === null) { + throw new Error('the row did not mount') + } + return tree +} + +async function toggle(tree: ReactTestRenderer, next: boolean): Promise { + await act(async () => { + only(tree, 'mobile-web-shell-flag').props.onValueChange(next) + }) +} + +describe('the hybrid shell developer row', () => { + beforeEach(() => { + doubles.stored = false + doubles.saves.length = 0 + doubles.pushes.length = 0 + }) + + it('offers neither the new position nor the route until the write lands', async () => { + const tree = await mountRow() + await toggle(tree, true) + + expect(doubles.saves).toHaveLength(1) + expect(only(tree, 'mobile-web-shell-flag').props.value).toBe(false) + expect(only(tree, 'mobile-web-shell-flag').props.disabled).toBe(true) + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(true) + + await act(async () => { + doubles.saves[0]?.settle() + }) + expect(only(tree, 'mobile-web-shell-flag').props.value).toBe(true) + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(false) + }) + + it('keeps the open button shut while a write that turns the flag off is still in flight', async () => { + doubles.stored = true + const tree = await mountRow() + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(false) + + await toggle(tree, false) + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(true) + }) + + it('leaves the switch where storage still is when the write fails', async () => { + const tree = await mountRow() + await toggle(tree, true) + await act(async () => { + doubles.saves[0]?.fail() + }) + + expect(only(tree, 'mobile-web-shell-flag').props.value).toBe(false) + expect(only(tree, 'mobile-web-shell-flag').props.disabled).toBe(false) + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(true) + }) +}) diff --git a/mobile/src/diagnostics/mobile-web-shell-dev-row.tsx b/mobile/src/diagnostics/mobile-web-shell-dev-row.tsx new file mode 100644 index 00000000000..1f24c11fbe3 --- /dev/null +++ b/mobile/src/diagnostics/mobile-web-shell-dev-row.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { Pressable, Switch, Text, View } from 'react-native' +import { useRouter } from 'expo-router' +import { LayoutTemplate } from 'lucide-react-native' +import { loadHosts } from '../transport/host-store' +import { loadMobileWebShellEnabled, saveMobileWebShellEnabled } from '../storage/preferences' +import { colors } from '../theme/mobile-theme' +import { troubleshootScreenStyles as styles } from './troubleshoot-screen-styles' + +/** + * Development-only: the one caller of `saveMobileWebShellEnabled`, and the one way into the hybrid + * shell route that is not a deep link. + * + * `app/troubleshoot.tsx` mounts it behind `__DEV__`, exactly as it mounts A5's probe row, so a + * shipped build never renders the toggle and the flag it guards can only stay off. The route itself + * reads the flag again rather than trusting this screen, because a deep link arrives without it. + */ +export function MobileWebShellDevRow() { + const router = useRouter() + const [enabled, setEnabled] = useState(null) + const [saving, setSaving] = useState(false) + const [hostId, setHostId] = useState(null) + + useEffect(() => { + let stale = false + void Promise.all([loadMobileWebShellEnabled(), loadHosts()]).then(([flag, hosts]) => { + if (!stale) { + setEnabled(flag) + setHostId(hosts[0]?.id ?? null) + } + }) + return () => { + stale = true + } + }, []) + + // Not while a write is in flight: the route reads the key back from storage, so a button that + // opened on the switch's position would mount a shell the persisted flag does not permit yet. + const openable = enabled === true && hostId !== null && !saving + return ( + + + Hybrid shell (dev) + { + setSaving(true) + void saveMobileWebShellEnabled(next) + .then(() => { + setEnabled(next) + }) + // A write that never landed leaves the previous position showing, because that is + // still what the route will read. + .catch(() => undefined) + .finally(() => { + setSaving(false) + }) + }} + /> + + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed, + !openable && styles.diagnosticButtonDisabled + ]} + testID="mobile-web-shell-open" + disabled={!openable} + onPress={() => { + if (hostId !== null) { + router.push(`/h/${hostId}/web`) + } + }} + > + + + Open hybrid shell for the first paired host + + + + ) +} diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx new file mode 100644 index 00000000000..b148374e9dd --- /dev/null +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -0,0 +1,234 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' + +type ScreenDependencies = { + retry: Mock + reportShellFailure: Mock + openUrl: Mock + lifecycle: string[] + state: MobileWebShellSessionState +} + +const dependencies = vi.hoisted((): ScreenDependencies => { + // Before the module under test is imported, so its `__DEV__` guard is on and the developer facts + // are reachable at all — they are the one thing here that must never grow a secret. + Object.assign(globalThis, { __DEV__: true }) + return { + retry: vi.fn(), + reportShellFailure: vi.fn(), + openUrl: vi.fn(), + lifecycle: [], + state: { kind: 'checking' } + } +}) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Linking: { openURL: dependencies.openUrl }, + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 }) +})) +vi.mock('expo-router', () => ({ router: { replace: vi.fn() } })) +// A component rather than a host string: the React key is what makes a retry a rebuilt WebView, +// and a mount/unmount log is the only thing that can tell a remount from a prop update. +vi.mock('../../modules/orca-mobile-web-shell/src', async () => { + const React = await import('react') + const loadState = await import('../../modules/orca-mobile-web-shell/src/load-state') + return { + OrcaMobileWebShellView: (props: { sessionId: string }) => { + React.useEffect(() => { + dependencies.lifecycle.push(`mount:${props.sessionId}`) + return () => { + dependencies.lifecycle.push(`unmount:${props.sessionId}`) + } + }, [props.sessionId]) + return React.createElement('ShellViewProbe', props) + }, + parseMobileWebShellLoadState: loadState.parseMobileWebShellLoadState + } +}) +vi.mock('./use-mobile-web-shell-session', () => ({ + useMobileWebShellSession: () => ({ + state: dependencies.state, + retry: dependencies.retry, + reportShellFailure: dependencies.reportShellFailure + }) +})) + +import { MobileWebShellScreen } from './MobileWebShellScreen' + +const BUILD_ID = 'a1b2c3d4e5f6'.repeat(5) + 'abcd' +const DIRECTORY = '/var/mobile/Containers/Data/Caches/mobile-web/deadbeef/generations/a1b2' + +async function render(state: MobileWebShellSessionState): Promise { + dependencies.state = state + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(MobileWebShellScreen, { hostId: 'host-1' })) + }) + if (rendered.tree === null) { + throw new Error('screen did not render') + } + return rendered.tree +} + +function readyState(sessionId: string): MobileWebShellSessionState { + return { + kind: 'ready', + generationDirectory: DIRECTORY, + sessionId, + buildId: BUILD_ID, + totalBytes: 4096, + elapsedMs: 811 + } +} + +async function update(tree: ReactTestRenderer, state: MobileWebShellSessionState): Promise { + dependencies.state = state + await act(async () => { + tree.update(createElement(MobileWebShellScreen, { hostId: 'host-1' })) + }) +} + +/** Host elements are matched by name, not by `findAllByType`: React's `ElementType` does not admit + * an arbitrary React Native host name, so the typed form is a predicate. */ +function byName(tree: ReactTestRenderer, name: string): ReactTestInstance[] { + return tree.root.findAll((node) => String(node.type) === name) +} + +function textOf(tree: ReactTestRenderer): string { + return byName(tree, 'Text') + .map((node) => node.children.filter((child) => typeof child === 'string').join('')) + .join('\n') +} + +describe('the hybrid shell screen', () => { + beforeEach(() => { + dependencies.retry.mockReset() + dependencies.reportShellFailure.mockReset() + dependencies.lifecycle.length = 0 + }) + + it('renders the update wall for a bundle verdict, with no shell view', async () => { + const tree = await render({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-unavailable' } + }) + expect(textOf(tree)).toContain('Update Orca on your computer') + expect(byName(tree, 'ShellViewProbe')).toEqual([]) + }) + + it('renders the refetch wall a cached generation older than the host earns', async () => { + const tree = await render({ + kind: 'wall', + verdict: { + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'mobile', + bundleRuntimeProtocolVersion: 3, + requiredBundleRuntimeProtocolVersion: 9 + } + }) + expect(textOf(tree)).toContain('Refresh the mobile workspace') + }) + + it('offers Try again on a failure a retry can clear', async () => { + const tree = await render({ + kind: 'failed', + reason: 'document-load-failed', + retriedOnce: true + }) + expect(textOf(tree)).toContain('The downloaded workspace could not be opened.') + const retry = tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry') + expect(retry).toHaveLength(1) + await act(async () => { + retry[0].props.onPress() + }) + expect(dependencies.retry).toHaveBeenCalledTimes(1) + }) + + it('offers no retry when the device cannot isolate a WebView', async () => { + const tree = await render({ + kind: 'failed', + reason: 'isolation-unavailable', + retriedOnce: false + }) + expect(textOf(tree)).toContain("This device's WebView is too old") + expect(tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry')).toEqual([]) + }) + + it('offers no retry for a status that could not be read, since the gate is settled', async () => { + const tree = await render({ + kind: 'failed', + reason: 'status-unreadable', + retriedOnce: false + }) + expect(textOf(tree)).toContain("Could not read this host's status") + expect(tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry')).toEqual([]) + }) + + it('names what is missing when the host is unreachable and nothing is cached', async () => { + expect(textOf(await render({ kind: 'offline' }))).toContain( + 'Connect to this host to download the workspace' + ) + }) + + it('counts assets and bytes while downloading', async () => { + const tree = await render({ + kind: 'fetching', + completedAssets: 2, + totalAssets: 4, + receivedBytes: 2048, + totalBytes: 4096 + }) + expect(textOf(tree)).toContain('2/4 files') + expect(textOf(tree)).toContain('2048/4096 bytes') + }) + + it('hands the shell view the generation path and the session id', async () => { + const tree = await render(readyState('session-one')) + const view = byName(tree, 'ShellViewProbe')[0] + expect(view.props.generationDirectory).toBe(DIRECTORY) + expect(view.props.sessionId).toBe('session-one') + }) + + it('rebuilds the view rather than updating it when the session id changes', async () => { + const tree = await render(readyState('session-one')) + await update(tree, readyState('session-two')) + expect(dependencies.lifecycle).toEqual([ + 'mount:session-one', + 'unmount:session-one', + 'mount:session-two' + ]) + }) + + it('forwards a failure the native view reports and drops a payload it cannot read', async () => { + const tree = await render(readyState('session-one')) + const view = byName(tree, 'ShellViewProbe')[0] + await act(async () => { + view.props.onLoadState({ nativeEvent: { state: 'ready' } }) + view.props.onLoadState({ nativeEvent: { state: 'failed', reason: 'invented' } }) + view.props.onLoadState({ nativeEvent: { state: 'failed', reason: 'render-process-gone' } }) + }) + expect(dependencies.reportShellFailure.mock.calls).toEqual([['render-process-gone']]) + }) + + it('shows a build id prefix and never the whole one, the cache path, or the host id', async () => { + const tree = await render(readyState('session-one')) + const text = textOf(tree) + expect(text).toContain(BUILD_ID.slice(0, 12)) + expect(text).toContain('4096 B') + expect(text).toContain('811 ms') + expect(text).not.toContain(BUILD_ID) + expect(text).not.toContain(DIRECTORY) + expect(text).not.toContain('host-1') + }) +}) diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx new file mode 100644 index 00000000000..0d73b5a8adf --- /dev/null +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -0,0 +1,229 @@ +import type { ReactNode } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { + OrcaMobileWebShellView, + parseMobileWebShellLoadState +} from '../../modules/orca-mobile-web-shell/src' +import { ProtocolBlockScreen } from '../components/ProtocolBlockScreen' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { + MobileWebShellFailureCause, + MobileWebShellSessionState +} from './mobile-web-shell-session-contract' +import { + useMobileWebShellSession, + type MobileWebShellRuntime +} from './use-mobile-web-shell-session' + +// Same guard as the Troubleshoot developer row: `__DEV__` is undefined outside the React Native +// runtime, and the facts below are for whoever is bringing the shell up, not for a user. +const isDevelopmentBuild = typeof __DEV__ !== 'undefined' && __DEV__ + +/** Enough of a build id to tell two generations apart in a screenshot, and not enough to be one. */ +const BUILD_ID_PREFIX_LENGTH = 12 + +function failureMessage(reason: MobileWebShellFailureCause): string { + switch (reason) { + case 'isolation-unavailable': + return "This device's WebView is too old to open the workspace safely." + case 'download-failed': + return 'The workspace could not be downloaded from this host.' + case 'status-unreadable': + return "Could not read this host's status. Go back and reopen it." + case 'render-process-gone': + return 'The workspace stopped responding.' + case 'generation-unreadable': + case 'document-load-failed': + return 'The downloaded workspace could not be opened.' + } +} + +function Centered({ children }: { children: ReactNode }) { + return {children} +} + +function Waiting({ label }: { label: string }) { + return ( + + + {label} + + ) +} + +function Fetching({ state }: { state: Extract }) { + return ( + + + Downloading workspace + + {`${state.completedAssets}/${state.totalAssets} files · ${state.receivedBytes}/${state.totalBytes} bytes`} + + + ) +} + +function Failed({ + state, + onRetry +}: { + state: Extract + onRetry: () => void +}) { + // No retry for the fence, and none for an unread status: a device whose WebView cannot be + // isolated will not grow one on a tap, and a retry re-reads the same settled gate it already has. + const retryable = state.reason !== 'isolation-unavailable' && state.reason !== 'status-unreadable' + return ( + + + {failureMessage(state.reason)} + + {retryable ? ( + [styles.retryButton, pressed && styles.pressed]} + testID="mobile-web-shell-retry" + onPress={onRetry} + > + Try again + + ) : null} + + ) +} + +/** Never the generation directory, never the whole build id, never the host id: this renders on a + * device someone may be screen-sharing, and none of those three tell them anything a prefix does + * not. */ +function DevFacts({ state }: { state: Extract }) { + if (!isDevelopmentBuild) { + return null + } + return ( + + + {`${state.buildId.slice(0, BUILD_ID_PREFIX_LENGTH)} · ${state.totalBytes} B · ${state.elapsedMs} ms`} + + + ) +} + +export type MobileWebShellScreenProps = { + hostId: string + runtime?: MobileWebShellRuntime +} + +/** + * The hybrid shell route's screen: one generation, rendered by the native view, or the plain state + * that says why it is not. + * + * The native view is keyed on the session id, so a remount the reducer asks for is a new key and a + * rebuilt WebView with every fence reinstalled — the view has no reload of its own by design. + */ +export function MobileWebShellScreen({ hostId, runtime }: MobileWebShellScreenProps) { + const insets = useSafeAreaInsets() + const { state, retry, reportShellFailure } = useMobileWebShellSession({ hostId, runtime }) + + if (state.kind === 'wall') { + return + } + if (state.kind === 'failed') { + return + } + if (state.kind === 'offline') { + return ( + + + Connect to this host to download the workspace + + + ) + } + if (state.kind === 'fetching') { + return + } + if (state.kind !== 'ready') { + return + } + return ( + + { + const parsed = parseMobileWebShellLoadState(event.nativeEvent) + if (parsed?.state === 'failed') { + reportShellFailure(parsed.reason) + } + }} + /> + + + ) +} + +const styles = StyleSheet.create({ + shellRoot: { + flex: 1, + backgroundColor: colors.bgBase + }, + shellView: { + flex: 1 + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg + }, + waitingLabel: { + fontSize: typography.bodySize, + color: colors.textSecondary, + marginTop: spacing.md, + textAlign: 'center' + }, + progress: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginTop: spacing.sm + }, + failedMessage: { + fontSize: typography.bodySize, + color: colors.textPrimary, + textAlign: 'center', + marginBottom: spacing.lg + }, + retryButton: { + backgroundColor: colors.bgRaised, + paddingVertical: spacing.sm + 2, + paddingHorizontal: spacing.lg, + borderRadius: radii.button + }, + retryLabel: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textPrimary + }, + pressed: { + opacity: 0.7 + }, + devFacts: { + position: 'absolute', + left: spacing.sm, + bottom: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: radii.button, + backgroundColor: colors.bgPanel + }, + devFactsText: { + fontSize: typography.metaSize, + color: colors.textMuted + } +}) diff --git a/mobile/src/mobile-web-shell/generation-store-file-system.test.ts b/mobile/src/mobile-web-shell/generation-store-file-system.test.ts new file mode 100644 index 00000000000..19bbf29f1bd --- /dev/null +++ b/mobile/src/mobile-web-shell/generation-store-file-system.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' + +// The module pulls in expo-file-system at import time; only the pure converter is under test here. +vi.mock('expo-file-system', () => ({ + Directory: class {}, + File: class {}, + Paths: { cache: '' } +})) + +import { generationDirectoryPath } from './generation-store-file-system' + +describe('generationDirectoryPath', () => { + it('hands the native view the absolute path both loaders demand', () => { + // Both refuse anything without a leading slash, and the store speaks file:// uris. + expect( + generationDirectoryPath('file:///var/mobile/Caches/mobile-web/abc/generations/def') + ).toBe('/var/mobile/Caches/mobile-web/abc/generations/def') + expect(generationDirectoryPath('file:///data/user/0/com.stably.orca.mobile/cache/mw')).toBe( + '/data/user/0/com.stably.orca.mobile/cache/mw' + ) + }) + + it('decodes what a uri escaped and a path spells literally', () => { + expect(generationDirectoryPath('file:///var/Orca%20Mobile/mobile-web')).toBe( + '/var/Orca Mobile/mobile-web' + ) + }) + + it('leaves a value that is already a path alone, so nobody can decode one twice', () => { + expect(generationDirectoryPath('/var/mobile/Caches/100%25')).toBe('/var/mobile/Caches/100%25') + }) +}) diff --git a/mobile/src/mobile-web-shell/generation-store-file-system.ts b/mobile/src/mobile-web-shell/generation-store-file-system.ts index 4c5718fabca..79241093b73 100644 --- a/mobile/src/mobile-web-shell/generation-store-file-system.ts +++ b/mobile/src/mobile-web-shell/generation-store-file-system.ts @@ -34,6 +34,23 @@ export type GenerationFileSystem = { moveDirectory(fromUri: string, toUri: string): Promise } +const FILE_URI_PREFIX = 'file://' + +/** + * The `file://` uri the store works in, as the absolute path the native shell view requires. + * + * The two sides speak different dialects of the same location: `expo-file-system` hands out uris, + * and both native loaders refuse anything that does not start with `/`. Percent-decoded because a + * uri escapes what a path spells literally, and left alone when it is already a path so a caller + * cannot double-decode one. + */ +export function generationDirectoryPath(uri: string): string { + if (!uri.startsWith(FILE_URI_PREFIX)) { + return uri + } + return decodeURIComponent(uri.slice(FILE_URI_PREFIX.length)) +} + export function createExpoGenerationFileSystem(): GenerationFileSystem { return { rootUri: new Directory(Paths.cache, MOBILE_WEB_CACHE_DIRECTORY_NAME).uri, diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts new file mode 100644 index 00000000000..347bf41fd32 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts @@ -0,0 +1,74 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * The hybrid shell flag is the whole of what keeps this feature dark, so who touches it is a + * product invariant rather than a convention. A second reader is how a dark feature stops being + * dark: a launch-time sweep, a prefetch or a menu item that consults the flag would run in a store + * build the moment anything flipped it, and none of those would fail a type check. + */ +const MOBILE_ROOT = join(import.meta.dirname, '..', '..') +const FLAG_KEY = 'orca:mobileWebShellEnabled' +const DEFINITION = 'src/storage/preferences.ts' +const ROUTE = 'app/h/[hostId]/web.tsx' +const DEVELOPER_ROW = 'src/diagnostics/mobile-web-shell-dev-row.tsx' +/** Every tree that ships in the app bundle, with the floor each must clear. `modules` is two files, + * but it is where the native view lives and so the easiest place for a second reader to hide. */ +const TREES = { src: 200, app: 10, modules: 1 } +const SHELL_VIEW = 'modules/orca-mobile-web-shell/src/index.ts' + +function sourceFiles(directory: string): string[] { + const found: string[] = [] + for (const entry of readdirSync(join(MOBILE_ROOT, directory), { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + found.push(...sourceFiles(path)) + } else if (/\.tsx?$/.test(entry.name) && !entry.name.includes('.test.')) { + found.push(path) + } + } + return found +} + +const SOURCES = Object.keys(TREES) + .flatMap((tree) => sourceFiles(tree)) + .map((path) => ({ + path: path.split('\\').join('/'), + text: readFileSync(join(MOBILE_ROOT, path), 'utf8') + })) + +function filesContaining(needle: string): string[] { + return SOURCES.filter((file) => file.text.includes(needle)) + .map((file) => file.path) + .sort() +} + +describe('who touches the hybrid shell flag', () => { + it('reaches every shipped tree, so the absence assertions below cannot pass vacuously', () => { + const paths = SOURCES.map((file) => file.path) + expect(paths).toContain(DEFINITION) + expect(paths).toContain(ROUTE) + expect(paths).toContain(DEVELOPER_ROW) + expect(paths).toContain(SHELL_VIEW) + const trees = Object.keys(TREES) + for (const [tree, floor] of Object.entries(TREES)) { + expect(paths.filter((path) => path.startsWith(`${tree}/`)).length).toBeGreaterThan(floor) + } + expect(paths.filter((path) => !trees.some((tree) => path.startsWith(`${tree}/`)))).toEqual([]) + }) + + it('keeps the storage key itself in one module', () => { + expect(filesContaining(FLAG_KEY)).toEqual([DEFINITION]) + }) + + it('is read by the route and by the developer row that writes it, and nowhere else', () => { + expect(filesContaining('loadMobileWebShellEnabled')).toEqual( + [DEFINITION, DEVELOPER_ROW, ROUTE].sort() + ) + }) + + it('is written only by the developer row', () => { + expect(filesContaining('saveMobileWebShellEnabled')).toEqual([DEFINITION, DEVELOPER_ROW].sort()) + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-reachability.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-reachability.test.ts new file mode 100644 index 00000000000..5996a335bf9 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-reachability.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' + +import { readMobileWebShellReachability } from './mobile-web-shell-session' + +/** Only `client === null` is read, but a real shape keeps this out of the casting gate. */ +function fakeClient(): RpcClient { + return { + sendRequest: vi.fn(), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: vi.fn(), + close: vi.fn() + } +} + +const CLIENT = fakeClient() + +function reachability(state: ConnectionState, client: RpcClient | null = CLIENT): string { + return readMobileWebShellReachability(state, client) +} + +describe('readMobileWebShellReachability', () => { + it('is connected only with a live client on a connected socket', () => { + expect(reachability('connected')).toBe('connected') + expect(reachability('connected', null)).toBe('connecting') + }) + + it('waits through the first dial', () => { + expect(reachability('connecting')).toBe('connecting') + expect(reachability('handshaking')).toBe('connecting') + }) + + /** + * Observed on a simulator with the paired desktop stopped: the client never settles on + * `disconnected`. It dials, fails, schedules a retry and cycles `connecting` -> `reconnecting` + * with the delay growing to a minute. Reading `reconnecting` as "still dialling" left a phone + * holding a verified cached generation spinning on `checking` forever instead of opening it. + */ + it('treats a scheduled retry as an unreachable host, not as a dial in progress', () => { + expect(reachability('reconnecting')).toBe('unreachable') + }) + + it('treats a settled non-connection as unreachable', () => { + expect(reachability('disconnected')).toBe('unreachable') + expect(reachability('auth-failed')).toBe('unreachable') + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-route.test.tsx new file mode 100644 index 00000000000..b2753ae54f2 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-route.test.tsx @@ -0,0 +1,112 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RouteDependencies = { storage: Map; mounted: string[] } + +const dependencies = vi.hoisted((): RouteDependencies => ({ storage: new Map(), mounted: [] })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: async (key: string) => dependencies.storage.get(key) ?? null, + setItem: async (key: string, value: string) => { + dependencies.storage.set(key, value) + } + } +})) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + StyleSheet: { create: (styles: unknown) => styles }, + View: 'View' +})) + +vi.mock('expo-router', () => ({ + Redirect: 'Redirect', + useLocalSearchParams: () => ({ hostId: 'host-1' }) +})) + +vi.mock('./MobileWebShellScreen', () => ({ + MobileWebShellScreen: (props: { hostId: string }) => { + dependencies.mounted.push(props.hostId) + return null + } +})) + +import MobileWebShellRoute from '../../app/h/[hostId]/web' + +/** Host elements are matched by name, not by `findAllByType`: React's `ElementType` does not admit + * an arbitrary React Native host name, so the typed form is a predicate. */ +function byName(tree: ReactTestRenderer, name: string): ReactTestInstance[] { + return tree.root.findAll((node) => String(node.type) === name) +} + +async function renderRoute(): Promise { + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(MobileWebShellRoute)) + }) + if (rendered.tree === null) { + throw new Error('route did not render') + } + return rendered.tree +} + +/** `__DEV__` is a React Native global, absent outside that runtime; assigned rather than cast so + * the test says which build kind it is running as without asserting a type on `globalThis`. */ +function setDevelopmentBuild(isDevelopmentBuild: boolean | undefined): void { + if (isDevelopmentBuild === undefined) { + Reflect.deleteProperty(globalThis, '__DEV__') + return + } + Object.assign(globalThis, { __DEV__: isDevelopmentBuild }) +} + +describe('the hybrid shell route', () => { + beforeEach(() => { + dependencies.storage.clear() + dependencies.mounted.length = 0 + setDevelopmentBuild(true) + }) + + it('redirects to the host screen with the flag unset, and mounts nothing', async () => { + const tree = await renderRoute() + expect(byName(tree, 'Redirect').map((node) => node.props.href)).toEqual(['/h/host-1']) + expect(dependencies.mounted).toEqual([]) + }) + + it('redirects with the flag explicitly off', async () => { + dependencies.storage.set('orca:mobileWebShellEnabled', 'false') + const tree = await renderRoute() + expect(byName(tree, 'Redirect')).toHaveLength(1) + expect(dependencies.mounted).toEqual([]) + }) + + it('mounts the shell screen for this host with the flag on', async () => { + dependencies.storage.set('orca:mobileWebShellEnabled', 'true') + const tree = await renderRoute() + expect(byName(tree, 'Redirect')).toEqual([]) + expect(dependencies.mounted).toEqual(['host-1']) + }) + + it('redirects a store build whose container kept a flag a development build set', async () => { + setDevelopmentBuild(undefined) + dependencies.storage.set('orca:mobileWebShellEnabled', 'true') + const tree = await renderRoute() + expect(byName(tree, 'Redirect')).toHaveLength(1) + expect(dependencies.mounted).toEqual([]) + }) + + it('neither redirects nor mounts until the flag has been read', async () => { + dependencies.storage.set('orca:mobileWebShellEnabled', 'true') + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + // No `await` inside act: the effect's promise is deliberately left unsettled. + act(() => { + rendered.tree = create(createElement(MobileWebShellRoute)) + }) + const tree = rendered.tree + expect(tree === null ? [] : byName(tree, 'Redirect')).toEqual([]) + expect(dependencies.mounted).toEqual([]) + await act(async () => {}) + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts new file mode 100644 index 00000000000..d9fa56a3b88 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts @@ -0,0 +1,177 @@ +import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' +import type { + MobileWebBundleCompatManifest, + MobileWebBundleCompatVerdict, + MobileWebBundleHostStatus +} from '../transport/mobile-web-bundle-compat' + +/** + * Whether the host can be asked anything right now. + * + * Three values, not a boolean: a connection still being made is not an offline host, and opening a + * cached generation with no compat check for the second or two before a socket completes would + * flash a workspace this host may already have replaced. `connecting` waits; only a settled + * non-connection opens the cache unchecked. + */ +export type MobileWebShellReachability = 'connected' | 'connecting' | 'unreachable' + +/** Everything the gates say that decides a step here, as one value so a transition is a pure + * function of it rather than of four separately-arriving props. */ +export type MobileWebShellGates = { + readonly statusPending: boolean + /** False for a status nobody answered *and* for one this client could not decode. Both leave the + * capability list empty, which would otherwise read as `bundle-unavailable` and wall a host that + * simply did not reply. */ + readonly statusReadable: boolean + readonly reachability: MobileWebShellReachability + readonly hostCapabilities: readonly string[] + readonly hostStatus: MobileWebBundleHostStatus +} + +/** The manifest fields a transition reads: the wall's three, plus what names and sizes the + * generation the cache is compared against. */ +export type MobileWebShellManifestFacts = MobileWebBundleCompatManifest & { + readonly buildId: string + readonly totalBytes: number + readonly totalAssets: number +} + +/** What `readActiveGeneration` found, reduced to what a transition reads. */ +export type CachedGeneration = { + readonly buildId: string + readonly directory: string + readonly totalBytes: number +} + +export type MobileWebShellBlockedVerdict = Extract< + MobileWebBundleCompatVerdict, + { kind: 'blocked' } +> + +/** Which side a bundle read failed on. `transport` is the link between phone and host, which says + * nothing about the bundle; `bundle` is a verdict about it, from the host or from the bytes. */ +export type MobileWebShellReadFailure = 'transport' | 'bundle' + +/** The shell's own failures plus the one the view cannot report: a download or a cache write that + * never produced a generation to hand it. */ +export type MobileWebShellFailureCause = + | MobileWebShellFailureReason + | 'download-failed' + | 'status-unreadable' + +export type MobileWebShellSessionState = + /** Gates unsettled, cache being read, or a manifest in flight. Nothing is on screen yet. */ + | { readonly kind: 'checking' } + | { + readonly kind: 'fetching' + readonly completedAssets: number + readonly totalAssets: number + readonly receivedBytes: number + readonly totalBytes: number + } + /** Bytes are in; the store is staging and committing, or a cache hit is being opened. */ + | { readonly kind: 'activating' } + | { + readonly kind: 'ready' + readonly generationDirectory: string + readonly sessionId: string + readonly buildId: string + readonly totalBytes: number + readonly elapsedMs: number + } + | { readonly kind: 'wall'; readonly verdict: MobileWebShellBlockedVerdict } + | { + readonly kind: 'failed' + readonly reason: MobileWebShellFailureCause + readonly retriedOnce: boolean + } + | { readonly kind: 'offline' } + +export type MobileWebShellSessionEffect = + /** Sweep every host's staging tree, then read this host's activation. Lazy on purpose: with the + * flag off nothing in the app reaches this, so nothing sweeps at launch. */ + | { readonly kind: 'open-cache' } + | { readonly kind: 'read-manifest' } + /** Fetch, stage, commit. The runner reports progress, then `download-staged`, then `activated`. */ + | { readonly kind: 'download' } + /** A cache hit: nothing to download, so this only mints a session id and reports the activation. */ + | { + readonly kind: 'open-generation' + readonly directory: string + readonly buildId: string + readonly totalBytes: number + } + | { readonly kind: 'delete-cache' } + /** Mint a new session id for the generation already on screen, which is what remounts the view. */ + | { readonly kind: 'remount' } + +/** + * Events, in two kinds. + * + * The seven that carry a `flow` are results reported out of an effect, and the number is the flow + * the step that asked for them was in. Anything a superseded flow reports is dropped: a manifest + * read that was in flight when the socket dropped still rejects afterwards, and applying that + * rejection would replace a workspace already on screen with a download failure. The other three + * come from outside the flow: the gates and the retry button always apply, and the view's failure + * applies only while its generation is the one on screen, which is the only state that mounted it. + */ +export type MobileWebShellSessionEvent = + | { readonly type: 'gates-changed'; readonly gates: MobileWebShellGates } + | { + readonly type: 'cache-read' + readonly flow: number + readonly generation: CachedGeneration | null + } + | { + readonly type: 'manifest-read' + readonly flow: number + readonly manifest: MobileWebShellManifestFacts + } + | { + readonly type: 'fetch-progress' + readonly flow: number + readonly completedAssets: number + readonly totalAssets: number + readonly receivedBytes: number + readonly totalBytes: number + } + | { readonly type: 'download-staged'; readonly flow: number } + | { + readonly type: 'activated' + readonly flow: number + readonly generationDirectory: string + readonly sessionId: string + readonly buildId: string + readonly totalBytes: number + readonly elapsedMs: number + } + | { readonly type: 'remounted'; readonly flow: number; readonly sessionId: string } + | { + readonly type: 'download-failed' + readonly flow: number + readonly failure: MobileWebShellReadFailure + } + | { readonly type: 'shell-failed'; readonly reason: MobileWebShellFailureReason } + | { readonly type: 'retry-pressed' } + +/** Latches live beside the state because both outlive the state they were set in: `retriedOnce` + * spans the delete-and-refetch that puts the state back to `checking`, and `remountedOnce` spans a + * `ready` that is replaced by a `ready` under a new session id. */ +export type MobileWebShellSession = { + readonly state: MobileWebShellSessionState + readonly retriedOnce: boolean + readonly remountedOnce: boolean + /** The gates the current step was taken on; null until the first one arrives. */ + readonly gates: MobileWebShellGates | null + readonly cached: CachedGeneration | null + /** Which run of the flow the session is on. Bumped by every restart, stamped on the effects that + * run belongs to, and echoed back on their results. */ + readonly flow: number +} + +/** A transition: the session it produced and the effects it owes. Every effect belongs to + * `session.flow`, which is what the runner echoes back on the result. */ +export type MobileWebShellStep = { + readonly session: MobileWebShellSession + readonly effects: readonly MobileWebShellSessionEffect[] +} diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts new file mode 100644 index 00000000000..1af288f069d --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts @@ -0,0 +1,717 @@ +import { describe, expect, it } from 'vitest' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import { + createMobileWebShellSession, + reduceMobileWebShellSession +} from './mobile-web-shell-session' +import type { + CachedGeneration, + MobileWebShellGates, + MobileWebShellManifestFacts, + MobileWebShellSession, + MobileWebShellSessionEvent, + MobileWebShellStep +} from './mobile-web-shell-session-contract' + +function gates(overrides: Partial = {}): MobileWebShellGates { + return { + statusPending: false, + statusReadable: true, + reachability: 'connected', + hostCapabilities: [MOBILE_WEB_BUNDLE_CAPABILITY], + hostStatus: { protocolVersion: 10, minCompatibleMobileVersion: 1 }, + ...overrides + } +} + +const MANIFEST: MobileWebShellManifestFacts = { + buildId: 'b'.repeat(64), + schemaVersion: 1, + runtimeProtocolVersion: 5, + minCompatibleRuntimeProtocolVersion: 2, + totalBytes: 4096, + totalAssets: 4 +} + +const CACHED: CachedGeneration = { + buildId: MANIFEST.buildId, + directory: '/cache/mobile-web/host/generations/b', + totalBytes: 4096 +} + +/** An event as a test writes it. An effect result is stamped with the flow the session is on, which + * is what an in-order runner does; a test replaying a superseded run pins the flow itself. */ +type PendingEvent = E extends { flow: number } + ? Omit & { readonly flow?: number } + : E + +function stamp(flow: number, event: PendingEvent): MobileWebShellSessionEvent { + switch (event.type) { + case 'gates-changed': + case 'shell-failed': + case 'retry-pressed': + return event + case 'cache-read': + case 'manifest-read': + case 'fetch-progress': + case 'download-staged': + case 'activated': + case 'remounted': + case 'download-failed': + return { ...event, flow: event.flow ?? flow } + } +} + +function run( + session: MobileWebShellSession, + ...events: readonly PendingEvent[] +): MobileWebShellStep { + let step: MobileWebShellStep = { session, effects: [] } + for (const event of events) { + step = reduceMobileWebShellSession(step.session, stamp(step.session.flow, event)) + } + return step +} + +function started(overrides: Partial = {}): MobileWebShellStep { + return run(createMobileWebShellSession(), { type: 'gates-changed', gates: gates(overrides) }) +} + +/** Connected, capability present, cache read, manifest in flight. */ +function afterCacheRead(generation: CachedGeneration | null): MobileWebShellStep { + return run(started().session, { type: 'cache-read', generation }) +} + +function readySession(): MobileWebShellStep { + return run( + afterCacheRead(CACHED).session, + { type: 'manifest-read', manifest: MANIFEST }, + { + type: 'activated', + generationDirectory: CACHED.directory, + sessionId: 'session-one', + buildId: MANIFEST.buildId, + totalBytes: MANIFEST.totalBytes, + elapsedMs: 12 + } + ) +} + +/** The second half of a recovery: the refetch the delete queued, through to a mounted view. */ +function readyAgain(session: MobileWebShellSession, sessionId: string): MobileWebShellStep { + return run( + session, + { type: 'cache-read', generation: null }, + { type: 'manifest-read', manifest: MANIFEST }, + { type: 'download-staged' }, + { + type: 'activated', + generationDirectory: '/cache/gen', + sessionId, + buildId: MANIFEST.buildId, + totalBytes: MANIFEST.totalBytes, + elapsedMs: 7 + } + ) +} + +describe('the gates decide whether a step is taken at all', () => { + it('waits while a connection is still being made', () => { + const step = started({ reachability: 'connecting' }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([]) + }) + + it('waits while status.get is still pending rather than reading its empty capabilities', () => { + const step = started({ statusPending: true, hostCapabilities: [] }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([]) + }) + + it('says a status could not be read rather than walling or waiting on it forever', () => { + const step = started({ statusReadable: false, hostCapabilities: [] }) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'status-unreadable', + retriedOnce: false + }) + expect(step.effects).toEqual([]) + }) + + it('picks the flow back up if that status ever becomes readable', () => { + const unreadable = started({ statusReadable: false, hostCapabilities: [] }) + const step = run(unreadable.session, { type: 'gates-changed', gates: gates() }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([{ kind: 'open-cache' }]) + }) + + it('walls a readable host that serves no bundle', () => { + const step = started({ hostCapabilities: [] }) + expect(step.session.state).toEqual({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-unavailable' } + }) + expect(step.effects).toEqual([]) + }) + + it('sweeps and reads the cache once the capability is answered', () => { + expect(started().effects).toEqual([{ kind: 'open-cache' }]) + }) + + it('reads the cache for an unreachable host too, before deciding anything', () => { + expect(started({ reachability: 'unreachable' }).effects).toEqual([{ kind: 'open-cache' }]) + }) +}) + +describe('the offline rule', () => { + it('opens a cached generation with no compat check when the host is unreachable', () => { + const start = started({ reachability: 'unreachable', hostCapabilities: [] }) + const step = run(start.session, { type: 'cache-read', generation: CACHED }) + expect(step.session.state).toEqual({ kind: 'activating' }) + expect(step.effects).toEqual([ + { + kind: 'open-generation', + directory: CACHED.directory, + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes + } + ]) + }) + + it('says so when an unreachable host has nothing cached', () => { + const start = started({ reachability: 'unreachable' }) + const step = run(start.session, { type: 'cache-read', generation: null }) + expect(step.session.state).toEqual({ kind: 'offline' }) + expect(step.effects).toEqual([]) + }) + + it('waits on a cache read that lands mid-dial instead of opening it unchecked', () => { + const dialling = run(started().session, { + type: 'gates-changed', + gates: gates({ reachability: 'connecting' }) + }) + const step = run(dialling.session, { type: 'cache-read', generation: CACHED }) + // Connecting is not unreachable: the compat check is a moment away, and skipping it would put a + // generation on screen the host is about to say it no longer serves. + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.session.cached).toEqual(CACHED) + expect(step.effects).toEqual([]) + }) + + it('restarts the flow when the host becomes reachable while offline is showing', () => { + const offline = run(started({ reachability: 'unreachable' }).session, { + type: 'cache-read', + generation: null + }) + const step = run(offline.session, { type: 'gates-changed', gates: gates() }) + expect(step.effects).toEqual([{ kind: 'open-cache' }]) + }) +}) + +describe('the connected flow', () => { + it('asks the host for a manifest once the cache has been read', () => { + expect(afterCacheRead(null).effects).toEqual([{ kind: 'read-manifest' }]) + expect(afterCacheRead(CACHED).effects).toEqual([{ kind: 'read-manifest' }]) + }) + + it('walls a manifest written in a schema this shell does not know', () => { + const step = run(afterCacheRead(null).session, { + type: 'manifest-read', + manifest: { ...MANIFEST, schemaVersion: 99 } + }) + expect(step.session.state).toEqual({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-shell-too-old', schemaVersion: 99 } + }) + expect(step.effects).toEqual([]) + }) + + it('opens the cached generation without paging when the build ids match', () => { + const step = run(afterCacheRead(CACHED).session, { type: 'manifest-read', manifest: MANIFEST }) + expect(step.session.state).toEqual({ kind: 'activating' }) + expect(step.effects).toEqual([ + { + kind: 'open-generation', + directory: CACHED.directory, + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes + } + ]) + }) + + it('downloads when the cached build id is a different one', () => { + const stale = { ...CACHED, buildId: 'c'.repeat(64) } + const step = run(afterCacheRead(stale).session, { type: 'manifest-read', manifest: MANIFEST }) + expect(step.session.state).toEqual({ + kind: 'fetching', + completedAssets: 0, + totalAssets: 4, + receivedBytes: 0, + totalBytes: 4096 + }) + expect(step.effects).toEqual([{ kind: 'download' }]) + }) + + it('downloads when there is no cache at all', () => { + const step = run(afterCacheRead(null).session, { type: 'manifest-read', manifest: MANIFEST }) + expect(step.effects).toEqual([{ kind: 'download' }]) + }) + + it('carries download progress and then stages and activates', () => { + const fetching = run(afterCacheRead(null).session, { + type: 'manifest-read', + manifest: MANIFEST + }) + const progressed = run(fetching.session, { + type: 'fetch-progress', + completedAssets: 2, + totalAssets: 4, + receivedBytes: 2048, + totalBytes: 4096 + }) + expect(progressed.session.state).toMatchObject({ kind: 'fetching', completedAssets: 2 }) + const staged = run(progressed.session, { type: 'download-staged' }) + expect(staged.session.state).toEqual({ kind: 'activating' }) + const ready = run(staged.session, { + type: 'activated', + generationDirectory: '/cache/gen', + sessionId: 'session-one', + buildId: MANIFEST.buildId, + totalBytes: 4096, + elapsedMs: 900 + }) + expect(ready.session.state).toEqual({ + kind: 'ready', + generationDirectory: '/cache/gen', + sessionId: 'session-one', + buildId: MANIFEST.buildId, + totalBytes: 4096, + elapsedMs: 900 + }) + }) + + it('ignores progress that arrives after the fetching state is gone', () => { + const ready = readySession() + const step = run(ready.session, { + type: 'fetch-progress', + completedAssets: 1, + totalAssets: 4, + receivedBytes: 1, + totalBytes: 4096 + }) + expect(step.session.state).toEqual(ready.session.state) + }) + + it('fails when the download or the cache write never produced a generation', () => { + const step = run(afterCacheRead(null).session, { + type: 'download-failed', + failure: 'bundle' + }) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'download-failed', + retriedOnce: false + }) + }) +}) + +describe('a read the link cut short falls back to what is on disk', () => { + /** Connected, a generation cached, the manifest read in flight — where the drop is felt. */ + function manifestInFlight() { + return afterCacheRead(CACHED) + } + + it('opens the cached generation when the socket drops before the reachability change does', () => { + const step = run(manifestInFlight().session, { type: 'download-failed', failure: 'transport' }) + expect(step.session.state).toEqual({ kind: 'activating' }) + expect(step.effects).toEqual([ + { + kind: 'open-generation', + directory: CACHED.directory, + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes + } + ]) + const ready = run(step.session, { + type: 'activated', + generationDirectory: CACHED.directory, + sessionId: 'session-one', + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes, + elapsedMs: 12 + }) + expect(ready.session.state).toMatchObject({ kind: 'ready', buildId: CACHED.buildId }) + }) + + it('still says the workspace could not be downloaded when nothing is on disk', () => { + const step = run(afterCacheRead(null).session, { + type: 'download-failed', + failure: 'transport' + }) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'download-failed', + retriedOnce: false + }) + expect(step.effects).toEqual([]) + }) + + it('fails on a verdict about the bundle even with a generation cached', () => { + // A host that refuses the read, or bytes that do not hash, is an answer about the bundle. A + // cached generation is no reason to hide it behind a workspace that is merely older. + const step = run(manifestInFlight().session, { type: 'download-failed', failure: 'bundle' }) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'download-failed', + retriedOnce: false + }) + expect(step.effects).toEqual([]) + }) +}) + +describe('a displayed generation is not restarted by the gates', () => { + it.each(['connected', 'unreachable', 'connecting'] as const)( + 'keeps a ready session when reachability becomes %s', + (reachability) => { + const ready = readySession() + const step = run(ready.session, { type: 'gates-changed', gates: gates({ reachability }) }) + expect(step.session.state).toEqual(ready.session.state) + expect(step.effects).toEqual([]) + } + ) + + it('keeps a wall and a terminal failure', () => { + const wall = started({ hostCapabilities: [] }) + expect(run(wall.session, { type: 'gates-changed', gates: gates() }).effects).toEqual([]) + const failed = run(afterCacheRead(null).session, { + type: 'download-failed', + failure: 'bundle' + }) + expect(run(failed.session, { type: 'gates-changed', gates: gates() }).effects).toEqual([]) + }) +}) + +describe('recovery follows the shell view contract', () => { + it.each(['generation-unreadable', 'document-load-failed'] as const)( + 'deletes this host cache and runs once more on %s', + (reason) => { + const step = run(readySession().session, { type: 'shell-failed', reason }) + expect(step.effects).toEqual([{ kind: 'delete-cache' }, { kind: 'open-cache' }]) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.session.retriedOnce).toBe(true) + expect(step.session.cached).toBeNull() + } + ) + + it('takes a recovery through the gate rather than back to a manifest check', () => { + const ready = readySession() + // A reconnect whose status probe failed. Stored, not acted on: a workspace on screen is not + // restarted by a gates change, which is how a ready session ends up holding one like this. + const stale = run(ready.session, { + type: 'gates-changed', + gates: gates({ statusReadable: false, hostCapabilities: [] }) + }) + expect(stale.session.state).toMatchObject({ kind: 'ready' }) + const step = run(stale.session, { type: 'shell-failed', reason: 'document-load-failed' }) + // Not the wall the empty capability list would have produced, which nothing leaves. + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'status-unreadable', + retriedOnce: true + }) + expect(step.effects).toEqual([{ kind: 'delete-cache' }]) + const rearmed = run(step.session, { type: 'gates-changed', gates: gates() }) + expect(rearmed.session.state).toEqual({ kind: 'checking' }) + expect(rearmed.effects).toEqual([{ kind: 'open-cache' }]) + }) + + it('still walls a recovery whose host readably serves no bundle', () => { + const stale = run(readySession().session, { + type: 'gates-changed', + gates: gates({ hostCapabilities: [] }) + }) + const step = run(stale.session, { type: 'shell-failed', reason: 'document-load-failed' }) + expect(step.session.state).toEqual({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-unavailable' } + }) + expect(step.effects).toEqual([{ kind: 'delete-cache' }]) + }) + + it('deletes the suspect cache and waits when the recovery lands mid-reconnect', () => { + const dialling = run(readySession().session, { + type: 'gates-changed', + gates: gates({ reachability: 'connecting' }) + }) + const step = run(dialling.session, { type: 'shell-failed', reason: 'generation-unreadable' }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([{ kind: 'delete-cache' }]) + expect(run(step.session, { type: 'gates-changed', gates: gates() }).effects).toEqual([ + { kind: 'open-cache' } + ]) + }) + + it.each(['generation-unreadable', 'document-load-failed'] as const)( + 'is terminal the second time %s is reported', + (reason) => { + const first = run(readySession().session, { type: 'shell-failed', reason }) + const refetched = readyAgain(first.session, 'session-two') + const second = run(refetched.session, { type: 'shell-failed', reason }) + expect(second.effects).toEqual([]) + expect(second.session.state).toEqual({ kind: 'failed', reason, retriedOnce: true }) + } + ) + + it('remounts once on render-process-gone and never deletes anything', () => { + const ready = readySession() + const step = run(ready.session, { type: 'shell-failed', reason: 'render-process-gone' }) + expect(step.effects).toEqual([{ kind: 'remount' }]) + expect(step.session.state).toEqual(ready.session.state) + const remounted = run(step.session, { type: 'remounted', sessionId: 'session-two' }) + expect(remounted.session.state).toMatchObject({ + kind: 'ready', + sessionId: 'session-two', + generationDirectory: CACHED.directory + }) + }) + + it('is terminal the second time the render process is gone, still without a delete', () => { + const first = run(readySession().session, { + type: 'shell-failed', + reason: 'render-process-gone' + }) + const remounted = run(first.session, { type: 'remounted', sessionId: 'session-two' }) + const second = run(remounted.session, { type: 'shell-failed', reason: 'render-process-gone' }) + expect(second.effects).toEqual([]) + expect(second.session.state).toEqual({ + kind: 'failed', + reason: 'render-process-gone', + retriedOnce: false + }) + }) + + it('is terminal on the first isolation-unavailable, with no retry and no delete', () => { + const step = run(readySession().session, { + type: 'shell-failed', + reason: 'isolation-unavailable' + }) + expect(step.effects).toEqual([]) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'isolation-unavailable', + retriedOnce: false + }) + }) + + it('ignores a session id for a generation that is no longer ready', () => { + const step = run(started().session, { type: 'remounted', sessionId: 'session-two' }) + expect(step.session.state).toEqual({ kind: 'checking' }) + }) +}) + +describe('try again', () => { + it('clears both latches and restarts the flow', () => { + const first = run(readySession().session, { + type: 'shell-failed', + reason: 'document-load-failed' + }) + const refetched = readyAgain(first.session, 'session-two') + const failed = run(refetched.session, { type: 'shell-failed', reason: 'document-load-failed' }) + const retried = run(failed.session, { type: 'retry-pressed' }) + expect(retried.session.retriedOnce).toBe(false) + expect(retried.session.remountedOnce).toBe(false) + expect(retried.session.cached).toBeNull() + expect(retried.effects).toEqual([{ kind: 'open-cache' }]) + // And the delete-and-refetch is available again. + const again = run( + run(retried.session, { type: 'cache-read', generation: CACHED }).session, + { type: 'manifest-read', manifest: MANIFEST }, + { + type: 'activated', + generationDirectory: CACHED.directory, + sessionId: 'session-three', + buildId: MANIFEST.buildId, + totalBytes: 4096, + elapsedMs: 3 + }, + { type: 'shell-failed', reason: 'document-load-failed' } + ) + expect(again.effects).toEqual([{ kind: 'delete-cache' }, { kind: 'open-cache' }]) + }) + + it('walls again rather than looping when the host still serves no bundle', () => { + const wall = started({ hostCapabilities: [] }) + const retried = run(wall.session, { type: 'retry-pressed' }) + expect(retried.session.state).toMatchObject({ kind: 'wall' }) + expect(retried.effects).toEqual([]) + }) + + it('does nothing but reset when no gates have arrived yet', () => { + const step = run(createMobileWebShellSession(), { type: 'retry-pressed' }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([]) + }) +}) + +describe('a result from a superseded flow reports into nothing', () => { + it('drops the cache read of a run a reconnect replaced, so nothing opens unchecked', () => { + const first = started({ reachability: 'unreachable' }) + const restarted = run(first.session, { type: 'gates-changed', gates: gates() }) + expect(restarted.effects).toEqual([{ kind: 'open-cache' }]) + // The offline read would have opened this generation with no compat check at all. + const stale = run(restarted.session, { + type: 'cache-read', + flow: first.session.flow, + generation: CACHED + }) + expect(stale.effects).toEqual([]) + expect(stale.session.cached).toBeNull() + expect(run(stale.session, { type: 'cache-read', generation: CACHED }).effects).toEqual([ + { kind: 'read-manifest' } + ]) + }) + + it('drops the manifest of a run the socket drop replaced, so no download is asked for', () => { + const first = afterCacheRead(null) + const restarted = run(first.session, { + type: 'gates-changed', + gates: gates({ reachability: 'unreachable' }) + }) + const stale = run(restarted.session, { + type: 'manifest-read', + flow: first.session.flow, + manifest: MANIFEST + }) + expect(stale.effects).toEqual([]) + expect(stale.session.state).toEqual({ kind: 'checking' }) + const current = run(stale.session, { type: 'cache-read', generation: null }) + expect(current.session.state).toEqual({ kind: 'offline' }) + expect(current.effects).toEqual([]) + }) + + it('keeps a workspace on screen when the manifest read the drop abandoned finally rejects', () => { + // The reproduced sequence: connected, cache read, manifest in flight, socket drops, the offline + // path opens the cached generation, and only then does the abandoned RPC settle. + const inFlight = afterCacheRead(CACHED) + const offline = run(inFlight.session, { + type: 'gates-changed', + gates: gates({ reachability: 'unreachable' }) + }) + const ready = run( + offline.session, + { type: 'cache-read', generation: CACHED }, + { + type: 'activated', + generationDirectory: CACHED.directory, + sessionId: 'session-one', + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes, + elapsedMs: 4 + } + ) + expect(ready.session.state).toMatchObject({ kind: 'ready' }) + const late = run(ready.session, { + type: 'download-failed', + failure: 'bundle', + flow: inFlight.session.flow + }) + expect(late.session.state).toEqual(ready.session.state) + }) + + it('applies a remount of the current flow and ignores one from a replaced run', () => { + const ready = readySession() + const remounting = run(ready.session, { type: 'shell-failed', reason: 'render-process-gone' }) + const stale = run(remounting.session, { + type: 'remounted', + flow: remounting.session.flow - 1, + sessionId: 'session-stale' + }) + expect(stale.session.state).toEqual(ready.session.state) + expect( + run(stale.session, { type: 'remounted', sessionId: 'session-two' }).session.state + ).toMatchObject({ sessionId: 'session-two' }) + }) +}) + +describe('the remount budget is one per session, not one per reconnect', () => { + it('keeps the latch set when the gates restart the flow after a load failure', () => { + const remounted = run( + readySession().session, + { type: 'shell-failed', reason: 'render-process-gone' }, + { type: 'remounted', sessionId: 'session-two' }, + { type: 'shell-failed', reason: 'document-load-failed' } + ) + expect(remounted.session.remountedOnce).toBe(true) + const restarted = run(remounted.session, { type: 'gates-changed', gates: gates() }) + expect(restarted.session.remountedOnce).toBe(true) + expect(run(restarted.session, { type: 'retry-pressed' }).session.remountedOnce).toBe(false) + }) +}) + +describe('only the state that mounted the view hears the view', () => { + it('ignores the second failure of one native batch, leaving the first recovery running', () => { + const recovering = run(readySession().session, { + type: 'shell-failed', + reason: 'document-load-failed' + }) + const batched = run(recovering.session, { + type: 'shell-failed', + reason: 'render-process-gone' + }) + expect(batched.session.state).toEqual({ kind: 'checking' }) + expect(batched.effects).toEqual([]) + expect(batched.session.flow).toBe(recovering.session.flow) + // And the cache read the recovery already asked for still lands on the recovery. + expect(run(batched.session, { type: 'cache-read', generation: null }).effects).toEqual([ + { kind: 'read-manifest' } + ]) + }) + + it('leaves a wall standing when a view that is no longer mounted reports a failure', () => { + const wall = started({ hostCapabilities: [] }) + const step = run(wall.session, { type: 'shell-failed', reason: 'isolation-unavailable' }) + expect(step.session.state).toEqual(wall.session.state) + expect(step.effects).toEqual([]) + }) +}) + +describe('a gates change that says nothing new starts nothing', () => { + it('leaves a check in flight alone rather than sweeping and reading a second time', () => { + const checking = started() + const again = run(checking.session, { type: 'gates-changed', gates: gates() }) + expect(again.effects).toEqual([]) + expect(again.session.flow).toBe(checking.session.flow) + }) + + it('holds the offline screen through a reconnect cycle that never reaches the host', () => { + const offline = run(started({ reachability: 'unreachable' }).session, { + type: 'cache-read', + generation: null + }) + const cycled = run( + offline.session, + { type: 'gates-changed', gates: gates({ reachability: 'unreachable' }) }, + { type: 'gates-changed', gates: gates({ reachability: 'unreachable' }) } + ) + expect(cycled.effects).toEqual([]) + expect(cycled.session.state).toEqual({ kind: 'offline' }) + }) + + it('restarts on the verdict that changed, not on the object that was rebuilt', () => { + const checking = started({ statusPending: true }) + const settled = run(checking.session, { type: 'gates-changed', gates: gates() }) + expect(settled.effects).toEqual([{ kind: 'open-cache' }]) + }) + + it('walls a check in flight the moment the host stops serving a bundle', () => { + const checking = started() + const step = run(checking.session, { + type: 'gates-changed', + gates: gates({ hostCapabilities: [] }) + }) + expect(step.session.state).toEqual({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-unavailable' } + }) + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts new file mode 100644 index 00000000000..5bc9f893ab6 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts @@ -0,0 +1,385 @@ +import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { evaluateMobileWebBundleCompat } from '../transport/mobile-web-bundle-compat' +import type { + CachedGeneration, + MobileWebShellBlockedVerdict, + MobileWebShellGates, + MobileWebShellManifestFacts, + MobileWebShellReachability, + MobileWebShellReadFailure, + MobileWebShellSession, + MobileWebShellSessionEffect, + MobileWebShellSessionEvent, + MobileWebShellSessionState, + MobileWebShellStep +} from './mobile-web-shell-session-contract' + +/** + * The host's connection state as the three answers a step here needs. + * + * `reconnecting` is unreachable, not connecting, and that is the whole point of the distinction: a + * host whose desktop is gone never settles on `disconnected`. The client dials, fails, schedules a + * retry and cycles `connecting` -> `reconnecting` -> `connecting` with the delay growing to a + * minute, so treating `reconnecting` as "still dialling" leaves a phone with a perfectly good + * cached workspace spinning forever. `connecting` alone is the first dial, which is worth the wait + * because it usually succeeds; a scheduled retry after a failure is evidence the host is not there. + */ +export function readMobileWebShellReachability( + connState: ConnectionState, + client: RpcClient | null +): MobileWebShellReachability { + if (connState === 'connected') { + return client === null ? 'connecting' : 'connected' + } + return connState === 'connecting' || connState === 'handshaking' ? 'connecting' : 'unreachable' +} + +const CHECKING: MobileWebShellSessionState = { kind: 'checking' } + +export function createMobileWebShellSession(): MobileWebShellSession { + return { + state: CHECKING, + retriedOnce: false, + remountedOnce: false, + gates: null, + cached: null, + flow: 0 + } +} + +function step( + session: MobileWebShellSession, + patch: Partial, + effects: readonly MobileWebShellSessionEffect[] = [] +): MobileWebShellStep { + return { session: { ...session, ...patch }, effects } +} + +/** + * Whether a gates change may start or restart the flow. + * + * Only from the two states still waiting on one. A displayed generation is not restarted by a + * reconnect: the manifest check that would follow swaps the page out from under whoever is reading + * it, and a cached generation stays valid until the route is entered again. A wall and a terminal + * failure are both left by acting, so neither reacts either. + */ +function awaitsGates(state: MobileWebShellSessionState): boolean { + if (state.kind === 'failed') { + // The one failure the gates can answer: a status that becomes readable is a different host + // screen, and it costs nothing to take it rather than make someone walk back out. + return state.reason === 'status-unreadable' + } + return state.kind === 'checking' || state.kind === 'offline' +} + +/** + * What the gates permit, before any manifest is read. + * + * One answer for both ways into the flow. A recovery used to keep whatever gates the `ready` + * session was holding and go straight back to the manifest check, and gates that arrive while a + * generation is on screen are stored without restarting: a reconnect whose status probe failed + * therefore left a ready session carrying an unreadable status and an empty capability list, and + * the next view failure walled the host as `bundle-unavailable` — terminal, no retry, about a host + * that had simply not answered. + */ +type MobileWebShellGateVerdict = + /** Nothing is decidable yet. Two kinds rather than one so a dial that settles into a pending + * status still counts as a change worth restarting on. */ + | { readonly kind: 'dialling' } + | { readonly kind: 'pending' } + | { readonly kind: 'offline' } + | { readonly kind: 'status-unreadable' } + | { readonly kind: 'wall'; readonly verdict: MobileWebShellBlockedVerdict } + | { readonly kind: 'open' } + +function gateVerdict(gates: MobileWebShellGates): MobileWebShellGateVerdict { + if (gates.reachability === 'connecting') { + return { kind: 'dialling' } + } + if (gates.reachability === 'unreachable') { + return { kind: 'offline' } + } + if (gates.statusPending) { + return { kind: 'pending' } + } + // Never a wall on an unreadable status: the empty capability list it leaves behind is + // indistinguishable from a desktop that ships no bundle, and that wall tells the wrong story. It + // is not a wait either — the gate settles once per host screen and does not probe again — so the + // one honest answer is to say the status could not be read and let a fresh gate reopen it. + if (!gates.statusReadable) { + return { kind: 'status-unreadable' } + } + const verdict = evaluateMobileWebBundleCompat({ + hostCapabilities: gates.hostCapabilities, + hostStatus: gates.hostStatus, + manifest: null + }) + // Which block, not why: any blocked verdict walls, and the wall reads its own reason. + return verdict.kind === 'blocked' ? { kind: 'wall', verdict } : { kind: 'open' } +} + +/** + * The gate verdict as one comparable value. + * + * A restart is worth taking only when this changes. The gates object is rebuilt on every status + * refetch and every connection event, and most of those say exactly what the last one said: a + * reconnect cycle that re-derives the same verdict used to re-sweep the staging tree and flip an + * offline screen to a spinner and back for as long as the cycle ran. + */ +function gateKey(gates: MobileWebShellGates): string { + return gateVerdict(gates).kind +} + +/** + * The step the gate takes, and every entry into the flow goes through it. + * + * The first run, the one "Try again" returns to, and the recovery a failed view triggers, which + * passes the delete it owes as `before` so the cache goes whatever the gate then decides. + */ +function startFlow( + session: MobileWebShellSession, + gates: MobileWebShellGates, + patch: Partial = {}, + before: readonly MobileWebShellSessionEffect[] = [] +): MobileWebShellStep { + // A new flow, so nothing the replaced one has in flight can land on this one. That is also what + // keeps a status refetch arriving mid-check from running the cache read and the download twice. + const base = { ...patch, gates, flow: session.flow + 1 } + const verdict = gateVerdict(gates) + if (verdict.kind === 'wall') { + return step(session, { ...base, state: { kind: 'wall', verdict: verdict.verdict } }, before) + } + if (verdict.kind === 'status-unreadable') { + return step( + session, + { + ...base, + state: { + kind: 'failed', + reason: 'status-unreadable', + retriedOnce: patch.retriedOnce ?? session.retriedOnce + } + }, + before + ) + } + if (verdict.kind === 'dialling' || verdict.kind === 'pending') { + return step(session, { ...base, state: CHECKING }, before) + } + // Offline sweeps and reads the cache exactly as a connected host does. What it skips is the + // compat check, and `onCacheRead` is where that shows. + return step(session, { ...base, state: CHECKING }, [...before, { kind: 'open-cache' }]) +} + +/** Puts a generation that is already on disk on screen. The only producer of `open-generation`. */ +function openCached( + session: MobileWebShellSession, + generation: CachedGeneration, + patch: Partial = {} +): MobileWebShellStep { + return step(session, { ...patch, state: { kind: 'activating' } }, [ + { + kind: 'open-generation', + directory: generation.directory, + buildId: generation.buildId, + totalBytes: generation.totalBytes + } + ]) +} + +function onCacheRead( + session: MobileWebShellSession, + generation: CachedGeneration | null +): MobileWebShellStep { + const gates = session.gates + if (gates === null) { + return step(session, { cached: generation }) + } + if (gates.reachability === 'connecting') { + // A dial in progress is not a host that cannot be reached: opening the cache here would skip a + // compat check the connection about to land is what makes answerable. + return step(session, { cached: generation }) + } + if (gates.reachability === 'unreachable') { + // No compat check on this path, by design: the generation was compatible when it was cached and + // a host nobody can reach cannot have changed since. The next entry while connected re-checks. + return generation === null + ? step(session, { cached: null, state: { kind: 'offline' } }) + : openCached(session, generation, { cached: generation }) + } + return step(session, { cached: generation, state: CHECKING }, [{ kind: 'read-manifest' }]) +} + +function onManifestRead( + session: MobileWebShellSession, + manifest: MobileWebShellManifestFacts +): MobileWebShellStep { + const gates = session.gates + if (gates === null) { + return step(session, {}) + } + const verdict = evaluateMobileWebBundleCompat({ + hostCapabilities: gates.hostCapabilities, + hostStatus: gates.hostStatus, + manifest + }) + if (verdict.kind === 'blocked') { + return step(session, { state: { kind: 'wall', verdict } }) + } + const cached = session.cached + if (cached !== null && cached.buildId === manifest.buildId) { + return openCached(session, cached) + } + return step( + session, + { + state: { + kind: 'fetching', + completedAssets: 0, + totalAssets: manifest.totalAssets, + receivedBytes: 0, + totalBytes: manifest.totalBytes + } + }, + [{ kind: 'download' }] + ) +} + +/** + * B3's contract, and the only place it is interpreted. + * + * `generation-unreadable` and `document-load-failed` say the bytes on disk are suspect, so the + * host's cache goes and the flow runs once more. `render-process-gone` says nothing about the + * bytes — renderer memory pressure and a WebView provider update look identical from here — so it + * remounts and never deletes. `isolation-unavailable` is terminal on the first report: the fence is + * the whole reason this view exists, and a device that cannot install it will not on a retry. + * + * Only `ready` hears any of it. The view exists in no other state, so a report arriving outside one + * is from a view that has already been taken off screen: the second failure of a native batch that + * the first one's recovery has already answered, or a mount that a wall or a retry has replaced. + * Acting on it would strand the recovery already in flight — the delete-and-refetch would be made + * terminal while its own cache read was still coming back, and that read would then drag the + * session back to checking behind a failure screen. + */ +function onShellFailed( + session: MobileWebShellSession, + reason: MobileWebShellFailureReason +): MobileWebShellStep { + if (session.state.kind !== 'ready') { + return step(session, {}) + } + const failed = { kind: 'failed', reason, retriedOnce: session.retriedOnce } as const + if (reason === 'isolation-unavailable') { + return step(session, { state: failed }) + } + if (reason === 'render-process-gone') { + return session.remountedOnce + ? step(session, { state: failed }) + : step(session, { remountedOnce: true }, [{ kind: 'remount' }]) + } + if (session.retriedOnce || session.gates === null) { + return step(session, { state: failed }) + } + // Through the gate, not straight back to the manifest check: the gates a ready session holds are + // whatever the last reconnect stored, so a recovery that trusted them walled hosts whose status + // had gone unreadable underneath a workspace that was, until this failure, working. + return startFlow(session, session.gates, { retriedOnce: true, cached: null }, [ + { kind: 'delete-cache' } + ]) +} + +function onDownloadFailed( + session: MobileWebShellSession, + failure: MobileWebShellReadFailure +): MobileWebShellStep { + const cached = session.cached + if (failure === 'transport' && cached !== null) { + // The link went, not the bundle. A generation already on disk was compatible when it was + // written, and it is the same one the offline gate would have opened had the reachability + // change arrived before this rejection did; which of the two lands first is a race. + return openCached(session, cached) + } + return step(session, { + state: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce } + }) +} + +/** + * One transition of the hybrid shell session: a state and the effects the runner owes it. + * + * Pure, so every rule above is a table test rather than a simulator run. The runner may drop an + * effect's result (an unmount, a host change) but must never invent one, and a result it reports + * late is dropped here by its flow rather than by whatever state the session happens to be in. + */ +export function reduceMobileWebShellSession( + session: MobileWebShellSession, + event: MobileWebShellSessionEvent +): MobileWebShellStep { + if ('flow' in event && event.flow !== session.flow) { + return step(session, {}) + } + switch (event.type) { + case 'gates-changed': + return awaitsGates(session.state) && + (session.gates === null || gateKey(session.gates) !== gateKey(event.gates)) + ? startFlow(session, event.gates) + : step(session, { gates: event.gates }) + case 'cache-read': + return onCacheRead(session, event.generation) + case 'manifest-read': + return onManifestRead(session, event.manifest) + case 'fetch-progress': + return session.state.kind === 'fetching' + ? step(session, { + state: { + kind: 'fetching', + completedAssets: event.completedAssets, + totalAssets: event.totalAssets, + receivedBytes: event.receivedBytes, + totalBytes: event.totalBytes + } + }) + : step(session, {}) + case 'download-staged': + return session.state.kind === 'fetching' + ? step(session, { state: { kind: 'activating' } }) + : step(session, {}) + case 'activated': + return step(session, { + state: { + kind: 'ready', + generationDirectory: event.generationDirectory, + sessionId: event.sessionId, + buildId: event.buildId, + totalBytes: event.totalBytes, + elapsedMs: event.elapsedMs + } + }) + case 'remounted': + // Only the session id changes, so the view remounts against the same verified bytes. + return session.state.kind === 'ready' + ? step(session, { state: { ...session.state, sessionId: event.sessionId } }) + : step(session, {}) + case 'download-failed': + return onDownloadFailed(session, event.failure) + case 'shell-failed': + return onShellFailed(session, event.reason) + case 'retry-pressed': + // Clears both latches, so the delete-and-refetch and the remount are each available again. + // Only here: a reconnect is not a reason to grant a second remount of the same session. + return session.gates === null + ? step(session, { + retriedOnce: false, + remountedOnce: false, + state: CHECKING, + flow: session.flow + 1 + }) + : startFlow(session, session.gates, { + retriedOnce: false, + remountedOnce: false, + cached: null + }) + } +} diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts new file mode 100644 index 00000000000..5aa808c952a --- /dev/null +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts @@ -0,0 +1,346 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import type { MobileWebBundleFetchResult } from '../transport/mobile-web-bundle-fetch' +import type { MobileWebBundleManifestRead } from '../transport/mobile-web-bundle-reply-schemas' +import type { ActiveGeneration, GenerationStore, StagedGeneration } from './generation-store' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' + +/** + * The runner, not the rules: what the reducer decides has table tests, and this covers the three + * things only the wiring can get wrong — abandoning an effect whose session is gone, aborting the + * bytes it was pulling, and doing both again when someone taps Try again. A cancellation that is + * merely intended is a download that keeps four of the host's read slots and a cache write that + * lands under a host nobody is looking at any more. + * + * The React Native and Expo modules are mocked at the edge of the import graph rather than stubbed + * one deep, because importing any of them pulls the runtime this test does not have. + */ +type Settle = (value: T) => void + +type Doubles = { + connection: { client: object | null; state: string } + gates: { + statusPending: boolean + statusReadable: boolean + hostCapabilities: string[] + hostProtocolWindow: { protocolVersion: number; minCompatibleMobileVersion: number } + } + manifestReads: number + manifestClients: unknown[] + manifestRejection: unknown + fetches: { signal: AbortSignal; settle: Settle }[] + manifest: MobileWebBundleManifestRead +} + +const doubles = vi.hoisted((): Doubles => { + const manifest: MobileWebBundleManifestRead = { + schemaVersion: 1, + buildId: 'b'.repeat(64), + minCompatibleRuntimeProtocolVersion: 2, + runtimeProtocolVersion: 5, + entrypoint: 'index.html', + totalBytes: 2048, + assets: [ + { path: 'index.html', sha256: 'c'.repeat(64), byteLength: 2048, contentType: 'text/html' } + ] + } + return { + connection: { client: {}, state: 'connected' }, + gates: { + statusPending: false, + statusReadable: true, + // Filled in `beforeEach`: a hoisted factory runs before this module's imports do. + hostCapabilities: [], + hostProtocolWindow: { protocolVersion: 10, minCompatibleMobileVersion: 1 } + }, + manifestReads: 0, + manifestClients: [], + manifestRejection: null, + fetches: [], + manifest + } +}) + +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) +vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths: { cache: '' } })) +vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({ + encodeBase64Url: () => 'session-id' +})) +vi.mock('../components/HostProtocolGate', () => ({ useHostProtocolGates: () => doubles.gates })) +vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection })) +vi.mock('../transport/rpc-operation', () => ({ + defineRpcOperation: (definition: unknown) => definition, + runRpcOperation: async (client: unknown) => { + doubles.manifestReads += 1 + doubles.manifestClients.push(client) + if (doubles.manifestRejection !== null) { + throw doubles.manifestRejection + } + return { manifest: doubles.manifest } + } +})) +vi.mock('../transport/mobile-web-bundle-fetch', () => ({ + fetchMobileWebBundle: (args: { signal: AbortSignal }) => + new Promise((resolve) => { + doubles.fetches.push({ signal: args.signal, settle: resolve }) + }) +})) + +import { useMobileWebShellSession } from './use-mobile-web-shell-session' + +const HOST_ID = 'host-1' +const DIRECTORY = 'file:///cache/mobile-web/host/generations/b' + +function activeGeneration(): ActiveGeneration { + return { buildId: doubles.manifest.buildId, directory: DIRECTORY, manifest: doubles.manifest } +} + +function stagedGeneration(): StagedGeneration { + return { + hostKey: 'host-key', + buildId: doubles.manifest.buildId, + directory: DIRECTORY, + manifest: doubles.manifest + } +} + +/** A store whose cache read is held open, so a test can decide when the answer arrives. Staging can + * be held open too, which is the only way to stand inside the window between it and the commit. */ +function createFakeStore(): { + store: GenerationStore + settleCacheRead: Settle + holdStage: () => void + settleStage: () => void + staged: () => number + committed: () => number + aborted: () => number +} { + let settleCacheRead: Settle = () => {} + let releaseStage: () => void = () => {} + let heldStage = false + let staged = 0 + let committed = 0 + let aborted = 0 + const store: GenerationStore = { + readActiveGeneration: () => + new Promise((resolve) => { + settleCacheRead = resolve + }), + stageGeneration: async () => { + staged += 1 + if (heldStage) { + await new Promise((resolve) => { + releaseStage = resolve + }) + } + return stagedGeneration() + }, + commitGeneration: async () => { + committed += 1 + return activeGeneration() + }, + abortStagedGeneration: async () => { + aborted += 1 + }, + sweepStagedGenerations: async () => undefined, + deleteHostCache: async () => undefined + } + return { + store, + settleCacheRead: (value) => settleCacheRead(value), + holdStage: () => { + heldStage = true + }, + settleStage: () => releaseStage(), + staged: () => staged, + committed: () => committed, + aborted: () => aborted + } +} + +type Mounted = { + tree: ReactTestRenderer + retry: () => void + rerender: () => void + states: () => readonly MobileWebShellSessionState[] +} + +async function mount(store: GenerationStore): Promise { + const handle: { retry: () => void; states: MobileWebShellSessionState[] } = { + retry: () => {}, + states: [] + } + function Probe() { + const session = useMobileWebShellSession({ + hostId: HOST_ID, + runtime: { createStore: () => store, mintSessionId: () => 'session-id', now: () => 0 } + }) + handle.retry = session.retry + handle.states.push(session.state) + return null + } + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(Probe)) + }) + const tree = rendered.tree + if (tree === null) { + throw new Error('the hook did not mount') + } + return { + tree, + retry: () => handle.retry(), + rerender: () => tree.update(createElement(Probe)), + states: () => handle.states + } +} + +async function flush(): Promise { + await act(async () => undefined) +} + +describe('the hybrid shell runner', () => { + beforeEach(() => { + doubles.manifestReads = 0 + doubles.manifestClients.length = 0 + doubles.manifestRejection = null + doubles.fetches.length = 0 + doubles.connection = { client: {}, state: 'connected' } + doubles.gates.hostCapabilities = [MOBILE_WEB_BUNDLE_CAPABILITY] + }) + + it('abandons the cache read of a session that has been unmounted', async () => { + const fake = createFakeStore() + const mounted = await mount(fake.store) + await act(async () => { + mounted.tree.unmount() + }) + fake.settleCacheRead(null) + await flush() + // The read came back to nobody: had it been applied, the next effect would have asked the host + // for a manifest on behalf of a screen that is gone. + expect(doubles.manifestReads).toBe(0) + }) + + it('aborts the download an unmount interrupts, and never writes what it was pulling', async () => { + const fake = createFakeStore() + const mounted = await mount(fake.store) + fake.settleCacheRead(null) + await flush() + expect(doubles.fetches).toHaveLength(1) + const inFlight = doubles.fetches[0] + if (inFlight === undefined) { + throw new Error('no download was started') + } + await act(async () => { + mounted.tree.unmount() + }) + expect(inFlight.signal.aborted).toBe(true) + inFlight.settle({ + manifest: doubles.manifest, + assets: new Map(), + totalBytes: 2048, + elapsedMs: 1 + }) + await flush() + expect(fake.staged()).toBe(0) + expect(fake.committed()).toBe(0) + }) + + it('shows the cached workspace when the socket drops the manifest read it was waiting on', async () => { + // The device repro: the rejection reaches the reducer before the reachability change does, so + // the offline gate never fires and only the error's own marks say the link was what went. + doubles.manifestRejection = markRpcDeliveryUnknown(new Error('Connection interrupted')) + const fake = createFakeStore() + const mounted = await mount(fake.store) + fake.settleCacheRead(activeGeneration()) + await flush() + + expect(doubles.manifestReads).toBe(1) + expect(doubles.fetches).toHaveLength(0) + expect(mounted.states().map((state) => state.kind)).toContain('ready') + await act(async () => { + mounted.tree.unmount() + }) + }) + + it('takes the staged tree back out when the unmount lands between staging and the commit', async () => { + const fake = createFakeStore() + fake.holdStage() + const mounted = await mount(fake.store) + fake.settleCacheRead(null) + await flush() + const inFlight = doubles.fetches[0] + if (inFlight === undefined) { + throw new Error('no download was started') + } + inFlight.settle({ + manifest: doubles.manifest, + assets: new Map(), + totalBytes: 2048, + elapsedMs: 1 + }) + await flush() + expect(fake.staged()).toBe(1) + + await act(async () => { + mounted.tree.unmount() + }) + await act(async () => { + fake.settleStage() + }) + // The commit is the write the staging tree cannot undo: it renames into the active slot and + // moves the host index, so a generation nobody asked for would be the one the next mount opens. + expect(fake.committed()).toBe(0) + expect(fake.aborted()).toBe(1) + expect(mounted.states().map((state) => state.kind)).not.toContain('ready') + }) + + it('reads the manifest through the client the host has now, not the one it opened with', async () => { + const fake = createFakeStore() + const mounted = await mount(fake.store) + fake.settleCacheRead(null) + await flush() + expect(doubles.manifestClients).toHaveLength(1) + // A reconnect hands the screen a new client object with the same reachability, so nothing the + // gates effect watches changes; only the next flow can show which one the runner kept. + const reconnected = {} + doubles.connection = { client: reconnected, state: 'connected' } + await act(async () => { + mounted.rerender() + }) + await act(async () => { + mounted.retry() + }) + fake.settleCacheRead(null) + await flush() + expect(doubles.manifestClients.at(-1)).toBe(reconnected) + await act(async () => { + mounted.tree.unmount() + }) + }) + + it('abandons the download still in flight when Try again starts a new one', async () => { + const fake = createFakeStore() + const mounted = await mount(fake.store) + fake.settleCacheRead(null) + await flush() + const first = doubles.fetches[0] + if (first === undefined) { + throw new Error('no download was started') + } + await act(async () => { + mounted.retry() + }) + expect(first.signal.aborted).toBe(true) + first.settle({ manifest: doubles.manifest, assets: new Map(), totalBytes: 2048, elapsedMs: 1 }) + await flush() + expect(fake.staged()).toBe(0) + await act(async () => { + mounted.tree.unmount() + }) + }) +}) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts new file mode 100644 index 00000000000..87f7e5defd8 --- /dev/null +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts @@ -0,0 +1,335 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import * as ExpoCrypto from 'expo-crypto' +import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' +import { useHostProtocolGates } from '../components/HostProtocolGate' +import { useHostClient } from '../transport/client-context' +import { encodeBase64Url } from '../transport/mobile-endpoint-supervisor-support' +import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch' +import { + isMobileWebBundleTransportFailure, + mobileWebBundleManifestRead +} from '../transport/mobile-web-bundle-operations' +import { runRpcOperation } from '../transport/rpc-operation' +import type { RpcClient } from '../transport/rpc-client' +import { createGenerationStore, type GenerationStore } from './generation-store' +import { + createExpoGenerationFileSystem, + generationDirectoryPath +} from './generation-store-file-system' +import { deriveHostCacheKey } from './host-cache-key' +import { + createMobileWebShellSession, + readMobileWebShellReachability, + reduceMobileWebShellSession +} from './mobile-web-shell-session' +import type { + MobileWebShellReadFailure, + MobileWebShellSessionEffect, + MobileWebShellSessionEvent, + MobileWebShellSessionState +} from './mobile-web-shell-session-contract' + +/** 32 bytes, base64url: the session id scopes the view's private origin, so two mounts must never + * share one and a remount must never reuse the one that was just on screen. */ +const SESSION_ID_BYTES = 32 + +/** The impure edges, injectable so the wiring is testable without a simulator. */ +export type MobileWebShellRuntime = { + createStore(): GenerationStore + mintSessionId(): string + now(): number +} + +function defaultRuntime(): MobileWebShellRuntime { + return { + createStore: () => createGenerationStore({ fileSystem: createExpoGenerationFileSystem() }), + mintSessionId: () => encodeBase64Url(ExpoCrypto.getRandomBytes(SESSION_ID_BYTES)), + now: Date.now + } +} + +export type MobileWebShellSessionView = { + readonly state: MobileWebShellSessionState + readonly retry: () => void + /** B3's failure reasons, forwarded verbatim; the reducer owns what each one means. */ + readonly reportShellFailure: (reason: MobileWebShellFailureReason) => void +} + +/** + * Drives one hybrid shell session for one host: the reducer decides, this runs what it asks for. + * + * Every effect result is checked against an epoch before it is dispatched, so an unmount, a host + * change or a retry abandons work in flight instead of applying it to the next session. Nothing + * here decides anything — a rule that lived in this file would be a rule with no table test. + */ +export function useMobileWebShellSession(args: { + hostId: string + runtime?: MobileWebShellRuntime +}): MobileWebShellSessionView { + const { hostId } = args + const gates = useHostProtocolGates() + const { client, state: connState } = useHostClient(hostId) + + const runtimeRef = useRef(null) + runtimeRef.current ??= args.runtime ?? defaultRuntime() + const runtime = runtimeRef.current + const storeRef = useRef(null) + storeRef.current ??= runtime.createStore() + + const sessionRef = useRef(createMobileWebShellSession()) + const [state, setState] = useState(sessionRef.current.state) + const hostKey = useMemo(() => deriveHostCacheKey(hostId), [hostId]) + const startedAtRef = useRef(runtime.now()) + // Bumped by anything that invalidates work in flight; every dispatch out of an effect checks it. + const epochRef = useRef(0) + // Aborted on the same bump: a download nobody will use still holds four of the host's read slots. + const downloadsRef = useRef>(new Set()) + const runEffectRef = useRef< + ((epoch: number, flow: number, effect: MobileWebShellSessionEffect) => void) | null + >(null) + + const dispatch = useCallback((epoch: number, event: MobileWebShellSessionEvent): void => { + if (epoch !== epochRef.current) { + return + } + const stepped = reduceMobileWebShellSession(sessionRef.current, event) + sessionRef.current = stepped.session + setState(stepped.session.state) + for (const effect of stepped.effects) { + // Every effect of a step belongs to the flow that step produced, and its result carries that + // number back, so a flow the session has since restarted reports into nothing. + runEffectRef.current?.(epoch, stepped.session.flow, effect) + } + }, []) + + const invalidate = useCallback((): void => { + epochRef.current += 1 + for (const controller of downloadsRef.current) { + controller.abort() + } + downloadsRef.current.clear() + }, []) + + const runEffect = useCallback( + async (epoch: number, flow: number, effect: MobileWebShellSessionEffect): Promise => { + const store = storeRef.current + if (store === null) { + return + } + const send = (event: MobileWebShellSessionEvent) => dispatch(epoch, event) + switch (effect.kind) { + case 'delete-cache': + // Reports nothing: the store serialises its own queue, so the sweep and read the reducer + // queued behind this one already run after it. + await store.deleteHostCache(hostKey).catch(() => undefined) + return + case 'open-cache': + send({ type: 'cache-read', flow, generation: await openCache(store, hostKey) }) + return + case 'read-manifest': + await readManifest(client, flow, send) + return + case 'download': + await download({ + client, + store, + hostKey, + flow, + runtime, + startedAt: startedAtRef.current, + downloads: downloadsRef.current, + send + }) + return + case 'open-generation': + send({ + type: 'activated', + flow, + generationDirectory: effect.directory, + sessionId: runtime.mintSessionId(), + buildId: effect.buildId, + totalBytes: effect.totalBytes, + elapsedMs: runtime.now() - startedAtRef.current + }) + return + case 'remount': + send({ type: 'remounted', flow, sessionId: runtime.mintSessionId() }) + return + } + }, + [client, dispatch, hostKey, runtime] + ) + // Written after the commit, never during render: React may replay or discard a render, and a + // closure from one that never committed would run effects for a session that never existed. + // Declared above every effect that dispatches, so the first one already finds it. + useEffect(() => { + runEffectRef.current = (epoch, flow, effect) => { + void runEffect(epoch, flow, effect) + } + }, [runEffect]) + + useEffect(() => { + // A new host is a new session: the old one's latches, cache handle and in-flight work all go. + invalidate() + sessionRef.current = createMobileWebShellSession() + startedAtRef.current = runtime.now() + setState(sessionRef.current.state) + return invalidate + }, [hostId, invalidate, runtime]) + + const { statusPending, statusReadable, hostCapabilities, hostProtocolWindow } = gates + const reachability = readMobileWebShellReachability(connState, client) + useEffect(() => { + dispatch(epochRef.current, { + type: 'gates-changed', + gates: { + statusPending, + statusReadable, + reachability, + hostCapabilities, + hostStatus: hostProtocolWindow + } + }) + // `hostId` is in the list for the host whose gates read identically to the last one's: the + // reducer now starts nothing on a repeat verdict, so a session that never re-armed would sit + // in `checking` forever. + }, [ + dispatch, + hostCapabilities, + hostId, + hostProtocolWindow, + reachability, + statusPending, + statusReadable + ]) + + const retry = useCallback(() => { + // A fresh epoch first: a failed download still in flight must not land on the retried session. + invalidate() + startedAtRef.current = runtime.now() + dispatch(epochRef.current, { type: 'retry-pressed' }) + }, [dispatch, invalidate, runtime]) + + const reportShellFailure = useCallback( + (reason: MobileWebShellFailureReason) => { + dispatch(epochRef.current, { type: 'shell-failed', reason }) + }, + [dispatch] + ) + + return { state, retry, reportShellFailure } +} + +async function openCache( + store: GenerationStore, + hostKey: string +): Promise<{ buildId: string; directory: string; totalBytes: number } | null> { + try { + // Here and nowhere earlier: with the flag off no code path reaches this hook, so a store build + // never sweeps a cache it never wrote. + await store.sweepStagedGenerations() + const active = await store.readActiveGeneration(hostKey) + return active === null + ? null + : { + buildId: active.buildId, + directory: generationDirectoryPath(active.directory), + totalBytes: active.manifest.totalBytes + } + } catch { + // A cache that cannot be read is not a cache that is wrong: nothing is deleted, and the flow + // treats it as absent, which downloads when connected and says so when not. + return null + } +} + +/** A rejection the link caused says nothing about the bundle, and the reducer opens the cache on it + * rather than telling a phone that already holds a workspace it could not be downloaded. */ +function readFailure(error: unknown): MobileWebShellReadFailure { + return isMobileWebBundleTransportFailure(error) ? 'transport' : 'bundle' +} + +async function readManifest( + client: RpcClient | null, + flow: number, + send: (event: MobileWebShellSessionEvent) => void +): Promise { + if (client === null) { + // No client is no link, and the gates are about to say so. + send({ type: 'download-failed', flow, failure: 'transport' }) + return + } + try { + const opened = await runRpcOperation(client, mobileWebBundleManifestRead, null) + const manifest = opened.manifest + send({ + type: 'manifest-read', + flow, + manifest: { + buildId: manifest.buildId, + schemaVersion: manifest.schemaVersion, + runtimeProtocolVersion: manifest.runtimeProtocolVersion, + minCompatibleRuntimeProtocolVersion: manifest.minCompatibleRuntimeProtocolVersion, + totalBytes: manifest.totalBytes, + totalAssets: manifest.assets.length + } + }) + } catch (error) { + send({ type: 'download-failed', flow, failure: readFailure(error) }) + } +} + +async function download(args: { + client: RpcClient | null + store: GenerationStore + hostKey: string + flow: number + runtime: MobileWebShellRuntime + startedAt: number + downloads: Set + send: (event: MobileWebShellSessionEvent) => void +}): Promise { + const { client, store, hostKey, flow, runtime, send } = args + if (client === null) { + send({ type: 'download-failed', flow, failure: 'transport' }) + return + } + const controller = new AbortController() + args.downloads.add(controller) + try { + const fetched = await fetchMobileWebBundle({ + client, + signal: controller.signal, + onProgress: (progress) => send({ type: 'fetch-progress', flow, ...progress }) + }) + // The bytes are in; the session they were for may not be. The fetch throws on an abort it sees, + // but an abort landing between its last read and this line would otherwise still write a + // generation for a host screen nobody is on any more. + if (controller.signal.aborted) { + return + } + send({ type: 'download-staged', flow }) + const staged = await store.stageGeneration(hostKey, fetched) + // Again before the commit, because the commit is the write that is not the staging tree's to + // undo: it renames into the active slot and moves the host index. An abort that landed while + // the bytes were being staged takes the staged tree back out instead. + if (controller.signal.aborted) { + await store.abortStagedGeneration(staged).catch(() => undefined) + return + } + const committed = await store.commitGeneration(staged) + send({ + type: 'activated', + flow, + generationDirectory: generationDirectoryPath(committed.directory), + sessionId: runtime.mintSessionId(), + buildId: committed.buildId, + totalBytes: committed.manifest.totalBytes, + elapsedMs: runtime.now() - args.startedAt + }) + } catch (error) { + send({ type: 'download-failed', flow, failure: readFailure(error) }) + } finally { + args.downloads.delete(controller) + } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index c8cfb54863a..f338918af42 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -10,6 +10,7 @@ import { clampHostSidebarWidth, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, + loadMobileWebShellEnabled, loadPushNotificationsEnabled, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, @@ -504,3 +505,40 @@ describe('terminal link open mode preference', () => { expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalLinkOpenMode', 'phone-browser') }) }) + +/** `__DEV__` is a React Native global, absent outside that runtime; assigned rather than cast so + * the test says which build kind it is running as without asserting a type on `globalThis`. */ +function setDevelopmentBuild(isDevelopmentBuild: boolean | undefined): void { + if (isDevelopmentBuild === undefined) { + Reflect.deleteProperty(globalThis, '__DEV__') + return + } + Object.assign(globalThis, { __DEV__: isDevelopmentBuild }) +} + +describe('hybrid shell flag', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + setDevelopmentBuild(undefined) + }) + + it('reads the developer toggle in a development build', async () => { + setDevelopmentBuild(true) + vi.mocked(AsyncStorage.getItem).mockResolvedValue('true') + + await expect(loadMobileWebShellEnabled()).resolves.toBe(true) + expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:mobileWebShellEnabled') + }) + + it.each([ + ['a release build', false], + ['a runtime with no __DEV__ at all', undefined] + ])('is off in %s even with the key left on, and never reads it', async (_label, isDev) => { + setDevelopmentBuild(isDev) + // The value a development build left behind in a container the install-over kept. + vi.mocked(AsyncStorage.getItem).mockResolvedValue('true') + + await expect(loadMobileWebShellEnabled()).resolves.toBe(false) + expect(AsyncStorage.getItem).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 57420469609..2b417ce9ae2 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -117,6 +117,30 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled)) } +const MOBILE_WEB_SHELL_KEY = 'orca:mobileWebShellEnabled' + +// Why: the hybrid shell route is dark. Default-off means a store build never fetches, writes or +// sweeps a bundle cache, and the only writer is the __DEV__ Troubleshoot toggle — anything but +// `'true'`, including an unreadable store, is off. +export async function loadMobileWebShellEnabled(): Promise { + // A release build never reads the key at all: it shares its bundle id with the development build + // and the iOS data container survives an install-over, so a flag a developer left on would + // otherwise follow the store build in and mount the shell on a deep link. + if (typeof __DEV__ === 'undefined' || !__DEV__) { + return false + } + try { + const raw = await AsyncStorage.getItem(MOBILE_WEB_SHELL_KEY) + return raw === 'true' + } catch { + return false + } +} + +export async function saveMobileWebShellEnabled(enabled: boolean): Promise { + await AsyncStorage.setItem(MOBILE_WEB_SHELL_KEY, String(enabled)) +} + const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:' export type DisabledTerminalLiveInputHandlesPreference = { diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts index 9418750b22e..c9f92cb2305 100644 --- a/mobile/src/transport/host-status-gates.ts +++ b/mobile/src/transport/host-status-gates.ts @@ -3,6 +3,7 @@ import type { RpcClient } from './rpc-client' import type { ConnectionState } from './types' import { hostStatusProbe, readHostStatusGates } from './host-status-probe-operations' import { evaluateCompat, type CompatVerdict } from './protocol-compat' +import type { HostStatusReply } from './host-status-reply-schema' import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store' export type HostStatusGates = { @@ -10,7 +11,17 @@ export type HostStatusGates = { floatingWorkspaceEnabled: boolean desktopAppVersion: string | null compatVerdict: CompatVerdict + /** The two protocol numbers the status carried, for callers that evaluate a compat window this + * hook does not own — the mobile web bundle's. Kept as the reply's own fields rather than a + * restated shape so a rename upstream is a build error here. */ + hostProtocolWindow: HostProtocolWindow statusPending: boolean + /** Whether the settled answer came from a status this host actually returned and this client + * could decode. Both failure paths below settle the same closed gates an old host with no + * capabilities would produce, so without this a caller cannot tell "this desktop does not have + * the feature" from "nobody answered" — and the mobile web shell's wall is terminal, so it must + * never be shown for the second. */ + statusReadable: boolean } // statusPending is not stored: pending-ness belongs to the live connection, not to the answer. @@ -19,7 +30,19 @@ type LoadedHostStatusGates = Omit & { client: RpcClient } +export type HostProtocolWindow = Pick< + HostStatusReply, + 'protocolVersion' | 'minCompatibleMobileVersion' +> + const EMPTY_HOST_CAPABILITIES: string[] = [] +// Stable identities: consumers compare gates by reference to decide whether to re-run a step. +// Both keys stated: the reply schema salvages them as present-and-possibly-undefined, and +// `evaluateMobileWebBundleCompat` reads an absent number as "oldest host" and "no floor". +const EMPTY_HOST_PROTOCOL_WINDOW: HostProtocolWindow = { + protocolVersion: undefined, + minCompatibleMobileVersion: undefined +} // Reads status.get on connect for capabilities, protocol-compat verdict, and the // floating-workspace flag. Compat constants are wide-open today so this never blocks yet. @@ -57,7 +80,9 @@ export function useHostStatusGates(args: { hostCapabilities: [], floatingWorkspaceEnabled: false, desktopAppVersion: null, - compatVerdict: { kind: 'ok' } + compatVerdict: { kind: 'ok' }, + hostProtocolWindow: EMPTY_HOST_PROTOCOL_WINDOW, + statusReadable: false }) return } @@ -73,7 +98,12 @@ export function useHostStatusGates(args: { hostCapabilities: status.capabilities ?? [], floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, desktopAppVersion, - compatVerdict: verdict + compatVerdict: verdict, + hostProtocolWindow: { + protocolVersion: status.protocolVersion, + minCompatibleMobileVersion: status.minCompatibleMobileVersion + }, + statusReadable: true }) if (verdict.kind === 'blocked') { // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. @@ -91,7 +121,9 @@ export function useHostStatusGates(args: { hostCapabilities: [], floatingWorkspaceEnabled: false, desktopAppVersion: null, - compatVerdict: { kind: 'ok' } + compatVerdict: { kind: 'ok' }, + hostProtocolWindow: EMPTY_HOST_PROTOCOL_WINDOW, + statusReadable: false }) } } @@ -109,7 +141,9 @@ export function useHostStatusGates(args: { floatingWorkspaceEnabled: false, desktopAppVersion: null, compatVerdict: { kind: 'ok' }, - statusPending: connState === 'connected' && client !== null + hostProtocolWindow: EMPTY_HOST_PROTOCOL_WINDOW, + statusPending: connState === 'connected' && client !== null, + statusReadable: false } } return { @@ -117,6 +151,8 @@ export function useHostStatusGates(args: { floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled, desktopAppVersion: proven.desktopAppVersion, compatVerdict: proven.compatVerdict, + hostProtocolWindow: proven.hostProtocolWindow, + statusReadable: proven.statusReadable, // Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no // longer blanks the capabilities this same host already proved. statusPending: connState === 'connected' && unverified diff --git a/mobile/src/transport/mobile-web-bundle-operations.ts b/mobile/src/transport/mobile-web-bundle-operations.ts index accd27bdfe5..7db7b510c65 100644 --- a/mobile/src/transport/mobile-web-bundle-operations.ts +++ b/mobile/src/transport/mobile-web-bundle-operations.ts @@ -8,7 +8,9 @@ import { MobileWebBundleChunkReplySchema, MobileWebBundleManifestReplySchema } from './mobile-web-bundle-reply-schemas' +import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { defineRpcOperation } from './rpc-operation' +import { isLogicalClientCutoverError } from './stable-logical-rpc-client' import { rpcResultVariant } from './rpc-operation-result-reader' // The two reads that hand a paired phone the desktop's mobile web bundle. Both are @@ -75,3 +77,16 @@ export function readMobileWebBundleErrorCode(error: unknown): MobileWebBundleErr const parsed = MobileWebBundleErrorCodeSchema.safeParse(nested) return parsed.success ? parsed.data : null } + +/** + * True when a bundle read failed on the link to the host rather than on the bundle it serves. + * + * Both marks come from the transport itself: delivery-unknown on every request a socket close, a + * relay drop or a timeout cut off, and the cutover error on a connection migration. Nothing else + * qualifies, on purpose — the fetch raises plain errors for a hash mismatch, a short asset and a + * build that changed mid-fetch, and every one of those is a verdict about the bytes that arrived. + * `readMobileWebBundleErrorCode` above reads the host's own refusals, which are verdicts too. + */ +export function isMobileWebBundleTransportFailure(error: unknown): boolean { + return isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error) +} diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts index c80073ea511..8c723ba1116 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts @@ -11,10 +11,12 @@ import { import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' import { evaluateMobileWebBundleCompat } from './mobile-web-bundle-compat' import { + isMobileWebBundleTransportFailure, mobileWebBundleChunkRead, mobileWebBundleManifestRead, readMobileWebBundleErrorCode } from './mobile-web-bundle-operations' +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { MobileWebBundleManifestReplySchema } from './mobile-web-bundle-reply-schemas' import type { RpcReadResult } from './rpc-operation-contract' @@ -329,3 +331,30 @@ describe('mobile web bundle operation descriptors', () => { } }) }) + +describe('which side a bundle read failed on', () => { + it('reads the transport marks the transport itself sets', () => { + // Every socket close, relay drop and request timeout rejects in-flight requests with this mark. + expect( + isMobileWebBundleTransportFailure(markRpcDeliveryUnknown(new Error('Connection closed'))) + ).toBe(true) + // The cutover error matches by message as well as by class, across bundle copies. + expect( + isMobileWebBundleTransportFailure(new Error('RPC interrupted by connection migration')) + ).toBe(true) + }) + + it.each([ + ['a host refusal', `invalid_argument: ${MOBILE_WEB_BUNDLE_ERROR_CODES[0]}`], + ['bytes that do not hash', 'bundle asset index.html hashed aa, not bb'], + ['a build that changed mid-fetch', 'bundle build changed mid-fetch: asked aa, served bb'], + ['an unread reply', 'The host sent a reply this app could not read (mobileWeb.bundle.manifest)'] + ])('treats %s as a verdict about the bundle', (_label, message) => { + expect(isMobileWebBundleTransportFailure(new Error(message))).toBe(false) + }) + + it('treats anything that is not an error as a verdict too, rather than guessing', () => { + expect(isMobileWebBundleTransportFailure('Connection closed')).toBe(false) + expect(isMobileWebBundleTransportFailure(null)).toBe(false) + }) +}) From a98314e8bb8e33d1129d8091d6bc831b763380a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:35:55 +0000 Subject: [PATCH 027/224] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 20fd5d9c2f2..2d1c9bb4dec 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 62m + + downloads: 64m @@ -15,7 +15,7 @@ downloads downloads - 62m - 62m + 64m + 64m From 381a3da46f829ffc7e0f778322ec64c27c0d35e7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:50:37 -0400 Subject: [PATCH 028/224] feat(build): Route A, the phone's host routes bundled for the web, dark (OTA phase C, C0.7) (#21449) * refactor(mobile-web): share the bundle manifest assembly with a second builder Manifest assembly and the on-disk write move to writeMobileWebBundleTree, and the helpers the Phase C app builder needs become exports. No behaviour change to the shipped bootstrap bundle. The CRLF guard grows two exemptions it needs once it is pointed at mobile/src: the image and font extensions .gitattributes already pins -text, and the gitignored webview engine modules the postinstall writes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): web entry for the host route tree, and its two transport siblings The entry mounts app/h on react-native-web through expo-router's own ExpoRoot. It lives inside mobile/ so one React resolves, and supplies RpcClientProvider itself: the route tree starts below the native root layout that owns it. route-manifest.ts is a real typed module whose body the builder replaces -- esbuild has no require.context. A virtual specifier would need an ambient declaration and would leave the entry unchecked. Two .web.* siblings, both listed with a reason in web-overrides.json: the transport substitution point (a placeholder client until C0.4 lands BridgeRpcClient) and the device token store, whose native path imports expo-secure-store, which is {} on web. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(build): build:mobile-web:app, the phone's host routes bundled for the web Same builder shape as the Phase A bootstrap into a separate out/mobile-web-app, with the same manifest and the same two-scratch-build determinism check. Dark: build:mobile-web, packaging and the A2 census are untouched, and C1 is what flips build:release. Six shims, each a named Metro or RN Web gap. Images are emitted as same-origin hashed assets rather than data: URLs, because the shell's CSP sets img-src 'self'; the render check under that exact header is what found it. The script is referenced root-absolute for the same reason a tag cannot be used: the document is served at every route depth and base-uri is 'none'. The budget sits below the contract's per-asset ceiling so growth trips a build rather than a refused asset on a phone. esbuild splitting does not lower it: one entry with only static imports emits one chunk (measured). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let React Native Web paint under the shell CSP RN Web 0.21.2 injects its stylesheet at runtime with no nonce support, so style-src 'self' blocks every rule and the page renders unstyled. Measured, not predicted: the render check serves the document under this exact header and reported the violation. 'unsafe-inline' is granted to style-src and nothing else. script-src 'self' holds, which is the directive that decides whether page code can arrive any way other than as a fetched same-origin script. The test now pins that scoping rather than rejecting the token everywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: prove the Route A app bundle on every PR A dedicated job, for the same reason the browser provider has one: it needs mobile/node_modules and a real browser, and the sharded test matrix would pay for both on every shard. It builds the bundle, verifies it, and runs the builder, override-census and render suites. It ships nothing. The mobile_web_app signal is lifted out of should_run the way static_analysis is. A mobile-only diff is desktop-irrelevant and skips every gated job, and that is exactly the diff that changes the page this job builds. Also the C0.6 review follow-up: mobile/package.json and mobile/pnpm-lock.yaml join the installer cache keys in the two workflows that build an installer off a hashFiles key, since beforePack requires out/mobile-web and a mobile-only change must miss those caches rather than reuse a stale build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): pin the shipped builder against the app builder's own module name The assertion named a specifier that no longer exists, so it held vacuously. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the RN Web style-src grant in the Swift checks The Swift twin of the Kotlin CSP test still required style-src 'self' and no unsafe-inline anywhere, so it trapped on the approved grant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): make the Route A render check name what each route paints The check asserted only "some html, no errors", which expo-router's Unmatched screen satisfies: pointing HOST_ROUTE at /zzz/not-a-real-prefix stayed green. Each route now asserts content only its own component produces, and the unmatched case asserts the screen positively so the negatives discriminate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): read the shell CSP past the comments that quote directives Both constants document themselves with // comments containing quoted directive text, which the quoted-string scan picked up as directives. One parser now drops comment lines, and iOS and Android go through it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(build): honour a .web.* route sibling in the app bundle Routes were imported by absolute path with the extension, so esbuild's resolveExtensions never applied and a .web.tsx under app/ was dead code the census still accepted. The manifest now carries a key and a module: the key stays the native filename so the URL does not move, and the module is the web sibling when one exists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): tie each named shim to the esbuild option that implements it The shim list was asserted against a literal copy of itself, which passes however the build is configured. Each entry now carries an appliesTo that reads its own option, checked against the real options object. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(build): line up the CRLF exemptions, the budget comment, and the job scope The builder loads .gif as a file but neither .gitattributes nor the CRLF scan exempted it, so the blanket eol=lf pin would have rewritten one. A test now keeps the two lists in step. The Phase C byte budget's comment sat on the asset count, and a root package.json edit could change build:mobile-web:app without running the job that proves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(build): satisfy the index-check lint rule in the CSP parser Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: key the installer caches on the mobile page trees too beforePack builds the mobile web bundle into the installer. Today those bytes are Phase A's, which src/** already covers, but once C1 flips the entry to mobile/app a page-only change would hit a cache holding a stale installer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): skip the bundling tests where mobile dependencies are absent The sharded `test` job collects config/scripts/**/*.test.mjs and installs no mobile dependencies, so the two new suites failed there on "Could not resolve react-native-web". They now skip themselves with a message naming the job that runs them, and that job sets ORCA_MOBILE_WEB_APP_DEPS_REQUIRED so a missing install fails it instead of skipping everything it exists to prove. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(build): scan mobile/packages in the .web.* census The census claimed the app entry never resolves into packages/, but the dictation hook imports @orca/expo-two-way-audio and the built script carries ExpoTwoWayAudioModule.web.ts. That file is now listed with its reason, and planting a .web.* in each scanned tree proves the scan is not passing because a tree happens to be empty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): assert the route exclusions against a tree that has them mobile/app holds no test, spec or +api file, so the exclusion rule was asserted against a tree it could not fire on. A scratch tree plants one of each; dropping the rule now fails this test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): 404 unknown file paths in the render check's page server The server answered every path with the document, so pointing publicPath at /wrong-prefix still rendered three green routes: the script is fetched from the one prefix that is served. A path naming a file now has to come out of the bundle, which is what the shell's manifest map does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): cover the app bundle verifier's own checks The verifier had no test. One doctors the buildId, which the packaged assert catches; the other rewrites the tree so every digest still agrees and only the two fresh builds can tell, which is what a stale out/ looks like. Deleting either check now fails a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(build): tidy the app bundle comments and the job's path prefixes Drops an export nothing read, merges two comments that had drifted apart from the constant they describe, and corrects the claim that the job runs on every PR when it is path-gated. package.json leaves the prefix list because GLOBAL_FORCE_FILES already forces every job on it; mobile/packages/ joins it, since the page resolves a .web.ts out of there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(build): merge the duplicate node:fs/promises import in the census Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): redirect the hybrid shell route on the web page app/h/[hostId]/web.tsx reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import. In a browser that throws before React mounts, and the route manifest imports every route statically, so one native route left the whole page blank at every URL. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): fail the render check with the error that stopped the mount The check waited on "#root has children" with Playwright's animation-frame polling, so a route module that threw at import read as a bare 30s timeout naming nothing. It now waits on a mount attribute the entry sets after the router commits, polls on a timer, and races the wait against the first uncaught error so the failure carries it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): answer the favicon the render browser asks for CI resolves the runner's Google Chrome, which requests /favicon.ico; the bundled headless shell does not. The bundle carries no icon, so the server answers 204 rather than turning a browser habit into a console error the render assertions read as a page fault. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): settle the render check's uncaught-error race without rejecting The entry throws during goto, before anything awaits the race, so a rejected promise surfaced as an unhandled rejection beside the real failure. The same signal now resolves with the error. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the page transport in the raw request port inventory The placeholder client implements the port, so the boundary test counts it as an unlisted file. It belongs under OWNERS until C0.4's BridgeRpcClient replaces it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .gitattributes | 6 + .github/workflows/daemon-relocation-spike.yml | 17 +- .github/workflows/pr.yml | 63 +++ .github/workflows/win-crash-survival-e2e.yml | 10 +- .../scripts/build-mobile-web-app-bundle.mjs | 228 +++++++++++ .../build-mobile-web-app-bundle.test.mjs | 379 ++++++++++++++++++ config/scripts/build-mobile-web-bundle.mjs | 38 +- .../mobile-web-app-bundle-dependencies.mjs | 34 ++ config/scripts/mobile-web-app-render.test.mjs | 275 +++++++++++++ .../scripts/mobile-web-app-route-manifest.mjs | 101 +++++ .../mobile-web-app-web-overrides.test.mjs | 131 ++++++ config/scripts/pr-code-change-scope.mjs | 30 ++ config/scripts/pr-code-change-scope.test.mjs | 33 ++ .../scripts/pr-workflow-parallelism.test.mjs | 16 + .../scripts/verify-mobile-web-app-bundle.mjs | 93 +++++ config/scripts/verify-mobile-web-bundle.mjs | 27 +- mobile/app/h/[hostId]/web.web.tsx | 12 + .../orcamobilewebshell/MobileWebShellCsp.kt | 7 +- .../MobileWebShellCspTest.kt | 13 +- .../ios/MobileWebShellCsp.swift | 7 +- .../tests/MobileWebShellChecks.swift | 7 +- mobile/src/transport/client-context.web.tsx | 84 ++++ .../transport/host-device-token-store.web.ts | 13 + .../unvalidated-rpc-request-port-inventory.ts | 2 + mobile/web-entry/index.tsx | 29 ++ mobile/web-entry/route-manifest.ts | 21 + mobile/web-entry/web-overrides.json | 21 + package.json | 1 + 28 files changed, 1676 insertions(+), 22 deletions(-) create mode 100644 config/scripts/build-mobile-web-app-bundle.mjs create mode 100644 config/scripts/build-mobile-web-app-bundle.test.mjs create mode 100644 config/scripts/mobile-web-app-bundle-dependencies.mjs create mode 100644 config/scripts/mobile-web-app-render.test.mjs create mode 100644 config/scripts/mobile-web-app-route-manifest.mjs create mode 100644 config/scripts/mobile-web-app-web-overrides.test.mjs create mode 100644 config/scripts/verify-mobile-web-app-bundle.mjs create mode 100644 mobile/app/h/[hostId]/web.web.tsx create mode 100644 mobile/src/transport/client-context.web.tsx create mode 100644 mobile/src/transport/host-device-token-store.web.ts create mode 100644 mobile/web-entry/index.tsx create mode 100644 mobile/web-entry/route-manifest.ts create mode 100644 mobile/web-entry/web-overrides.json diff --git a/.gitattributes b/.gitattributes index 2f291d4d627..8bfd4043164 100644 --- a/.gitattributes +++ b/.gitattributes @@ -62,6 +62,8 @@ /mobile/src/**/*.png -text /mobile/src/**/*.jpg -text /mobile/src/**/*.jpeg -text +/mobile/src/**/*.gif -text +/mobile/src/**/*.ico -text /mobile/src/**/*.webp -text /mobile/src/**/*.ttf -text /mobile/src/**/*.otf -text @@ -70,6 +72,8 @@ /mobile/app/**/*.png -text /mobile/app/**/*.jpg -text /mobile/app/**/*.jpeg -text +/mobile/app/**/*.gif -text +/mobile/app/**/*.ico -text /mobile/app/**/*.webp -text /mobile/app/**/*.ttf -text /mobile/app/**/*.otf -text @@ -78,6 +82,8 @@ /mobile/web-entry/**/*.png -text /mobile/web-entry/**/*.jpg -text /mobile/web-entry/**/*.jpeg -text +/mobile/web-entry/**/*.gif -text +/mobile/web-entry/**/*.ico -text /mobile/web-entry/**/*.webp -text /mobile/web-entry/**/*.ttf -text /mobile/web-entry/**/*.otf -text diff --git a/.github/workflows/daemon-relocation-spike.yml b/.github/workflows/daemon-relocation-spike.yml index bbca7e7a044..ac9c1bd4ba4 100644 --- a/.github/workflows/daemon-relocation-spike.yml +++ b/.github/workflows/daemon-relocation-spike.yml @@ -57,7 +57,22 @@ jobs: uses: actions/cache@v4 with: path: dist/win-unpacked - key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + # mobile/ is in the key because beforePack requires out/mobile-web, whose bytes come from + # the mobile install and, once Phase C flips the bundle, from the page trees below; a + # mobile-only change must miss this cache, not reuse a stale installer. src/** and + # config/** already cover src/mobile-web and the two bundle builders. + key: >- + win-unpacked-${{ hashFiles( + 'src/**', + 'config/**', + 'package.json', + 'pnpm-lock.yaml', + 'mobile/package.json', + 'mobile/pnpm-lock.yaml', + 'mobile/app/**', + 'mobile/src/**', + 'mobile/web-entry/**' + ) }} # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle # build resolves React Native and Expo from mobile/node_modules. Gated with the diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c857b8df1f0..bb9bccdfa25 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -29,6 +29,7 @@ jobs: should_run: ${{ steps.filter.outputs.should_run }} native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} + mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }} static_analysis: ${{ steps.filter.outputs.static_analysis }} typecheck: ${{ steps.filter.outputs.typecheck }} git_compatibility: ${{ steps.filter.outputs.git_compatibility }} @@ -648,6 +649,64 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/main/orcad/external-chromium-browser-process.integration.test.ts + # Why its own job: it needs mobile/node_modules and a real browser, and the sharded `test` + # matrix would pay for both on every shard to run two files. Dark through Phase C: this proves + # `build:mobile-web:app` on every PR that touches the page, and ships nothing -- packaging still + # builds the Phase A bootstrap via build:mobile-web. + mobile_web_app: + name: mobile web app bundle + needs: [code_paths] + if: needs.code_paths.outputs.mobile_web_app == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + # Why no native-runtime: the builder is esbuild and the render check is a browser. Nothing + # in this job loads node-pty. + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml + + # The entry lives in mobile/ so one React resolves; without this every RN import is nothing. + - uses: ./.github/actions/install-mobile-dependencies + + # Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad + # browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb. + # Why fail instead of skip: a silently skipped render check is the failure this job exists + # to prevent. + - name: Resolve Chrome for the render check + run: | + set -euo pipefail + chrome="$(command -v google-chrome || command -v google-chrome-stable || true)" + if [ -z "$chrome" ]; then + echo "::error::No Google Chrome on the runner; the render check would silently skip." + exit 1 + fi + "$chrome" --version + echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV" + + - name: Build and verify the app bundle + run: pnpm run build:mobile-web:app + + # The bundling tests skip themselves where mobile dependencies are absent, which is how they + # stay green in the sharded `test` job. This is the job that installs them, so here a missing + # install has to fail rather than skip everything the job exists to run. + - name: Builder, override census and render check + env: + ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1' + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + config/scripts/build-mobile-web-app-bundle.test.mjs \ + config/scripts/mobile-web-app-web-overrides.test.mjs \ + config/scripts/mobile-web-app-render.test.mjs + cross-version-wire: name: cross-version wire compatibility needs: [code_paths] @@ -1020,6 +1079,7 @@ jobs: - shell_contracts - test - orcad_browser + - mobile_web_app - cross-version-wire - managed_hook_node18 - package @@ -1056,6 +1116,8 @@ jobs: TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }} ORCAD_BROWSER: ${{ needs.orcad_browser.result }} ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }} + MOBILE_WEB_APP: ${{ needs.mobile_web_app.result }} + MOBILE_WEB_APP_SHOULD_RUN: ${{ needs.code_paths.outputs.mobile_web_app }} CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }} CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }} MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }} @@ -1098,6 +1160,7 @@ jobs: check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN" check_job test "$TEST" "$TEST_SHOULD_RUN" check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN" + check_job mobile_web_app "$MOBILE_WEB_APP" "$MOBILE_WEB_APP_SHOULD_RUN" check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN" check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN" check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN" diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index 1e0efae0a23..91ac8fcf226 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -70,6 +70,9 @@ jobs: uses: actions/cache@v4 with: path: dist/orca-windows-setup.exe + # The mobile page trees are in the key because beforePack builds the mobile web bundle + # into the installer; src/** and config/** already cover src/mobile-web and the two + # bundle builders. A mobile-only change must miss this cache, not reuse a stale exe. key: >- crash-survival-installer-${{ hashFiles( 'src/**', @@ -88,7 +91,12 @@ jobs: '.npmrc', 'package.json', 'pnpm-lock.yaml', - 'pnpm-workspace.yaml' + 'pnpm-workspace.yaml', + 'mobile/package.json', + 'mobile/pnpm-lock.yaml', + 'mobile/app/**', + 'mobile/src/**', + 'mobile/web-entry/**' ) }} # Why: production edits miss the installer cache by design, but Electron diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs new file mode 100644 index 00000000000..3b311cce800 --- /dev/null +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -0,0 +1,228 @@ +import { readFile } from 'node:fs/promises' +import { basename, extname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as esbuild from 'esbuild' +import { + MOBILE_WEB_BUNDLE_ENTRYPOINT, + hashedAsset, + isDirectInvocation, + readDesktopVersion, + readProtocolWindow, + sha256Hex, + writeMobileWebBundleTree, + contentTypeForExtension +} from './build-mobile-web-bundle.mjs' +import { + collectMobileWebAppRoutes, + renderMobileWebAppRouteManifest +} from './mobile-web-app-route-manifest.mjs' + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const mobileDir = join(projectDir, 'mobile') +const defaultAppDir = join(mobileDir, 'app') +const entryPoint = join(mobileDir, 'web-entry', 'index.tsx') +const defaultOutDir = join(projectDir, 'out', 'mobile-web-app') + +/** + * Every shim the app bundle needs, each one a documented Metro/RN-Web gap. `appliesTo` reads the + * esbuild option that implements the shim, so the list cannot claim a shim the build does not + * apply and a dropped option fails the named shim rather than the whole build. + */ +export const MOBILE_WEB_APP_SHIMS = [ + { + // react-native has no browser build; react-native-web is the whole point of Route A. + name: 'react-native-web-alias', + appliesTo: (options) => options.alias?.['react-native'] === 'react-native-web' + }, + { + // RN ships untranspiled JSX inside .js files (expo-router's own build/ included). + name: 'js-as-jsx', + appliesTo: (options) => options.loader?.['.js'] === 'jsx' + }, + { + // RN code assumes a Hermes/Metro `global`; the browser only has `globalThis`. + name: 'global-as-globalthis', + appliesTo: (options) => options.define?.global === 'globalThis' + }, + { + // RN and Expo modules read process.env at module scope, before any of our code runs. + name: 'process-banner', + appliesTo: (options) => options.banner?.js?.includes('globalThis.process ??=') === true + }, + { + // lucide-react-native@1.14.0's barrel re-exports LucideProvider from a context.mjs that does + // not export it. Metro's loose CJS interop tolerates it; esbuild's strict ESM does not. + // Web-build only: patching the package would change what the shipped native app consumes. + name: 'lucide-barrel-provider', + appliesTo: (options) => + options.plugins?.some((plugin) => plugin.name === LUCIDE_PLUGIN_NAME) === true + }, + { + // esbuild has no require.context, so the route tree is generated and injected. + name: 'route-manifest', + appliesTo: (options) => + options.plugins?.some((plugin) => plugin.name === ROUTE_MANIFEST_PLUGIN_NAME) === true + } +] + +const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest' +const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider' + +// mobile/web-entry/route-manifest.ts is a real typed file rather than a virtual specifier, so the +// entry typechecks and Metro can still resolve it; only its body is replaced here. +function routeManifestPlugin(manifestSource) { + return { + name: ROUTE_MANIFEST_PLUGIN_NAME, + setup(build) { + build.onLoad({ filter: /web-entry[\\/]route-manifest\.ts$/ }, () => ({ + contents: manifestSource, + loader: 'js', + resolveDir: mobileDir + })) + } + } +} + +const lucideBarrelPlugin = { + name: LUCIDE_PLUGIN_NAME, + setup(build) { + build.onLoad({ filter: /lucide-react-native[\\/].*[\\/]context\.mjs$/ }, async (args) => ({ + contents: `${await readFile(args.path, 'utf8')}\nexport const LucideProvider = ({ children }) => children;\n`, + loader: 'js' + })) + } +} + +/** Split out so a test can read the options MOBILE_WEB_APP_SHIMS claims, without a build. */ +export function mobileWebAppBuildOptions(routes) { + return { + // Fixed so no absolute path of this checkout can reach the output. + absWorkingDir: mobileDir, + entryPoints: [entryPoint], + bundle: true, + minify: true, + // Virtual: write is false, so outdir only names the emitted files esbuild hands back. + outdir: 'dist', + write: false, + format: 'iife', + target: ['es2022'], + charset: 'utf8', + legalComments: 'none', + // Why no sourcemap and no metafile: both embed absolute paths, which would break reproducibility. + sourcemap: false, + logLevel: 'silent', + jsx: 'automatic', + // One React: resolve everything from mobile/node_modules, which is where the entry lives. + nodePaths: [join(mobileDir, 'node_modules')], + alias: { 'react-native': 'react-native-web' }, + plugins: [routeManifestPlugin(renderMobileWebAppRouteManifest(routes)), lucideBarrelPlugin], + resolveExtensions: [ + '.web.tsx', + '.web.ts', + '.web.jsx', + '.web.js', + '.tsx', + '.ts', + '.jsx', + '.js', + '.json' + ], + // Images are emitted as same-origin assets, not data: URLs: the shell's CSP sets + // img-src 'self', which refuses data:. Content-hashed names keep the buildId reproducible. + // A font would fail the build here rather than silently ship under font-src 'none'. + loader: { + '.js': 'jsx', + '.png': 'file', + '.jpg': 'file', + '.jpeg': 'file', + '.gif': 'file', + '.webp': 'file', + '.svg': 'file' + }, + assetNames: '[hash]', + // Absolute, because the document is served at every route depth and a path relative to the + // script would resolve against the route instead. + publicPath: '/assets', + banner: { + js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };" + }, + define: { + global: 'globalThis', + __DEV__: 'false', + 'process.env.NODE_ENV': '"production"', + 'process.env.EXPO_OS': '"web"', + 'process.env.EXPO_ROUTER_IMPORT_MODE': '"sync"' + } + } +} + +// appDir is a seam for the tests, which bundle a scratch route tree; production always uses mobile/app. +export async function bundleMobileWebApp({ appDir = defaultAppDir } = {}) { + const routes = await collectMobileWebAppRoutes(appDir) + const result = await esbuild.build(mobileWebAppBuildOptions(routes)) + const script = result.outputFiles.find((file) => file.path.endsWith('.js')) + if (!script) { + throw new Error('[build-mobile-web-app-bundle] esbuild emitted no script') + } + const images = result.outputFiles + .filter((file) => file !== script) + .map((file) => ({ name: basename(file.path), bytes: Buffer.from(file.contents) })) + .sort((left, right) => (left.name < right.name ? -1 : 1)) + return { + script: Buffer.from(script.contents), + images, + routeKeys: routes.map((route) => route.key) + } +} + +export async function buildMobileWebAppBundle({ outDir = defaultOutDir } = {}) { + const [desktopVersion, protocolWindow, { script, images, routeKeys }] = await Promise.all([ + readDesktopVersion(), + readProtocolWindow(), + bundleMobileWebApp() + ]) + const scriptAsset = hashedAsset(script, 'js') + // esbuild already named these by content hash; keep that name so the reference inside the + // script stays valid, and carry the sha256 in the manifest entry as every asset does. + const imageAssets = images.map(({ name, bytes }) => ({ + bytes, + path: `assets/${name}`, + sha256: sha256Hex(bytes), + byteLength: bytes.byteLength, + contentType: contentTypeForExtension(extname(name).slice(1)) + })) + + // Root-absolute, unlike the Phase A bootstrap's bare relative src: this document is served at + // every route depth (/h//tasks), where a relative href resolves against the route and + // 404s. A tag would be the other fix, but the shell's CSP sets base-uri 'none'. + const html = + '\n\n\n\n' + + '\n' + + 'Orca\n\n\n
\n' + + `\n\n\n` + const indexBytes = Buffer.from(html, 'utf8') + const indexAsset = { + bytes: indexBytes, + path: MOBILE_WEB_BUNDLE_ENTRYPOINT, + sha256: sha256Hex(indexBytes), + byteLength: indexBytes.byteLength, + contentType: contentTypeForExtension('html') + } + + const { manifest } = await writeMobileWebBundleTree({ + outDir, + written: [indexAsset, scriptAsset, ...imageAssets], + desktopVersion, + protocolWindow + }) + return { manifest, outDir, routeKeys } +} + +if (isDirectInvocation(import.meta.url, process.argv[1])) { + const { manifest, outDir, routeKeys } = await buildMobileWebAppBundle() + console.log( + `[build-mobile-web-app-bundle] OK — ${String(routeKeys.length)} route(s), ` + + `${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes, ` + + `buildId ${manifest.buildId} -> ${outDir}` + ) +} diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs new file mode 100644 index 00000000000..a1446854d68 --- /dev/null +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -0,0 +1,379 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + MOBILE_WEB_APP_SHIMS, + bundleMobileWebApp, + buildMobileWebAppBundle, + mobileWebAppBuildOptions +} from './build-mobile-web-app-bundle.mjs' +import { + MOBILE_WEB_APP_ROUTE_ROOT, + ROUTE_CONTEXT_SOURCE, + collectMobileWebAppRouteKeys, + collectMobileWebAppRoutes, + renderMobileWebAppRouteManifest +} from './mobile-web-app-route-manifest.mjs' +import { + MOBILE_WEB_APP_BUNDLE_MAX_ASSETS, + MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES, + MOBILE_WEB_APP_SOURCE_DIRS, + verifyMobileWebAppBundle +} from './verify-mobile-web-app-bundle.mjs' +import { + BINARY_SOURCE_EXTENSIONS, + assertNoCarriageReturnsInSource +} from './verify-mobile-web-bundle.mjs' +import { + readDesktopVersion, + readProtocolWindow, + sha256Hex, + writeMobileWebBundleTree +} from './build-mobile-web-bundle.mjs' +import { MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES } from '../../src/shared/mobile-web-bundle/manifest-contract.js' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const appDir = join(projectDir, 'mobile', 'app') + +// The sharded `test` job does not install mobile dependencies, so anything that runs esbuild over +// the route tree is skipped there and run for real in pr.yml's mobile_web_app job. +const bundles = mobileWebAppDependenciesPresent() +const describeBundling = bundles ? describe : describe.skip +const itBundling = bundles ? it : it.skip + +async function withScratch(run) { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-test-')) + try { + return await run(scratch) + } finally { + await rm(scratch, { recursive: true, force: true }) + } +} + +describe('route manifest', () => { + it('collects the h/ subtree and nothing above it', async () => { + const keys = await collectMobileWebAppRouteKeys(appDir) + expect(keys.length).toBeGreaterThan(0) + for (const key of keys) { + expect(key.startsWith(`./${MOBILE_WEB_APP_ROUTE_ROOT}/`)).toBe(true) + } + // The native-only shell (pairing, settings, notifications) must not reach the page bundle. + expect(keys).not.toContain('./_layout.tsx') + expect(keys).not.toContain('./pair.tsx') + }) + + it('is sorted, so the generated module is a pure function of the tree', async () => { + const keys = await collectMobileWebAppRouteKeys(appDir) + expect(keys).toEqual([...keys].sort()) + }) + + it('excludes test files and API routes', async () => { + // mobile/app holds none of these today, so assert the rule against a tree that does. + await withScratch(async (scratch) => { + const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + for (const name of [ + 'index.tsx', + 'index.test.tsx', + 'index.spec.tsx', + 'shape.d.ts', + '+api.ts', + 'tokens+api.ts', + '+middleware.ts', + 'notes.md' + ]) { + await writeFile(join(directory, name), 'export default null\n', 'utf8') + } + expect(await collectMobileWebAppRouteKeys(scratch)).toEqual(['./h/index.tsx']) + }) + expect(await collectMobileWebAppRouteKeys(appDir)).not.toContain('./h/_layout.test.tsx') + }) + + it('refuses an empty subtree rather than emitting a context with no routes', async () => { + await expect(collectMobileWebAppRouteKeys(appDir, 'does-not-exist')).rejects.toThrow() + }) + + it('emits one static import per key', async () => { + const source = renderMobileWebAppRouteManifest([ + { key: './h/index.tsx', module: '/app/h/index.tsx' }, + { key: './h/_layout.tsx', module: '/app/h/_layout.tsx' } + ]) + expect(source).toContain('import * as route0 from "/app/h/index.tsx"') + expect(source).toContain('import * as route1 from "/app/h/_layout.tsx"') + // A lazy getter would need a chunk fetch, which the page's script-src 'self' does not serve. + expect(source).not.toContain('import(') + }) + + it('imports a .web.tsx sibling under the native route key', async () => { + await withScratch(async (scratch) => { + const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'index.tsx'), 'export default function Route() {}\n') + expect(await collectMobileWebAppRoutes(scratch)).toEqual([ + { key: './h/index.tsx', module: join(directory, 'index.tsx') } + ]) + await writeFile(join(directory, 'index.web.tsx'), 'export default function Route() {}\n') + // The key is still the native filename, so the override changes the code and not the URL. + expect(await collectMobileWebAppRoutes(scratch)).toEqual([ + { key: './h/index.tsx', module: join(directory, 'index.web.tsx') } + ]) + }) + }) +}) + +describe('the synthesized RequireContext', () => { + const build = (modules) => + new Function('modules', `${ROUTE_CONTEXT_SOURCE}; return routeContext`)(modules) + + it('answers the four members expo-router reads', () => { + const context = build({ './h/index.tsx': { default: 'screen' } }) + expect(context.keys()).toEqual(['./h/index.tsx']) + expect(context('./h/index.tsx')).toEqual({ default: 'screen' }) + expect(context.resolve('./h/index.tsx')).toBe('./h/index.tsx') + expect(context.id).toBe('orca-mobile-web-app-routes') + }) + + it('hands out a copy of keys, so a caller cannot mutate the route tree', () => { + const context = build({ './h/index.tsx': {} }) + context.keys().push('./injected.tsx') + expect(context.keys()).toEqual(['./h/index.tsx']) + }) + + it('throws rather than returning undefined for an unknown key', () => { + const context = build({ './h/index.tsx': {} }) + expect(() => context('./missing.tsx')).toThrow('no route module') + expect(() => context.resolve('./missing.tsx')).toThrow('cannot resolve route') + }) + + it('does not answer inherited Object keys', () => { + const context = build({ './h/index.tsx': {} }) + expect(() => context('constructor')).toThrow('no route module') + }) +}) + +describe('the CRLF pin', () => { + it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => { + const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8') + for (const tree of MOBILE_WEB_APP_SOURCE_DIRS) { + const pattern = `/${relative(projectDir, tree).split('\\').join('/')}/**` + for (const extension of BINARY_SOURCE_EXTENSIONS) { + // Without the exemption the blanket `text eol=lf` pin above it rewrites the binary and + // every asset hash with it. + expect(attributes, `${pattern}/*${extension} is not exempt`).toContain( + `${pattern}/*${extension} -text` + ) + } + } + }) +}) + +describeBundling('the app bundle', () => { + it('resolves react-native to react-native-web and leaves no require.context', async () => { + const { script } = await bundleMobileWebApp() + const source = script.toString('utf8') + expect(source).not.toContain('require.context') + // react-native-web's touch responder is proof the alias resolved rather than the native stub. + expect(source).toContain('ResponderTouchHistoryStore') + }, 120_000) + + it('bundles every route module', async () => { + const { routeKeys } = await bundleMobileWebApp() + expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir)) + }, 120_000) + + it("bundles a route's .web.tsx sibling instead of the native file, changing the bytes", async () => { + await withScratch(async (scratch) => { + const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + const route = (marker) => `export default function Route() { return '${marker}' }\n` + await writeFile(join(directory, 'index.tsx'), route('native-route-marker')) + const before = await bundleMobileWebApp({ appDir: scratch }) + expect(before.script.toString('utf8')).toContain('native-route-marker') + + await writeFile(join(directory, 'index.web.tsx'), route('web-route-marker')) + const after = await bundleMobileWebApp({ appDir: scratch }) + expect(after.script.toString('utf8')).toContain('web-route-marker') + expect(after.script.toString('utf8')).not.toContain('native-route-marker') + // Different script bytes means a different asset sha and so a different buildId. + expect(after.script.equals(before.script)).toBe(false) + }) + }, 240_000) + + it('applies every shim it names', async () => { + const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) + for (const shim of MOBILE_WEB_APP_SHIMS) { + expect(shim.appliesTo(options), `${shim.name} is named but not applied`).toBe(true) + } + }) + + it('fails the named shim, not the whole build, when its option goes missing', async () => { + const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) + // Each shim reads a different option, so removing one leaves the other five true. Without + // that, the list could name a shim the build stopped applying. + const stripped = { + ...options, + alias: {}, + loader: {}, + define: {}, + banner: {}, + plugins: [] + } + expect(MOBILE_WEB_APP_SHIMS.filter((shim) => shim.appliesTo(stripped))).toEqual([]) + }) + + it('keeps the shims out of the shipped Phase A bootstrap builder', async () => { + const shipped = await readFile( + join(projectDir, 'config', 'scripts', 'build-mobile-web-bundle.mjs'), + 'utf8' + ) + for (const { name } of MOBILE_WEB_APP_SHIMS) { + expect(shipped, `the Phase A bootstrap builder mentions ${name}`).not.toContain(name) + } + expect(shipped).not.toContain('react-native-web') + expect(shipped).not.toContain('lucide') + }) + + it('embeds no absolute path from this checkout', async () => { + const { script } = await bundleMobileWebApp() + expect(script.toString('utf8')).not.toContain(projectDir) + }, 120_000) + + it('builds the same buildId twice', async () => { + const first = await withScratch((scratch) => + buildMobileWebAppBundle({ outDir: join(scratch, 'a') }) + ) + const second = await withScratch((scratch) => + buildMobileWebAppBundle({ outDir: join(scratch, 'b') }) + ) + expect(first.manifest.buildId).toBe(second.manifest.buildId) + }, 120_000) + + it('writes the manifest shape the packaging contract reads', async () => { + const { manifest } = await withScratch((scratch) => + buildMobileWebAppBundle({ outDir: join(scratch, 'c') }) + ) + expect(manifest.schemaVersion).toBe(1) + expect(manifest.entrypoint).toBe('index.html') + expect(manifest.assets.map((asset) => asset.path)).toContain('index.html') + expect(manifest.totalBytes).toBe( + manifest.assets.reduce((total, asset) => total + asset.byteLength, 0) + ) + }, 120_000) +}) + +describe('the Phase C budget', () => { + it('sits below the contract per-asset ceiling, so growth trips a build not a phone', () => { + expect(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES).toBeLessThan(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES) + expect(MOBILE_WEB_APP_BUNDLE_MAX_ASSETS).toBeGreaterThan(1) + }) + + itBundling( + 'is not already exceeded by the current bundle', + async () => { + const { manifest } = await withScratch((scratch) => + buildMobileWebAppBundle({ outDir: join(scratch, 'd') }) + ) + expect(manifest.totalBytes).toBeLessThanOrEqual(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) + expect(manifest.assets.length).toBeLessThanOrEqual(MOBILE_WEB_APP_BUNDLE_MAX_ASSETS) + }, + 120_000 + ) +}) + +describe('the verifier', () => { + itBundling( + 'accepts a bundle it has just built', + async () => { + await withScratch(async (scratch) => { + const outDir = join(scratch, 'mobile-web-app') + await buildMobileWebAppBundle({ outDir }) + await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).resolves.toBeDefined() + }) + }, + 240_000 + ) + + itBundling( + "rejects a buildId the manifest's own asset list does not derive", + async () => { + await withScratch(async (scratch) => { + const outDir = join(scratch, 'mobile-web-app') + await buildMobileWebAppBundle({ outDir }) + const manifestPath = join(outDir, 'manifest.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + manifest.buildId = 'f'.repeat(64) + await writeFile(manifestPath, JSON.stringify(manifest), 'utf8') + await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).rejects.toThrow( + 'does not match its asset list' + ) + }) + }, + 240_000 + ) + + itBundling( + 'rejects a self-consistent bundle a fresh build does not reproduce', + async () => { + await withScratch(async (scratch) => { + const outDir = join(scratch, 'mobile-web-app') + const { manifest } = await buildMobileWebAppBundle({ outDir }) + // What a stale out/ actually looks like: every digest agrees with its bytes and the + // buildId derives from the asset list, but the source has moved on. Only the two fresh + // builds the verifier runs can tell, which is the check this covers. + const assets = await Promise.all( + manifest.assets.map(async (asset) => ({ + ...asset, + bytes: await readFile(join(outDir, asset.path)) + })) + ) + const document = assets.find((asset) => asset.path === manifest.entrypoint) + document.bytes = Buffer.concat([document.bytes, Buffer.from('\n', 'utf8')]) + document.sha256 = sha256Hex(document.bytes) + document.byteLength = document.bytes.byteLength + const [desktopVersion, protocolWindow] = await Promise.all([ + readDesktopVersion(), + readProtocolWindow() + ]) + await writeMobileWebBundleTree({ outDir, written: assets, desktopVersion, protocolWindow }) + + await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).rejects.toThrow('is stale') + }) + }, + 240_000 + ) +}) + +describe('the CRLF guard', () => { + it('covers the three trees whose bytes reach the buildId', () => { + expect(MOBILE_WEB_APP_SOURCE_DIRS.map((dir) => dir.slice(projectDir.length))).toEqual([ + join('mobile', 'web-entry'), + join('mobile', 'app'), + join('mobile', 'src') + ]) + }) + + it('fails on a CRLF source file', async () => { + await withScratch(async (scratch) => { + await writeFile(join(scratch, 'route.tsx'), 'export default null\r\n', 'utf8') + await expect(assertNoCarriageReturnsInSource(scratch)).rejects.toThrow('CRLF') + }) + }) + + it('exempts the binary assets .gitattributes pins -text', async () => { + await withScratch(async (scratch) => { + await writeFile(join(scratch, 'icon.ttf'), Buffer.from([0x00, 0x0d, 0x0a])) + await writeFile(join(scratch, 'shot.png'), Buffer.from([0x0d])) + await expect(assertNoCarriageReturnsInSource(scratch)).resolves.toBeUndefined() + }) + }) + + it('exempts the gitignored generated webview engine modules', async () => { + await withScratch(async (scratch) => { + await writeFile(join(scratch, 'engine.generated.ts'), 'export const X = "a\r\n"', 'utf8') + await expect(assertNoCarriageReturnsInSource(scratch)).resolves.toBeUndefined() + }) + }) +}) diff --git a/config/scripts/build-mobile-web-bundle.mjs b/config/scripts/build-mobile-web-bundle.mjs index 52957858784..2dc3f6d5ec7 100644 --- a/config/scripts/build-mobile-web-bundle.mjs +++ b/config/scripts/build-mobile-web-bundle.mjs @@ -16,7 +16,14 @@ const CONTENT_TYPE_BY_EXTENSION = { css: 'text/css; charset=utf-8', html: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', - png: 'image/png' + png: 'image/png', + // The Phase C app bundle emits images as same-origin assets rather than data: URLs, which the + // shell's img-src 'self' refuses. Fonts are absent by design: the policy sets font-src 'none'. + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml' } /** @@ -41,11 +48,11 @@ export function computeMobileWebBundleBuildId(assets) { return createHash('sha256').update(serializeMobileWebBundleAssets(assets), 'utf8').digest('hex') } -function sha256Hex(bytes) { +export function sha256Hex(bytes) { return createHash('sha256').update(bytes).digest('hex') } -function contentTypeForExtension(extension) { +export function contentTypeForExtension(extension) { const contentType = CONTENT_TYPE_BY_EXTENSION[extension] if (!contentType) { throw new Error(`[build-mobile-web-bundle] no content type registered for .${extension}`) @@ -65,7 +72,7 @@ function readIntegerConstant(source, name) { * Parsed rather than imported because protocol-version.ts is TypeScript and this script runs on * bare node during packaging, before any build output exists. */ -async function readProtocolWindow() { +export async function readProtocolWindow() { const source = await readFile(join(projectDir, 'src', 'shared', 'protocol-version.ts'), 'utf8') return { runtimeProtocolVersion: readIntegerConstant(source, 'RUNTIME_PROTOCOL_VERSION'), @@ -77,7 +84,7 @@ async function readProtocolWindow() { } } -async function readDesktopVersion() { +export async function readDesktopVersion() { const packageJson = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8')) if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) { throw new Error('[build-mobile-web-bundle] root package.json has no version') @@ -124,7 +131,7 @@ async function transformEntries(protocolWindow, desktopVersion) { return { script, stylesheet } } -function hashedAsset(bytes, extension) { +export function hashedAsset(bytes, extension) { const sha256 = sha256Hex(bytes) return { bytes, @@ -172,7 +179,24 @@ export async function buildMobileWebBundle({ outDir = defaultOutDir } = {}) { contentType: contentTypeForExtension('html') } - const written = [indexAsset, ...hashed] + return writeMobileWebBundleTree({ + outDir, + written: [indexAsset, ...hashed], + desktopVersion, + protocolWindow + }) +} + +/** + * Manifest assembly and the on-disk write, shared by the Phase A bootstrap bundle and the Phase C + * app bundle so both produce the same manifest shape the contract module and verifier read. + */ +export async function writeMobileWebBundleTree({ + outDir, + written, + desktopVersion, + protocolWindow +}) { const assets = written .map(({ path, sha256, byteLength, contentType }) => ({ path, sha256, byteLength, contentType })) .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) diff --git a/config/scripts/mobile-web-app-bundle-dependencies.mjs b/config/scripts/mobile-web-app-bundle-dependencies.mjs new file mode 100644 index 00000000000..57ec6325033 --- /dev/null +++ b/config/scripts/mobile-web-app-bundle-dependencies.mjs @@ -0,0 +1,34 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) + +/** + * Set by the one CI job that installs mobile dependencies, so a broken install there fails the + * job instead of quietly skipping every test that would have caught it. + */ +export const MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV = 'ORCA_MOBILE_WEB_APP_DEPS_REQUIRED' + +const SKIP_NOTICE = + '[mobile-web-app] skipping the bundling tests: mobile/node_modules/react-native-web is absent. ' + + 'They run for real in pr.yml, in the mobile_web_app job, which installs mobile dependencies.' + +/** + * Bundling the Route A page resolves react-native-web out of mobile/node_modules, which the + * sharded `test` job deliberately does not install. Tests that bundle ask this first. + */ +export function mobileWebAppDependenciesPresent( + modulePath = join(projectDir, 'mobile', 'node_modules', 'react-native-web') +) { + if (existsSync(modulePath)) { + return true + } + if (process.env[MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV] === '1') { + throw new Error( + `[mobile-web-app] ${modulePath} is missing in a job that installs mobile dependencies` + ) + } + console.log(SKIP_NOTICE) + return false +} diff --git a/config/scripts/mobile-web-app-render.test.mjs b/config/scripts/mobile-web-app-render.test.mjs new file mode 100644 index 00000000000..42c1f57a560 --- /dev/null +++ b/config/scripts/mobile-web-app-render.test.mjs @@ -0,0 +1,275 @@ +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { chromium } from 'playwright-core' +import { fileURLToPath } from 'node:url' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) + +// Why a real browser: the route tree is handed to expo-router's own ExpoRoot through a synthesized +// RequireContext. Nothing short of mounting it proves that object is the shape ExpoRoot reads. +const HOST_ROUTE = '/h/render-check-host' + +// The sharded `test` job does not install mobile dependencies, so the page cannot be built there. +// The CSP suite below needs none of them and still runs. pr.yml's mobile_web_app job runs both. +const bundles = mobileWebAppDependenciesPresent() +const describeRender = bundles ? describe : describe.skip + +let scratch +let server +let browser +let origin +let cspHeader = null + +/** + * Both CSP constants are a list of quoted directives with `//` comments between them, and those + * comments quote directive text. Dropping comment lines first is what keeps a comment out of the + * header this test serves. + */ +export function parseCspDirectives(source, startMarker, endMarker) { + const start = source.indexOf(startMarker) + const end = source.indexOf(endMarker) + if (start === -1 || end < start) { + throw new Error(`could not find ${startMarker} .. ${endMarker}`) + } + const body = source + .slice(start, end) + .split('\n') + .filter((line) => !line.trimStart().startsWith('//')) + .join('\n') + const directives = [...body.matchAll(/"([^"]+)"/g)].map((match) => match[1]) + if (directives.length < 10) { + throw new Error('could not parse the shell CSP') + } + return directives.join('; ') +} + +/** + * The shipped policy, read from the Kotlin source so this test cannot drift from what the shell + * actually sends. Parsed rather than imported: the constant lives in a JVM module. + */ +async function readShellCsp() { + const source = await readFile( + join( + projectDir, + 'mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt' + ), + 'utf8' + ) + return parseCspDirectives(source, 'listOf(', ').joinToString') +} + +beforeAll(async () => { + cspHeader = await readShellCsp() + if (!bundles) { + return + } + scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-render-')) + const { outDir } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') }) + server = createServer((request, response) => { + const path = new URL(request.url, 'http://localhost').pathname + // A browser asks for this on its own and the shell's WebView never does. The bundle carries + // no icon, so a 404 would put a console error in every check that runs against a full Chrome + // -- which is what CI resolves -- and none against the bundled headless shell. + if (path === '/favicon.ico') { + response.writeHead(204) + response.end() + return + } + // A route path serves the entrypoint and the page routes client-side. A path naming a file + // has to come out of the bundle or 404, the same as the shell's manifest map: answering it + // with the document instead would hide a publicPath the script cannot fetch from. + const namesAFile = path.slice(path.lastIndexOf('/')).includes('.') + const file = namesAFile ? path.slice(1) : 'index.html' + readFile(join(outDir, file)).then( + (bytes) => { + const headers = { + 'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html' + } + // The document carries the shell's real policy, so a directive the page violates fails + // here rather than on a phone. Assets carry none, exactly as the native handler does. + if (file === 'index.html' && cspHeader) { + headers['content-security-policy'] = cspHeader + } + response.writeHead(200, headers) + response.end(bytes) + }, + () => { + response.writeHead(404) + response.end() + } + ) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + origin = `http://127.0.0.1:${String(server.address().port)}` + // CI runs this against the runner's Google Chrome rather than paying for a browser download, + // the same reason and the same override shape as the orcad browser-provider job. + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) +}, 180_000) + +afterAll(async () => { + await browser?.close() + server?.close() + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) + +// expo-router's Unmatched screen mounts cleanly and paints text, so "no errors, some html" stays +// green with every host route unreachable. Each route below names content only it can produce. +const UNMATCHED = 'Unmatched Route' + +async function render(route) { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + const errors = [] + let reportUncaught = () => {} + // An uncaught error from the entry means nothing will ever mount. Racing it against the wait + // reports that error in a second instead of a 30s timeout that names nothing -- which is what a + // native-only route module, throwing at import before React runs, looks like from here. + // Resolved rather than rejected: this one settles during goto, before anything awaits it. + const uncaught = new Promise((resolve) => { + reportUncaught = resolve + }) + page.on('pageerror', (error) => { + errors.push(`${error.name}: ${error.message}`) + reportUncaught(error) + }) + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(`console.error: ${message.text()}`) + } + }) + await page.goto(`${origin}${route}`, { waitUntil: 'load' }) + // The entry's own signal, not "#root has children": an error boundary or a half-painted tree + // also fills #root, and this only lands once expo-router's tree below the wrapper has committed. + // Polled on a timer rather than Playwright's default animation frames, which a page that never + // paints never delivers. + const mounted = page.waitForFunction( + () => document.documentElement.dataset.orcaWebEntry === 'mounted', + { + timeout: 30_000, + polling: 250 + } + ) + const cause = await Promise.race([ + mounted.then( + () => null, + (error) => error + ), + uncaught + ]) + if (cause) { + const state = await page.evaluate( + () => document.documentElement.dataset.orcaWebEntry ?? 'absent' + ) + throw new Error( + `${route} never mounted (entry ${state}): ${errors.join(' | ') || 'no page or console error'}`, + { cause } + ) + } + const text = await page.evaluate(() => document.body.innerText) + await page.close() + // A CSP refusal reaches the page as a console error, so the caller's empty-errors assertion is + // also the policy assertion; name it here so a failure says which one broke. + return { + errors, + cspErrors: errors.filter((entry) => entry.includes('Content Security Policy')), + text + } +} + +describe('the shell policy this page is tested under', () => { + it('is the same on both platforms, so one render check covers both', async () => { + const swift = await readFile( + join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'), + 'utf8' + ) + expect(parseCspDirectives(swift, 'static let header = [', '].joined')).toBe(cspHeader) + }) + + it('reads directives from the source and not from the comments around them', () => { + const source = [ + 'static let header = [', + " // React Native Web needs \"style-src 'self' 'unsafe-inline'\" and nothing more.", + ' "default-src \'none\'",', + ' "script-src \'self\'",', + " \"style-src 'self' 'unsafe-inline'\",", + ' "img-src \'self\'",', + ' "connect-src \'self\'",', + ' "worker-src \'none\'",', + ' "frame-src \'none\'",', + ' "child-src \'none\'",', + ' "object-src \'none\'",', + ' "base-uri \'none\'",', + ' "form-action \'none\'",', + ' "frame-ancestors \'none\'"', + '].joined' + ].join('\n') + const parsed = parseCspDirectives(source, 'static let header = [', '].joined') + expect(parsed.split('; ')[0]).toBe("default-src 'none'") + expect(parsed.split('; ').filter((entry) => entry.includes('unsafe-inline'))).toEqual([ + "style-src 'self' 'unsafe-inline'" + ]) + }) + + it('still refuses inline script, which is the directive that matters', () => { + expect(cspHeader).toContain("script-src 'self';") + expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'") + }) +}) + +describeRender('the page server this check runs against', () => { + it('404s a file path the bundle does not contain', async () => { + // Without this the document answers every path, and a publicPath the script cannot fetch + // from still renders, because the script is fetched from the one prefix that is served. + expect((await fetch(`${origin}/wrong-prefix/entry.js`)).status).toBe(404) + expect((await fetch(`${origin}/assets/not-a-real-hash.js`)).status).toBe(404) + }) + + it('answers the icon a browser asks for without an error', async () => { + expect((await fetch(`${origin}/favicon.ico`)).status).toBe(204) + }) + + it('still serves the document at every route depth', async () => { + for (const route of ['/', HOST_ROUTE, `${HOST_ROUTE}/tasks`]) { + const response = await fetch(`${origin}${route}`) + expect(response.status, route).toBe(200) + expect(await response.text(), route).toContain('
') + } + }) +}) + +describeRender('the Route A page in a real browser', () => { + it('mounts the worktree list route, not the unmatched screen', async () => { + const { errors, cspErrors, text } = await render(HOST_ROUTE) + expect(cspErrors).toEqual([]) + expect(errors).toEqual([]) + // app/h/[hostId]/index.tsx: the placeholder client knows no host, so the list paints its + // not-found state. Only that route's own component produces this string. + expect(text).toContain('Host not found') + expect(text).not.toContain(UNMATCHED) + }, 60_000) + + it('routes a nested dynamic segment through the same context', async () => { + const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/tasks`) + expect(cspErrors).toEqual([]) + expect(errors).toEqual([]) + // app/h/[hostId]/tasks.tsx paints its header and its GitHub filter row. + expect(text).toContain('Tasks') + expect(text).toContain('Issues') + expect(text).not.toContain(UNMATCHED) + }, 60_000) + + it('renders the unmatched route rather than crashing on a path with no module', async () => { + const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/not-a-route`) + expect(cspErrors).toEqual([]) + expect(errors).toEqual([]) + // Asserted positively so the two negatives above are known to discriminate. + expect(text).toContain(UNMATCHED) + }, 60_000) +}) diff --git a/config/scripts/mobile-web-app-route-manifest.mjs b/config/scripts/mobile-web-app-route-manifest.mjs new file mode 100644 index 00000000000..2caf772b083 --- /dev/null +++ b/config/scripts/mobile-web-app-route-manifest.mjs @@ -0,0 +1,101 @@ +import { readdir } from 'node:fs/promises' +import { extname, join, relative } from 'node:path' + +/** The route subtree the page mounts. The rest of mobile/app is native-only (pairing, settings). */ +export const MOBILE_WEB_APP_ROUTE_ROOT = 'h' + +const ROUTE_FILE = /\.[tj]sx?$/ +const NOT_A_ROUTE = /(\.(test|spec|d)\.|\+api\.|\+middleware\.)/ +// esbuild's resolveExtensions order, which only applies to an extensionless import. Routes are +// imported by full path, so the web sibling is picked here instead. +const WEB_SIBLING_EXTENSIONS = ['.web.tsx', '.web.ts', '.web.jsx', '.web.js'] + +function webSiblingOf(name, siblings) { + const stem = name.slice(0, name.length - extname(name).length) + return WEB_SIBLING_EXTENSIONS.map((extension) => `${stem}${extension}`).find((candidate) => + siblings.has(candidate) + ) +} + +/** + * Every route in the mounted subtree, sorted by key so the generated module is a pure function of + * the tree on disk. `key` is the require.context key expo-router names the screen by, always the + * native filename; `module` is the file the bundle imports, which is the `.web.*` sibling when one + * exists. They differ so a web override changes the code without moving the URL. + */ +export async function collectMobileWebAppRoutes(appDir, routeRoot = MOBILE_WEB_APP_ROUTE_ROOT) { + const routes = [] + async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const siblings = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)) + for (const entry of entries) { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + await walk(entryPath) + } else if ( + entry.isFile() && + ROUTE_FILE.test(entry.name) && + !NOT_A_ROUTE.test(entry.name) && + !entry.name.includes('.web.') + ) { + const override = webSiblingOf(entry.name, siblings) + routes.push({ + key: `./${relative(appDir, entryPath).split('\\').join('/')}`, + module: override ? join(directory, override) : entryPath + }) + } + } + } + await walk(join(appDir, routeRoot)) + if (routes.length === 0) { + throw new Error(`[mobile-web-app] no routes under ${join(appDir, routeRoot)}`) + } + return routes.sort((left, right) => (left.key < right.key ? -1 : 1)) +} + +/** The require.context keys alone, for callers that only need the route names. */ +export async function collectMobileWebAppRouteKeys(appDir, routeRoot = MOBILE_WEB_APP_ROUTE_ROOT) { + return (await collectMobileWebAppRoutes(appDir, routeRoot)).map((route) => route.key) +} + +/** + * The RequireContext behaviour, kept as source so a test can evaluate it against a fake `modules` + * without bundling the real route tree. Inlined into the generated module because that module is + * bundled for the browser and cannot import from config/scripts. + */ +export const ROUTE_CONTEXT_SOURCE = `const keys = Object.keys(modules) +function routeContext(id) { + if (!Object.prototype.hasOwnProperty.call(modules, id)) { + throw new Error('[orca-mobile-web-app] no route module for ' + id) + } + return modules[id] +} +routeContext.keys = () => keys.slice() +routeContext.resolve = (id) => { + if (!Object.prototype.hasOwnProperty.call(modules, id)) { + throw new Error('[orca-mobile-web-app] cannot resolve route ' + id) + } + return id +} +routeContext.id = 'orca-mobile-web-app-routes'` + +/** + * esbuild has no `require.context`, so the builder synthesizes the RequireContext expo-router's + * own ExpoRoot consumes. Static imports, not a lazy getter: one chunk, no fetch behind the + * page's CSP. + */ +export function renderMobileWebAppRouteManifest(routes) { + const importLines = routes.map( + ({ module }, index) => `import * as route${String(index)} from ${JSON.stringify(module)}` + ) + const entryLines = routes.map( + ({ key }, index) => ` [${JSON.stringify(key)}]: route${String(index)}` + ) + return `${importLines.join('\n')} +const modules = { +${entryLines.join(',\n')} +} +${ROUTE_CONTEXT_SOURCE} +export default routeContext +` +} diff --git a/config/scripts/mobile-web-app-web-overrides.test.mjs b/config/scripts/mobile-web-app-web-overrides.test.mjs new file mode 100644 index 00000000000..f903c48259c --- /dev/null +++ b/config/scripts/mobile-web-app-web-overrides.test.mjs @@ -0,0 +1,131 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const mobileDir = join(projectDir, 'mobile') +const allowlistPath = join(mobileDir, 'web-entry', 'web-overrides.json') + +// Every tree the app entry can resolve a .web.* sibling out of: src and web-entry and packages +// through the builder's resolveExtensions, app through the route manifest's own sibling +// preference. packages is in the list because the dictation hook imports the vendored +// @orca/expo-two-way-audio, whose web module then reaches the page. +const SCANNED = ['src', 'app', 'web-entry', 'packages'] +const WEB_SIBLING = /\.web\.(tsx|ts|jsx|js)$/ + +async function listFiles(directory) { + const out = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name === 'node_modules') { + continue + } + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + out.push(...(await listFiles(entryPath))) + } else if (entry.isFile()) { + out.push(entryPath) + } + } + return out +} + +/** Takes the root so the census can be run against a scratch tree and shown to fail. */ +export async function findWebSiblings(rootDir) { + const found = [] + for (const tree of SCANNED) { + const directory = join(rootDir, tree) + if (!existsSync(directory)) { + continue + } + for (const file of await listFiles(directory)) { + if (WEB_SIBLING.test(file)) { + found.push(relative(rootDir, file).split('\\').join('/')) + } + } + } + return found.sort() +} + +async function readAllowlist() { + return JSON.parse(await readFile(allowlistPath, 'utf8')) +} + +async function exists(path) { + return readFile(path).then( + () => true, + () => false + ) +} + +async function withScratch(run) { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-overrides-')) + try { + return await run(scratch) + } finally { + await rm(scratch, { recursive: true, force: true }) + } +} + +async function plant(scratch, file) { + await mkdir(join(scratch, file, '..'), { recursive: true }) + await writeFile(join(scratch, file), 'export default null\n', 'utf8') +} + +describe('mobile web app .web.* overrides', () => { + it('lists exactly the .web.* files on disk', async () => { + const { overrides } = await readAllowlist() + expect(overrides.map((entry) => entry.file).sort()).toEqual(await findWebSiblings(mobileDir)) + }) + + it('gives every override a non-web sibling, so the native build still has a module', async () => { + const { overrides } = await readAllowlist() + for (const { file } of overrides) { + const native = join(mobileDir, file.replace('.web.', '.')) + // A .web.tsx may shadow a .tsx or a .ts; try both before failing. + const alternative = native.replace(/\.tsx$/, '.ts').replace(/\.jsx$/, '.js') + expect( + (await exists(native)) || (await exists(alternative)), + `${file} has no non-web sibling` + ).toBe(true) + } + }) + + it('states a reason for every override', async () => { + const { overrides } = await readAllowlist() + for (const entry of overrides) { + expect(entry.reason.length, `${entry.file} has no reason`).toBeGreaterThan(20) + } + }) +}) + +// A census that scans only trees which happen to hold no .web.* file passes for the wrong reason. +// These plant one in each scanned tree and show the first assertion above would report it. +describe('the census scan', () => { + it('reports an unlisted .web.* in every tree it claims to cover', async () => { + const planted = { + src: 'src/transport/planted.web.ts', + app: 'app/h/[hostId]/edit.web.tsx', + 'web-entry': 'web-entry/planted.web.tsx', + packages: 'packages/expo-two-way-audio/src/Planted.web.ts' + } + for (const [tree, file] of Object.entries(planted)) { + await withScratch(async (scratch) => { + await plant(scratch, file) + expect( + await findWebSiblings(scratch), + `${tree} is scanned but ${file} went unseen` + ).toEqual([file]) + }) + } + }) + + it('skips node_modules, which vendors thousands of unrelated .web.js files', async () => { + await withScratch(async (scratch) => { + await plant(scratch, 'packages/x/node_modules/dep/index.web.js') + expect(await findWebSiblings(scratch)).toEqual([]) + }) + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 0d31e58f9bc..a2dfcc0082f 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -26,6 +26,7 @@ export const PR_CHECK_JOBS = [ 'shell_contracts', 'test', 'orcad_browser', + 'mobile_web_app', 'cross-version-wire', 'managed_hook_node18', 'package', @@ -106,6 +107,27 @@ const ORCAD_BROWSER_PREFIXES = [ 'src/main/orcad/electron-serve-browser-process' ] +// The Route A page bundle: the builder and verifier, the entry, the route tree it mounts, the +// mobile source those routes import, and the shell policy the render check runs the page under. +const MOBILE_WEB_APP_PREFIXES = [ + 'config/scripts/build-mobile-web-app', + 'config/scripts/verify-mobile-web-app-bundle', + 'config/scripts/mobile-web-app-', + 'config/scripts/build-mobile-web-bundle', + 'config/scripts/verify-mobile-web-bundle', + 'mobile/web-entry/', + 'mobile/app/', + 'mobile/src/', + 'mobile/packages/', + 'mobile/package.json', + 'mobile/pnpm-lock.yaml', + 'mobile/modules/orca-mobile-web-shell/' +] + +function changesMobileWebApp(changedFiles) { + return changedFiles.some((file) => matchesPrefix(file, MOBILE_WEB_APP_PREFIXES)) +} + const CROSS_VERSION_WIRE_PREFIXES = [ 'tests/e2e/cross-version-wire/', 'src/shared/protocol-version', @@ -358,6 +380,10 @@ export function classifyPrJobs(changedFiles) { // but the repo-wide audits lint mobile/, and skipping them lands the violation on main, where // it then fails this same gate on every later PR's merge ref. jobs.static_analysis = jobs.static_analysis || changedFiles.some(isStaticAnalysisScannedPath) + // Why outside should_run, for the same reason: a mobile-only diff is desktop-irrelevant, and + // that is exactly the diff that changes the page this job builds. Gated on should_run it would + // skip on every PR that can break it and run on none. + jobs.mobile_web_app = jobs.mobile_web_app || changesMobileWebApp(changedFiles) return { should_run: shouldRun, native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)), @@ -380,6 +406,10 @@ function jobDetector(job) { return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES)) case 'orcad_browser': return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES)) + // Not redundant with the lift below the jobs map: without a case here the default detector + // returns true, which would run this job on every desktop-relevant PR. + case 'mobile_web_app': + return changesMobileWebApp case 'cross-version-wire': return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES)) case 'managed_hook_node18': diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 92c71a7809b..dbf90055c57 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -255,6 +255,39 @@ describe('per-job path classification', () => { }) }) + it('runs the mobile web app job for the builder, the page source and the shell policy', () => { + for (const file of [ + 'config/scripts/build-mobile-web-app-bundle.mjs', + 'config/scripts/mobile-web-app-route-manifest.mjs', + 'mobile/web-entry/index.tsx', + 'mobile/app/h/[hostId]/index.tsx', + 'mobile/src/transport/client-context.web.tsx', + 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift', + // The vendored Expo module the page resolves a .web.ts out of. + 'mobile/packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts' + ]) { + expect(classifyPrJobs([file]).mobile_web_app, file).toBe(true) + } + }) + + it('runs it on a mobile-only diff, which should_run alone would skip', () => { + const classified = classifyPrJobs(['mobile/app/h/[hostId]/tasks.tsx']) + expect(classified.should_run).toBe(false) + expect(classified.mobile_web_app).toBe(true) + }) + + it('needs no package.json prefix, because package.json already forces every job', () => { + // build:mobile-web:app is defined there, so the job has to run on an edit to it. A prefix + // that broad is not how: GLOBAL_FORCE_FILES already covers the file. + expect(classifyPrJobs(['package.json']).mobile_web_app).toBe(true) + }) + + it('leaves it off for changes that cannot reach the page', () => { + for (const file of ['docs/reference/x.md', 'src/main/orcad/orcad-native-preflight.ts']) { + expect(classifyPrJobs([file]).mobile_web_app, file).toBe(false) + } + }) + it('runs cross-version wire checks for every working-tree wire module', () => { for (const file of [ 'src/shared/protocol-version.ts', diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index d18837a1573..0ed60ffb4bb 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -1,6 +1,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' +import { MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV } from './mobile-web-app-bundle-dependencies.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8')) @@ -463,6 +464,7 @@ describe('PR workflow parallelism', () => { 'shell_contracts', 'test', 'orcad_browser', + 'mobile_web_app', 'cross-version-wire', 'managed_hook_node18', 'package', @@ -479,5 +481,19 @@ describe('PR workflow parallelism', () => { expect(verifyStep.run).toContain('"$ORCAD_BROWSER"') expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}') expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"') + // Same reason as the browser provider: the render check fails loudly on a runner with no + // Chrome, which only guards the page if verify reads the job's result. + expect(verifyStep.env.MOBILE_WEB_APP).toBe('${{ needs.mobile_web_app.result }}') + expect(verifyStep.run).toContain('"$MOBILE_WEB_APP"') + }) + + it('makes the mobile_web_app job refuse to skip the tests it exists to run', () => { + // The bundling tests skip themselves without mobile/node_modules, which is what keeps the + // sharded `test` job green. Only this env var stops that skip from spreading to the one job + // that installs them, so a typo here would leave the whole job passing vacuously. + const step = workflow.jobs.mobile_web_app.steps.find((entry) => + entry.run?.includes('build-mobile-web-app-bundle.test.mjs') + ) + expect(step.env[MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV]).toBe('1') }) }) diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs new file mode 100644 index 00000000000..4d5e101ad20 --- /dev/null +++ b/config/scripts/verify-mobile-web-app-bundle.mjs @@ -0,0 +1,93 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { isDirectInvocation } from './build-mobile-web-bundle.mjs' +import { assertNoCarriageReturnsInSource } from './verify-mobile-web-bundle.mjs' +import { assertMobileWebBundleBuilt } from './verify-packaged-mobile-web-bundle.cjs' + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const defaultBundleDir = join(projectDir, 'out', 'mobile-web-app') + +/** One script, one document, and the images the route tree imports. */ +export const MOBILE_WEB_APP_BUNDLE_MAX_ASSETS = 64 + +/** + * Phase C byte budget for the app bundle, not the contract ceiling (10 MiB per asset, + * MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES). Deliberately below it so growth trips a build rather than a + * refused asset on a phone. esbuild `splitting` does not help a single entry with only static + * imports — it emits one chunk — so shrinking this means cutting code, not re-chunking. + */ +export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024 + +/** Every tree whose bytes reach the buildId, so a CRLF checkout cannot fork it. */ +export const MOBILE_WEB_APP_SOURCE_DIRS = [ + join(projectDir, 'mobile', 'web-entry'), + join(projectDir, 'mobile', 'app'), + join(projectDir, 'mobile', 'src') +] + +class VerificationError extends Error {} + +function fail(message) { + throw new VerificationError(message) +} + +async function buildIntoScratch() { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-verify-')) + try { + const { manifest } = await buildMobileWebAppBundle({ outDir: join(scratch, 'mobile-web-app') }) + return manifest + } finally { + await rm(scratch, { recursive: true, force: true }) + } +} + +// bundleDir is a seam for the tests, which verify a scratch build; the script always verifies out/. +export async function verifyMobileWebAppBundle({ bundleDir = defaultBundleDir } = {}) { + for (const directory of MOBILE_WEB_APP_SOURCE_DIRS) { + await assertNoCarriageReturnsInSource(directory) + } + + const manifest = assertMobileWebBundleBuilt(bundleDir) + + if (manifest.assets.length > MOBILE_WEB_APP_BUNDLE_MAX_ASSETS) { + fail( + `bundle has ${String(manifest.assets.length)} assets, over the Phase C budget of ` + + `${String(MOBILE_WEB_APP_BUNDLE_MAX_ASSETS)}` + ) + } + if (manifest.totalBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) { + fail( + `bundle is ${String(manifest.totalBytes)} bytes, over the Phase C budget of ` + + `${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)}` + ) + } + + const first = await buildIntoScratch() + const second = await buildIntoScratch() + if (first.buildId !== second.buildId) { + fail(`buildId is not reproducible: ${first.buildId} then ${second.buildId}`) + } + if (first.buildId !== manifest.buildId) { + fail( + `${bundleDir} is stale: it carries buildId ${manifest.buildId}, a fresh build produces ${first.buildId}` + ) + } + return manifest +} + +if (isDirectInvocation(import.meta.url, process.argv[1])) { + try { + const manifest = await verifyMobileWebAppBundle() + console.log( + `[verify-mobile-web-app-bundle] OK — ${String(manifest.assets.length)} asset(s), ` + + `${String(manifest.totalBytes)}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes, ` + + `reproducible buildId ${manifest.buildId}` + ) + } catch (error) { + console.error(`[verify-mobile-web-app-bundle] ${error.message}`) + process.exit(1) + } +} diff --git a/config/scripts/verify-mobile-web-bundle.mjs b/config/scripts/verify-mobile-web-bundle.mjs index 0fedaac11c1..f75a0e69afd 100644 --- a/config/scripts/verify-mobile-web-bundle.mjs +++ b/config/scripts/verify-mobile-web-bundle.mjs @@ -44,6 +44,24 @@ async function listSourceFiles(directory) { return files.sort() } +/** + * Pinned `-text` in .gitattributes and skipped below, because a 0x0d in them means nothing. .svg + * is absent on purpose: it is text, so the eol=lf pin applies and a CRLF .svg forks the buildId. + * A test keeps this list and the .gitattributes exemptions in step. + */ +export const BINARY_SOURCE_EXTENSIONS = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.ico', + '.webp', + '.ttf', + '.otf', + '.woff', + '.woff2' +] + /** * A CRLF checkout changes the bytes of every text source, which changes every asset hash and so * the buildId. .gitattributes pins eol=lf; this is what notices when that pin stops working. @@ -51,8 +69,11 @@ async function listSourceFiles(directory) { export async function assertNoCarriageReturnsInSource(directory = sourceDir) { const offenders = [] for (const file of await listSourceFiles(directory)) { - // Binary assets are pinned -text and may legitimately contain 0x0d. - if (file.endsWith('.png')) { + if (BINARY_SOURCE_EXTENSIONS.some((extension) => file.endsWith(extension))) { + continue + } + // Written by mobile's postinstall, gitignored, so no eol pin applies and none is needed. + if (file.endsWith('.generated.ts')) { continue } if ((await readFile(file)).includes(0x0d)) { @@ -62,7 +83,7 @@ export async function assertNoCarriageReturnsInSource(directory = sourceDir) { if (offenders.length > 0) { fail( `CRLF in mobile web source, which would change every asset hash and the buildId: ` + - `${offenders.join(', ')}. Check the .gitattributes eol=lf pin for src/mobile-web.` + `${offenders.join(', ')}. Check the .gitattributes eol=lf pin for ${directory}.` ) } } diff --git a/mobile/app/h/[hostId]/web.web.tsx b/mobile/app/h/[hostId]/web.web.tsx new file mode 100644 index 00000000000..4d3570b4773 --- /dev/null +++ b/mobile/app/h/[hostId]/web.web.tsx @@ -0,0 +1,12 @@ +import { Redirect, useLocalSearchParams } from 'expo-router' + +/** + * Web sibling for the hybrid shell route. This page is what that route's WebView displays, so the + * shell has nowhere to nest here; the native file also reaches OrcaMobileWebShellView, whose + * module calls requireNativeViewManager at import and throws in a browser, and one throwing route + * module takes the whole bundle down because the manifest imports them all. + */ +export default function MobileWebShellRoute() { + const { hostId } = useLocalSearchParams<{ hostId: string }>() + return +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt index 47abc1c1f98..4aa96ce4a4f 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt @@ -8,9 +8,10 @@ package expo.modules.orcamobilewebshell internal val MOBILE_WEB_SHELL_CSP = listOf( "default-src 'none'", "script-src 'self'", - // 'self' holds only while the bundle ships linked stylesheets. React Native Web emits runtime - // style elements, so Phase C has to revisit this openly rather than relax it quietly. - "style-src 'self'", + // React Native Web 0.21.2 injects its stylesheet at runtime with no nonce support, so the + // Phase C page cannot paint under 'self' alone (measured: the render check under this exact + // header). This relaxes styling only; script-src 'self' is untouched. + "style-src 'self' 'unsafe-inline'", "img-src 'self'", "font-src 'none'", // The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt index 75006761d0d..3ee20a7832b 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt @@ -1,5 +1,6 @@ package expo.modules.orcamobilewebshell +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -10,7 +11,8 @@ class MobileWebShellCspTest { val directives = MOBILE_WEB_SHELL_CSP.split("; ") assertTrue(directives.contains("default-src 'none'")) assertTrue(directives.contains("script-src 'self'")) - assertTrue(directives.contains("style-src 'self'")) + // React Native Web injects runtime styles with no nonce; see MobileWebShellCsp. + assertTrue(directives.contains("style-src 'self' 'unsafe-inline'")) assertTrue(directives.contains("img-src 'self'")) // The bootstrap page reads ./manifest.json from its own origin, which is one read-only // directory behind the manifest map, so 'self' reaches nothing it cannot already read. @@ -26,7 +28,14 @@ class MobileWebShellCspTest { @Test fun `grants nothing the build rules say the bundle never needs`() { - assertFalse(MOBILE_WEB_SHELL_CSP.contains("unsafe-inline")) + // 'unsafe-inline' is granted to style-src and to nothing else: the page's code still has to + // arrive as a fetched same-origin script, which is the directive that matters. + val directives = MOBILE_WEB_SHELL_CSP.split("; ") + assertEquals( + listOf("style-src 'self' 'unsafe-inline'"), + directives.filter { it.contains("unsafe-inline") } + ) + assertTrue(directives.contains("script-src 'self'")) assertFalse(MOBILE_WEB_SHELL_CSP.contains("unsafe-eval")) assertFalse(MOBILE_WEB_SHELL_CSP.contains("data:")) assertFalse(MOBILE_WEB_SHELL_CSP.contains("blob:")) diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift index de76cc4613c..a467bf67a24 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift @@ -4,9 +4,10 @@ enum MobileWebShellCsp { static let header = [ "default-src 'none'", "script-src 'self'", - // 'self' holds only while the bundle ships linked stylesheets. React Native Web emits runtime - // style elements, so Phase C has to revisit this openly rather than relax it quietly. - "style-src 'self'", + // React Native Web 0.21.2 injects its stylesheet at runtime with no nonce support, so the + // Phase C page cannot paint under 'self' alone (measured: the render check under this exact + // header). This relaxes styling only; script-src 'self' is untouched. + "style-src 'self' 'unsafe-inline'", "img-src 'self'", "font-src 'none'", // The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the diff --git a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift index b84dc374bcf..f7f3fdded09 100644 --- a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift +++ b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift @@ -206,14 +206,17 @@ import Foundation let directives = header.components(separatedBy: "; ") precondition(directives.contains("default-src 'none'")) precondition(directives.contains("script-src 'self'")) + // React Native Web injects runtime styles with no nonce; see MobileWebShellCsp. + precondition(directives.contains("style-src 'self' 'unsafe-inline'")) precondition(directives.contains("connect-src 'self'")) precondition(directives.contains("worker-src 'none'")) precondition(directives.contains("frame-src 'none'")) precondition(directives.contains("base-uri 'none'")) precondition(directives.contains("form-action 'none'")) precondition(directives.contains("frame-ancestors 'none'")) - // An inline script or an eval would make the no-inline-script build rule unenforced. - precondition(!header.contains("unsafe-inline")) + // 'unsafe-inline' is granted to style-src and to nothing else: the page's code still has to + // arrive as a fetched same-origin script, which is the directive that matters. + precondition(directives.filter { $0.contains("unsafe-inline") } == ["style-src 'self' 'unsafe-inline'"]) precondition(!header.contains("unsafe-eval")) precondition(!header.contains("data:")) precondition(!header.contains("blob:")) diff --git a/mobile/src/transport/client-context.web.tsx b/mobile/src/transport/client-context.web.tsx new file mode 100644 index 00000000000..1776cbb859b --- /dev/null +++ b/mobile/src/transport/client-context.web.tsx @@ -0,0 +1,84 @@ +// Web sibling: RN Web has no pairing keychain and no websocket transport of its own, so the page +// gets a placeholder client until C0.4 lands BridgeRpcClient over the shell bridge. +import { createContext, useContext, useMemo, type ReactNode } from 'react' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, HostProfile } from './types' +import type { RpcClientContextValue } from './rpc-client-context-contract' + +export { + useDisconnectHostClient, + useForceReconnect, + useForgetHostClient, + useHostClient, + usePrimeHosts, + useRefreshHostClient +} from './host-client-hooks' + +/** Named so a page-side failure is never mistaken for a host RpcFailure. */ +export class BridgeTransportUnavailableError extends Error { + constructor(what: string) { + super(`bridge transport unavailable: ${what}`) + this.name = 'BridgeTransportUnavailableError' + } +} + +function createPlaceholderClient(): RpcClient { + return { + sendRequest: (method) => Promise.reject(new BridgeTransportUnavailableError(method)), + // No synthetic frame: stream readers are checked, and inventing a shape they must parse + // would fail differently from the real bridge. Screens stay in their loading state. + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'disconnected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + getLastInboundAt: () => null, + getGeneration: () => 0, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } +} + +const Ctx = createContext(null) + +export function RpcClientProvider({ children }: { children: ReactNode }) { + const value = useMemo(() => { + const client = createPlaceholderClient() + const disconnected: ConnectionState = 'disconnected' + return { + acquire: () => client, + release: () => {}, + releaseAndCloseIfUnused: () => {}, + closeIfUnused: () => {}, + forceReconnect: () => Promise.resolve(), + refreshHostClient: () => {}, + forgetHostClient: () => {}, + disconnectHostClient: () => {}, + getState: () => disconnected, + getKnownState: () => disconnected, + getClientId: () => null, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + // The page reaches its host through the shell bridge, which rides whatever path the RN + // client already negotiated. 'relay' is the honest default until init carries the real one. + getActivePath: () => 'relay', + getPendingPath: () => null, + isPairingRejected: () => false, + isHostSignedOut: () => false, + subscribeHostState: () => () => {}, + getAllClients: () => [], + subscribeAllHosts: () => () => {}, + primeHosts: (_hosts: HostProfile[]) => {} + } + }, []) + return {children} +} + +export function useRpcClientContext(): RpcClientContextValue { + const value = useContext(Ctx) + if (!value) { + throw new Error('useRpcClientContext must be used within RpcClientProvider') + } + return value +} diff --git a/mobile/src/transport/host-device-token-store.web.ts b/mobile/src/transport/host-device-token-store.web.ts new file mode 100644 index 00000000000..d6d740bc5fc --- /dev/null +++ b/mobile/src/transport/host-device-token-store.web.ts @@ -0,0 +1,13 @@ +// Web sibling: the bridge carries RPC, so the page holds no device token and must not import +// the pairing keychain (expo-secure-store resolves to {} on web). +export function readHostDeviceToken(_hostId: string): Promise { + return Promise.resolve(null) +} + +export function writeHostDeviceToken(_hostId: string, _token: string): Promise { + return Promise.resolve() +} + +export function deleteHostDeviceToken(_hostId: string): Promise { + return Promise.resolve() +} diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts index 4cfa928b66f..6698a9be723 100644 --- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -25,6 +25,8 @@ export type UnvalidatedRpcRequestPortEntry = { /** Modules whose job is the port. These do not shrink to zero. */ export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequestPortEntry[] = [ + // Placeholder page transport until C0.4's BridgeRpcClient replaces it; rejects every call, reads no reply. + { file: 'src/transport/client-context.web.tsx', references: 1 }, // Implements the port over the device-to-host websocket. { file: 'src/transport/direct-rpc-client.ts', references: 3 }, // Fakes the port for the supervisor suites; a non-test file only because tsconfig excludes tests. diff --git a/mobile/web-entry/index.tsx b/mobile/web-entry/index.tsx new file mode 100644 index 00000000000..acc5e784636 --- /dev/null +++ b/mobile/web-entry/index.tsx @@ -0,0 +1,29 @@ +// Route A web entry: mounts the phone's h/[hostId] route tree on react-native-web. +// Dark: built by `build:mobile-web:app` into out/mobile-web-app, shipped by nothing until C1. +import { useEffect, type PropsWithChildren } from 'react' +import { createRoot } from 'react-dom/client' +import { ExpoRoot } from 'expo-router' +import { RpcClientProvider } from '../src/transport/client-context' +// Body replaced at build time: esbuild has no require.context, so the builder synthesizes one. +import routeContext from './route-manifest' + +// Progress of the mount, in one attribute, so the render check can tell a page that never ran +// its script from one that ran it and threw. Effects run child-first, so 'mounted' lands only +// after the router tree below this wrapper has committed. +const MOUNT_STATE_ATTRIBUTE = 'orcaWebEntry' + +// The route tree starts at app/h, below the native root layout that owns the provider, so the +// page supplies it here through ExpoRoot's own wrapper rather than mounting the native shell. +function RootProviders({ children }: PropsWithChildren) { + useEffect(() => { + document.documentElement.dataset[MOUNT_STATE_ATTRIBUTE] = 'mounted' + }, []) + return {children} +} + +const container = document.getElementById('root') +if (!container) { + throw new Error('[orca-mobile-web-app] #root missing') +} +document.documentElement.dataset[MOUNT_STATE_ATTRIBUTE] = 'started' +createRoot(container).render() diff --git a/mobile/web-entry/route-manifest.ts b/mobile/web-entry/route-manifest.ts new file mode 100644 index 00000000000..f29ce14d8f0 --- /dev/null +++ b/mobile/web-entry/route-manifest.ts @@ -0,0 +1,21 @@ +import type { RequireContext } from 'expo-router/build/types' + +/** + * Replaced wholesale at build time by config/scripts/build-mobile-web-app-bundle.mjs, which + * generates the static imports esbuild needs in place of Metro's require.context. This body is + * what typechecking and Metro see; it never runs, because only the web build resolves this file. + */ +const routeContext: RequireContext = Object.assign( + (id: string): never => { + throw new Error(`[orca-mobile-web-app] route manifest was not generated: ${id}`) + }, + { + keys: (): string[] => [], + resolve: (id: string): string => { + throw new Error(`[orca-mobile-web-app] route manifest was not generated: ${id}`) + }, + id: 'orca-mobile-web-app-routes' + } +) + +export default routeContext diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json new file mode 100644 index 00000000000..d1a67d58ecb --- /dev/null +++ b/mobile/web-entry/web-overrides.json @@ -0,0 +1,21 @@ +{ + "$comment": "Every .web.* sibling the Route A web build resolves ahead of its native file. One entry per documented React Native Web gap; config/scripts/mobile-web-app-web-overrides.test.mjs fails on an unlisted one, a listed file that is gone, or one with no native sibling.", + "overrides": [ + { + "file": "src/transport/client-context.web.tsx", + "reason": "The page has no websocket transport and no pairing keychain. This is the single transport substitution point: a placeholder RpcClient until C0.4 lands BridgeRpcClient over the shell bridge." + }, + { + "file": "packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts", + "reason": "Vendored with the module, not added for Route A. The dictation hook imports @orca/expo-two-way-audio, whose native module is a Swift/Kotlin JSI binding with no browser counterpart; the web file answers the same surface with denied microphone permission and no playback." + }, + { + "file": "src/transport/host-device-token-store.web.ts", + "reason": "expo-secure-store resolves to {} on web, and the bridge carries the RPC, so the page holds no device token." + }, + { + "file": "app/h/[hostId]/web.web.tsx", + "reason": "The hybrid shell route opens a WebView on this very page, so on web it redirects to the host instead of nesting the shell inside itself. Its native file pulls in OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser." + } + ] +} diff --git a/package.json b/package.json index 23d1ecf3e34..1f63bc14e1a 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", "build:mobile-web": "node config/scripts/build-mobile-web-bundle.mjs && node config/scripts/verify-mobile-web-bundle.mjs", + "build:mobile-web:app": "node config/scripts/build-mobile-web-app-bundle.mjs && node config/scripts/verify-mobile-web-app-bundle.mjs", "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", "build": "pnpm run build:desktop && pnpm run build:native", "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", From f2be6299c852b69929edb1778bf7963ca56fda84 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:46:47 -0400 Subject: [PATCH 029/224] feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) (#21459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) One page document's end of the bridge: page frames in through the C0.1 reader, one RpcClient behind it, host frames out. Requests forward with the arity the page used and answer with the verbatim RpcResponse, chunked when it is over the frame cap; a rejection crosses as the five-field capture instead. Subscriptions carry a seq and an unacked window, and end with `overflow` rather than dropping frames a reader cannot see are missing. The fence is structural: the protocol names no host, so the client is whichever this host was built with, and the in-flight caps the page is told about in `init` are enforced here rather than trusted from there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3) The channel opens on the session B4 put on screen and closes with it. The session id is B4's: nothing new is minted, and a remount is a new one, which is what makes a dead page's frames fail the native origin check. Both halves are stamped with the session they belong to, because React swaps refs during the commit and runs the retiring effect's cleanup after it — a host disposing on a remount would otherwise post its teardown into the page that replaced it. `bridgeEnabled` is derived from the session step alone, since the native side treats a prop change as a reload. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the bridge fence holds for traffic, not just for answers A mutation that dropped the post-teardown guard in `receive` survived: the teardown case only fed a frame whose answer the outbound guard already swallowed, so nothing observed that a dead page could still reach a live client. Both teardown paths now feed a request, a subscribe and a notify, and assert the client saw none of them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the hook's frames through the page's own reader `JSON.parse` returns `any`, and taming it with an assertion is a cast the gate refuses and a check nobody gets. Reading each posted frame through `readBridgeHostMessage` types it and proves the same thing the host's own suite does: a frame the page would refuse is a frame that never arrives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the bridge host as a raw request port owner The boundary ratchet reads a `.sendRequest` access as a call site, and the host has three: one per arity the page can use. It is not a call site. It picks no method, reads no reply and decides no acceptance — the page names the method and runs the typed operation over the client this carries, which is what the C0 design put page-side so `runRpcOperation` stays unchanged there. That makes it an owner, beside the socket and relay senders, not a migration backlog entry. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove a stream that overflows inside subscribe is unsubscribed A client that emits synchronously from `subscribe` can retire a stream before its unsubscribe exists to be stored. The identity check that calls it instead had no test; deleting it left the suite green while the client's stream leaked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the bridge host over in the commit, not after it A client swap that keeps the session id leaves the handler's own fence inert: until the passive effect ran, a native frame reached the retiring host and the client it closed over. A layout effect swaps both inside the commit. Teardown on unmount now runs while the view is still attached, so a pending request is answered delivery-unknown instead of being dropped on the floor. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold a refused page frame to one warning per page A page that sends one bad frame usually sends many, and a line each buries the first — the one that says why. Same bound the host already keeps on a failing post, applied per kind and reset when a new page gets a new host. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound the page's terminal viewport at the bridge contract A viewport crossing the bridge is written into the cached subscribe params of every stream naming that terminal, including the native terminal screen's, and the desktop refuses cols over 1000 or rows over 500 when those streams resubscribe. Unbounded, one page could kill streams it never opened; the frame is refused instead, and the bound is pinned to the desktop's own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the page's close from latching the bridge host shut One view carries every document the shell loads, so the page that says `close` is not the last one. A latched host dropped the next document's `ready` in silence, and a page that re-sends `ready` on a backoff would retry forever with nothing posted and nothing logged. Close now cancels what the page owned and leaves the host live; only dispose shuts it, and a frame arriving after that is diagnosed rather than dropped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a throwing client or post inside the bridge host The `state` frame is sent from inside the client's own state-change fan-out and a notify runs on the native event handler that delivered the page's frame, so a synchronous throw from either escapes into a loop the bridge does not own and takes unrelated listeners with it. Both are fenced and reported once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove an ack releases the stream's unacked bytes The frame window reopens on ack through the splice, so deleting the byte release left every existing test green while a long-lived stream of large frames would end with overflow on its first frame after an ack. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pass the commit-window harness its children as a prop `createElement`'s variadic children do not satisfy a props type that declares `children`, so the file dropped out of the tests typecheck ratchet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): render the harness from the commit-window wrapper, not as children A props type that declares `children` is what `createElement`'s variadic form does not satisfy, and passing it as a prop instead trips the react rule. The wrapper renders the harness itself, which is the parent position the layout effect ordering needs anyway. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the desktop viewport bound by reading it, not importing it Mobile may not pull an rpc-contract *value* into its bundle, and the boundary test that enforces that scans this test file too. The pin reads the schema's own source instead, so drift in either bound still fails loudly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve the bridge to one document at a time A page's `close` now ends that document's turn: until the next `ready` claims the view, every other frame is dropped and diagnosed instead of reaching the client, and nothing is posted. Without the fence a straggler from the closed document was still forwarded, and a `state` frame from the still-running client landed in the replacement document before its `init`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the request cap against the calls, not the page's ledger `sendRequest` has no cancel, so a request the page cancelled or closed out keeps running on the desktop until it answers. The cap now counts those calls until each settles; counting the pending map let a page interleaving `close` with batches hold many more than the cap `init` advertises. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../MobileWebShellScreen.test.tsx | 14 + .../mobile-web-shell/MobileWebShellScreen.tsx | 5 + .../bridge-host-subscriptions.ts | 153 ++++ .../bridge-host-test-fakes.ts | 103 +++ .../src/mobile-web-shell/bridge-host.test.ts | 735 ++++++++++++++++++ mobile/src/mobile-web-shell/bridge-host.ts | 385 +++++++++ .../mobile-web-shell/bridge/bridge-caps.ts | 11 + .../bridge/bridge-envelope.test.ts | 56 +- .../bridge/bridge-envelope.ts | 6 +- .../use-mobile-web-shell-bridge.test.ts | 311 ++++++++ .../use-mobile-web-shell-bridge.ts | 136 ++++ .../unvalidated-rpc-request-port-inventory.ts | 6 + 12 files changed, 1918 insertions(+), 3 deletions(-) create mode 100644 mobile/src/mobile-web-shell/bridge-host-subscriptions.ts create mode 100644 mobile/src/mobile-web-shell/bridge-host-test-fakes.ts create mode 100644 mobile/src/mobile-web-shell/bridge-host.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge-host.ts create mode 100644 mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts create mode 100644 mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index b148374e9dd..34562932177 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -55,6 +55,9 @@ vi.mock('../../modules/orca-mobile-web-shell/src', async () => { parseMobileWebShellLoadState: loadState.parseMobileWebShellLoadState } }) +// The real bridge hook runs, so the props it owns are the ones the view is handed here; only the +// client lookup is stubbed, because reaching it imports the Expo runtime this test does not have. +vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null }) })) vi.mock('./use-mobile-web-shell-session', () => ({ useMobileWebShellSession: () => ({ state: dependencies.state, @@ -200,6 +203,17 @@ describe('the hybrid shell screen', () => { expect(view.props.sessionId).toBe('session-one') }) + it('opens the bridge channel on a ready session and hands it a receiver', async () => { + const tree = await render(readyState('session-one')) + const view = byName(tree, 'ShellViewProbe')[0] + expect(view.props.bridgeEnabled).toBe(true) + expect(typeof view.props.onBridgeMessage).toBe('function') + // Delivered with no client behind it: there is no host to answer, and nothing throws. + await act(async () => { + view.props.onBridgeMessage({ nativeEvent: { json: '{"v":1,"type":"ready"}' } }) + }) + }) + it('rebuilds the view rather than updating it when the session id changes', async () => { const tree = await render(readyState('session-one')) await update(tree, readyState('session-two')) diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index 0d73b5a8adf..8acb8042eba 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -11,6 +11,7 @@ import type { MobileWebShellFailureCause, MobileWebShellSessionState } from './mobile-web-shell-session-contract' +import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge' import { useMobileWebShellSession, type MobileWebShellRuntime @@ -123,6 +124,7 @@ export type MobileWebShellScreenProps = { export function MobileWebShellScreen({ hostId, runtime }: MobileWebShellScreenProps) { const insets = useSafeAreaInsets() const { state, retry, reportShellFailure } = useMobileWebShellSession({ hostId, runtime }) + const bridge = useMobileWebShellBridge({ hostId, session: state }) if (state.kind === 'wall') { return @@ -152,9 +154,12 @@ export function MobileWebShellScreen({ hostId, runtime }: MobileWebShellScreenPr > { const parsed = parseMobileWebShellLoadState(event.nativeEvent) if (parsed?.state === 'failed') { diff --git a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts new file mode 100644 index 00000000000..b2019a2168f --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts @@ -0,0 +1,153 @@ +import { BRIDGE_MAX_MESSAGE_BYTES, utf8ByteLength } from './bridge/bridge-caps' +import { BRIDGE_PROTOCOL_VERSION, type BridgeHostMessage } from './bridge/bridge-envelope' +import type { RpcClient } from '../transport/rpc-client' + +/** Derived, so an arm added to the envelope's closed list is a compile error here rather than a + * reason this module never sends. */ +export type BridgeEndReason = Extract['reason'] + +/** + * Frames the page has not acked, per subscription. `postBridgeMessage` resolves on enqueue and + * proves nothing about delivery, so a page that has stopped reading is invisible until it stops + * acking: this window is the only evidence the shell gets, and without it a stalled page grows the + * native queue until the process dies. + */ +export const BRIDGE_MAX_UNACKED_FRAMES = 256 +export const BRIDGE_MAX_UNACKED_BYTES = 4 * 1024 * 1024 + +type UnackedFrame = { seq: number; bytes: number } + +type OpenSubscription = { + unsubscribe: () => void + /** Last seq sent. Starts at 0 so `ack{seq:0}` is the honest "nothing yet". */ + seq: number + unacked: UnackedFrame[] + unackedBytes: number +} + +/** + * Every host subscription the page opened, and the backpressure window each one carries. + * + * Ending a stream is never silent. Dropping terminal bytes to keep a stream alive corrupts a + * transcript, which the reader cannot see; a stream that ends says so, and the page can resubscribe. + */ +export class BridgeHostSubscriptions { + private readonly open = new Map() + + constructor( + private readonly options: { + client: RpcClient + /** Fire and forget: the host owns rejection logging, and no post proves delivery. */ + post: (json: string) => void + } + ) {} + + get size(): number { + return this.open.size + } + + has(id: string): boolean { + return this.open.has(id) + } + + /** Throws whatever `client.subscribe` throws; the caller answers the page with `error`. */ + start(id: string, method: string, params: unknown): void { + const record: OpenSubscription = { + unsubscribe: () => undefined, + seq: 0, + unacked: [], + unackedBytes: 0 + } + this.open.set(id, record) + let unsubscribe: () => void + try { + unsubscribe = this.options.client.subscribe(method, params, (payload) => + this.deliver(id, payload) + ) + } catch (error) { + this.open.delete(id) + throw error + } + // A stream that emitted and overflowed inside `subscribe` is already retired, and its + // unsubscribe arrived too late to be stored: calling it here is what keeps it from leaking. + if (this.open.get(id) === record) { + record.unsubscribe = unsubscribe + } else { + unsubscribe() + } + } + + ack(id: string, seq: number): void { + const record = this.open.get(id) + if (record === undefined) { + return + } + let acked = 0 + for (const frame of record.unacked) { + if (frame.seq > seq) { + break + } + record.unackedBytes -= frame.bytes + acked += 1 + } + record.unacked.splice(0, acked) + } + + /** `null` tears the stream down without telling the page, for a page that already said goodbye. */ + cancel(id: string, reason: BridgeEndReason | null): void { + const record = this.open.get(id) + if (record === undefined) { + return + } + this.open.delete(id) + try { + record.unsubscribe() + } catch { + // A client whose unsubscribe throws must not keep the rest of the ledger open. + } + if (reason !== null) { + this.options.post(JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'end', id, reason })) + } + } + + // Deleting the visited entry is what a `Map` iterator is specified to survive, so the ledger is + // walked in place rather than copied. + closeAll(reason: BridgeEndReason | null): void { + for (const id of this.open.keys()) { + this.cancel(id, reason) + } + } + + private deliver(id: string, payload: unknown): void { + const record = this.open.get(id) + if (record === undefined) { + return + } + const seq = record.seq + 1 + let json: string + try { + json = JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'event', id, seq, payload }) + } catch { + // Nothing off the wire is cyclic, but a stream that cannot be serialized ends rather than + // silently skipping the frame the reader is missing. + this.cancel(id, 'closed') + return + } + const bytes = utf8ByteLength(json) + // An event is never chunked, so one over the frame cap would be refused by the page's reader + // and leave a hole nothing reports. Over the window, or too big to carry: same verdict, because + // both mean this stream cannot be delivered whole. + if ( + bytes > BRIDGE_MAX_MESSAGE_BYTES || + record.unacked.length >= BRIDGE_MAX_UNACKED_FRAMES || + record.unackedBytes + bytes > BRIDGE_MAX_UNACKED_BYTES + ) { + this.cancel(id, 'overflow') + return + } + record.seq = seq + record.unacked.push({ seq, bytes }) + record.unackedBytes += bytes + this.options.post(json) + } +} diff --git a/mobile/src/mobile-web-shell/bridge-host-test-fakes.ts b/mobile/src/mobile-web-shell/bridge-host-test-fakes.ts new file mode 100644 index 00000000000..d3c558cd675 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-host-test-fakes.ts @@ -0,0 +1,103 @@ +import type { RpcClient, SendRequestOptions } from '../transport/rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { BRIDGE_PROTOCOL_VERSION } from './bridge/bridge-envelope' + +export type SentRequest = { + method: string + /** The arity the host used, which the golden recorder reads as part of the call. */ + args: readonly unknown[] + resolve: (response: RpcResponse) => void + reject: (error: unknown) => void +} + +export type OpenStream = { + method: string + params: unknown + emit: (payload: unknown) => void + unsubscribes: number +} + +export type FakeRpcClient = RpcClient & { + readonly requests: SentRequest[] + readonly streams: OpenStream[] + readonly foregroundCalls: (readonly unknown[])[] + readonly viewports: { terminal: string; cols: number; rows: number }[] + pushState: (state: ConnectionState) => void + stateListeners: () => number +} + +type ClientGetters = Partial< + Pick< + RpcClient, + 'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getLastInboundAt' | 'getGeneration' + > +> + +/** Every call the host can make, recorded; nothing settles until the test says so. */ +export function createFakeRpcClient(getters: ClientGetters = {}): FakeRpcClient { + const requests: SentRequest[] = [] + const streams: OpenStream[] = [] + const foregroundCalls: (readonly unknown[])[] = [] + const viewports: { terminal: string; cols: number; rows: number }[] = [] + const listeners = new Set<(state: ConnectionState) => void>() + return { + sendRequest: (...args: [string, unknown?, SendRequestOptions?]) => + new Promise((resolve, reject) => { + requests.push({ method: args[0], args, resolve, reject }) + }), + subscribe: (method, params, onData) => { + const stream: OpenStream = { method, params, emit: onData, unsubscribes: 0 } + streams.push(stream) + return () => { + stream.unsubscribes += 1 + } + }, + updateTerminalSubscriptionViewport: (terminal, viewport) => { + viewports.push({ terminal, cols: viewport.cols, rows: viewport.rows }) + }, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + notifyForeground: (...args: Parameters) => { + foregroundCalls.push(args) + }, + close: () => undefined, + requests, + streams, + foregroundCalls, + viewports, + pushState: (state) => { + for (const listener of listeners) { + listener(state) + } + }, + stateListeners: () => listeners.size, + ...getters + } +} + +/** 22 chars of base64url, which is what the envelope's id pattern accepts. */ +export function bridgeId(index: number): string { + return index.toString(36).padStart(22, 'a') +} + +export function clientFrame(fields: Record): string { + return JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, ...fields }) +} + +export function rpcSuccess(id: string, result: unknown): RpcResponse { + return { id, ok: true, result, _meta: { runtimeId: 'runtime-a' } } +} + +/** Two microtask turns: a settled `sendRequest` posts from a `then`, and a post rejection is + * reported from a `catch` chained onto it. */ +export async function flushBridge(): Promise { + await Promise.resolve() + await Promise.resolve() +} diff --git a/mobile/src/mobile-web-shell/bridge-host.test.ts b/mobile/src/mobile-web-shell/bridge-host.test.ts new file mode 100644 index 00000000000..e3badea4f87 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-host.test.ts @@ -0,0 +1,735 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from '../transport/types' +import { BRIDGE_MAX_UNACKED_BYTES, BRIDGE_MAX_UNACKED_FRAMES } from './bridge-host-subscriptions' +import { + bridgeId, + clientFrame, + createFakeRpcClient, + flushBridge, + rpcSuccess, + type FakeRpcClient +} from './bridge-host-test-fakes' +import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from './bridge-host' +import { + BRIDGE_MAX_MESSAGE_BYTES, + BRIDGE_MAX_PENDING_REQUESTS, + BRIDGE_MAX_REPLY_BYTES, + BRIDGE_MAX_SUBSCRIPTIONS +} from './bridge/bridge-caps' +import { readBridgeHostMessage, type BridgeHostMessage } from './bridge/bridge-envelope' +import { BridgeReplyAssembler } from './bridge/bridge-reply-chunking' + +const ID = bridgeId(1) +const OTHER = bridgeId(2) + +type Harness = { + host: BridgeHost + client: FakeRpcClient + posted: string[] + diagnostics: BridgeHostDiagnostic[] + frames: () => BridgeHostMessage[] + last: () => BridgeHostMessage +} + +function harness( + options: { client?: FakeRpcClient; post?: (json: string) => Promise } = {} +): Harness { + const client = options.client ?? createFakeRpcClient() + const posted: string[] = [] + const diagnostics: BridgeHostDiagnostic[] = [] + const host = createBridgeHost({ + client, + post: (json) => { + posted.push(json) + return options.post?.(json) ?? Promise.resolve() + }, + buildId: 'build-a', + sessionId: 'session-a', + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) + }) + // Read back through the page's own reader: a frame the host sends that the page would refuse is + // a frame that never arrives, and this is the only place both halves meet in one test. + const frames = (): BridgeHostMessage[] => + posted.map((json) => { + const read = readBridgeHostMessage(json) + if (!read.ok) { + throw new Error(`the page would refuse this frame: ${read.refusal}`) + } + return read.message + }) + return { + host, + client, + posted, + diagnostics, + frames, + last: () => { + const all = frames() + const tail = all.at(-1) + if (tail === undefined) { + throw new Error('nothing was posted') + } + return tail + } + } +} + +function subscribeFrame(id: string, method = 'terminal.subscribe'): string { + return clientFrame({ type: 'subscribe', id, method, params: { terminal: 't' } }) +} + +describe('init and state', () => { + it('answers ready with the getters, the caps it enforces, and no native grant', () => { + const client = createFakeRpcClient({ + getState: () => 'reconnecting', + getReconnectAttempt: () => 3, + getLastConnectedAt: () => 1_700_000_000_000, + getLastInboundAt: () => 1_700_000_000_500, + getGeneration: () => 7 + }) + const bridge = harness({ client }) + bridge.host.receive(clientFrame({ type: 'ready' })) + expect(bridge.last()).toEqual({ + v: 1, + type: 'init', + sessionId: 'session-a', + buildId: 'build-a', + connection: { + state: 'reconnecting', + reconnectAttempt: 3, + lastConnectedAt: 1_700_000_000_000, + lastInboundAt: 1_700_000_000_500, + generation: 7 + }, + grants: { + rpc: { + maxPendingRequests: BRIDGE_MAX_PENDING_REQUESTS, + maxSubscriptions: BRIDGE_MAX_SUBSCRIPTIONS + }, + native: [] + } + }) + }) + + it('reports a client without the optional getters as null rather than omitting the field', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + const init = bridge.last() + expect(init.type === 'init' && init.connection).toEqual({ + state: 'connected', + reconnectAttempt: 0, + lastConnectedAt: null, + lastInboundAt: null, + generation: null + }) + }) + + it('re-answers ready, which is how a page that missed a state frame recovers', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(clientFrame({ type: 'ready' })) + expect(bridge.frames().filter((frame) => frame.type === 'init')).toHaveLength(2) + }) + + it('pushes the event state, not the getter a listener can outrun', () => { + const bridge = harness() + bridge.client.pushState('disconnected') + const pushed = bridge.last() + expect(pushed.type === 'state' && pushed.connection.state).toBe('disconnected') + }) + + it('drops the state listener on dispose', () => { + const bridge = harness() + expect(bridge.client.stateListeners()).toBe(1) + bridge.host.dispose() + expect(bridge.client.stateListeners()).toBe(0) + }) +}) + +describe('requests', () => { + it('replays the arity the page used', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + bridge.host.receive( + clientFrame({ type: 'request', id: OTHER, method: 'status.get', params: undefined }) + ) + bridge.host.receive( + clientFrame({ type: 'request', id: bridgeId(3), method: 'status.get', params: { a: 1 } }) + ) + bridge.host.receive( + clientFrame({ + type: 'request', + id: bridgeId(4), + method: 'status.get', + options: { timeoutMs: 50 } + }) + ) + expect(bridge.client.requests.map((request) => request.args)).toEqual([ + ['status.get'], + ['status.get'], + ['status.get', { a: 1 }], + ['status.get', undefined, { timeoutMs: 50 }] + ]) + }) + + it('carries a host failure through as data, _meta and error.data included', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + const failure: RpcResponse = { + id: 'wire-1', + ok: false, + error: { code: 'not_found', message: 'gone', data: { path: '/x' } }, + _meta: { runtimeId: 'runtime-a' } + } + bridge.client.requests[0]?.resolve(failure) + await flushBridge() + expect(bridge.last()).toEqual({ v: 1, type: 'reply', id: ID, payload: failure }) + }) + + it('turns a rejection into the five-field capture, delivery mark and cause included', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + const cause = new Error('socket closed') + const error = new TypeError('send failed') + error.cause = cause + bridge.client.requests[0]?.reject(error) + await flushBridge() + expect(bridge.last()).toEqual({ + v: 1, + type: 'error', + id: ID, + error: { + category: 'TypeError', + message: 'send failed', + isRpcDeliveryUnknown: false, + cause: { category: 'Error', message: 'socket closed', isRpcDeliveryUnknown: false } + } + }) + }) + + it('answers a synchronous throw from the client and frees the slot', () => { + const client = createFakeRpcClient() + const bridge = harness({ + client: { + ...client, + sendRequest: () => { + throw new Error('no socket') + } + } + }) + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + const errors = bridge.frames().filter((frame) => frame.type === 'error') + expect(errors).toHaveLength(2) + expect( + errors.every((frame) => frame.type === 'error' && frame.error.category === 'Error') + ).toBe(true) + }) + + it('refuses an id already in flight without settling the exchange it collided with', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'other.get' })) + expect(bridge.client.requests).toHaveLength(1) + expect(bridge.last().type).toBe('error') + bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok')) + await flushBridge() + expect(bridge.last()).toEqual({ + v: 1, + type: 'reply', + id: ID, + payload: rpcSuccess('wire-1', 'ok') + }) + }) + + it('refuses a subscription id as a request id, because one ledger answers for both', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + expect(bridge.client.requests).toHaveLength(0) + expect(bridge.last().type).toBe('error') + }) + + it('admits exactly the in-flight cap and refuses the next', () => { + const bridge = harness() + for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) { + bridge.host.receive( + clientFrame({ type: 'request', id: bridgeId(index), method: 'status.get' }) + ) + } + expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS) + expect(bridge.posted).toHaveLength(0) + bridge.host.receive( + clientFrame({ type: 'request', id: bridgeId(BRIDGE_MAX_PENDING_REQUESTS), method: 'x.get' }) + ) + expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS) + expect(bridge.last().type).toBe('error') + }) + + it('reopens a slot when a request settles', async () => { + const bridge = harness() + for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) { + bridge.host.receive( + clientFrame({ type: 'request', id: bridgeId(index), method: 'status.get' }) + ) + } + bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok')) + await flushBridge() + bridge.host.receive( + clientFrame({ type: 'request', id: bridgeId(BRIDGE_MAX_PENDING_REQUESTS), method: 'x.get' }) + ) + expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS + 1) + }) + + it('holds the cap against a page that closes between batches', async () => { + const bridge = harness() + const fill = (offset: number): void => { + for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) { + bridge.host.receive( + clientFrame({ type: 'request', id: bridgeId(offset + index), method: 'status.get' }) + ) + } + } + fill(0) + // `close` empties the page's ledger, but the desktop is still running all 64 and `sendRequest` + // has no cancel: counting the ledger would hand the cap over again to the next document. + bridge.host.receive(clientFrame({ type: 'close' })) + bridge.host.receive(clientFrame({ type: 'ready' })) + fill(100) + expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS) + expect(bridge.frames().filter((frame) => frame.type === 'error')).toHaveLength( + BRIDGE_MAX_PENDING_REQUESTS + ) + for (const request of bridge.client.requests) { + request.resolve(rpcSuccess('wire-1', 'ok')) + } + await flushBridge() + fill(200) + expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS * 2) + }) + + it('stops answering a cancelled request without pretending the desktop stopped running it', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + bridge.host.receive(clientFrame({ type: 'cancel', id: ID, target: 'request' })) + bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok')) + await flushBridge() + expect(bridge.posted).toHaveLength(0) + }) +}) + +describe('replies too big for one frame', () => { + it('chunks and reassembles to the same payload', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'worktree.list' })) + const payload = rpcSuccess('wire-1', 'y'.repeat(BRIDGE_MAX_MESSAGE_BYTES * 2)) + bridge.client.requests[0]?.resolve(payload) + await flushBridge() + const replies = bridge.frames() + expect(replies.length).toBeGreaterThan(1) + const assembler = new BridgeReplyAssembler() + const assembled = replies.map((frame) => + frame.type === 'reply' ? assembler.accept(frame) : { status: 'pending' as const } + ) + expect(assembled.at(-1)).toEqual({ status: 'complete', payload }) + }) + + it('aborts the request over the reply ceiling rather than truncating an answer', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'worktree.list' })) + bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'y'.repeat(BRIDGE_MAX_REPLY_BYTES + 1))) + await flushBridge() + const frame = bridge.last() + expect(frame.type === 'error' && frame.error).toMatchObject({ + category: 'BridgeReplyUndeliverableError', + isRpcDeliveryUnknown: false + }) + }) +}) + +describe('subscriptions', () => { + it('forwards with the arity the recorder reads and streams events from seq 1', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + expect(bridge.client.streams[0]?.method).toBe('terminal.subscribe') + bridge.client.streams[0]?.emit({ chunk: 'a' }) + bridge.client.streams[0]?.emit({ chunk: 'b' }) + expect(bridge.frames()).toEqual([ + { v: 1, type: 'event', id: ID, seq: 1, payload: { chunk: 'a' } }, + { v: 1, type: 'event', id: ID, seq: 2, payload: { chunk: 'b' } } + ]) + }) + + it('admits exactly the subscription cap and refuses the next', () => { + const bridge = harness() + for (let index = 0; index < BRIDGE_MAX_SUBSCRIPTIONS; index += 1) { + bridge.host.receive(subscribeFrame(bridgeId(index))) + } + expect(bridge.client.streams).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS) + expect(bridge.posted).toHaveLength(0) + bridge.host.receive(subscribeFrame(bridgeId(BRIDGE_MAX_SUBSCRIPTIONS))) + expect(bridge.client.streams).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS) + expect(bridge.last().type).toBe('error') + }) + + it('reopens a slot when a stream is cancelled', () => { + const bridge = harness() + for (let index = 0; index < BRIDGE_MAX_SUBSCRIPTIONS; index += 1) { + bridge.host.receive(subscribeFrame(bridgeId(index))) + } + bridge.host.receive(clientFrame({ type: 'cancel', id: bridgeId(0), target: 'subscription' })) + bridge.host.receive(subscribeFrame(bridgeId(BRIDGE_MAX_SUBSCRIPTIONS))) + expect(bridge.client.streams).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS + 1) + }) + + it('unsubscribes on cancel, says so, and delivers nothing after', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + bridge.client.streams[0]?.emit({ chunk: 'a' }) + bridge.host.receive(clientFrame({ type: 'cancel', id: ID, target: 'subscription' })) + expect(bridge.client.streams[0]?.unsubscribes).toBe(1) + expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'unsubscribed' }) + bridge.client.streams[0]?.emit({ chunk: 'b' }) + expect(bridge.frames().filter((frame) => frame.type === 'event')).toHaveLength(1) + }) + + it('answers a client whose subscribe throws and holds no slot', () => { + const client = createFakeRpcClient() + const bridge = harness({ + client: { + ...client, + subscribe: () => { + throw new Error('no socket') + } + } + }) + bridge.host.receive(subscribeFrame(ID)) + expect(bridge.last().type).toBe('error') + bridge.host.receive(subscribeFrame(ID)) + expect(bridge.frames()).toHaveLength(2) + }) + + it('unsubscribes a stream that overflowed inside subscribe, exactly once', () => { + const client = createFakeRpcClient() + let unsubscribes = 0 + const bridge = harness({ + client: { + ...client, + subscribe: (_method, _params, onData) => { + onData('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)) + return () => { + unsubscribes += 1 + } + } + } + }) + bridge.host.receive(subscribeFrame(ID)) + expect(bridge.frames()).toEqual([{ v: 1, type: 'end', id: ID, reason: 'overflow' }]) + // The stream was already retired when its unsubscribe arrived, so storing it on the record + // would leak the client's stream with nothing left to read it. + expect(unsubscribes).toBe(1) + }) +}) + +describe('backpressure', () => { + function fill(bridge: Harness, frames: number): void { + for (let index = 0; index < frames; index += 1) { + bridge.client.streams[0]?.emit({ n: index }) + } + } + + it('sends exactly the unacked frame window and then ends with overflow', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + fill(bridge, BRIDGE_MAX_UNACKED_FRAMES) + expect(bridge.frames().filter((frame) => frame.type === 'event')).toHaveLength( + BRIDGE_MAX_UNACKED_FRAMES + ) + fill(bridge, 1) + expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' }) + expect(bridge.client.streams[0]?.unsubscribes).toBe(1) + }) + + it('reopens the window on ack', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + fill(bridge, BRIDGE_MAX_UNACKED_FRAMES) + bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: BRIDGE_MAX_UNACKED_FRAMES })) + fill(bridge, 1) + const events = bridge.frames().filter((frame) => frame.type === 'event') + expect(events).toHaveLength(BRIDGE_MAX_UNACKED_FRAMES + 1) + expect(events.at(-1)).toMatchObject({ seq: BRIDGE_MAX_UNACKED_FRAMES + 1 }) + }) + + it('acks only up to the seq it was given', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + fill(bridge, BRIDGE_MAX_UNACKED_FRAMES) + bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: 1 })) + fill(bridge, 1) + expect(bridge.frames().filter((frame) => frame.type === 'event')).toHaveLength( + BRIDGE_MAX_UNACKED_FRAMES + 1 + ) + fill(bridge, 1) + expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' }) + }) + + it('ends on the unacked byte window well before the frame window is reached', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + const chunk = 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES - 1024) + const ended = (): boolean => (bridge.posted.at(-1) ?? '').includes('"type":"end"') + for (let index = 0; index < BRIDGE_MAX_UNACKED_FRAMES && !ended(); index += 1) { + bridge.client.streams[0]?.emit(chunk) + } + const events = bridge.posted.length - 1 + expect(events).toBeLessThan(BRIDGE_MAX_UNACKED_FRAMES) + const eventBytes = bridge.posted + .slice(0, events) + .reduce((total, json) => total + json.length, 0) + // Brackets the window: everything sent fits under it, and one more frame would not have. + expect(eventBytes).toBeLessThanOrEqual(BRIDGE_MAX_UNACKED_BYTES) + expect(eventBytes + chunk.length).toBeGreaterThan(BRIDGE_MAX_UNACKED_BYTES) + expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' }) + }) + + it('reopens the byte window on ack, not just the frame window', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + const chunk = 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES - 1024) + // What fits under the byte window, which leaves the next frame of this size to overflow it. + const fits = Math.floor(BRIDGE_MAX_UNACKED_BYTES / (chunk.length + 128)) + const events = (): BridgeHostMessage[] => bridge.frames().filter((f) => f.type === 'event') + const emit = (times: number): void => { + for (let index = 0; index < times; index += 1) { + bridge.client.streams[0]?.emit(chunk) + } + } + emit(fits) + expect(events()).toHaveLength(fits) + bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: fits })) + emit(fits) + // The frame window is nowhere near full, so releasing the acked bytes is the only thing that + // can let the second batch through. + expect(fits * 2).toBeLessThan(BRIDGE_MAX_UNACKED_FRAMES) + expect(events()).toHaveLength(fits * 2) + expect(bridge.frames().some((frame) => frame.type === 'end')).toBe(false) + }) + + it('ends rather than posting an event the page would refuse as oversized', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + bridge.client.streams[0]?.emit('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)) + expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' }) + }) + + it('keeps each stream on its own window', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + bridge.host.receive(subscribeFrame(OTHER)) + for (let index = 0; index <= BRIDGE_MAX_UNACKED_FRAMES; index += 1) { + bridge.client.streams[0]?.emit({ n: index }) + } + bridge.client.streams[1]?.emit({ n: 0 }) + expect(bridge.last()).toEqual({ v: 1, type: 'event', id: OTHER, seq: 1, payload: { n: 0 } }) + }) +}) + +describe('teardown', () => { + it('rejects every pending as delivery-unknown, ends every stream, and refuses later frames', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + bridge.host.receive(subscribeFrame(OTHER)) + bridge.host.dispose() + expect(bridge.frames()).toEqual([ + { + v: 1, + type: 'error', + id: ID, + error: { + category: 'BridgeHostDisposedError', + message: 'the page bridge was torn down before this request answered', + isRpcDeliveryUnknown: true + } + }, + { v: 1, type: 'end', id: OTHER, reason: 'closed' } + ]) + expect(bridge.client.streams[0]?.unsubscribes).toBe(1) + bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok')) + bridge.client.streams[0]?.emit({ chunk: 'a' }) + // Nothing reaches the client either: a page that outlived its host is a page the fence is for. + bridge.host.receive(clientFrame({ type: 'request', id: bridgeId(9), method: 'status.get' })) + bridge.host.receive(subscribeFrame(bridgeId(10))) + bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' })) + bridge.host.receive(clientFrame({ type: 'ready' })) + await flushBridge() + expect(bridge.frames()).toHaveLength(2) + expect(bridge.client.requests).toHaveLength(1) + expect(bridge.client.streams).toHaveLength(1) + expect(bridge.client.foregroundCalls).toEqual([]) + // A view still posting into a disposed host is a leak, and the diagnostic is how it is found. + expect(bridge.diagnostics).toEqual( + Array.from({ length: 4 }, () => ({ kind: 'frame-after-dispose' })) + ) + }) + + it('is idempotent', () => { + const bridge = harness() + bridge.host.receive(subscribeFrame(ID)) + bridge.host.dispose() + bridge.host.dispose() + expect(bridge.frames()).toHaveLength(1) + expect(bridge.client.streams[0]?.unsubscribes).toBe(1) + }) + + it('settles what the page owned on close without answering a page that said goodbye', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + bridge.host.receive(subscribeFrame(OTHER)) + bridge.host.receive(clientFrame({ type: 'close' })) + expect(bridge.posted).toHaveLength(0) + expect(bridge.client.streams[0]?.unsubscribes).toBe(1) + bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok')) + bridge.client.streams[0]?.emit({ chunk: 'a' }) + await flushBridge() + expect(bridge.posted).toHaveLength(0) + }) + + it('answers the document that loads in after a close, rather than latching shut', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'close' })) + // The next page shares this host, and a host that had shut itself would leave its `ready` + // retrying forever with nothing posted and nothing logged. + bridge.host.receive(clientFrame({ type: 'ready' })) + expect(bridge.last().type).toBe('init') + expect(bridge.client.stateListeners()).toBe(1) + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + expect(bridge.client.requests).toHaveLength(1) + // Full service, not just an answered `ready`: the state fan-out reaches this document too. + bridge.client.pushState('reconnecting') + expect(bridge.last()).toMatchObject({ type: 'state', connection: { state: 'reconnecting' } }) + expect(bridge.diagnostics).toEqual([]) + }) + + it('forwards no straggler from the document that said goodbye', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'close' })) + // Frames the closed document posted before it went away. Forwarding one now would answer it + // into whichever document loads in next. + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + bridge.host.receive(subscribeFrame(OTHER)) + bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' })) + expect(bridge.client.requests).toHaveLength(0) + expect(bridge.client.streams).toHaveLength(0) + expect(bridge.client.foregroundCalls).toEqual([]) + expect(bridge.posted).toHaveLength(0) + expect(bridge.diagnostics).toEqual( + Array.from({ length: 3 }, () => ({ kind: 'frame-after-close' })) + ) + }) + + it('posts nothing into a view that belongs to no document yet', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'close' })) + // The client keeps running between documents, and this listener is still attached: a `state` + // posted now arrives in the replacement document before its own `init`. + bridge.client.pushState('reconnecting') + bridge.client.pushState('connected') + expect(bridge.posted).toHaveLength(0) + expect(bridge.diagnostics).toEqual([]) + }) +}) + +describe('notifications, refusals and the fence', () => { + it('forwards foreground with the arity the page used, and the viewport whole', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' })) + bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground', reason: 'app-resume' })) + bridge.host.receive( + clientFrame({ type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 80, rows: 24 }) + ) + expect(bridge.client.foregroundCalls).toEqual([[], ['app-resume']]) + expect(bridge.client.viewports).toEqual([{ terminal: 't1', cols: 80, rows: 24 }]) + }) + + it('reports a refused frame and forwards nothing from it', () => { + const bridge = harness() + bridge.host.receive('{"v":1,"type":') + bridge.host.receive(clientFrame({ type: 'request', id: 'short', method: 'x' })) + expect(bridge.diagnostics).toEqual([ + { kind: 'refused', refusal: 'malformed-json' }, + { kind: 'refused', refusal: 'unrecognised-message' } + ]) + expect(bridge.client.requests).toHaveLength(0) + }) + + it('reports a client that throws on a notify once per session, and keeps reading', () => { + const client = createFakeRpcClient() + const failure = new Error('no client') + const bridge = harness({ + client: { + ...client, + notifyForeground: () => { + throw failure + }, + updateTerminalSubscriptionViewport: () => { + throw failure + } + } + }) + // The page's frame arrives on a native event handler, and a throw that escapes this arm takes + // that handler down with it. + bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' })) + bridge.host.receive( + clientFrame({ type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 80, rows: 24 }) + ) + expect(bridge.diagnostics).toEqual([{ kind: 'notify-failed', error: failure }]) + bridge.host.receive(clientFrame({ type: 'ready' })) + expect(bridge.last().type).toBe('init') + }) + + it('reports a post that throws instead of rejecting, and does not take the sender down', () => { + const failure = new Error('the bridge module is gone') + const client = createFakeRpcClient() + const bridge = harness({ + client, + post: () => { + throw failure + } + }) + // The `state` frame is sent from inside the client's own fan-out, so a throw here would reach + // every other listener that client has. + expect(() => client.pushState('reconnecting')).not.toThrow() + expect(bridge.diagnostics).toEqual([{ kind: 'post-failed', error: failure }]) + }) + + it('reports a failing post once per session', async () => { + const failure = new Error('nowhere to post') + const bridge = harness({ post: () => Promise.reject(failure) }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(clientFrame({ type: 'ready' })) + await flushBridge() + expect(bridge.diagnostics).toEqual([{ kind: 'post-failed', error: failure }]) + expect(bridge.posted).toHaveLength(2) + }) + + it('forwards to the client it was built with, whatever the frame names', () => { + const mine = createFakeRpcClient() + const theirs = createFakeRpcClient() + const bridge = harness({ client: mine }) + harness({ client: theirs }) + bridge.host.receive( + clientFrame({ type: 'request', id: ID, method: 'status.get', hostId: 'other-host' }) + ) + expect(mine.requests.map((request) => request.method)).toEqual(['status.get']) + expect(theirs.requests).toHaveLength(0) + }) + + it('carries no host name into the client message it parsed', () => { + const bridge = harness() + bridge.host.receive( + clientFrame({ type: 'request', id: ID, method: 'status.get', hostId: 'other-host' }) + ) + expect(bridge.client.requests[0]?.args).toEqual(['status.get']) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts new file mode 100644 index 00000000000..14410dcdfb3 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-host.ts @@ -0,0 +1,385 @@ +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { BridgeHostSubscriptions } from './bridge-host-subscriptions' +import { + BRIDGE_MAX_PENDING_REQUESTS, + BRIDGE_MAX_SUBSCRIPTIONS, + type BridgeRefusal +} from './bridge/bridge-caps' +import { + BRIDGE_PROTOCOL_VERSION, + readBridgeClientMessage, + type BridgeClientMessage, + type BridgeConnectionSnapshot, + type BridgeHostMessage +} from './bridge/bridge-envelope' +import { captureBridgeError } from './bridge/bridge-error-capture' +import { splitBridgeReply } from './bridge/bridge-reply-chunking' + +type RequestMessage = Extract +type SubscribeMessage = Extract +type NotifyMessage = Extract + +/** Live until something settles it; the flag is what keeps a cancelled request's late answer from + * being posted under an id the page has moved on from. */ +type PendingRequest = { live: boolean } + +/** Nothing here is recoverable in place; each is worth a line in a log and none of them is retried. */ +export type BridgeHostDiagnostic = + | { kind: 'refused'; refusal: BridgeRefusal } + | { kind: 'post-failed'; error: unknown } + /** A page posting into a host that has already been disposed, which its own view is the only + * thing that can do. Dropping it silently is what hides a leaked view. */ + | { kind: 'frame-after-dispose' } + /** A client that threw where the bridge only forwards. Nothing is owed to the page for a notify, + * so the throw is reported rather than answered. */ + | { kind: 'notify-failed'; error: unknown } + /** A frame that arrived between a page's `close` and the next document's `ready`. It belongs to + * the closed document, and serving it would answer into whatever loads in next. */ + | { kind: 'frame-after-close' } + +export type BridgeHostOptions = { + client: RpcClient + /** + * Rejects when there is nowhere to post. Resolving proves the message was handed over, never that + * the page received it, so nothing here treats a resolve as an acknowledgement. + */ + post: (json: string) => Promise + buildId: string + sessionId: string + onDiagnostic?: (diagnostic: BridgeHostDiagnostic) => void +} + +export type BridgeHost = { + receive: (json: string) => void + dispose: () => void +} + +class BridgeHostDisposedError extends Error { + constructor() { + super('the page bridge was torn down before this request answered') + this.name = 'BridgeHostDisposedError' + } +} + +class BridgeCapExceededError extends Error { + constructor(message: string) { + super(message) + this.name = 'BridgeCapExceededError' + } +} + +class BridgeReplyUndeliverableError extends Error { + constructor(refusal: BridgeRefusal) { + super(`the reply could not be delivered to the page (${refusal})`) + this.name = 'BridgeReplyUndeliverableError' + } +} + +/** + * One page document's end of the bridge: page frames in, host frames out, one RPC client behind it. + * + * The fence is structural rather than checked. The protocol names no host, so a page cannot ask for + * one: the client is whichever this host was built with, and a page that outlives its session has + * its frames refused at the native origin check before this module ever sees them. The caps the + * page is told about in `init` are enforced here and not trusted from there. + */ +export function createBridgeHost(options: BridgeHostOptions): BridgeHost { + const { client, buildId, sessionId } = options + const pending = new Map() + let closed = false + // Requests the client is still running. `pending` is the page's view and empties on a cancel or a + // `close`, but `sendRequest` has no cancel: the call keeps its slot on the wire until it settles, + // and a page that closed between batches would otherwise be handed the cap over again. + let inFlight = 0 + // One document's turn at the bridge. `close` ends it and the next `ready` begins the next one; + // between the two the view belongs to no document, so nothing is served and nothing is posted. + let serving = true + let postFailureReported = false + let notifyFailureReported = false + + // Once per session: a page that cannot be posted to fails every frame after the first, and a + // line per frame buries the one that says why. + function reportPostFailure(error: unknown): void { + if (postFailureReported) { + return + } + postFailureReported = true + options.onDiagnostic?.({ kind: 'post-failed', error }) + } + + function sendJson(json: string): void { + // Defensive: teardown already settles everything that could post; this fences callers added later. + if (closed) { + return + } + // Between documents the view still exists and still accepts posts, which is exactly why this is + // checked: a `state` frame sent now lands in the next document before it has said `ready`. + if (!serving) { + return + } + // A `post` that throws where it should reject would escape into the client's own state-change + // fan-out, which is what sends the `state` frame, and take the other listeners down with it. + try { + void options.post(json).catch(reportPostFailure) + } catch (error) { + reportPostFailure(error) + } + } + + // Every value in a host frame has already been serialized by whoever produced it — a reply by + // `splitBridgeReply`, an error `code` by the capture's round trip — so this cannot throw. + function send(frame: BridgeHostMessage): void { + sendJson(JSON.stringify(frame)) + } + + function sendError(id: string, error: unknown): void { + send({ v: BRIDGE_PROTOCOL_VERSION, type: 'error', id, error: captureBridgeError(error) }) + } + + const subscriptions = new BridgeHostSubscriptions({ client, post: sendJson }) + + /** `state` is the event's own value: a listener can run before the getter it mirrors is updated. */ + function snapshot(state?: ConnectionState): BridgeConnectionSnapshot { + return { + state: state ?? client.getState(), + reconnectAttempt: client.getReconnectAttempt(), + lastConnectedAt: client.getLastConnectedAt(), + lastInboundAt: client.getLastInboundAt?.() ?? null, + generation: client.getGeneration?.() ?? null + } + } + + // Answered every time it is asked: a page that saw a `state` older than the one it holds recovers + // by asking again rather than by living with a cache it knows is wrong. + function sendInit(): void { + send({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'init', + sessionId, + buildId, + connection: snapshot(), + grants: { + rpc: { + maxPendingRequests: BRIDGE_MAX_PENDING_REQUESTS, + maxSubscriptions: BRIDGE_MAX_SUBSCRIPTIONS + }, + // Every native capability is out of C0. A name added here is never a version bump. + native: [] + } + }) + } + + function settle(id: string, record: PendingRequest): boolean { + if (!record.live) { + return false + } + record.live = false + pending.delete(id) + return true + } + + /** The arity the page used, replayed exactly: `sendRequest(m)` and `sendRequest(m, undefined)` + * are different calls to the golden recorder. */ + function forwardRequest(message: RequestMessage): Promise { + if (message.options !== undefined) { + return client.sendRequest(message.method, message.params, message.options) + } + return 'params' in message + ? client.sendRequest(message.method, message.params) + : client.sendRequest(message.method) + } + + function sendReply(id: string, payload: RpcResponse): void { + const split = splitBridgeReply(id, payload) + if (!split.ok) { + sendError(id, new BridgeReplyUndeliverableError(split.refusal)) + return + } + for (const frame of split.frames) { + send(frame) + } + } + + /** An id already in flight is a page bug; refusing the newcomer leaves the exchange it collided + * with intact, which settling it would not. */ + function idInFlight(id: string): boolean { + return pending.has(id) || subscriptions.has(id) + } + + function handleRequest(message: RequestMessage): void { + const { id } = message + if (idInFlight(id)) { + sendError(id, new BridgeCapExceededError('that id is already in flight')) + return + } + if (inFlight >= BRIDGE_MAX_PENDING_REQUESTS) { + sendError(id, new BridgeCapExceededError(`over ${BRIDGE_MAX_PENDING_REQUESTS} requests`)) + return + } + const record: PendingRequest = { live: true } + pending.set(id, record) + let answer: Promise + try { + answer = forwardRequest(message) + } catch (error) { + settle(id, record) + sendError(id, error) + return + } + inFlight += 1 + void answer.then( + (payload) => { + inFlight -= 1 + if (settle(id, record)) { + sendReply(id, payload) + } + }, + (error: unknown) => { + inFlight -= 1 + if (settle(id, record)) { + sendError(id, error) + } + } + ) + } + + // `wantsBinary` is read by the contract and acted on in C6, which owns the screencast encoder and + // the measurement that earns it. Until then every stream crosses as JSON. + function handleSubscribe(message: SubscribeMessage): void { + const { id } = message + if (idInFlight(id)) { + sendError(id, new BridgeCapExceededError('that id is already in flight')) + return + } + if (subscriptions.size >= BRIDGE_MAX_SUBSCRIPTIONS) { + sendError(id, new BridgeCapExceededError(`over ${BRIDGE_MAX_SUBSCRIPTIONS} subscriptions`)) + return + } + try { + subscriptions.start(id, message.method, message.params) + } catch (error) { + sendError(id, error) + } + } + + /** The client's own work runs inside these calls, and a throw from one would otherwise escape into + * the native event handler that delivered the page's frame. Nothing is owed to the page here. */ + function forwardNotify(message: NotifyMessage): void { + try { + if (message.name === 'foreground') { + if (message.reason === undefined) { + client.notifyForeground() + } else { + client.notifyForeground(message.reason) + } + return + } + client.updateTerminalSubscriptionViewport(message.terminal, { + cols: message.cols, + rows: message.rows + }) + } catch (error) { + // Once per session, for the reason a failing post is: a page nudging a broken client nudges it + // again on every foreground. + if (notifyFailureReported) { + return + } + notifyFailureReported = true + options.onDiagnostic?.({ kind: 'notify-failed', error }) + } + } + + /** Cancels everything the page had open. `notify` is false for the page's own `close`, which has + * already settled what it owned. */ + function settleAll(notify: boolean): void { + for (const [id, record] of pending) { + record.live = false + // In flight when the door shut: the desktop may already have run it, and a page told this was + // a definite send failure would offer to retry something that already happened. + if (notify) { + sendError(id, markRpcDeliveryUnknown(new BridgeHostDisposedError())) + } + } + pending.clear() + subscriptions.closeAll(notify ? 'closed' : null) + } + + function dispose(): void { + if (closed) { + return + } + settleAll(true) + closed = true + unsubscribeState() + } + + function dispatch(message: BridgeClientMessage): void { + // `ready` is what claims the view, whether it is the first document's or a replacement's; a + // re-asked `ready` from the document already being served is answered the same way. + if (message.type === 'ready') { + serving = true + sendInit() + return + } + if (!serving) { + options.onDiagnostic?.({ kind: 'frame-after-close' }) + return + } + switch (message.type) { + case 'request': + handleRequest(message) + return + case 'subscribe': + handleSubscribe(message) + return + case 'cancel': { + if (message.target === 'subscription') { + subscriptions.cancel(message.id, 'unsubscribed') + return + } + // `sendRequest` has no cancel: the desktop still runs it, and this only stops the host from + // posting an answer under an id the page has stopped waiting on. + const record = pending.get(message.id) + if (record !== undefined) { + settle(message.id, record) + } + return + } + case 'ack': + subscriptions.ack(message.id, message.seq) + return + case 'notify': + forwardNotify(message) + return + case 'close': + // Not a latch. The document that loads next into this same view says `ready` over this same + // host, and a host that had shut itself would leave that `ready` retrying forever. + settleAll(false) + serving = false + return + } + } + + const unsubscribeState = client.onStateChange((state) => { + send({ v: BRIDGE_PROTOCOL_VERSION, type: 'state', connection: snapshot(state) }) + }) + + return { + receive(json: string): void { + if (closed) { + // Only a disposed host reaches this, and it can neither answer the frame nor refuse it. + options.onDiagnostic?.({ kind: 'frame-after-dispose' }) + return + } + const read = readBridgeClientMessage(json) + if (!read.ok) { + options.onDiagnostic?.({ kind: 'refused', refusal: read.refusal }) + return + } + dispatch(read.message) + }, + dispose + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-caps.ts b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts index 652a5154d42..d8ddedc4539 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-caps.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts @@ -35,6 +35,17 @@ export const BRIDGE_MAX_METHOD_CHARS = 64 export const BRIDGE_MAX_PENDING_REQUESTS = 64 export const BRIDGE_MAX_SUBSCRIPTIONS = 32 +/** + * Viewport bounds, held to the desktop's `TerminalViewport` by the envelope's test. + * + * A viewport the page sends is written into the cached subscribe params of every stream naming that + * terminal, the native terminal screens' included, and the desktop refuses an out-of-range one when + * those streams resubscribe. Refusing it at the frame is what keeps a bad page's reach inside its + * own document. + */ +export const BRIDGE_MAX_VIEWPORT_COLS = 1000 +export const BRIDGE_MAX_VIEWPORT_ROWS = 500 + /** * A reply above this aborts its request rather than being chunked further. The frame cap is a * transport bound; this is the policy. The native screens have no reply byte cap at all, so a diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts index 3c6b1ee13bf..ca062513931 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts @@ -6,10 +6,14 @@ import { } from '../../transport/browser-screencast-protocol' import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from '../../transport/types' import type { SendRequestOptions } from '../../transport/unvalidated-rpc-request-port' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import { BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_MAX_METHOD_CHARS, - BRIDGE_MAX_REPLY_PARTS + BRIDGE_MAX_REPLY_PARTS, + BRIDGE_MAX_VIEWPORT_COLS, + BRIDGE_MAX_VIEWPORT_ROWS } from './bridge-caps' import { BRIDGE_BINARY_FORMATS, @@ -99,6 +103,16 @@ describe('client messages', () => { 'terminal viewport notify', { type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 80, rows: 24 } ], + [ + 'a terminal viewport notify of exactly the bounds', + { + type: 'notify', + name: 'terminalViewport', + terminal: 't1', + cols: BRIDGE_MAX_VIEWPORT_COLS, + rows: BRIDGE_MAX_VIEWPORT_ROWS + } + ], ['close', { type: 'close' }] ] as const @@ -127,6 +141,26 @@ describe('client messages', () => { 'a viewport of zero columns', client({ type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 0, rows: 24 }) ], + [ + 'a viewport one column over the bound', + client({ + type: 'notify', + name: 'terminalViewport', + terminal: 't1', + cols: BRIDGE_MAX_VIEWPORT_COLS + 1, + rows: 24 + }) + ], + [ + 'a viewport one row over the bound', + client({ + type: 'notify', + name: 'terminalViewport', + terminal: 't1', + cols: 80, + rows: BRIDGE_MAX_VIEWPORT_ROWS + 1 + }) + ], ['a bare array', []], ['a bare string', 'ready'] ] as const @@ -355,6 +389,26 @@ describe('type pins', () => { expect(readClient(client({ type: 'request', id: ID, method: 'm', options })).ok).toBe(true) }) + it('bounds the viewport exactly where the desktop terminal contract does', () => { + // A viewport the page sends is replayed on resubscribe by every stream naming that terminal, + // the native screens' included. One the desktop refuses there would kill a stream the page + // never opened, so the two bounds have to be the same number. + // + // Read rather than imported: mobile may not pull a contract *value* into its bundle, and the + // boundary test that enforces that scans this file too. + const contract = readFileSync( + fileURLToPath( + new URL('../../../../src/shared/rpc-contract/terminal-unary-params.ts', import.meta.url) + ), + 'utf8' + ) + const start = contract.indexOf('export const TerminalViewport') + expect(start).toBeGreaterThan(-1) + const declaration = contract.slice(start, contract.indexOf('})', start)) + expect(declaration).toContain(`cols: z.number().int().min(1).max(${BRIDGE_MAX_VIEWPORT_COLS})`) + expect(declaration).toContain(`rows: z.number().int().min(1).max(${BRIDGE_MAX_VIEWPORT_ROWS})`) + }) + it('closes the binary formats over the screencast protocol', () => { const asProtocol = (value: (typeof BRIDGE_BINARY_FORMATS)[number]): BrowserScreencastFormat => value diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts index d3d034f4d9c..453c5e6bfd9 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts @@ -3,6 +3,8 @@ import { BridgeErrorCaptureSchema } from './bridge-error-capture' import { BRIDGE_MAX_METHOD_CHARS, BRIDGE_MAX_REPLY_PARTS, + BRIDGE_MAX_VIEWPORT_COLS, + BRIDGE_MAX_VIEWPORT_ROWS, parseBridgeMessage, type BridgeDirection, type BridgeRead @@ -183,8 +185,8 @@ const BridgeClientMessageSchema = z.discriminatedUnion('type', [ type: z.literal('notify'), name: z.literal('terminalViewport'), terminal: z.string().min(1), - cols: z.number().int().positive(), - rows: z.number().int().positive() + cols: z.number().int().min(1).max(BRIDGE_MAX_VIEWPORT_COLS), + rows: z.number().int().min(1).max(BRIDGE_MAX_VIEWPORT_ROWS) }) ]), z.object({ v: versionSchema, type: z.literal('close') }) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts new file mode 100644 index 00000000000..7a579d90cf5 --- /dev/null +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts @@ -0,0 +1,311 @@ +import { createElement, useImperativeHandle, useLayoutEffect, type ReactElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import type { OrcaMobileWebShellViewHandle } from '../../modules/orca-mobile-web-shell/src' +import { readBridgeHostMessage, type BridgeHostMessage } from './bridge/bridge-envelope' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' +import type { FakeRpcClient } from './bridge-host-test-fakes' + +const doubles = vi.hoisted((): { client: FakeRpcClient | null } => ({ client: null })) + +// Reaching the real one imports the Expo runtime this test does not have; the hook reads one field. +vi.mock('../transport/client-context', () => ({ + useHostClient: () => ({ client: doubles.client }) +})) + +import { + bridgeId, + clientFrame, + createFakeRpcClient, + flushBridge, + rpcSuccess +} from './bridge-host-test-fakes' +import { + useMobileWebShellBridge, + type MobileWebShellBridgeView +} from './use-mobile-web-shell-bridge' + +const ID = bridgeId(1) +const DIRECTORY = '/caches/mobile-web/deadbeef/generations/a1b2' + +/** Each post is stamped with the mount that carried it, which is the only way to see a retiring + * host's teardown land in the page that replaced it. */ +type PostedFrame = { sessionId: string; json: string } + +type Probe = { view: MobileWebShellBridgeView | null } + +function fakeClient(): FakeRpcClient { + const client = doubles.client + if (client === null) { + throw new Error('this test has no client') + } + return client +} + +function FakeShellView(props: { + sessionId: string + viewRef: (handle: OrcaMobileWebShellViewHandle | null) => void + posted: PostedFrame[] +}): null { + useImperativeHandle( + props.viewRef, + () => ({ + postBridgeMessage: (json: string) => { + props.posted.push({ sessionId: props.sessionId, json }) + return Promise.resolve() + } + }), + [props.posted, props.sessionId] + ) + return null +} + +/** + * Delivers a frame from a layout effect of the hook's *parent*, which React runs after the hook's + * own commit work and before any passive effect. That is where a native message lands while React + * still has passive work queued, and it is the only window this suite can address. + */ +function DeliverDuringCommit(props: { + deliver: string | null + posted: PostedFrame[] + probe: Probe +}): ReactElement { + const { deliver, probe } = props + useLayoutEffect(() => { + if (deliver !== null) { + probe.view?.onBridgeMessage({ nativeEvent: { json: deliver } }) + } + }, [deliver, probe]) + return createElement(Harness, { + session: readyState('session-one'), + posted: props.posted, + probe + }) +} + +function Harness(props: { + session: MobileWebShellSessionState + posted: PostedFrame[] + probe: Probe +}): ReactElement | null { + const view = useMobileWebShellBridge({ hostId: 'host-1', session: props.session }) + props.probe.view = view + return props.session.kind === 'ready' + ? createElement(FakeShellView, { + key: props.session.sessionId, + sessionId: props.session.sessionId, + viewRef: view.viewRef, + posted: props.posted + }) + : null +} + +function readyState(sessionId: string): MobileWebShellSessionState { + return { + kind: 'ready', + generationDirectory: DIRECTORY, + sessionId, + buildId: 'build-a', + totalBytes: 4096, + elapsedMs: 11 + } +} + +type Mounted = { + tree: ReactTestRenderer + posted: PostedFrame[] + probe: Probe + update: (session: MobileWebShellSessionState) => Promise + deliver: (json: string) => Promise + frames: (sessionId: string) => BridgeHostMessage[] +} + +let warned: MockInstance + +async function mount(session: MobileWebShellSessionState): Promise { + const posted: PostedFrame[] = [] + const probe: Probe = { view: null } + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + const render = (next: MobileWebShellSessionState): ReactElement => + createElement(Harness, { session: next, posted, probe }) + await act(async () => { + rendered.tree = create(render(session)) + }) + const tree = rendered.tree + if (tree === null) { + throw new Error('the harness did not render') + } + return { + tree, + posted, + probe, + update: async (next) => { + await act(async () => { + tree.update(render(next)) + }) + }, + deliver: async (json) => { + await act(async () => { + probe.view?.onBridgeMessage({ nativeEvent: { json } }) + }) + }, + // Read back through the page's own reader: a frame the page would refuse never arrives. + frames: (sessionId) => + posted + .filter((frame) => frame.sessionId === sessionId) + .map((frame) => { + const read = readBridgeHostMessage(frame.json) + if (!read.ok) { + throw new Error(`the page would refuse this frame: ${read.refusal}`) + } + return read.message + }) + } +} + +beforeEach(() => { + doubles.client = createFakeRpcClient() + warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // `spyOn` on an already-spied method hands back the same mock, calls and all. + warned.mockClear() +}) + +describe('the bridge channel', () => { + it('is closed until the session is ready and opens with it', async () => { + const mounted = await mount({ kind: 'checking' }) + expect(mounted.probe.view?.bridgeEnabled).toBe(false) + await mounted.update(readyState('session-one')) + expect(mounted.probe.view?.bridgeEnabled).toBe(true) + }) + + it('answers the page through the handle of the session it belongs to', async () => { + const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'ready' })) + expect(mounted.frames('session-one')).toEqual([ + expect.objectContaining({ type: 'init', sessionId: 'session-one', buildId: 'build-a' }) + ]) + }) + + it('forwards to the client the hook was given', async () => { + const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + expect(fakeClient().requests.map((request) => request.method)).toEqual(['status.get']) + }) + + it('builds no host while the ready session has no client, and answers nothing', async () => { + doubles.client = null + const mounted = await mount(readyState('session-one')) + expect(mounted.probe.view?.bridgeEnabled).toBe(true) + await mounted.deliver(clientFrame({ type: 'ready' })) + expect(mounted.posted).toEqual([]) + }) +}) + +describe('teardown', () => { + it('posts a retiring session nothing into the page that replaced it', async () => { + const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + await mounted.update(readyState('session-two')) + expect(mounted.frames('session-two')).toEqual([]) + // The retiring host still tried, and the rejection is what said the view was gone. + expect(warned).toHaveBeenCalledTimes(1) + }) + + it(`routes the next session's frames to the next host`, async () => { + const mounted = await mount(readyState('session-one')) + await mounted.update(readyState('session-two')) + await mounted.deliver(clientFrame({ type: 'ready' })) + expect(mounted.frames('session-two')).toEqual([ + expect.objectContaining({ type: 'init', sessionId: 'session-two' }) + ]) + }) + + it('disposes when the session leaves ready, and answers nothing after', async () => { + const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'subscribe', id: ID, method: 'x.sub', params: {} })) + await mounted.update({ kind: 'failed', reason: 'render-process-gone', retriedOnce: false }) + expect(fakeClient().streams[0]?.unsubscribes).toBe(1) + await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + await mounted.deliver(clientFrame({ type: 'ready' })) + expect(mounted.frames('session-one')).toEqual([]) + expect(fakeClient().requests).toEqual([]) + }) + + it('disposes on unmount and settles what was in flight as delivery-unknown', async () => { + const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + await act(async () => { + mounted.tree.unmount() + }) + // The commit tears the host down while its own view is still attached, so the page hears why + // its request will never answer instead of being left holding it. + expect(mounted.frames('session-one')).toEqual([ + expect.objectContaining({ type: 'error', id: ID }) + ]) + expect(warned).not.toHaveBeenCalled() + fakeClient().requests[0]?.resolve(rpcSuccess('wire-1', 'ok')) + await flushBridge() + expect(mounted.posted).toHaveLength(1) + }) + + it('ignores a frame that arrives for a session the hook has moved past', async () => { + const mounted = await mount(readyState('session-one')) + const stale = mounted.probe.view + await mounted.update(readyState('session-two')) + await act(async () => { + stale?.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } }) + }) + expect(mounted.posted).toEqual([]) + }) +}) + +describe('diagnostics', () => { + it('warns once for the frames one page has refused, not once each', async () => { + const mounted = await mount(readyState('session-one')) + await mounted.deliver('{"v":1,"type":') + await mounted.deliver(clientFrame({ type: 'request', id: 'short', method: 'x' })) + expect(warned).toHaveBeenCalledTimes(1) + }) + + it('starts the count over for the next page', async () => { + const mounted = await mount(readyState('session-one')) + await mounted.deliver('{"v":1,"type":') + await mounted.update(readyState('session-two')) + await mounted.deliver('{"v":1,"type":') + expect(warned).toHaveBeenCalledTimes(2) + }) +}) + +describe('client changes', () => { + it('rebuilds the host on a new client, so nothing crosses to the one that was replaced', async () => { + const first = fakeClient() + const mounted = await mount(readyState('session-one')) + const next = createFakeRpcClient() + doubles.client = next + await mounted.update(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + expect(next.requests).toHaveLength(1) + expect(first.requests).toHaveLength(0) + }) + + it('hands the host over in the commit, so no frame reaches the replaced client', async () => { + const first = fakeClient() + const posted: PostedFrame[] = [] + const probe: Probe = { view: null } + const render = (deliver: string | null): ReactElement => + createElement(DeliverDuringCommit, { deliver, posted, probe }) + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create(render(null)) + }) + const next = createFakeRpcClient() + doubles.client = next + // The session id does not change, so the handler's own fence does not apply: only handing the + // host over in the commit keeps this frame off the client that was replaced. + await act(async () => { + rendered.tree?.update(render(clientFrame({ type: 'request', id: ID, method: 'status.get' }))) + }) + expect(first.requests).toHaveLength(0) + expect(next.requests).toHaveLength(1) + }) +}) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts new file mode 100644 index 00000000000..3386e744132 --- /dev/null +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts @@ -0,0 +1,136 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import type { + MobileWebShellBridgeMessagePayload, + OrcaMobileWebShellViewHandle +} from '../../modules/orca-mobile-web-shell/src' +import { useHostClient } from '../transport/client-context' +import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from './bridge-host' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' + +class BridgeViewGoneError extends Error { + constructor() { + super('the shell view for this session is not mounted') + this.name = 'BridgeViewGoneError' + } +} + +/** + * One line per kind, for the life of one host. + * + * A page that is failing frames fails all of them, and a line each buries the first — the one that + * says why. The host already holds `post-failed` to one; this is the same bound for the kinds it + * does not, and a new host starts the count over because a new page is new evidence. + */ +function createBridgeDiagnosticReporter(): (diagnostic: BridgeHostDiagnostic) => void { + const reported = new Set() + return (diagnostic) => { + if (reported.has(diagnostic.kind)) { + return + } + reported.add(diagnostic.kind) + if (diagnostic.kind === 'refused') { + console.warn('[web-shell-bridge] refused a page frame', diagnostic.refusal) + return + } + if (diagnostic.kind === 'post-failed') { + console.warn('[web-shell-bridge] the page could not be posted to', diagnostic.error) + return + } + if (diagnostic.kind === 'notify-failed') { + console.warn('[web-shell-bridge] the client threw on a page notification', diagnostic.error) + return + } + console.warn('[web-shell-bridge] a view outlived its host and is still posting') + } +} + +/** + * Both halves are stamped with the session they belong to. + * + * React swaps refs in the commit phase and runs the retiring effect's cleanup after it, so a host + * disposing on a remount would otherwise post its teardown frames into the page that replaced it. + */ +type MountedView = { sessionId: string; handle: OrcaMobileWebShellViewHandle } +type MountedHost = { sessionId: string; host: BridgeHost } + +/** Exactly the field the handler reads. The view's own `NativeSyntheticEvent` prop type is + * assignable to this, and a handler declared this narrowly is one a test can call honestly. */ +export type MobileWebShellBridgeMessageEvent = { + readonly nativeEvent: MobileWebShellBridgeMessagePayload +} + +export type MobileWebShellBridgeView = { + /** + * Changing this prop re-enters the native load, so it is derived from the session step alone and + * is constant for the life of a mount. A ready session whose client has not arrived yet gets the + * channel and no host: there is no honest `init` to answer with, and `ready` is answered every + * time it is asked so the page can ask again. + */ + readonly bridgeEnabled: boolean + readonly viewRef: (handle: OrcaMobileWebShellViewHandle | null) => void + readonly onBridgeMessage: (event: MobileWebShellBridgeMessageEvent) => void +} + +/** + * Wires B4's session to one bridge host: the session the reducer put on screen owns the channel, + * and nothing here mints, retries or decides anything. + * + * The session id is B4's — a remount is a new one, which is what makes a dead page's frames fail + * the native origin check rather than reach a live client. + */ +export function useMobileWebShellBridge(args: { + hostId: string + session: MobileWebShellSessionState +}): MobileWebShellBridgeView { + const { client } = useHostClient(args.hostId) + const ready = args.session.kind === 'ready' ? args.session : null + const sessionId = ready?.sessionId ?? null + const buildId = ready?.buildId ?? null + const viewRef = useRef(null) + const hostRef = useRef(null) + + // Commit-phase, not passive: a native frame that arrives between the two carries the session id + // the handler is fenced on, so only handing the host over here keeps it off the retired client. + useLayoutEffect(() => { + if (client === null || sessionId === null || buildId === null) { + return + } + const host = createBridgeHost({ + client, + buildId, + sessionId, + post: (json) => { + const mounted = viewRef.current + return mounted === null || mounted.sessionId !== sessionId + ? Promise.reject(new BridgeViewGoneError()) + : mounted.handle.postBridgeMessage(json) + }, + onDiagnostic: createBridgeDiagnosticReporter() + }) + hostRef.current = { sessionId, host } + return () => { + hostRef.current = null + host.dispose() + } + }, [buildId, client, sessionId]) + + return { + bridgeEnabled: ready !== null, + viewRef: useCallback( + (handle: OrcaMobileWebShellViewHandle | null) => { + viewRef.current = handle === null || sessionId === null ? null : { sessionId, handle } + }, + [sessionId] + ), + onBridgeMessage: useCallback( + (event: MobileWebShellBridgeMessageEvent) => { + const mounted = hostRef.current + if (mounted === null || mounted.sessionId !== sessionId) { + return + } + mounted.host.receive(event.nativeEvent.json) + }, + [sessionId] + ) + } +} diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts index 6698a9be723..d38baf9a76a 100644 --- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -25,6 +25,12 @@ export type UnvalidatedRpcRequestPortEntry = { /** Modules whose job is the port. These do not shrink to zero. */ export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequestPortEntry[] = [ + // Carries the port across the page boundary for the hybrid shell. Not a call site: it picks no + // method, reads no reply and decides no acceptance — the page names the method and runs the + // typed operation over it, exactly as a native screen does over a socket client. + { file: 'src/mobile-web-shell/bridge-host.ts', references: 3 }, + // Fakes the port for the bridge host suites; a non-test file only because tsconfig excludes tests. + { file: 'src/mobile-web-shell/bridge-host-test-fakes.ts', references: 1 }, // Placeholder page transport until C0.4's BridgeRpcClient replaces it; rejects every call, reads no reply. { file: 'src/transport/client-context.web.tsx', references: 1 }, // Implements the port over the device-to-host websocket. From ddbb194585dbd4f569273bae082f4e01e1a783d0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:51:38 -0400 Subject: [PATCH 030/224] feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4) (#21467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) One page document's end of the bridge: page frames in through the C0.1 reader, one RpcClient behind it, host frames out. Requests forward with the arity the page used and answer with the verbatim RpcResponse, chunked when it is over the frame cap; a rejection crosses as the five-field capture instead. Subscriptions carry a seq and an unacked window, and end with `overflow` rather than dropping frames a reader cannot see are missing. The fence is structural: the protocol names no host, so the client is whichever this host was built with, and the in-flight caps the page is told about in `init` are enforced here rather than trusted from there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3) The channel opens on the session B4 put on screen and closes with it. The session id is B4's: nothing new is minted, and a remount is a new one, which is what makes a dead page's frames fail the native origin check. Both halves are stamped with the session they belong to, because React swaps refs during the commit and runs the retiring effect's cleanup after it — a host disposing on a remount would otherwise post its teardown into the page that replaced it. `bridgeEnabled` is derived from the session step alone, since the native side treats a prop change as a reload. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the bridge fence holds for traffic, not just for answers A mutation that dropped the post-teardown guard in `receive` survived: the teardown case only fed a frame whose answer the outbound guard already swallowed, so nothing observed that a dead page could still reach a live client. Both teardown paths now feed a request, a subscribe and a notify, and assert the client saw none of them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the hook's frames through the page's own reader `JSON.parse` returns `any`, and taming it with an assertion is a cast the gate refuses and a check nobody gets. Reading each posted frame through `readBridgeHostMessage` types it and proves the same thing the host's own suite does: a frame the page would refuse is a frame that never arrives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the bridge host as a raw request port owner The boundary ratchet reads a `.sendRequest` access as a call site, and the host has three: one per arity the page can use. It is not a call site. It picks no method, reads no reply and decides no acceptance — the page names the method and runs the typed operation over the client this carries, which is what the C0 design put page-side so `runRpcOperation` stays unchanged there. That makes it an owner, beside the socket and relay senders, not a migration backlog entry. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4) Every member of the native contract, carried over the C0.1 envelope so the screens above it cannot tell a bridge from a socket: requests keep the arity the caller used, a host RpcFailure resolves as data while a rejection is rebuilt with its class and its delivery-unknown mark, subscriptions stream with periodic acks, and the synchronous getters read a cache primed by init rather than answering before they know. A state whose generation went backwards is refused and re-asked for, because a shell rebuilt under the page makes what the page holds the newer of the two. close settles what the page owns and never touches the shell's client, which the native screens and the host catalog still share. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): run the page client against the shell host over an in-memory port pair One FIFO per direction and delivery on a microtask, which is what C0.5's golden replay needs: a subscribe that overtook a sendRequest would move the recorder's shared ordinal, and anything stronger than a microtask moves a virtual millisecond. Every member round-trips through the real host over a fake client; the frame-level suite covers what no pair can reach, including the handshake backoff, refusals and the binary lane C6 will fill. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the page bridge client as a raw request port owner Both ends of the bridge hold the port as a transport: one forwards raw requests and the other offers them, and neither picks a method or reads a reply. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the assembler discard no abandoned request can reach A request is only abandoned when its frame never left the page, so the shell was never told the id and no part can have arrived under it. Says what actually keeps an omitted param omitted while it is here: JSON drops an undefined value, so the spread states the intent rather than producing the result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close the three gaps a mutation sweep found in the page client A settled id has to give its assembler slot back, or 64 replies that were cut short before an error leave the page unable to read the next chunked one. Close says goodbye once rather than cancelling each stream first. And the read guard is only observable through a port that ignores its own unsubscribe, which is what the harness can now be. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove a stream that overflows inside subscribe is unsubscribed A client that emits synchronously from `subscribe` can retire a stream before its unsubscribe exists to be stored. The identity check that calls it instead had no test; deleting it left the suite green while the client's stream leaked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the bridge host over in the commit, not after it A client swap that keeps the session id leaves the handler's own fence inert: until the passive effect ran, a native frame reached the retiring host and the client it closed over. A layout effect swaps both inside the commit. Teardown on unmount now runs while the view is still attached, so a pending request is answered delivery-unknown instead of being dropped on the floor. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold a refused page frame to one warning per page A page that sends one bad frame usually sends many, and a line each buries the first — the one that says why. Same bound the host already keeps on a failing post, applied per kind and reset when a new page gets a new host. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound the page's terminal viewport at the bridge contract A viewport crossing the bridge is written into the cached subscribe params of every stream naming that terminal, including the native terminal screen's, and the desktop refuses cols over 1000 or rows over 500 when those streams resubscribe. Unbounded, one page could kill streams it never opened; the frame is refused instead, and the bound is pinned to the desktop's own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the page's close from latching the bridge host shut One view carries every document the shell loads, so the page that says `close` is not the last one. A latched host dropped the next document's `ready` in silence, and a page that re-sends `ready` on a backoff would retry forever with nothing posted and nothing logged. Close now cancels what the page owned and leaves the host live; only dispose shuts it, and a frame arriving after that is diagnosed rather than dropped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a throwing client or post inside the bridge host The `state` frame is sent from inside the client's own state-change fan-out and a notify runs on the native event handler that delivered the page's frame, so a synchronous throw from either escapes into a loop the bridge does not own and takes unrelated listeners with it. Both are fenced and reported once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove an ack releases the stream's unacked bytes The frame window reopens on ack through the splice, so deleting the byte release left every existing test green while a long-lived stream of large frames would end with overflow on its first frame after an ack. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pass the commit-window harness its children as a prop `createElement`'s variadic children do not satisfy a props type that declares `children`, so the file dropped out of the tests typecheck ratchet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): render the harness from the commit-window wrapper, not as children A props type that declares `children` is what `createElement`'s variadic form does not satisfy, and passing it as a prop instead trips the react rule. The wrapper renders the harness itself, which is the parent position the layout effect ordering needs anyway. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the desktop viewport bound by reading it, not importing it Mobile may not pull an rpc-contract *value* into its bundle, and the boundary test that enforces that scans this test file too. The pin reads the schema's own source instead, so drift in either bound still fails loudly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): settle a refused subscribe as the stream it was The shell answers a refused `subscribe` with `error` on the stream's id. Routing that to the pending requests dropped it, because no request is open under that id: the page heard nothing and kept the slot forever. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report a reply or an error the page has no id for Silently dropped before. Nothing recovers it in place, but a frame the page cannot place means the two ledgers disagree, which is worth a line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say disconnected on close instead of going silent Every native client publishes the transition and keeps answering its last snapshot; the screens read both. The page's client cleared the cache instead, so a closing page left its listeners on a dot that never moved and every getter throwing underneath it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a closed page client go inert, not throw An unmounting screen still calls, and nothing on a teardown path catches. Subscribe hands back a no-op dispose and the notifies do nothing, as the native client's do, and a request rejects rather than throwing past the caller's catch. A call before init still throws: that one is a bug. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): lift the init handshake out of the page client The backoff that asks the shell for a session is its own concern, and the client had grown past the file's line budget holding it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the cancel a page owes for a stream already ended A screen unmounts on its own schedule, routinely after the shell gave up on the stream. Only the double-dispose order was covered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state what the page client does after close The doc gave the pre-init rule and stopped; the after-close rule is the opposite one, and subscription failures have no channel but a diagnostic. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): read the shell's page channel as a client transport The document-start installer leaves `postMessage` and one `onmessage` slot, the intersection of what the two platforms inject. A page opened outside the shell has no global at all, so reading it answers null rather than throwing: the bundle still has to open in a browser. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the page its bridge client instead of a placeholder The web provider now builds BridgeRpcClient over the shell channel and mounts nothing until `init` lands: every member throws before a session, and a screen that rendered first would record its first frame against a client that has none. Outside the shell there is no session coming, so the placeholder stays and the route tree mounts at once, which is what the Route A render check exercises. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): declare the page provider test's probe instead of casting it Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve the bridge to one document at a time A page's `close` now ends that document's turn: until the next `ready` claims the view, every other frame is dropped and diagnosed instead of reaching the client, and nothing is posted. Without the fence a straggler from the closed document was still forwarded, and a `state` frame from the still-running client landed in the replacement document before its `init`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the request cap against the calls, not the page's ledger `sendRequest` has no cancel, so a request the page cancelled or closed out keeps running on the desktop until it answers. The cap now counts those calls until each settles; counting the pending map let a page interleaving `close` with batches hold many more than the cap `init` advertises. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ack ratio to the shell's window, not a copy of it The ack interval test held 256 and 4 MiB as literals, so narrowing the shell's window would have left the page acking too late with the test still green. The comment naming the test that pins the ratio pointed at the wrong file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give back the slot of a subscribe that never left the page A post that threw left the stream in the page's ledger with nothing open on the shell's side, so 32 of them exhausted the subscription budget for the life of the document. The slot goes back and the listener hears a terminal error result, which is what the native client does with the same failure. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): end a page stream through its listener, not only the log A stream the shell ends or fails now reaches its listener as a terminal error result, the way the native client's emitError does. A consumer reads that result: host-worktree-refresh clears the flag that says the event stream is live, and without it the worktree list stops updating for the life of the document. A dispose the page asked for stays silent. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): settle the old shell's work before adopting a new session A second `init` naming a different sessionId is a rebuilt host with empty tables: every pending request and every open stream the page still held belonged to the shell that is gone. They now settle delivery-unknown and end through their listeners before the new session is adopted. A second `init` for the same session is what a re-asked `ready` earns, and keeps everything. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): take the page's streams out of the ledger before failing them A listener that resubscribes while the old shell's streams are being ended is opening one against the shell that is arriving; draining the map first is what keeps this loop from tearing that one down too. Fixes the lint the previous commit left behind. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say how long a reply assembler's refusal actually lives The tombstone is not kept forever: the request ledger discards the id as it settles the caller, so it normally outlives only the rest of the reply that raised it. The bounded map is there for the ids nothing settles. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ceiling the ready backoff stops widening at An unclamped backoff reads the same for the first minute and then leaves a page asking once an hour into a shell that is still booting behind it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why the document fence carries no epoch Page frames reach the shell through one native listener per platform, so a straggler from the closed document lands before the next document's `ready` and the flag alone catches it. An echoed epoch would be a wire change for nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- mobile/src/mobile-web-shell/bridge-host.ts | 2 + .../bridge/bridge-client-connection-cache.ts | 85 ++ .../bridge/bridge-client-errors.ts | 50 ++ .../bridge/bridge-client-init-handshake.ts | 48 ++ .../bridge/bridge-client-requests.ts | 89 ++ .../bridge/bridge-client-subscriptions.ts | 183 +++++ .../bridge/bridge-port-pair-test-harness.ts | 160 ++++ .../bridge/bridge-reply-chunking.ts | 10 +- .../bridge/bridge-rpc-client-frames.test.ts | 759 ++++++++++++++++++ .../bridge/bridge-rpc-client.test.ts | 264 ++++++ .../bridge/bridge-rpc-client.ts | 382 +++++++++ .../bridge/bridge-screencast-binary.ts | 53 ++ .../bridge/orca-bridge-page-channel.test.ts | 53 ++ .../bridge/orca-bridge-page-channel.ts | 51 ++ .../src/transport/client-context.web.test.tsx | 161 ++++ mobile/src/transport/client-context.web.tsx | 111 ++- .../unvalidated-rpc-request-port-inventory.ts | 12 +- mobile/web-entry/web-overrides.json | 2 +- 18 files changed, 2452 insertions(+), 23 deletions(-) create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-client-connection-cache.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-client-errors.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-client-requests.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-client-subscriptions.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-rpc-client.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-screencast-binary.ts create mode 100644 mobile/src/mobile-web-shell/bridge/orca-bridge-page-channel.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/orca-bridge-page-channel.ts create mode 100644 mobile/src/transport/client-context.web.test.tsx diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts index 14410dcdfb3..4241e34a295 100644 --- a/mobile/src/mobile-web-shell/bridge-host.ts +++ b/mobile/src/mobile-web-shell/bridge-host.ts @@ -95,6 +95,8 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { let inFlight = 0 // One document's turn at the bridge. `close` ends it and the next `ready` begins the next one; // between the two the view belongs to no document, so nothing is served and nothing is posted. + // No epoch rides along: one native listener delivers page frames in order, so a straggler from + // the closed document is always behind it and ahead of the next document's `ready`. let serving = true let postFailureReported = false let notifyFailureReported = false diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-connection-cache.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-connection-cache.ts new file mode 100644 index 00000000000..260d9bb044d --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-connection-cache.ts @@ -0,0 +1,85 @@ +import type { ConnectionState } from '../../transport/types' +import type { BridgeConnectionSnapshot } from './bridge-envelope' + +/** Why a `state` frame did not land. `unprimed` is a frame that beat `init`, `stale` one that lost to it. */ +export type BridgeSnapshotOutcome = 'applied' | 'stale' | 'unprimed' + +/** + * What the page's synchronous `RpcClient` getters read. + * + * Screens read `getState()` during render, so the answer has to already be here when the first one + * mounts: `init` primes it, every `state` refreshes it, and nothing is ever derived or guessed. A + * cache that answered `connecting` because it had not heard yet would move a golden. + */ +export class BridgeConnectionCache { + private held: BridgeConnectionSnapshot | null = null + private readonly listeners = new Set<(state: ConnectionState) => void>() + + read(): BridgeConnectionSnapshot | null { + return this.held + } + + /** From `init`. Re-priming with the same state is not a transition, so no listener hears one. */ + prime(snapshot: BridgeConnectionSnapshot): void { + const changed = this.held?.state !== snapshot.state + this.held = snapshot + if (changed) { + this.fanOut(snapshot.state) + } + } + + /** + * From `state`, one frame per transition on the shell's side, so every accepted one is fanned out. + * + * A snapshot whose generation went backwards is refused: the shell was rebuilt over a newer + * client and the page missed the `init` that would have said so, which makes what the page holds + * newer than what just arrived. Applying it would walk the cache backwards and leave every getter + * answering for a client that no longer exists. + */ + apply(snapshot: BridgeConnectionSnapshot): BridgeSnapshotOutcome { + const previous = this.held + if (previous === null) { + return 'unprimed' + } + if ( + previous.generation !== null && + snapshot.generation !== null && + snapshot.generation < previous.generation + ) { + return 'stale' + } + this.held = snapshot + this.fanOut(snapshot.state) + return 'applied' + } + + onStateChange(listener: (state: ConnectionState) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** + * The page said goodbye. Every native client publishes `disconnected` when it closes and keeps + * answering its last snapshot afterwards, and the screens above this one are written to that: a + * getter that threw here, or a listener that never heard the transition, would leave a closing + * page rendering a dot that is still connected. + */ + close(): void { + const held = this.held + if (held !== null && held.state !== 'disconnected') { + this.held = { ...held, state: 'disconnected' } + this.fanOut('disconnected') + } + this.listeners.clear() + } + + // Walked in place: a listener that unsubscribes a sibling during the fan-out is what a `Set` + // iterator is specified to survive. + private fanOut(state: ConnectionState): void { + for (const listener of this.listeners) { + listener(state) + } + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-errors.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-errors.ts new file mode 100644 index 00000000000..4445e5777f8 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-errors.ts @@ -0,0 +1,50 @@ +import type { BridgeRefusal } from './bridge-caps' + +/** Everything the page's own client raises, as opposed to what it reconstructs from the shell. */ + +/** A call that needs a session the page is not in yet. Always a mount-order bug, never a retry. */ +export class BridgeClientNotReadyError extends Error { + constructor() { + super('the page bridge has no session yet; wait for init before calling the client') + this.name = 'BridgeClientNotReadyError' + } +} + +export class BridgeClientClosedError extends Error { + constructor() { + super('the page bridge was closed') + this.name = 'BridgeClientClosedError' + } +} + +/** A second `init` naming a different session: whatever the page still held belonged to the shell + * that is now gone, and the one that replaced it has never heard of any of it. */ +export class BridgeShellReplacedError extends Error { + constructor() { + super('the shell behind this page was replaced') + this.name = 'BridgeShellReplacedError' + } +} + +/** The page's copy of the shell's in-flight caps, refusing before the round trip rather than after. */ +export class BridgeClientCapExceededError extends Error { + constructor(message: string) { + super(message) + this.name = 'BridgeClientCapExceededError' + } +} + +export class BridgeReplyRefusedError extends Error { + constructor(refusal: BridgeRefusal) { + super(`the reply could not be read (${refusal})`) + this.name = 'BridgeReplyRefusedError' + } +} + +/** The frame never left the page, so this is a definite send failure and carries no delivery mark. */ +export class BridgeSendFailedError extends Error { + constructor() { + super('the request could not be posted to the shell') + this.name = 'BridgeSendFailedError' + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts new file mode 100644 index 00000000000..60cb93aea40 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts @@ -0,0 +1,48 @@ +/** The page asks again until the shell answers; a session has no other way to start. */ +export const BRIDGE_READY_RETRY_MIN_MS = 50 +export const BRIDGE_READY_RETRY_MAX_MS = 2000 + +export type BridgeInitHandshake = { + /** Posts `ready` now, and again on a widening backoff until `stop`. */ + start: () => void + stop: () => void + /** For a shell rebuilt under the page: the wait starts over from the floor. */ + restart: () => void +} + +/** + * How the page gets a session. + * + * The shell posts `init` when it is ready, but a page that loaded first, or reloaded after the shell + * had already sent one, would wait forever for a frame that has been and gone. Asking on a widening + * backoff costs one frame at a time and needs nothing remembered on the shell's side. + */ +export function createBridgeInitHandshake(ask: () => void): BridgeInitHandshake { + let timer: ReturnType | null = null + let delayMs = BRIDGE_READY_RETRY_MIN_MS + + function start(): void { + ask() + timer = setTimeout(() => { + delayMs = Math.min(delayMs * 2, BRIDGE_READY_RETRY_MAX_MS) + start() + }, delayMs) + } + + function stop(): void { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + return { + start, + stop, + restart: (): void => { + stop() + delayMs = BRIDGE_READY_RETRY_MIN_MS + start() + } + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-requests.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-requests.ts new file mode 100644 index 00000000000..83c1880f80e --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-requests.ts @@ -0,0 +1,89 @@ +import { markRpcDeliveryUnknown } from '../../transport/rpc-delivery-ambiguity' +import type { RpcResponse } from '../../transport/types' +import { BridgeClientClosedError, BridgeReplyRefusedError } from './bridge-client-errors' +import type { BridgeReplyMessage } from './bridge-envelope' +import { BridgeReplyAssembler } from './bridge-reply-chunking' + +export type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: unknown) => void +} + +/** + * The page's in-flight requests, and the replies that settle them. + * + * Nothing here expires an id on its own, so every id this opens is discarded from the assembler the + * moment it settles or is abandoned: a reply whose last part never arrives would otherwise hold a + * slot until the page closes, and 64 of those are the whole in-flight budget. + */ +export class BridgeClientRequests { + private readonly pending = new Map() + private readonly assembler = new BridgeReplyAssembler() + + get size(): number { + return this.pending.size + } + + has(id: string): boolean { + return this.pending.has(id) + } + + open(id: string, request: PendingRequest): void { + this.pending.set(id, request) + } + + /** For a frame that never left the page: the caller settles it, and no part can have arrived for + * an id the shell was never told about, so there is no assembler slot to give back. */ + abandon(id: string): void { + this.pending.delete(id) + } + + acceptReply(message: BridgeReplyMessage): void { + const assembly = this.assembler.accept(message) + if (assembly.status === 'pending') { + // A part for an id nobody is waiting on still costs a slot until it is discarded. + if (!this.pending.has(message.id)) { + this.assembler.discard(message.id) + } + return + } + if (assembly.status === 'failed') { + this.fail(message.id, new BridgeReplyRefusedError(assembly.refusal)) + return + } + // A host `RpcFailure` resolves: it is data the caller reads, and the goldens record it. + this.settle(message.id, (request) => { + request.resolve(assembly.payload) + }) + } + + fail(id: string, error: unknown): void { + this.settle(id, (request) => { + request.reject(error) + }) + } + + /** + * Every pending request reaches its caller before this returns, and each one rejects + * delivery-unknown: the desktop may already have run it, and a caller told this was a definite + * send failure would offer to retry something that already happened. + */ + closeAll(reason: Error = new BridgeClientClosedError()): void { + const error = markRpcDeliveryUnknown(reason) + for (const request of this.pending.values()) { + request.reject(error) + } + this.pending.clear() + this.assembler.clear() + } + + private settle(id: string, settleWith: (request: PendingRequest) => void): void { + this.assembler.discard(id) + const request = this.pending.get(id) + if (request === undefined) { + return + } + this.pending.delete(id) + settleWith(request) + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-subscriptions.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-subscriptions.ts new file mode 100644 index 00000000000..cbcdc9a1e03 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-subscriptions.ts @@ -0,0 +1,183 @@ +import type { BrowserScreencastFrame } from '../../transport/browser-screencast-protocol' +import { + BRIDGE_PROTOCOL_VERSION, + type BridgeClientMessage, + type BridgeHostMessage +} from './bridge-envelope' +import { decodeBridgeScreencastFrame, type BridgeBinaryEvent } from './bridge-screencast-binary' + +type BridgeEventMessage = Extract + +/** Derived from the envelope's closed list, the same way the shell's ledger derives it: a reason + * added there is a compile error here rather than one this side silently never sees. */ +export type BridgeStreamEndReason = Extract['reason'] + +/** + * How far behind the page lets itself fall before it acks. + * + * The shell ends a stream at 256 unacked frames or 4 MiB. A quarter of each leaves room for the + * frames already in flight when an ack is posted, so a page that is keeping up never walks the + * shell's window down to the point where it ends a stream. `bridge-rpc-client-frames.test.ts` pins + * the ratio against the shell's own numbers. + */ +export const BRIDGE_ACK_INTERVAL_FRAMES = 64 +export const BRIDGE_ACK_INTERVAL_BYTES = 1024 * 1024 + +/** + * What a listener is handed when its stream dies under it, in the shape the native client's + * `emitError` uses. Consumers read `type` and act on it — `host-worktree-refresh.ts` clears the flag + * that says the event stream is live — so a stream that merely stops delivering leaves them waiting + * on a replay that is never coming. + */ +export type BridgeStreamErrorResult = { type: 'error'; message: string; error?: unknown } + +export function bridgeStreamError(message: string, error?: unknown): BridgeStreamErrorResult { + return error === undefined ? { type: 'error', message } : { type: 'error', message, error } +} + +type OpenStream = { + onData: (result: unknown) => void + onBinaryFrame?: (frame: BrowserScreencastFrame) => void + lastSeq: number + unackedFrames: number + unackedBytes: number +} + +type SubscriptionsOptions = { + /** False when the frame never left the page. */ + send: (frame: BridgeClientMessage) => boolean + /** A binary frame with no listener or no decodable image. Neither is recoverable in place. */ + onDroppedBinaryFrame: () => void +} + +/** Every stream the page opened, and the ack it owes the shell for each one. */ +export class BridgeClientSubscriptions { + private streams = new Map() + + constructor(private readonly options: SubscriptionsOptions) {} + + get size(): number { + return this.streams.size + } + + has(id: string): boolean { + return this.streams.has(id) + } + + /** False when the `subscribe` never left the page. The shell has not heard of the stream, so + * nothing will ever end it: the slot goes back here and the listener is told, which is what the + * native client does with a subscribe it could not send. */ + open( + id: string, + method: string, + params: unknown, + onData: (result: unknown) => void, + onBinaryFrame?: (frame: BrowserScreencastFrame) => void + ): boolean { + this.streams.set(id, { onData, onBinaryFrame, lastSeq: 0, unackedFrames: 0, unackedBytes: 0 }) + const sent = this.options.send({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'subscribe', + id, + method, + params, + // Asked for only when there is something to hand the frames to, so a shell that pays to + // encode binary is one a listener is waiting on. + ...(onBinaryFrame === undefined ? {} : { wantsBinary: true }) + }) + if (sent) { + return true + } + this.streams.delete(id) + onData(bridgeStreamError('the subscribe could not be posted to the shell')) + return false + } + + /** `bytes` is the raw frame as the shell measured it, so both sides' windows agree exactly. */ + deliver(message: BridgeEventMessage, bytes: number): void { + const stream = this.streams.get(message.id) + if (stream === undefined) { + return + } + stream.lastSeq = message.seq + stream.unackedFrames += 1 + stream.unackedBytes += bytes + // Acked before the listener runs: the frame was received and read either way, and a listener + // that throws must not also wedge the stream by stranding the ack behind it. + this.ackIfDue(message.id, stream) + if ('binary' in message) { + this.deliverBinary(stream, message.binary) + return + } + stream.onData(message.payload) + } + + /** The shell already retired this stream, so nothing is posted back for it. The listener is told + * before the record goes: frames that merely stop arriving are indistinguishable from a quiet + * stream, and a consumer waiting on a replay would wait for the life of the document. */ + end(id: string, message: string, error?: unknown): void { + const stream = this.streams.get(id) + if (stream === undefined) { + return + } + this.streams.delete(id) + stream.onData(bridgeStreamError(message, error)) + } + + /** The page is done with the stream. Idempotent: a second dispose posts nothing. */ + cancel(id: string): void { + if (!this.streams.delete(id)) { + return + } + this.options.send({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'cancel', + id, + target: 'subscription' + }) + } + + /** For `close`, which is the shell's authority to tear down both sides: a cancel per stream + * ahead of it would say the same thing twice. Silent, because the page asked for this one. */ + closeAll(): void { + this.streams.clear() + } + + /** For a shell replaced under the page: every stream it was serving died with it, and the + * listeners are the only ones in a position to do anything about that. */ + failAll(message: string): void { + // Out of the ledger before any listener runs: one that resubscribes on the way down is opening + // a stream against the shell that is arriving, and this loop must not take that one with it. + const ended = this.streams + this.streams = new Map() + for (const stream of ended.values()) { + stream.onData(bridgeStreamError(message)) + } + } + + private deliverBinary(stream: OpenStream, binary: BridgeBinaryEvent): void { + const onBinaryFrame = stream.onBinaryFrame + if (onBinaryFrame === undefined) { + this.options.onDroppedBinaryFrame() + return + } + const frame = decodeBridgeScreencastFrame(binary) + if (frame === null) { + this.options.onDroppedBinaryFrame() + return + } + onBinaryFrame(frame) + } + + private ackIfDue(id: string, stream: OpenStream): void { + if ( + stream.unackedFrames < BRIDGE_ACK_INTERVAL_FRAMES && + stream.unackedBytes < BRIDGE_ACK_INTERVAL_BYTES + ) { + return + } + stream.unackedFrames = 0 + stream.unackedBytes = 0 + this.options.send({ v: BRIDGE_PROTOCOL_VERSION, type: 'ack', id, seq: stream.lastSeq }) + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts new file mode 100644 index 00000000000..0758a09e692 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts @@ -0,0 +1,160 @@ +import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from '../bridge-host' +import { createFakeRpcClient, type FakeRpcClient } from '../bridge-host-test-fakes' +import { + readBridgeClientMessage, + readBridgeHostMessage, + type BridgeClientMessage, + type BridgeHostMessage +} from './bridge-envelope' +import { + createBridgeRpcClient, + type BridgeRpcClient, + type BridgeRpcClientDiagnostic +} from './bridge-rpc-client' + +/** + * The page and the shell wired to each other through the weakest transport that is still a + * transport, so a test of either one is a test of the pair. + * + * Two properties are the whole point. One FIFO per direction, because a `subscribe` that overtook a + * `sendRequest` would move the recorder's shared ordinal, which is what `write-ordinal.ts` exists to + * catch. And delivery on a microtask, the weakest async the golden runner's zero-time drains flush + * and the only one that moves no virtual millisecond. + */ +export type BridgePortPair = { + client: BridgeRpcClient + host: BridgeHost + rpc: FakeRpcClient + /** Everything each side posted, in the order it was posted, raw. */ + toShell: string[] + toPage: string[] + diagnostics: BridgeRpcClientDiagnostic[] + hostDiagnostics: BridgeHostDiagnostic[] + /** Runs both lanes until a full round moves nothing. */ + flush: () => Promise + /** Read back through the reader on the receiving side, so a frame this returns is one that lands. */ + readToShell: () => BridgeClientMessage[] + readToPage: () => BridgeHostMessage[] +} + +export type BridgePortPairOptions = { + rpc?: FakeRpcClient + sessionId?: string + buildId?: string +} + +type Lane = { + sent: string[] + push: (json: string) => void + readonly depth: number +} + +function createLane(deliver: (json: string) => void): Lane { + const sent: string[] = [] + const queue: string[] = [] + let scheduled = false + function drain(): void { + scheduled = false + const next = queue.shift() + if (next === undefined) { + return + } + deliver(next) + schedule() + } + function schedule(): void { + if (scheduled || queue.length === 0) { + return + } + scheduled = true + void Promise.resolve().then(drain) + } + return { + sent, + push(json: string): void { + sent.push(json) + queue.push(json) + schedule() + }, + get depth(): number { + return queue.length + } + } +} + +function readAll( + frames: readonly string[], + read: (json: string) => { ok: true; message: TMessage } | { ok: false; refusal: string } +): TMessage[] { + return frames.map((json) => { + const parsed = read(json) + if (!parsed.ok) { + throw new Error(`the other side would have refused this frame: ${parsed.refusal}`) + } + return parsed.message + }) +} + +export function createBridgePortPair(options: BridgePortPairOptions = {}): BridgePortPair { + const rpc = options.rpc ?? createFakeRpcClient() + const diagnostics: BridgeRpcClientDiagnostic[] = [] + const hostDiagnostics: BridgeHostDiagnostic[] = [] + let receiveOnPage: ((json: string) => void) | null = null + + const toPage = createLane((json) => { + receiveOnPage?.(json) + }) + const host = createBridgeHost({ + client: rpc, + post: (json) => { + toPage.push(json) + return Promise.resolve() + }, + buildId: options.buildId ?? 'build-a', + sessionId: options.sessionId ?? 'session-a', + onDiagnostic: (diagnostic) => hostDiagnostics.push(diagnostic) + }) + const toShell = createLane((json) => { + host.receive(json) + }) + const client = createBridgeRpcClient({ + send: (json) => { + toShell.push(json) + }, + onMessage: (handler) => { + receiveOnPage = handler + return () => { + receiveOnPage = null + } + }, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) + }) + + return { + client, + host, + rpc, + toShell: toShell.sent, + toPage: toPage.sent, + diagnostics, + hostDiagnostics, + async flush(): Promise { + for (let round = 0; round < 64; round += 1) { + const moved = toShell.sent.length + toPage.sent.length + for (let turn = 0; turn < 8; turn += 1) { + await Promise.resolve() + } + const quiet = + toShell.depth === 0 && + toPage.depth === 0 && + moved === toShell.sent.length + toPage.sent.length + if (quiet) { + return + } + } + throw new Error('the port pair never went quiet') + }, + readToShell: () => readAll(toShell.sent, readBridgeClientMessage), + readToPage: () => readAll(toPage.sent, readBridgeHostMessage) + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.ts b/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.ts index 5545030ad65..b05a0e2de85 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-reply-chunking.ts @@ -127,10 +127,12 @@ const BRIDGE_MAX_ASSEMBLING_BYTES = BRIDGE_MAX_REPLY_BYTES * 4 /** * Parts may arrive in any order, so they are held by index rather than appended. * - * A failed id stays failed. Dropping it and starting over on the next part is what lets a sender - * walk past the ceiling one refusal at a time, so the refusal is remembered and every later part - * for that id gets the same answer. `discard` is how the page says the id is finished with, which - * is also how it becomes usable again. + * A failed id stays failed while the page still holds it. Dropping the refusal and starting over on + * the next part is what would let a sender walk past the ceiling one refusal at a time, so every + * later part for that id gets the same answer instead. Nothing is remembered for long: `discard` + * reopens the id, and the page's request ledger calls it as it settles the caller, so the tombstone + * normally lives no longer than the rest of the reply that raised it. The bound below is for the + * ids nothing settles. * * The number of ids held at once is bounded by the in-flight request cap, since a reply only exists * for a request the page made, and their bytes together by `BRIDGE_MAX_ASSEMBLING_BYTES`. Nothing diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts new file mode 100644 index 00000000000..7c2ef099ce3 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts @@ -0,0 +1,759 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BrowserScreencastOpcode } from '../../transport/browser-screencast-protocol' +import { isRpcDeliveryUnknown } from '../../transport/rpc-delivery-ambiguity' +import { + BRIDGE_MAX_MESSAGE_BYTES, + BRIDGE_MAX_PENDING_REQUESTS, + BRIDGE_MAX_SUBSCRIPTIONS +} from './bridge-caps' +import { BRIDGE_MAX_UNACKED_BYTES, BRIDGE_MAX_UNACKED_FRAMES } from '../bridge-host-subscriptions' +import { + BRIDGE_ACK_INTERVAL_BYTES, + BRIDGE_ACK_INTERVAL_FRAMES +} from './bridge-client-subscriptions' +import { + BRIDGE_PROTOCOL_VERSION, + readBridgeClientMessage, + type BridgeClientMessage, + type BridgeHostMessage +} from './bridge-envelope' +import { + BRIDGE_READY_RETRY_MAX_MS, + BRIDGE_READY_RETRY_MIN_MS +} from './bridge-client-init-handshake' +import { + BridgeClientCapExceededError, + BridgeClientClosedError, + BridgeClientNotReadyError, + createBridgeRpcClient, + type BridgeRpcClientDiagnostic +} from './bridge-rpc-client' + +const CONNECTION = { + state: 'connected', + reconnectAttempt: 2, + lastConnectedAt: 1700, + lastInboundAt: 1800, + generation: 5 +} as const + +const INIT: BridgeHostMessage = { + v: BRIDGE_PROTOCOL_VERSION, + type: 'init', + sessionId: 'session-a', + buildId: 'build-a', + connection: CONNECTION, + grants: { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] } +} + +type PageClientOptions = { + send?: (json: string) => void + /** A port that ignores its own unsubscribe, which is the only way to observe the read guard. */ + keepDeliveringAfterUnsubscribe?: boolean +} + +function createPageClient(options: PageClientOptions = {}) { + const sent: string[] = [] + const diagnostics: BridgeRpcClientDiagnostic[] = [] + let handler: ((json: string) => void) | null = null + const client = createBridgeRpcClient({ + send: (json) => { + sent.push(json) + options.send?.(json) + }, + onMessage: (received) => { + handler = received + return () => { + if (options.keepDeliveringAfterUnsubscribe !== true) { + handler = null + } + } + }, + onDiagnostic: (diagnostic) => { + diagnostics.push(diagnostic) + } + }) + return { + client, + sent, + diagnostics, + deliver(frame: unknown): void { + handler?.(JSON.stringify(frame)) + }, + deliverRaw(json: string): void { + handler?.(json) + }, + frames(): BridgeClientMessage[] { + return sent.map((json) => { + const read = readBridgeClientMessage(json) + if (!read.ok) { + throw new Error(`the shell would have refused this frame: ${read.refusal}`) + } + return read.message + }) + }, + start(): void { + this.deliver(INIT) + } + } +} + +/** Narrows what a rejection handed back, so a test reads an error rather than asserting one. */ +function readError(thrown: unknown): Error { + if (!(thrown instanceof Error)) { + throw new Error(`expected an Error, got ${typeof thrown}`) + } + return thrown +} + +function eventFrame(id: string, seq: number, payload: unknown): BridgeHostMessage { + return { v: BRIDGE_PROTOCOL_VERSION, type: 'event', id, seq, payload } +} + +/** The id the client minted for the nth exchange it opened, read back off its own frame. */ +function idOf(page: ReturnType, index: number): string { + const frame = page.frames().filter((message) => 'id' in message)[index] + if (frame === undefined || !('id' in frame)) { + throw new Error('the page opened no such exchange') + } + return frame.id +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('bridge client handshake', () => { + it('asks for a session as soon as it exists', () => { + const page = createPageClient() + expect(page.frames()).toEqual([{ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }]) + }) + + it('keeps asking on a widening backoff until init answers', () => { + const page = createPageClient() + vi.advanceTimersByTime(BRIDGE_READY_RETRY_MIN_MS) + expect(page.sent).toHaveLength(2) + vi.advanceTimersByTime(BRIDGE_READY_RETRY_MIN_MS) + expect(page.sent).toHaveLength(2) + vi.advanceTimersByTime(BRIDGE_READY_RETRY_MIN_MS) + expect(page.sent).toHaveLength(3) + vi.advanceTimersByTime(BRIDGE_READY_RETRY_MAX_MS * 4) + expect(page.sent.length).toBeGreaterThan(3) + }) + + it('asks no less often than the ceiling, however long the shell stays quiet', () => { + const page = createPageClient() + // Past the ceiling: doubling from the floor reaches it in six steps. An unclamped backoff is + // the same thing for a minute and then a page that gives up on a shell booting behind it. + vi.advanceTimersByTime(BRIDGE_READY_RETRY_MAX_MS * 4) + const asked = page.sent.length + vi.advanceTimersByTime(BRIDGE_READY_RETRY_MAX_MS) + expect(page.sent).toHaveLength(asked + 1) + vi.advanceTimersByTime(BRIDGE_READY_RETRY_MAX_MS * 10) + expect(page.sent).toHaveLength(asked + 11) + }) + + it('stops asking once init lands', () => { + const page = createPageClient() + page.start() + vi.advanceTimersByTime(BRIDGE_READY_RETRY_MAX_MS * 10) + expect(page.sent).toHaveLength(1) + }) + + it('keeps what it holds when the same shell answers a second time', async () => { + const page = createPageClient() + page.start() + const onData = vi.fn() + const answer = page.client.sendRequest('worktree.ps') + page.client.subscribe('terminal.stream', {}, onData) + const id = idOf(page, 1) + // Every `ready` is answered, so a page that re-asked before the first init landed hears two. + page.deliver(INIT) + page.deliver(eventFrame(id, 1, 'still live')) + expect(onData.mock.calls).toEqual([['still live']]) + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id: idOf(page, 0), + payload: { id: 'wire-1', ok: true, result: 'ok', _meta: { runtimeId: 'runtime-a' } } + }) + await expect(answer).resolves.toMatchObject({ ok: true }) + }) + + it('settles everything the shell it lost was holding before adopting the new one', async () => { + const page = createPageClient() + page.start() + const onData = vi.fn() + const answer = page.client.sendRequest('worktree.ps') + page.client.subscribe('terminal.stream', {}, onData) + // A rebuilt host under the same page: its tables are empty, so nothing the page still holds + // would ever be answered or ended from there. + page.deliver({ ...INIT, sessionId: 'session-b' }) + const error = await answer.catch((thrown: unknown) => thrown) + expect(readError(error).name).toBe('BridgeShellReplacedError') + expect(isRpcDeliveryUnknown(error)).toBe(true) + expect(onData.mock.calls).toEqual([[{ type: 'error', message: expect.any(String) }]]) + expect(page.client.getShellSession()?.sessionId).toBe('session-b') + }) + + it('reads the connection snapshot init primed it with', () => { + const page = createPageClient() + page.start() + expect(page.client.getState()).toBe('connected') + expect(page.client.getReconnectAttempt()).toBe(2) + expect(page.client.getLastConnectedAt()).toBe(1700) + expect(page.client.getLastInboundAt?.()).toBe(1800) + expect(page.client.getGeneration?.()).toBe(5) + expect(page.client.getShellSession()).toEqual({ + sessionId: 'session-a', + buildId: 'build-a', + grants: INIT.grants + }) + }) + + it('answers a generation the shell does not keep with a constant epoch', () => { + const page = createPageClient() + page.deliver({ ...INIT, connection: { ...CONNECTION, generation: null } }) + expect(page.client.getGeneration?.()).toBe(0) + }) + + it('tells a waiting listener once, and a late one immediately', () => { + const page = createPageClient() + const early = vi.fn() + const dropped = vi.fn() + const release = page.client.onReady(dropped) + page.client.onReady(early) + release() + page.start() + expect(early).toHaveBeenCalledTimes(1) + expect(dropped).not.toHaveBeenCalled() + const late = vi.fn() + page.client.onReady(late) + expect(late).toHaveBeenCalledTimes(1) + page.deliver(INIT) + expect(early).toHaveBeenCalledTimes(1) + }) +}) + +describe('bridge client before a session', () => { + it('refuses every member that would have to answer for one', () => { + const page = createPageClient() + expect(() => page.client.getState()).toThrow(BridgeClientNotReadyError) + expect(() => page.client.getReconnectAttempt()).toThrow(BridgeClientNotReadyError) + expect(() => page.client.getLastConnectedAt()).toThrow(BridgeClientNotReadyError) + expect(() => page.client.getLastInboundAt?.()).toThrow(BridgeClientNotReadyError) + expect(() => page.client.getGeneration?.()).toThrow(BridgeClientNotReadyError) + expect(() => page.client.sendRequest('worktree.ps')).toThrow(BridgeClientNotReadyError) + expect(() => page.client.subscribe('terminal.stream', {}, vi.fn())).toThrow( + BridgeClientNotReadyError + ) + expect(() => page.client.notifyForeground()).toThrow(BridgeClientNotReadyError) + expect(() => + page.client.updateTerminalSubscriptionViewport('t', { cols: 80, rows: 24 }) + ).toThrow(BridgeClientNotReadyError) + expect(page.sent).toHaveLength(1) + }) + + it('still registers a state listener and still closes', () => { + const page = createPageClient() + const listener = vi.fn() + expect(() => page.client.onStateChange(listener)()).not.toThrow() + expect(() => { + page.client.close() + }).not.toThrow() + }) + + it('drops a state frame that beat init rather than priming from it', () => { + const page = createPageClient() + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'state', + connection: { ...CONNECTION, state: 'reconnecting' } + }) + expect(() => page.client.getState()).toThrow(BridgeClientNotReadyError) + expect(page.diagnostics).toEqual([]) + }) +}) + +describe('bridge client after close', () => { + it('goes inert instead of throwing into a teardown, and posts nothing more', async () => { + const page = createPageClient() + page.start() + page.client.close() + expect(page.frames().at(-1)).toEqual({ v: BRIDGE_PROTOCOL_VERSION, type: 'close' }) + const refused = page.client.sendRequest('worktree.ps') + await expect(refused).rejects.toThrow(BridgeClientClosedError) + expect(() => page.client.subscribe('terminal.stream', {}, vi.fn())()).not.toThrow() + expect(() => page.client.notifyForeground()).not.toThrow() + expect(() => { + page.client.updateTerminalSubscriptionViewport('t', { cols: 80, rows: 24 }) + }).not.toThrow() + page.client.close() + page.deliver(INIT) + expect(page.sent).toHaveLength(2) + }) + + it('publishes disconnected and keeps answering the snapshot it last held', () => { + const page = createPageClient() + page.start() + const listener = vi.fn() + page.client.onStateChange(listener) + page.client.close() + expect(listener).toHaveBeenCalledWith('disconnected') + expect(page.client.getState()).toBe('disconnected') + expect(page.client.getReconnectAttempt()).toBe(CONNECTION.reconnectAttempt) + expect(page.client.getLastConnectedAt()).toBe(CONNECTION.lastConnectedAt) + expect(page.client.getLastInboundAt?.()).toBe(CONNECTION.lastInboundAt) + expect(page.client.getGeneration?.()).toBe(CONNECTION.generation) + }) + + it('answers nothing it never heard: a close before init leaves the getters unready', () => { + const page = createPageClient() + const listener = vi.fn() + page.client.onStateChange(listener) + page.client.close() + expect(listener).not.toHaveBeenCalled() + expect(() => page.client.getState()).toThrow(BridgeClientNotReadyError) + }) + + it('reads nothing more, even from a port that kept delivering', () => { + const page = createPageClient({ keepDeliveringAfterUnsubscribe: true }) + page.start() + page.client.subscribe('terminal.stream', {}, vi.fn()) + const id = idOf(page, 0) + page.client.close() + page.deliver(INIT) + page.deliver(eventFrame(id, 1, 'late')) + page.deliverRaw('{ not json') + expect(page.diagnostics).toEqual([]) + expect(page.client.getState()).toBe('disconnected') + }) + + it('says goodbye once, without a cancel for each stream it owned', () => { + const page = createPageClient() + page.start() + page.client.subscribe('terminal.stream', {}, vi.fn()) + page.client.subscribe('terminal.stream', {}, vi.fn()) + page.client.close() + expect(page.frames().filter((frame) => frame.type === 'cancel')).toEqual([]) + expect(page.frames().filter((frame) => frame.type === 'close')).toHaveLength(1) + }) +}) + +describe('bridge client replies', () => { + it('rejects with the class and the delivery mark the shell captured', async () => { + const page = createPageClient() + page.start() + const answer = page.client.sendRequest('worktree.ps') + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'error', + id: idOf(page, 0), + error: { + category: 'RpcTimeoutError', + message: 'timed out', + isRpcDeliveryUnknown: true, + code: 'ETIMEDOUT', + cause: { category: 'Error', message: 'socket closed', isRpcDeliveryUnknown: false } + } + }) + const error = await answer.catch((thrown: unknown) => thrown) + expect(error).toBeInstanceOf(Error) + expect(readError(error).name).toBe('RpcTimeoutError') + expect(isRpcDeliveryUnknown(error)).toBe(true) + expect(readError(readError(error).cause).message).toBe('socket closed') + }) + + it('rejects a reply the assembler refuses', async () => { + const page = createPageClient() + page.start() + const answer = page.client.sendRequest('worktree.ps') + const id = idOf(page, 0) + const part = { + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id, + part: { i: 0, of: 2 }, + chunk: '{' + } + page.deliver(part) + page.deliver(part) + await expect(answer).rejects.toThrow('duplicate-part') + }) + + it('drops a reply or an error for an id it never opened, and says so', () => { + const page = createPageClient() + page.start() + const stranger = 'z'.repeat(22) + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id: stranger, + payload: { id: stranger, ok: true, result: 1, _meta: { runtimeId: 'runtime-a' } } + }) + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'error', + id: stranger, + error: { category: 'Error', message: 'gone', isRpcDeliveryUnknown: false } + }) + expect(page.diagnostics).toEqual([{ kind: 'unknown-id' }, { kind: 'unknown-id' }]) + }) + + it('frees the assembler slot of every id nobody is waiting on', async () => { + const page = createPageClient() + page.start() + const answer = page.client.sendRequest('worktree.ps') + const id = idOf(page, 0) + for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS * 2; index += 1) { + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id: index.toString(36).padStart(22, 'z'), + part: { i: 0, of: 2 }, + chunk: '{"a":' + }) + } + const payload = { id, ok: true, result: 7, _meta: { runtimeId: 'runtime-a' } } + const serialized = JSON.stringify(payload) + const cut = Math.floor(serialized.length / 2) + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id, + part: { i: 0, of: 2 }, + chunk: serialized.slice(0, cut) + }) + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id, + part: { i: 1, of: 2 }, + chunk: serialized.slice(cut) + }) + await expect(answer).resolves.toEqual(payload) + }) + + it('gives back the assembler slot of every id it settles', async () => { + const page = createPageClient() + page.start() + const settled: Promise[] = [] + for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) { + const abandoned = page.client.sendRequest('worktree.ps') + const id = idOf(page, index) + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id, + part: { i: 0, of: 2 }, + chunk: '{"a":' + }) + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'error', + id, + error: { category: 'Error', message: 'gone', isRpcDeliveryUnknown: false } + }) + settled.push(abandoned.catch(() => undefined)) + } + const answer = page.client.sendRequest('worktree.ps') + const id = idOf(page, BRIDGE_MAX_PENDING_REQUESTS) + const payload = { id, ok: true, result: 'assembled', _meta: { runtimeId: 'runtime-a' } } + const serialized = JSON.stringify(payload) + const cut = Math.floor(serialized.length / 2) + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id, + part: { i: 0, of: 2 }, + chunk: serialized.slice(0, cut) + }) + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'reply', + id, + part: { i: 1, of: 2 }, + chunk: serialized.slice(cut) + }) + await expect(answer).resolves.toEqual(payload) + await Promise.all(settled) + }) +}) + +describe('bridge client refusals and send failures', () => { + it('reports a frame its own reader will not take, and changes nothing', () => { + const page = createPageClient() + page.start() + page.deliverRaw('{ not json') + page.deliverRaw(JSON.stringify({ v: 99, type: 'state' })) + page.deliverRaw(`"${'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)}"`) + expect(page.diagnostics).toEqual([ + { kind: 'refused', refusal: 'malformed-json' }, + { kind: 'refused', refusal: 'unrecognised-message' }, + { kind: 'refused', refusal: 'oversized' } + ]) + expect(page.client.getState()).toBe('connected') + }) + + it('fails a request whose frame never left the page, without the delivery mark', async () => { + let live = true + const page = createPageClient({ + send: () => { + if (!live) { + throw new Error('the port is gone') + } + } + }) + page.start() + live = false + const answer = page.client.sendRequest('worktree.ps') + const error = await answer.catch((thrown: unknown) => thrown) + expect(readError(error).name).toBe('BridgeSendFailedError') + expect(isRpcDeliveryUnknown(error)).toBe(false) + expect(page.diagnostics.at(-1)).toEqual({ + kind: 'send-failed', + error: expect.any(Error) + }) + }) +}) + +describe('bridge client caps', () => { + it('refuses the request past the shell grant without a round trip', async () => { + const page = createPageClient() + page.start() + const answers: Promise[] = [] + for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) { + answers.push(page.client.sendRequest('worktree.ps')) + } + const refused = page.client.sendRequest('worktree.ps') + await expect(refused).rejects.toThrow(BridgeClientCapExceededError) + expect(page.sent).toHaveLength(1 + BRIDGE_MAX_PENDING_REQUESTS) + page.client.close() + await Promise.allSettled(answers) + }) + + it('frees the page slot when the subscribe frame never left the page', () => { + let live = true + const page = createPageClient({ + send: () => { + if (!live) { + throw new Error('the port is gone') + } + } + }) + page.start() + live = false + const onData = vi.fn() + // Every one of these is a slot the shell was never told about, and nothing will ever end it. + for (let index = 0; index < BRIDGE_MAX_SUBSCRIPTIONS; index += 1) { + page.client.subscribe('terminal.stream', {}, onData) + } + expect(onData).toHaveBeenCalledTimes(BRIDGE_MAX_SUBSCRIPTIONS) + expect(onData.mock.calls.at(-1)?.[0]).toEqual({ type: 'error', message: expect.any(String) }) + expect(page.diagnostics).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS) + live = true + // Short of this, the page is at its cap for the life of the document: only a reload clears it. + const dispose = page.client.subscribe('terminal.stream', {}, vi.fn()) + dispose() + expect(page.frames().filter((frame) => frame.type === 'cancel')).toHaveLength(1) + }) + + it('refuses the subscription past the shell grant at the call site', () => { + const page = createPageClient() + page.start() + for (let index = 0; index < BRIDGE_MAX_SUBSCRIPTIONS; index += 1) { + page.client.subscribe('terminal.stream', {}, vi.fn()) + } + expect(() => page.client.subscribe('terminal.stream', {}, vi.fn())).toThrow( + BridgeClientCapExceededError + ) + expect(page.sent).toHaveLength(1 + BRIDGE_MAX_SUBSCRIPTIONS) + }) +}) + +describe('bridge client acks', () => { + it('stays well inside the window the shell ends a stream at', () => { + // The shell's own numbers, not a copy of them: a window narrowed there has to fail here. + expect(BRIDGE_ACK_INTERVAL_FRAMES * 4).toBeLessThanOrEqual(BRIDGE_MAX_UNACKED_FRAMES) + expect(BRIDGE_ACK_INTERVAL_BYTES * 4).toBeLessThanOrEqual(BRIDGE_MAX_UNACKED_BYTES) + }) + + it('acks the last seq it read once the frame interval is due', () => { + const page = createPageClient() + page.start() + page.client.subscribe('terminal.stream', {}, vi.fn()) + const id = idOf(page, 0) + for (let seq = 1; seq < BRIDGE_ACK_INTERVAL_FRAMES; seq += 1) { + page.deliver(eventFrame(id, seq, seq)) + } + expect(page.frames().filter((frame) => frame.type === 'ack')).toEqual([]) + page.deliver(eventFrame(id, BRIDGE_ACK_INTERVAL_FRAMES, 'last')) + expect(page.frames().filter((frame) => frame.type === 'ack')).toEqual([ + { v: BRIDGE_PROTOCOL_VERSION, type: 'ack', id, seq: BRIDGE_ACK_INTERVAL_FRAMES } + ]) + }) + + it('acks early when the bytes are due before the frames are', () => { + const page = createPageClient() + page.start() + page.client.subscribe('terminal.stream', {}, vi.fn()) + const id = idOf(page, 0) + const heavy = 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES - 1024) + page.deliver(eventFrame(id, 1, heavy)) + page.deliver(eventFrame(id, 2, heavy)) + expect(page.frames().filter((frame) => frame.type === 'ack')).toEqual([ + { v: BRIDGE_PROTOCOL_VERSION, type: 'ack', id, seq: 2 } + ]) + }) + + it('acks a frame whose listener throws, so a listener bug cannot wedge the stream', () => { + const page = createPageClient() + page.start() + page.client.subscribe('terminal.stream', {}, () => { + throw new Error('listener bug') + }) + const id = idOf(page, 0) + for (let seq = 1; seq <= BRIDGE_ACK_INTERVAL_FRAMES; seq += 1) { + expect(() => page.deliver(eventFrame(id, seq, seq))).toThrow('listener bug') + } + expect(page.frames().filter((frame) => frame.type === 'ack')).toHaveLength(1) + }) + + it('ignores an event for a stream it already disposed', () => { + const page = createPageClient() + page.start() + const onData = vi.fn() + const dispose = page.client.subscribe('terminal.stream', {}, onData) + const id = idOf(page, 0) + dispose() + dispose() + page.deliver(eventFrame(id, 1, 'late')) + expect(onData).not.toHaveBeenCalled() + expect(page.frames().filter((frame) => frame.type === 'cancel')).toHaveLength(1) + }) + + it('posts no cancel for a stream the shell ended before the page let go', () => { + const page = createPageClient() + page.start() + const dispose = page.client.subscribe('terminal.stream', {}, vi.fn()) + const id = idOf(page, 0) + page.deliver({ v: BRIDGE_PROTOCOL_VERSION, type: 'end', id, reason: 'closed' }) + // The screen unmounts on its own schedule, which is routinely after the shell gave up. + dispose() + expect(page.frames().filter((frame) => frame.type === 'cancel')).toEqual([]) + }) + + it('retires a stream the shell ended, tells the listener, and reports why', () => { + const page = createPageClient() + page.start() + const onData = vi.fn() + page.client.subscribe('terminal.stream', {}, onData) + const id = idOf(page, 0) + page.deliver({ v: BRIDGE_PROTOCOL_VERSION, type: 'end', id, reason: 'overflow' }) + page.deliver(eventFrame(id, 1, 'after the end')) + // The terminal result is the only thing a consumer hears. `host-worktree-refresh.ts` reads it + // to clear the flag that says the event stream is live; without it the list never refreshes + // again, because frames that stop arriving look exactly like a stream with nothing to say. + expect(onData.mock.calls).toEqual([[{ type: 'error', message: expect.any(String) }]]) + expect(page.diagnostics).toEqual([{ kind: 'stream-ended', reason: 'overflow' }]) + expect(page.frames().filter((frame) => frame.type === 'cancel')).toEqual([]) + }) + + it('tells the listener nothing when the page itself let the stream go', () => { + const page = createPageClient() + page.start() + const onData = vi.fn() + const dispose = page.client.subscribe('terminal.stream', {}, onData) + dispose() + // The caller that disposed is the one that would hear it, and it has already moved on. + expect(onData).not.toHaveBeenCalled() + expect(page.frames().filter((frame) => frame.type === 'cancel')).toHaveLength(1) + }) +}) + +describe('bridge client binary frames', () => { + const image = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]) + const b64 = btoa(String.fromCharCode(...image)) + + function binaryFrame(id: string, b64Image: string): BridgeHostMessage { + return { + v: BRIDGE_PROTOCOL_VERSION, + type: 'event', + id, + seq: 1, + binary: { b64: b64Image, format: 'png', frameSeq: 41, metadata: { imageWidth: 8 } } + } + } + + it('asks for binary only when a listener is there to read it', () => { + const page = createPageClient() + page.start() + page.client.subscribe('browser.screencast', {}, vi.fn()) + page.client.subscribe('browser.screencast', {}, vi.fn(), { onBinaryFrame: vi.fn() }) + const opened = page.frames().filter((frame) => frame.type === 'subscribe') + expect(opened[0]).not.toHaveProperty('wantsBinary') + expect(opened[1]).toHaveProperty('wantsBinary', true) + }) + + it('decodes to the frame a native listener would have been handed', () => { + const page = createPageClient() + page.start() + const onBinaryFrame = vi.fn() + page.client.subscribe('browser.screencast', {}, vi.fn(), { onBinaryFrame }) + page.deliver(binaryFrame(idOf(page, 0), b64)) + expect(onBinaryFrame).toHaveBeenCalledWith({ + opcode: BrowserScreencastOpcode.Frame, + seq: 41, + format: 'png', + metadata: { imageWidth: 8 }, + image + }) + }) + + it('carries every metadata field the shell measured', () => { + const page = createPageClient() + page.start() + const onBinaryFrame = vi.fn() + page.client.subscribe('browser.screencast', {}, vi.fn(), { onBinaryFrame }) + const metadata = { + offsetTop: 1, + pageScaleFactor: 2, + deviceWidth: 3, + deviceHeight: 4, + imageWidth: 5, + imageHeight: 6, + scrollOffsetX: 7, + scrollOffsetY: 8, + timestamp: 9 + } + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'event', + id: idOf(page, 0), + seq: 1, + binary: { b64, format: 'jpeg', frameSeq: 0, metadata } + }) + expect(onBinaryFrame).toHaveBeenCalledWith( + expect.objectContaining({ format: 'jpeg', seq: 0, metadata }) + ) + }) + + it('drops a frame with no listener and one it cannot decode', () => { + const page = createPageClient() + page.start() + page.client.subscribe('browser.screencast', {}, vi.fn()) + page.deliver(binaryFrame(idOf(page, 0), b64)) + const onBinaryFrame = vi.fn() + page.client.subscribe('browser.screencast', {}, vi.fn(), { onBinaryFrame }) + page.deliver(binaryFrame(idOf(page, 1), '!!not base64!!')) + expect(onBinaryFrame).not.toHaveBeenCalled() + expect(page.diagnostics).toEqual([ + { kind: 'binary-frame-dropped' }, + { kind: 'binary-frame-dropped' } + ]) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.test.ts new file mode 100644 index 00000000000..1d31a4c1582 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + isRpcDeliveryUnknown, + markRpcDeliveryUnknown +} from '../../transport/rpc-delivery-ambiguity' +import type { RpcResponse } from '../../transport/types' +import { createFakeRpcClient } from '../bridge-host-test-fakes' +import { BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_MAX_SUBSCRIPTIONS } from './bridge-caps' +import { createBridgePortPair, type BridgePortPair } from './bridge-port-pair-test-harness' + +/** + * The page's client and the shell's host, over one FIFO per direction. + * + * That `createBridgeRpcClient` returns an `RpcClient` is the type system's job and it is already + * done; what a test has to prove is that each member still means the same thing after a round trip, + * because the screens above it cannot tell which client they are holding. + */ + +function success(id: string, result: unknown): RpcResponse { + return { id, ok: true, result, _meta: { runtimeId: 'runtime-a' } } +} + +/** Narrows what a rejection handed back, so a test reads an error rather than asserting one. */ +function readError(thrown: unknown): Error { + if (!(thrown instanceof Error)) { + throw new Error(`expected an Error, got ${typeof thrown}`) + } + return thrown +} + +async function ready(pair: BridgePortPair): Promise { + await pair.flush() + return pair +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('bridge round trip: requests', () => { + it('reaches the shell with the arity the page called with', async () => { + const pair = await ready(createBridgePortPair()) + void pair.client.sendRequest('worktree.ps') + void pair.client.sendRequest('worktree.ps', { host: 'a' }) + void pair.client.sendRequest('worktree.ps', { host: 'a' }, { timeoutMs: 50 }) + await pair.flush() + expect(pair.rpc.requests.map((request) => request.args)).toEqual([ + ['worktree.ps'], + ['worktree.ps', { host: 'a' }], + ['worktree.ps', { host: 'a' }, { timeoutMs: 50 }] + ]) + }) + + it('resolves the response the shell answered with, field for field', async () => { + const pair = await ready(createBridgePortPair()) + const answer = pair.client.sendRequest('worktree.ps') + await pair.flush() + const response: RpcResponse = { + id: 'shell-side-id', + ok: true, + result: { rows: [1, 2, 3] }, + streaming: true, + _meta: { runtimeId: 'runtime-a' } + } + pair.rpc.requests[0]?.resolve(response) + await pair.flush() + await expect(answer).resolves.toEqual(response) + }) + + it('resolves a host failure, because a failure is data and not a rejection', async () => { + const pair = await ready(createBridgePortPair()) + const answer = pair.client.sendRequest('worktree.ps') + await pair.flush() + const failure: RpcResponse = { + id: 'shell-side-id', + ok: false, + error: { code: 'not_found', message: 'no such worktree', data: { host: 'a' } }, + _meta: { runtimeId: 'runtime-a' } + } + pair.rpc.requests[0]?.resolve(failure) + await pair.flush() + await expect(answer).resolves.toEqual(failure) + }) + + it('rejects with the class, the code and the delivery mark the shell captured', async () => { + const pair = await ready(createBridgePortPair()) + const answer = pair.client.sendRequest('worktree.ps') + await pair.flush() + class RpcTimeoutError extends Error { + code = 'ETIMEDOUT' + } + const thrown = markRpcDeliveryUnknown(new RpcTimeoutError('timed out after 50ms')) + thrown.cause = new Error('socket closed') + pair.rpc.requests[0]?.reject(thrown) + await pair.flush() + const error = await answer.catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(Error) + expect(readError(error).name).toBe('RpcTimeoutError') + expect(readError(error).message).toBe('timed out after 50ms') + expect(isRpcDeliveryUnknown(error)).toBe(true) + expect(readError(readError(error).cause).message).toBe('socket closed') + }) + + it('reassembles a reply too big for one frame', async () => { + const pair = await ready(createBridgePortPair()) + const answer = pair.client.sendRequest('source-control.diff') + await pair.flush() + const result = { diff: 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 60_000) } + pair.rpc.requests[0]?.resolve(success('shell-side-id', result)) + await pair.flush() + await expect(answer).resolves.toEqual(success('shell-side-id', result)) + expect(pair.toPage.length).toBeGreaterThan(2) + }) +}) + +describe('bridge round trip: subscriptions', () => { + it('streams what the shell emits and stops when the page disposes', async () => { + const pair = await ready(createBridgePortPair()) + const onData = vi.fn() + const dispose = pair.client.subscribe('terminal.stream', { terminal: 't' }, onData) + await pair.flush() + expect(pair.rpc.streams[0]?.method).toBe('terminal.stream') + expect(pair.rpc.streams[0]?.params).toEqual({ terminal: 't' }) + pair.rpc.streams[0]?.emit({ type: 'data', chunk: 'hello' }) + await pair.flush() + expect(onData).toHaveBeenCalledWith({ type: 'data', chunk: 'hello' }) + dispose() + await pair.flush() + expect(pair.rpc.streams[0]?.unsubscribes).toBe(1) + pair.rpc.streams[0]?.emit({ type: 'data', chunk: 'after' }) + await pair.flush() + expect(onData).toHaveBeenCalledTimes(1) + }) + + it('frees the page slot when the shell refuses the subscribe', async () => { + const pair = await ready(createBridgePortPair()) + const onData = vi.fn() + const refuse = vi.spyOn(pair.rpc, 'subscribe').mockImplementation(() => { + throw new Error('the terminal is gone') + }) + pair.client.subscribe('terminal.stream', { terminal: 't' }, onData) + await pair.flush() + refuse.mockRestore() + expect(pair.diagnostics).toEqual([ + { kind: 'stream-failed', error: expect.objectContaining({ message: 'the terminal is gone' }) } + ]) + // The shell's own message reaches the listener, the way the native client passes one through. + expect(onData.mock.calls).toEqual([ + [{ type: 'error', message: 'the terminal is gone', error: expect.any(Error) }] + ]) + // A leaked slot is invisible until the page reaches its own cap, so that is where it is read. + for (let index = 0; index < BRIDGE_MAX_SUBSCRIPTIONS; index += 1) { + pair.client.subscribe('terminal.stream', {}, vi.fn()) + } + await pair.flush() + expect(pair.rpc.streams).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS) + }) + + it('keeps a long stream alive, because the acks free the shell window', async () => { + const pair = await ready(createBridgePortPair()) + const onData = vi.fn() + pair.client.subscribe('terminal.stream', {}, onData) + await pair.flush() + for (let batch = 0; batch < 8; batch += 1) { + for (let frame = 0; frame < 50; frame += 1) { + pair.rpc.streams[0]?.emit(`frame-${batch}-${frame}`) + } + await pair.flush() + } + expect(onData).toHaveBeenCalledTimes(400) + expect(pair.diagnostics).toEqual([]) + }) + + it('ends the stream when the page never gets a chance to ack', async () => { + const pair = await ready(createBridgePortPair()) + const onData = vi.fn() + pair.client.subscribe('terminal.stream', {}, onData) + await pair.flush() + for (let frame = 0; frame < 400; frame += 1) { + pair.rpc.streams[0]?.emit(`frame-${frame}`) + } + await pair.flush() + expect(pair.diagnostics).toEqual([{ kind: 'stream-ended', reason: 'overflow' }]) + expect(onData.mock.calls.length).toBeLessThan(400) + }) +}) + +describe('bridge round trip: notifications and state', () => { + it('carries both notifies to the shell client, with the arity each was called with', async () => { + const pair = await ready(createBridgePortPair()) + pair.client.notifyForeground() + pair.client.notifyForeground('app-resume') + pair.client.updateTerminalSubscriptionViewport('terminal-a', { cols: 120, rows: 40 }) + await pair.flush() + expect(pair.rpc.foregroundCalls).toEqual([[], ['app-resume']]) + expect(pair.rpc.viewports).toEqual([{ terminal: 'terminal-a', cols: 120, rows: 40 }]) + }) + + it('reads the shell client through init and fans out every change after it', async () => { + const rpc = createFakeRpcClient({ + getState: () => 'reconnecting', + getReconnectAttempt: () => 3, + getLastConnectedAt: () => 1234, + getLastInboundAt: () => 5678, + getGeneration: () => 9 + }) + const pair = await ready(createBridgePortPair({ rpc })) + expect(pair.client.getState()).toBe('reconnecting') + expect(pair.client.getReconnectAttempt()).toBe(3) + expect(pair.client.getLastConnectedAt()).toBe(1234) + expect(pair.client.getLastInboundAt?.()).toBe(5678) + expect(pair.client.getGeneration?.()).toBe(9) + const listener = vi.fn() + const release = pair.client.onStateChange(listener) + rpc.pushState('connected') + await pair.flush() + expect(listener).toHaveBeenCalledWith('connected') + expect(pair.client.getState()).toBe('connected') + release() + rpc.pushState('disconnected') + await pair.flush() + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('refuses a snapshot from a shell that was rebuilt, and asks for a fresh init', async () => { + let generation = 5 + const rpc = createFakeRpcClient({ getGeneration: () => generation }) + const pair = await ready(createBridgePortPair({ rpc })) + const listener = vi.fn() + pair.client.onStateChange(listener) + const asked = pair.readToShell().filter((frame) => frame.type === 'ready').length + generation = 2 + rpc.pushState('reconnecting') + await pair.flush() + expect(pair.diagnostics).toEqual([{ kind: 'state-out-of-order' }]) + expect(listener).not.toHaveBeenCalled() + expect(pair.readToShell().filter((frame) => frame.type === 'ready').length).toBe(asked + 1) + // The fresh init is what re-primes the cache; the refused frame never touched it. + expect(pair.client.getState()).toBe('connected') + expect(pair.client.getGeneration?.()).toBe(2) + }) +}) + +describe('bridge round trip: close', () => { + it('settles pendings delivery-unknown, retires the streams, and leaves the shell client open', async () => { + const pair = await ready(createBridgePortPair()) + const closeShellClient = vi.spyOn(pair.rpc, 'close') + const answer = pair.client.sendRequest('worktree.ps') + pair.client.subscribe('terminal.stream', {}, vi.fn()) + await pair.flush() + pair.client.close() + const error = await answer.catch((caught: unknown) => caught) + expect(readError(error).name).toBe('BridgeClientClosedError') + expect(isRpcDeliveryUnknown(error)).toBe(true) + await pair.flush() + expect(pair.rpc.streams[0]?.unsubscribes).toBe(1) + expect(closeShellClient).not.toHaveBeenCalled() + expect(pair.readToShell().at(-1)).toEqual({ v: 1, type: 'close' }) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts new file mode 100644 index 00000000000..5329dc8175d --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts @@ -0,0 +1,382 @@ +import type { BrowserScreencastFrame } from '../../transport/browser-screencast-protocol' +import type { RpcClient, SendRequestOptions } from '../../transport/rpc-client' +import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from '../../transport/types' +import { + BRIDGE_MAX_PENDING_REQUESTS, + BRIDGE_MAX_SUBSCRIPTIONS, + utf8ByteLength, + type BridgeRefusal +} from './bridge-caps' +import { BridgeConnectionCache } from './bridge-client-connection-cache' +import { createBridgeInitHandshake } from './bridge-client-init-handshake' +import { + BridgeClientCapExceededError, + BridgeClientClosedError, + BridgeClientNotReadyError, + BridgeSendFailedError, + BridgeShellReplacedError +} from './bridge-client-errors' +import { BridgeClientRequests } from './bridge-client-requests' +import { + BridgeClientSubscriptions, + type BridgeStreamEndReason +} from './bridge-client-subscriptions' +import { + BRIDGE_PROTOCOL_VERSION, + readBridgeHostMessage, + type BridgeClientMessage, + type BridgeConnectionSnapshot, + type BridgeGrants, + type BridgeHostMessage +} from './bridge-envelope' +import { reconstructBridgeError } from './bridge-error-capture' + +export { + BridgeClientCapExceededError, + BridgeClientClosedError, + BridgeClientNotReadyError, + BridgeReplyRefusedError, + BridgeSendFailedError, + BridgeShellReplacedError +} from './bridge-client-errors' + +/** Base64url, and the length the envelope's id pattern requires. Base36 digits are a subset of it. */ +const BRIDGE_ID_CHARS = 22 + +/** Nothing here is recoverable in place; each is worth a line in a log and none is retried. */ +export type BridgeRpcClientDiagnostic = + | { kind: 'refused'; refusal: BridgeRefusal } + | { kind: 'send-failed'; error: unknown } + | { kind: 'stream-ended'; reason: BridgeStreamEndReason } + | { kind: 'stream-failed'; error: unknown } + | { kind: 'state-out-of-order' } + | { kind: 'binary-frame-dropped' } + | { kind: 'unknown-id' } + +/** What `init` said this page is attached to. `grants` is what a call site checks before it posts. */ +export type BridgeShellSession = { + sessionId: string + buildId: string + grants: BridgeGrants +} + +export type BridgeRpcClientOptions = { + /** Posts one frame to the shell. May throw; nothing about returning proves delivery. */ + send: (json: string) => void + onMessage: (handler: (json: string) => void) => () => void + onDiagnostic?: (diagnostic: BridgeRpcClientDiagnostic) => void +} + +export type BridgeRpcClient = RpcClient & { + /** Fires once `init` has landed, immediately if it already has. Mount no screen before it. */ + onReady: (listener: () => void) => () => void + getShellSession: () => BridgeShellSession | null +} + +/** + * The page's `RpcClient`, which is a bridge and not a socket. + * + * Every member of the native contract is here, so `runRpcOperation` and the screens above it never + * learn which one they hold. Two properties make that honest. The getters are synchronous reads of a + * cache primed by `init`, because screens read them during render and an async read changes what the + * first render sees. And `close` never closes the shell's client: that one is shared with the native + * screens and the host catalog, so the page settles what it owns and says goodbye. + * + * Nothing may be called before `init`. The alternative is a stub answering `connecting` to a screen + * that then records the wrong first render, so a call arriving early throws instead. After `close` + * the opposite rule holds: every member goes inert and the getters keep answering the snapshot the + * page last held, marked `disconnected`, because an unmounting screen calls into a path with no + * catch on it. A stream the shell refuses or ends is not thrown anywhere either; it arrives as a + * diagnostic, which is the only channel `subscribe` leaves open once it has handed back a dispose. + */ +export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRpcClient { + const requests = new BridgeClientRequests() + const cache = new BridgeConnectionCache() + const readyListeners = new Set<() => void>() + let session: BridgeShellSession | null = null + let closed = false + let idCounter = 0 + + function report(diagnostic: BridgeRpcClientDiagnostic): void { + options.onDiagnostic?.(diagnostic) + } + + /** False when the frame never left. Every value in a page frame is one the caller handed in, so + * the throw this catches is the port's, never `JSON.stringify`'s. */ + function sendFrame(frame: BridgeClientMessage): boolean { + try { + options.send(JSON.stringify(frame)) + return true + } catch (error) { + report({ kind: 'send-failed', error }) + return false + } + } + + // Counted rather than random: a recorded run replays the same ids, and one page holds one client, + // so a counter is already unique across everything the shell is asked to keep in flight. + function nextId(): string { + idCounter += 1 + return idCounter.toString(36).padStart(BRIDGE_ID_CHARS, '0') + } + + const subscriptions = new BridgeClientSubscriptions({ + send: (frame) => sendFrame(frame), + onDroppedBinaryFrame: () => { + report({ kind: 'binary-frame-dropped' }) + } + }) + + const handshake = createBridgeInitHandshake(() => { + sendFrame({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }) + }) + + /** + * A call before `init` is a mount-order bug and throws. A call after `close` is not: an unmounting + * screen posts one more nudge on its way out, and the native clients answer those inertly rather + * than throwing into a teardown path nobody wrote a catch for. Each member below says what inert + * means for its own return type. + */ + function requireSession(): void { + if (session === null && !closed) { + throw new BridgeClientNotReadyError() + } + } + + // Answers after `close` as well: what it holds is then the last snapshot, marked `disconnected`. + function snapshot(): BridgeConnectionSnapshot { + const held = cache.read() + if (held === null) { + throw new BridgeClientNotReadyError() + } + return held + } + + /** A second `init` is ordinary: the shell answers every `ready`, and a page that re-asked hears + * its own session again. A different id is not, and nothing the page held survives it. */ + function acceptInit(message: Extract): void { + handshake.stop() + if (session !== null && session.sessionId !== message.sessionId) { + const replaced = new BridgeShellReplacedError() + requests.closeAll(replaced) + subscriptions.failAll(replaced.message) + } + session = { sessionId: message.sessionId, buildId: message.buildId, grants: message.grants } + cache.prime(message.connection) + for (const listener of readyListeners) { + listener() + } + readyListeners.clear() + } + + /** A shell rebuilt under the page: what the cache holds is for a client that is already gone. */ + function acceptState(snapshotFromShell: BridgeConnectionSnapshot): void { + if (cache.apply(snapshotFromShell) !== 'stale') { + return + } + report({ kind: 'state-out-of-order' }) + handshake.restart() + } + + /** The shell's own words where it had any, the way the native client passes an RPC error message + * through to the listener it ends. */ + function describeStreamFailure(error: unknown): string { + return error instanceof Error ? error.message : 'the shell could not keep this stream open' + } + + /** The shell answers a refused `subscribe` with `error` on the stream's id. Nothing is pending to + * reject there, so routing it to the requests would drop it and hold the page's slot forever. */ + function failExchange(id: string, error: unknown): void { + if (subscriptions.has(id)) { + // Reported before the listener runs, so a listener that throws cannot swallow the diagnostic. + report({ kind: 'stream-failed', error }) + subscriptions.end(id, describeStreamFailure(error), error) + return + } + if (!requests.has(id)) { + report({ kind: 'unknown-id' }) + } + // Still routed: an id with a half-assembled reply behind it holds a slot until it is discarded. + requests.fail(id, error) + } + + function dispatch(message: BridgeHostMessage, json: string): void { + switch (message.type) { + case 'init': + acceptInit(message) + return + case 'state': + acceptState(message.connection) + return + case 'reply': + if (!requests.has(message.id)) { + report({ kind: 'unknown-id' }) + } + requests.acceptReply(message) + return + case 'error': + failExchange(message.id, reconstructBridgeError(message.error)) + return + case 'event': + subscriptions.deliver(message, utf8ByteLength(json)) + return + case 'end': + report({ kind: 'stream-ended', reason: message.reason }) + subscriptions.end(message.id, `the shell ended this stream (${message.reason})`) + return + } + } + + function receive(json: string): void { + if (closed) { + return + } + const read = readBridgeHostMessage(json) + if (!read.ok) { + report({ kind: 'refused', refusal: read.refusal }) + return + } + dispatch(read.message, json) + } + + function sendRequest(...args: [string, unknown?, SendRequestOptions?]): Promise { + // A call with no session is a page bug and throws; a call over the in-flight cap is the answer + // the shell would have posted back, so it arrives the way the shell's does, as a rejection. + requireSession() + if (closed) { + // Rejected, not thrown: `bindDeferredRpcOperation` hands this promise straight back, so a + // synchronous throw would escape past the caller's `catch` on the promise. + return Promise.reject(new BridgeClientClosedError()) + } + if (requests.size >= BRIDGE_MAX_PENDING_REQUESTS) { + return Promise.reject( + new BridgeClientCapExceededError(`over ${BRIDGE_MAX_PENDING_REQUESTS} requests in flight`) + ) + } + const [method, params, requestOptions] = args + const id = nextId() + return new Promise((resolve, reject) => { + requests.open(id, { resolve, reject }) + const sent = sendFrame({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'request', + id, + method, + // Absent stays absent, because the shell replays whichever arity crossed. JSON drops an + // `undefined` value on its own, so an explicit `sendRequest(m, undefined)` reaches the shell + // as `sendRequest(m)`; no call site passes one, and no wire that carries `undefined` exists + // to carry it. The spread is what states the intent for a carrier that would. + ...(args.length > 1 ? { params } : {}), + ...(requestOptions === undefined ? {} : { options: requestOptions }) + }) + if (!sent) { + requests.abandon(id) + reject(new BridgeSendFailedError()) + } + }) + } + + function subscribe( + method: string, + params: unknown, + onData: (result: unknown) => void, + subscribeOptions?: { onBinaryFrame?: (frame: BrowserScreencastFrame) => void } + ): () => void { + requireSession() + if (closed) { + return () => undefined + } + // Thrown rather than reported: `subscribe` hands back an unsubscribe and nothing else, so a + // refusal the caller could read does not exist on this member. A refusal the shell posts back + // arrives too late to throw at all, and reaches the page as a `stream-failed` diagnostic. + if (subscriptions.size >= BRIDGE_MAX_SUBSCRIPTIONS) { + throw new BridgeClientCapExceededError(`over ${BRIDGE_MAX_SUBSCRIPTIONS} subscriptions`) + } + const id = nextId() + // A frame that never left already told the listener and gave the slot back; the caller still + // gets a dispose, because it has no way to know which of the two it is holding. + if (!subscriptions.open(id, method, params, onData, subscribeOptions?.onBinaryFrame)) { + return () => undefined + } + let disposed = false + return () => { + if (disposed) { + return + } + disposed = true + subscriptions.cancel(id) + } + } + + function close(): void { + if (closed) { + return + } + closed = true + handshake.stop() + subscriptions.closeAll() + sendFrame({ v: BRIDGE_PROTOCOL_VERSION, type: 'close' }) + requests.closeAll() + cache.close() + session = null + readyListeners.clear() + unsubscribeFromMessages() + } + + const unsubscribeFromMessages = options.onMessage(receive) + handshake.start() + + return { + sendRequest, + subscribe, + updateTerminalSubscriptionViewport: (terminal, viewport) => { + requireSession() + if (closed) { + return + } + sendFrame({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'notify', + name: 'terminalViewport', + terminal, + cols: viewport.cols, + rows: viewport.rows + }) + }, + getState: (): ConnectionState => snapshot().state, + getReconnectAttempt: () => snapshot().reconnectAttempt, + getLastConnectedAt: () => snapshot().lastConnectedAt, + getLastInboundAt: () => snapshot().lastInboundAt, + // A shell client with no generation of its own never migrates, so its epoch is a constant and + // zero is as true as any other. The page still answers a number, because the member it stands in + // for is one the native screens read without asking whether it exists. + getGeneration: () => snapshot().generation ?? 0, + // Not gated on the session: it registers a listener and reads nothing, so it cannot answer + // wrongly, and a provider that subscribes before `init` is how a screen hears the first change. + onStateChange: (listener) => cache.onStateChange(listener), + notifyForeground: (reason?: ForegroundNudgeReason) => { + requireSession() + if (closed) { + return + } + sendFrame({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'notify', + name: 'foreground', + ...(reason === undefined ? {} : { reason }) + }) + }, + close, + onReady: (listener) => { + if (session !== null) { + listener() + return () => undefined + } + readyListeners.add(listener) + return () => { + readyListeners.delete(listener) + } + }, + getShellSession: () => session + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-screencast-binary.ts b/mobile/src/mobile-web-shell/bridge/bridge-screencast-binary.ts new file mode 100644 index 00000000000..891c0e01238 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-screencast-binary.ts @@ -0,0 +1,53 @@ +import { + BrowserScreencastOpcode, + type BrowserScreencastFrame +} from '../../transport/browser-screencast-protocol' +import type { BridgeHostMessage } from './bridge-envelope' + +/** + * The binary lane's page-side half: base64 in, the same `BrowserScreencastFrame` a native listener + * is handed out. + * + * There is no wire header to parse here. `decodeBrowserScreencastFrame` reads one because the + * socket carries a frame as a single buffer; the envelope already carries `format`, `frameSeq` and + * the metadata as JSON beside the image, so only the image is base64. C6 owns the encoder that + * produces this shape, and this is the inverse it has to satisfy. + */ +export type BridgeBinaryEvent = Extract< + Extract, + { binary: unknown } +>['binary'] + +/** `null` when the image is not base64: an undecodable frame is dropped, never guessed at. */ +export function decodeBridgeScreencastFrame( + event: BridgeBinaryEvent +): BrowserScreencastFrame | null { + const image = decodeBase64(event.b64) + if (image === null) { + return null + } + return { + opcode: BrowserScreencastOpcode.Frame, + // The screencast's own counter. The event frame's `seq` is the bridge's backpressure ordinal, + // and handing that one over would renumber every frame the page reports. + seq: event.frameSeq, + format: event.format, + metadata: event.metadata, + image + } +} + +/** Metro ships no `Buffer`; `atob` is what the pairing and E2EE paths already decode with. */ +function decodeBase64(value: string): Uint8Array | null { + let binary: string + try { + binary = atob(value) + } catch { + return null + } + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes +} diff --git a/mobile/src/mobile-web-shell/bridge/orca-bridge-page-channel.test.ts b/mobile/src/mobile-web-shell/bridge/orca-bridge-page-channel.test.ts new file mode 100644 index 00000000000..518142a7796 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/orca-bridge-page-channel.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createOrcaBridgePageTransport, + readOrcaBridgePageChannel, + type OrcaBridgePageChannel +} from './orca-bridge-page-channel' + +function installChannel(channel: unknown): void { + Object.defineProperty(globalThis, 'orcaBridge', { value: channel, configurable: true }) +} + +function createChannel(): OrcaBridgePageChannel { + return { postMessage: vi.fn(), onmessage: null } +} + +afterEach(() => { + Reflect.deleteProperty(globalThis, 'orcaBridge') +}) + +describe('the page channel the shell installs', () => { + it('is absent in a browser, which is a page the bundle still has to open', () => { + expect(readOrcaBridgePageChannel()).toBeNull() + }) + + it('refuses a global of another shape rather than posting into it', () => { + installChannel({ postMessage: 'not a function', onmessage: null }) + expect(readOrcaBridgePageChannel()).toBeNull() + }) + + it('reads the installed object itself, so the page posts through the real sink', () => { + const channel = createChannel() + installChannel(channel) + expect(readOrcaBridgePageChannel()).toBe(channel) + }) +}) + +describe('the page channel as a client transport', () => { + it('posts what the client sends', () => { + const channel = createChannel() + createOrcaBridgePageTransport(channel).send('{"v":1}') + expect(channel.postMessage).toHaveBeenCalledWith('{"v":1}') + }) + + it('hands the client the frame off the event, and gives the slot back', () => { + const channel = createChannel() + const handler = vi.fn() + const release = createOrcaBridgePageTransport(channel).onMessage(handler) + channel.onmessage?.({ data: '{"v":1,"type":"init"}' }) + expect(handler).toHaveBeenCalledWith('{"v":1,"type":"init"}') + release() + expect(channel.onmessage).toBeNull() + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/orca-bridge-page-channel.ts b/mobile/src/mobile-web-shell/bridge/orca-bridge-page-channel.ts new file mode 100644 index 00000000000..57aa7f79fea --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/orca-bridge-page-channel.ts @@ -0,0 +1,51 @@ +import type { BridgeRpcClientOptions } from './bridge-rpc-client' + +/** + * The page's half of the native channel, as the document-start installer leaves it. + * + * `postMessage` and an `onmessage` assignment are the whole surface, and it is deliberately the + * intersection of the two platforms: Android's `addWebMessageListener` injects an object of this + * shape, and `MobileWebShellView.swift` installs one to match. Nothing else about the WebView is + * addressable from the page. + */ +export type OrcaBridgePageChannel = { + postMessage: (json: string) => void + onmessage: ((event: { data: string }) => void) | null +} + +/** + * `null` for a page that is not inside the shell — a browser, or a WebView mounted with the bridge + * off. That is a supported way to open the bundle, so the caller substitutes rather than throws. + */ +export function readOrcaBridgePageChannel(): OrcaBridgePageChannel | null { + const scope: typeof globalThis & { orcaBridge?: OrcaBridgePageChannel } = globalThis + const channel = scope.orcaBridge + if (channel === undefined || typeof channel.postMessage !== 'function') { + return null + } + return channel +} + +/** + * The channel as the page client's transport. + * + * One `onmessage` slot exists, so one client reads the channel; a second would silently take the + * first one's frames. The page holds exactly one client, which is what makes that safe. + */ +export function createOrcaBridgePageTransport( + channel: OrcaBridgePageChannel +): Pick { + return { + send: (json) => { + channel.postMessage(json) + }, + onMessage: (handler) => { + channel.onmessage = (event) => { + handler(event.data) + } + return () => { + channel.onmessage = null + } + } + } +} diff --git a/mobile/src/transport/client-context.web.test.tsx b/mobile/src/transport/client-context.web.test.tsx new file mode 100644 index 00000000000..b1164968e6a --- /dev/null +++ b/mobile/src/transport/client-context.web.test.tsx @@ -0,0 +1,161 @@ +import { createElement, type ReactElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BRIDGE_PROTOCOL_VERSION } from '../mobile-web-shell/bridge/bridge-envelope' +import type { RpcClientContextValue } from './rpc-client-context-contract' + +// The web file re-exports the screen hooks, and reaching the real ones imports the Expo runtime +// this test does not have. Nothing below calls one. +vi.mock('./host-client-hooks', () => ({ + useDisconnectHostClient: () => () => {}, + useForceReconnect: () => () => Promise.resolve(), + useForgetHostClient: () => () => {}, + useHostClient: () => ({ client: null, clientId: null, state: 'disconnected' }), + usePrimeHosts: () => () => {}, + useRefreshHostClient: () => () => {} +})) + +import { RpcClientProvider, useRpcClientContext } from './client-context.web' + +const INIT = { + v: BRIDGE_PROTOCOL_VERSION, + type: 'init', + sessionId: 'session-a', + buildId: 'build-a', + connection: { + state: 'connected', + reconnectAttempt: 2, + lastConnectedAt: 1700, + lastInboundAt: 1800, + generation: 5 + }, + grants: { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] } +} + +/** What the page mounted, and what it holds — the two things the provider decides. */ +const screen: { mounts: number; context: RpcClientContextValue | null } = { + mounts: 0, + context: null +} + +function Screen(): null { + screen.context = useRpcClientContext() + screen.mounts += 1 + return null +} + +function render(): ReactElement { + return createElement(RpcClientProvider, null, createElement(Screen)) +} + +/** The channel the shell's document-start script installs, as a double. */ +function installChannel(): { posted: string[]; deliver: (frame: unknown) => void } { + const posted: string[] = [] + const channel: { + postMessage: (json: string) => void + onmessage: ((e: { data: string }) => void) | null + } = { + postMessage: (json) => { + posted.push(json) + }, + onmessage: null + } + Object.defineProperty(globalThis, 'orcaBridge', { value: channel, configurable: true }) + return { + posted, + deliver: (frame) => { + channel.onmessage?.({ data: JSON.stringify(frame) }) + } + } +} + +function readContext(): RpcClientContextValue { + const context = screen.context + if (context === null) { + throw new Error('no screen mounted') + } + return context +} + +beforeEach(() => { + vi.useFakeTimers() + screen.mounts = 0 + screen.context = null +}) + +afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(globalThis, 'orcaBridge') +}) + +describe('the page provider inside the shell', () => { + it('mounts nothing until the shell answers with a session', () => { + const channel = installChannel() + act(() => { + create(render()) + }) + expect(screen.mounts).toBe(0) + expect(channel.posted.map((json: string) => JSON.parse(json).type)).toEqual(['ready']) + act(() => { + channel.deliver(INIT) + }) + expect(screen.mounts).toBe(1) + }) + + it('answers every screen with the one client the page has', () => { + const channel = installChannel() + act(() => { + create(render()) + }) + act(() => { + channel.deliver(INIT) + }) + const context = readContext() + const client = context.acquire('host-a', {}) + expect(client).not.toBeNull() + expect(context.getState('host-a')).toBe('connected') + expect(context.getReconnectAttempt('host-a')).toBe(2) + expect(context.getLastConnectedAt('host-a')).toBe(1700) + expect(context.getAllClients()).toEqual([{ hostId: 'host-a', client }]) + }) + + it('carries a state change from the shell to the screens watching it', () => { + const channel = installChannel() + act(() => { + create(render()) + }) + act(() => { + channel.deliver(INIT) + }) + const listener = vi.fn() + readContext().subscribeHostState('host-a', listener) + act(() => { + channel.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'state', + connection: { ...INIT.connection, state: 'reconnecting' } + }) + }) + expect(listener).toHaveBeenCalledWith('reconnecting') + expect(readContext().getState('host-a')).toBe('reconnecting') + }) +}) + +describe('the page provider outside the shell', () => { + it('mounts the route tree at once, because no session is ever coming', () => { + act(() => { + create(render()) + }) + expect(screen.mounts).toBe(1) + expect(readContext().getState('host-a')).toBe('disconnected') + }) + + it('hands out a client that reaches nothing rather than none at all', async () => { + act(() => { + create(render()) + }) + const client = readContext().acquire('host-a', {}) + expect(client).not.toBeNull() + await expect(client?.sendRequest('worktree.ps')).rejects.toThrow('bridge transport unavailable') + }) +}) diff --git a/mobile/src/transport/client-context.web.tsx b/mobile/src/transport/client-context.web.tsx index 1776cbb859b..b070a7e0edd 100644 --- a/mobile/src/transport/client-context.web.tsx +++ b/mobile/src/transport/client-context.web.tsx @@ -1,6 +1,24 @@ -// Web sibling: RN Web has no pairing keychain and no websocket transport of its own, so the page -// gets a placeholder client until C0.4 lands BridgeRpcClient over the shell bridge. -import { createContext, useContext, useMemo, type ReactNode } from 'react' +// Web sibling: RN Web has no pairing keychain and no websocket transport of its own, so the page's +// client is the shell bridge. Nothing here dials, retries or pairs — the native client on the other +// side of the bridge already did, and this provider only carries what it holds across the boundary. +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode +} from 'react' +import { + createBridgeRpcClient, + type BridgeRpcClient, + type BridgeRpcClientDiagnostic +} from '../mobile-web-shell/bridge/bridge-rpc-client' +import { + createOrcaBridgePageTransport, + readOrcaBridgePageChannel +} from '../mobile-web-shell/bridge/orca-bridge-page-channel' import type { RpcClient } from './rpc-client' import type { ConnectionState, HostProfile } from './types' import type { RpcClientContextValue } from './rpc-client-context-contract' @@ -22,6 +40,12 @@ export class BridgeTransportUnavailableError extends Error { } } +/** + * For a page opened outside the shell: a browser, or a WebView mounted with the bridge off. + * + * It answers every member and reaches nothing, which is what lets the route tree mount and paint + * its empty states instead of crashing on a client that is not there. + */ function createPlaceholderClient(): RpcClient { return { sendRequest: (method) => Promise.reject(new BridgeTransportUnavailableError(method)), @@ -40,14 +64,64 @@ function createPlaceholderClient(): RpcClient { } } +/** One line per kind for the life of one page: a page that is failing frames fails all of them. */ +function createPageDiagnosticReporter(): (diagnostic: BridgeRpcClientDiagnostic) => void { + const reported = new Set() + return (diagnostic) => { + if (reported.has(diagnostic.kind)) { + return + } + reported.add(diagnostic.kind) + console.warn('[page-bridge]', diagnostic.kind, diagnostic) + } +} + const Ctx = createContext(null) export function RpcClientProvider({ children }: { children: ReactNode }) { + // Held in a ref as well as in state: the context value is built once, because `useHostClient` + // re-acquires whenever the value's identity changes. + const clientRef = useRef(null) + const acquiredRef = useRef>(new Set()) + const [ready, setReady] = useState(false) + + useEffect(() => { + const channel = readOrcaBridgePageChannel() + if (channel === null) { + // Nothing to wait for, so the tree mounts against the placeholder rather than never. + clientRef.current = createPlaceholderClient() + setReady(true) + return + } + const client: BridgeRpcClient = createBridgeRpcClient({ + ...createOrcaBridgePageTransport(channel), + onDiagnostic: createPageDiagnosticReporter() + }) + // Nothing mounts before `init`: every member of this client throws until the shell answers, + // and a screen that rendered first would record its first frame against a session-less client. + const release = client.onReady(() => { + clientRef.current = client + setReady(true) + }) + return () => { + release() + clientRef.current = null + setReady(false) + client.close() + } + }, []) + const value = useMemo(() => { - const client = createPlaceholderClient() - const disconnected: ConnectionState = 'disconnected' + const state = (): ConnectionState => clientRef.current?.getState() ?? 'connecting' return { - acquire: () => client, + // One client for one page: the shell opened this document for one host, so whichever host + // the route names is the host on the other side of the bridge. + acquire: (hostId: string) => { + acquiredRef.current.add(hostId) + return clientRef.current + }, + // The shell owns the connection, and a page client cannot be reopened once it says goodbye. + // Every member that would close, drop or re-dial one is inert here for that reason. release: () => {}, releaseAndCloseIfUnused: () => {}, closeIfUnused: () => {}, @@ -55,24 +129,33 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { refreshHostClient: () => {}, forgetHostClient: () => {}, disconnectHostClient: () => {}, - getState: () => disconnected, - getKnownState: () => disconnected, + getState: state, + getKnownState: () => (clientRef.current === null ? null : state()), getClientId: () => null, - getReconnectAttempt: () => 0, - getLastConnectedAt: () => null, + getReconnectAttempt: () => clientRef.current?.getReconnectAttempt() ?? 0, + getLastConnectedAt: () => clientRef.current?.getLastConnectedAt() ?? null, // The page reaches its host through the shell bridge, which rides whatever path the RN // client already negotiated. 'relay' is the honest default until init carries the real one. getActivePath: () => 'relay', getPendingPath: () => null, + // Both are pairing verdicts, and pairing happened natively before this document existed. isPairingRejected: () => false, isHostSignedOut: () => false, - subscribeHostState: () => () => {}, - getAllClients: () => [], - subscribeAllHosts: () => () => {}, + subscribeHostState: (_hostId: string, listener: (next: ConnectionState) => void) => + clientRef.current?.onStateChange(listener) ?? (() => {}), + getAllClients: () => { + const client = clientRef.current + return client === null ? [] : [...acquiredRef.current].map((hostId) => ({ hostId, client })) + }, + subscribeAllHosts: (listener: () => void) => + clientRef.current?.onStateChange(() => { + listener() + }) ?? (() => {}), primeHosts: (_hosts: HostProfile[]) => {} } }, []) - return {children} + + return {ready ? children : null} } export function useRpcClientContext(): RpcClientContextValue { diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts index d38baf9a76a..54d1f6a500d 100644 --- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -25,13 +25,17 @@ export type UnvalidatedRpcRequestPortEntry = { /** Modules whose job is the port. These do not shrink to zero. */ export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequestPortEntry[] = [ - // Carries the port across the page boundary for the hybrid shell. Not a call site: it picks no - // method, reads no reply and decides no acceptance — the page names the method and runs the - // typed operation over it, exactly as a native screen does over a socket client. + // Forwards raw requests as a transport, reads no reply. Not a call site: it picks no method and + // decides no acceptance — the page names the method and runs the typed operation over it, exactly + // as a native screen does over a socket client. { file: 'src/mobile-web-shell/bridge-host.ts', references: 3 }, + // The far end of that transport: it offers the port to the page and posts what it is handed, + // reading neither the method nor the reply. + { file: 'src/mobile-web-shell/bridge/bridge-rpc-client.ts', references: 1 }, // Fakes the port for the bridge host suites; a non-test file only because tsconfig excludes tests. { file: 'src/mobile-web-shell/bridge-host-test-fakes.ts', references: 1 }, - // Placeholder page transport until C0.4's BridgeRpcClient replaces it; rejects every call, reads no reply. + // The page's client is BridgeRpcClient over the shell bridge; this one reference is the + // placeholder it falls back to outside the shell, which rejects every call and reads no reply. { file: 'src/transport/client-context.web.tsx', references: 1 }, // Implements the port over the device-to-host websocket. { file: 'src/transport/direct-rpc-client.ts', references: 3 }, diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index d1a67d58ecb..0484ee95c52 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -3,7 +3,7 @@ "overrides": [ { "file": "src/transport/client-context.web.tsx", - "reason": "The page has no websocket transport and no pairing keychain. This is the single transport substitution point: a placeholder RpcClient until C0.4 lands BridgeRpcClient over the shell bridge." + "reason": "The page has no websocket transport and no pairing keychain. This is the single transport substitution point: BridgeRpcClient over the shell bridge, and a placeholder RpcClient for a page opened outside it, which is what lets the route tree mount in a plain browser." }, { "file": "packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts", From 66e0847398fb3d6a07d91ad5b7b2e3480493e8cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:04:05 -0700 Subject: [PATCH 031/224] fix(agent-status): stop an auto-reviewed Codex approval reading as "Needs You" (#21389) * fix(agent-status): stop an auto-reviewed Codex approval reading as "Needs You" Codex runs its PermissionRequest hook as decider #1, ahead of both its own review agent and the user, so the event means "a decision is being made", not "a human is blocked". Under the "Approve for me" posture the review agent resolves it seconds later, so every gated tool call drove the pane from Working to Needs You and back, plus a desktop notification each time. The execution host now reads the turn's approvals_reviewer off the rollout it already tails for subagent reconciliation, and keeps a reviewer-owned approval as working. Positive evidence only: an absent field, an older rollout, or an unreadable file all still raise the wait, so this can never hide a real prompt. Splits the incremental rollout JSONL cursor out of the subagent transcript module, which the new reader pushed over the file-length cap. * fix(agent-status): avoid stale Codex approval ownership * fix(agent-status): reconcile Codex child approval ownership * perf(agent-status): avoid reads for Codex child activity * fix(agent-status): scope Codex reviewer ownership by transcript --- ...-listener-codex-approval-ownership.test.ts | 232 ++++++++++++++++++ .../providers/codex-events.ts | 100 ++++++-- .../providers/codex-state.ts | 10 +- src/shared/codex-rollout-jsonl-cursor.ts | 92 +++++++ src/shared/codex-subagent-reviewer.ts | 91 +++++++ src/shared/codex-subagent-transcript.ts | 134 +++------- 6 files changed, 543 insertions(+), 116 deletions(-) create mode 100644 src/shared/agent-hook-listener-codex-approval-ownership.test.ts create mode 100644 src/shared/codex-rollout-jsonl-cursor.ts create mode 100644 src/shared/codex-subagent-reviewer.ts diff --git a/src/shared/agent-hook-listener-codex-approval-ownership.test.ts b/src/shared/agent-hook-listener-codex-approval-ownership.test.ts new file mode 100644 index 00000000000..661aa79b5a5 --- /dev/null +++ b/src/shared/agent-hook-listener-codex-approval-ownership.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createHookListenerState, + type HookListenerState +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' +import { PANE_KEY } from './agent-hook-listener-test-harness' + +/** + * Codex runs its `PermissionRequest` hook as decider #1, ahead of its own review agent and ahead + * of the user, so the event alone never means a human is blocked. These pin which approvals stay + * "Needs You" and which read as ongoing work (STA-7698). + */ +describe('Codex approval ownership', () => { + let state: HookListenerState + const dirs: string[] = [] + + beforeEach(() => { + state = createHookListenerState() + }) + + afterEach(() => { + while (dirs.length > 0) { + rmSync(dirs.pop()!, { recursive: true, force: true }) + } + }) + + /** Writes a rollout carrying one `turn_context`, optionally naming a reviewer. */ + function writeRollout(options: { reviewer?: string; fileName?: string }): string { + const root = mkdtempSync(join(tmpdir(), 'codex-approval-ownership-')) + dirs.push(root) + const dayDir = join(root, '2026', '09', '17') + mkdirSync(dayDir, { recursive: true }) + const path = join(dayDir, options.fileName ?? 'rollout-session.jsonl') + writeFileSync( + path, + `${JSON.stringify({ + type: 'turn_context', + payload: { + cwd: '/repo', + model: 'gpt-5-codex', + approval_policy: 'on-request', + ...(options.reviewer === undefined ? {} : { approvals_reviewer: options.reviewer }) + } + })}\n` + ) + return path + } + + function appendRollout(path: string, value: unknown): void { + appendFileSync(path, `${JSON.stringify(value)}\n`) + } + + function post(payload: Record): ReturnType { + return normalizeHookPayload(state, 'codex', { paneKey: PANE_KEY, payload }, 'production') + } + + function permissionRequest(transcriptPath: string): ReturnType { + return post({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + transcript_path: transcriptPath + }) + } + + function childPermissionRequest(transcriptPath: string): ReturnType { + return post({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + transcript_path: transcriptPath, + agent_id: 'child-after-relay-restart', + agent_type: 'worker' + }) + } + + function childPostToolUse(): ReturnType { + return post({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + agent_id: 'child-after-relay-restart', + agent_type: 'worker' + }) + } + + it('reads an auto-reviewed approval as ongoing work, not as needing the user', () => { + const transcriptPath = writeRollout({ reviewer: 'auto_review' }) + + expect(permissionRequest(transcriptPath)?.payload.state).toBe('working') + }) + + it('keeps a user-reviewed approval waiting', () => { + const transcriptPath = writeRollout({ reviewer: 'user' }) + + expect(permissionRequest(transcriptPath)?.payload.state).toBe('waiting') + }) + + it('reconciles reviewer ownership before a child-first auto-reviewed approval', () => { + const transcriptPath = writeRollout({ reviewer: 'auto_review' }) + + expect(childPermissionRequest(transcriptPath)?.payload.state).toBe('working') + }) + + it('keeps a child-first manual approval waiting after reviewer reconciliation', () => { + const transcriptPath = writeRollout({ reviewer: 'user' }) + + expect(childPermissionRequest(transcriptPath)?.payload.state).toBe('waiting') + }) + + it('keeps the parent reviewer after a child with a different reviewer is observed', () => { + const parentPath = writeRollout({ reviewer: 'auto_review', fileName: 'rollout-parent.jsonl' }) + const childPath = writeRollout({ reviewer: 'user', fileName: 'rollout-child.jsonl' }) + + expect(permissionRequest(parentPath)?.payload.state).toBe('working') + expect(childPermissionRequest(childPath)?.payload.state).toBe('waiting') + expect(childPostToolUse()?.payload.state).toBe('working') + expect(permissionRequest(parentPath)?.payload.state).toBe('working') + }) + + it('does not clear a readable parent reviewer when a child rollout is unavailable', () => { + const parentPath = writeRollout({ reviewer: 'auto_review', fileName: 'rollout-parent.jsonl' }) + const childRoot = mkdtempSync(join(tmpdir(), 'codex-approval-ownership-child-')) + dirs.push(childRoot) + + expect(permissionRequest(parentPath)?.payload.state).toBe('working') + expect(childPermissionRequest(join(childRoot, 'missing-child.jsonl'))?.payload.state).toBe( + 'waiting' + ) + expect(childPostToolUse()?.payload.state).toBe('working') + expect(permissionRequest(parentPath)?.payload.state).toBe('working') + }) + + it('follows a thread settings update that switches the reviewer back to the user', () => { + const transcriptPath = writeRollout({ reviewer: 'auto_review' }) + expect(permissionRequest(transcriptPath)?.payload.state).toBe('working') + + appendRollout(transcriptPath, { + type: 'event_msg', + payload: { + type: 'thread_settings_applied', + thread_settings: { approvals_reviewer: 'user' } + } + }) + + expect(permissionRequest(transcriptPath)?.payload.state).toBe('waiting') + }) + + it('accepts Codex’s legacy guardian_subagent reviewer spelling as auto review', () => { + const transcriptPath = writeRollout({ reviewer: 'guardian_subagent' }) + + expect(permissionRequest(transcriptPath)?.payload.state).toBe('working') + }) + + it('keeps waiting when the rollout names no reviewer, as older Codex builds do not', () => { + const transcriptPath = writeRollout({}) + + expect(permissionRequest(transcriptPath)?.payload.state).toBe('waiting') + }) + + it('keeps waiting when the rollout cannot be read at all', () => { + const root = mkdtempSync(join(tmpdir(), 'codex-approval-ownership-')) + dirs.push(root) + + expect(permissionRequest(join(root, 'absent.jsonl'))?.payload.state).toBe('waiting') + }) + + it('does not retain auto review when a later rollout read is unreadable', () => { + const transcriptPath = writeRollout({ reviewer: 'auto_review' }) + expect(permissionRequest(transcriptPath)?.payload.state).toBe('working') + + rmSync(transcriptPath) + + expect(permissionRequest(transcriptPath)?.payload.state).toBe('waiting') + }) + + it('keeps waiting when no transcript path is supplied', () => { + expect(post({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' })?.payload.state).toBe( + 'waiting' + ) + }) + + it('still waits on request_user_input under auto review, which no reviewer can answer', () => { + const transcriptPath = writeRollout({ reviewer: 'auto_review' }) + permissionRequest(transcriptPath) + + const question = post({ + hook_event_name: 'PreToolUse', + tool_name: 'request_user_input', + transcript_path: transcriptPath + }) + + expect(question?.payload.state).toBe('waiting') + }) + + it('does not carry one session’s reviewer into the next rollout', () => { + const autoReviewed = writeRollout({ + reviewer: 'auto_review', + fileName: 'rollout-first.jsonl' + }) + expect(permissionRequest(autoReviewed)?.payload.state).toBe('working') + + const unstated = writeRollout({ fileName: 'rollout-second.jsonl' }) + + expect(permissionRequest(unstated)?.payload.state).toBe('waiting') + }) + + it('leaves the surrounding turn working, so an auto-reviewed turn never flaps', () => { + const transcriptPath = writeRollout({ reviewer: 'auto_review' }) + const states = [ + post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'ship it', + transcript_path: transcriptPath + }), + post({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + transcript_path: transcriptPath + }), + permissionRequest(transcriptPath), + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + transcript_path: transcriptPath + }) + ].map((event) => event?.payload.state) + + expect(states).toEqual(['working', 'working', 'working', 'working']) + }) +}) diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index bcd0474b940..ad2ce3db92f 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -12,6 +12,10 @@ import { upsertCodexSubagent } from '../../codex-subagent-roster' import { reconcileCodexSubagentTranscript } from '../../codex-subagent-transcript' +import { + codexTurnApprovalsAreAutoReviewed, + reconcileCodexSubagentReviewer +} from '../../codex-subagent-reviewer' import { readFirstString } from '../interactive-tool' import type { HookListenerState } from '../listener-state' import { resolvePrompt, resolveToolState } from '../prompt-fields' @@ -99,6 +103,35 @@ export function normalizeCodexSubagentLifecycleEvent( return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) } +/** + * Drops a `PermissionRequest` wait that Codex's own review agent owns. + * + * Codex runs this hook as decider #1, ahead of both its review agent and the user, so the event + * alone is "a decision is being made", not "a human is blocked". Under `approvals_reviewer = + * auto_review` ("Approve for me") the review agent resolves it seconds later and the pane flapped + * between Needs You and Working for every gated tool call (STA-7698). + * + * `request_user_input` is untouched: it arrives as `PreToolUse`, and no reviewer can answer a + * question addressed to the user (#9861). + */ +function resolveCodexApprovalOwnedState( + state: HookListenerState, + eventName: unknown, + paneKey: string, + transcriptPath: string | undefined, + stateName: 'working' | 'waiting' | 'done' +): 'working' | 'waiting' | 'done' { + if (stateName !== 'waiting' || eventName !== 'PermissionRequest') { + return stateName + } + return codexTurnApprovalsAreAutoReviewed( + state.codexSubagentTranscriptByPaneKey.get(paneKey), + transcriptPath + ) + ? 'working' + : stateName +} + export function normalizeCodexEvent( state: HookListenerState, eventName: unknown, @@ -130,47 +163,76 @@ export function normalizeCodexEvent( } const agentId = readString(hookPayload, 'agent_id') - if (agentId) { - upsertCodexSubagent( - getOrCreateCodexSubagentRoster(state, paneKey), - agentId, - { - agentType: readString(hookPayload, 'agent_type'), - model: readString(hookPayload, 'model'), - state: stateName === 'waiting' ? 'waiting' : 'working' - }, - Date.now() - ) - return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) - } - - if (eventName === 'SessionStart') { + const transcriptPath = readFirstString(hookPayload, ['transcript_path', 'transcriptPath']) + if (eventName === 'SessionStart' && !agentId) { // Why: a pane can host a new Codex process after the old one exited without child Stop hooks. state.codexSubagentRosterByPaneKey.delete(paneKey) state.codexSubagentTranscriptByPaneKey.delete(paneKey) } - const transcriptPath = readFirstString(hookPayload, ['transcript_path', 'transcriptPath']) - if (transcriptPath) { + if (agentId && transcriptPath && eventName === 'PermissionRequest') { + const transcriptState = getOrCreateCodexSubagentTranscriptState(state, paneKey) + if (transcriptState.parent.filePath === transcriptPath) { + reconcileCodexSubagentTranscript( + transcriptState, + getOrCreateCodexSubagentRoster(state, paneKey), + transcriptPath + ) + } else { + reconcileCodexSubagentReviewer(transcriptState, transcriptPath) + } + } + if (transcriptPath && !agentId) { reconcileCodexSubagentTranscript( getOrCreateCodexSubagentTranscriptState(state, paneKey), getOrCreateCodexSubagentRoster(state, paneKey), transcriptPath ) } + if (agentId) { + // Why: reconcile the child rollout reviewer before classifying its approval, including after relay restart. + const childState = resolveCodexApprovalOwnedState( + state, + eventName, + paneKey, + transcriptPath, + stateName + ) + upsertCodexSubagent( + getOrCreateCodexSubagentRoster(state, paneKey), + agentId, + { + agentType: readString(hookPayload, 'agent_type'), + model: readString(hookPayload, 'model'), + state: childState === 'waiting' ? 'waiting' : 'working' + }, + Date.now() + ) + return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + } + if (eventName === 'Stop' && !hasCodexTranscriptSubagents(state, paneKey)) { // Why: Codex CLI 0.144 can omit child Stop hooks; later child activity safely recreates any agent still running. state.codexSubagentRosterByPaneKey.delete(paneKey) } + // Why: resolved after the transcript reconcile above, so this turn's reviewer is read from the + // rollout during the very PermissionRequest being classified, not from a prior event. + const ownedState = resolveCodexApprovalOwnedState( + state, + eventName, + paneKey, + transcriptPath, + stateName + ) const previousLead = state.codexLeadStateByPaneKey.get(paneKey) state.codexLeadStateByPaneKey.set(paneKey, { - state: stateName, + state: ownedState, model: normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? (eventName === 'SessionStart' ? undefined : previousLead?.model) }) const effectiveState = codexRosterEffectiveState( state.codexSubagentRosterByPaneKey.get(paneKey), - stateName + ownedState ) return buildCodexStatusPayload(state, eventName, promptText, paneKey, hookPayload, { stateName: effectiveState, diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index 1131008ca7a..9add1822099 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -73,13 +73,17 @@ export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: } export function codexLeadStateForHookEvent( - eventName: string | undefined + eventName: string | undefined, + normalizedState?: ParsedAgentStatusPayload['state'] ): CodexLeadTurnState['state'] | undefined { if (eventName === 'Stop') { return 'done' } if (eventName === 'PermissionRequest') { - return 'waiting' + // Why: the execution host's normalizer already ruled on whether this approval is human-owned + // or reviewer-owned, reading the reviewer off that host's rollout (STA-7698). Re-deriving + // 'waiting' from the event name here would discard that verdict for every relayed pane. + return normalizedState === 'working' ? 'working' : 'waiting' } if ( eventName === 'SessionStart' || @@ -120,7 +124,7 @@ export function reconcileRemoteCodexState( finishCodexSubagent(roster, agentId) } } else { - const leadState = codexLeadStateForHookEvent(eventName) + const leadState = codexLeadStateForHookEvent(eventName, payload.state) if (eventName === 'SessionStart' || (eventName === 'Stop' && !payload.subagents)) { roster.clear() } diff --git a/src/shared/codex-rollout-jsonl-cursor.ts b/src/shared/codex-rollout-jsonl-cursor.ts new file mode 100644 index 00000000000..df47d5c3e68 --- /dev/null +++ b/src/shared/codex-rollout-jsonl-cursor.ts @@ -0,0 +1,92 @@ +import { closeSync, openSync, readSync, readdirSync, statSync, type Stats } from 'node:fs' + +const TRANSCRIPT_READ_MAX_BYTES = 1024 * 1024 +const TRANSCRIPT_LINE_MAX_BYTES = 256 * 1024 +const TRANSCRIPT_DIRECTORY_MAX_ENTRIES = 4096 + +/** Resume point for an incremental read of one Codex rollout file. */ +export type JsonlCursor = { + filePath?: string + offset: number + carry: string +} + +export type JsonRecord = Record + +export function record(value: unknown): JsonRecord | undefined { + return typeof value === 'object' && value !== null ? (value as JsonRecord) : undefined +} + +/** Returns undefined when the file is unreadable, distinguishing a vanished rollout from one with no new lines. */ +export function readJsonlCursor(cursor: JsonlCursor): JsonRecord[] | undefined { + if (!cursor.filePath) { + return undefined + } + let stats: Stats + try { + stats = statSync(cursor.filePath) + } catch { + return undefined + } + if (!stats.isFile()) { + return undefined + } + if (stats.size < cursor.offset) { + cursor.offset = 0 + cursor.carry = '' + } + if (stats.size === cursor.offset) { + return [] + } + const bytesToRead = Math.min(stats.size - cursor.offset, TRANSCRIPT_READ_MAX_BYTES) + const start = stats.size - cursor.offset > bytesToRead ? stats.size - bytesToRead : cursor.offset + const buffer = Buffer.allocUnsafe(bytesToRead) + let bytesRead = 0 + let fd: number | undefined + try { + fd = openSync(cursor.filePath, 'r') + bytesRead = readSync(fd, buffer, 0, bytesToRead, start) + } catch { + return undefined + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } + const skippedPrefix = start !== cursor.offset + const content = `${skippedPrefix ? '' : cursor.carry}${buffer.toString('utf8', 0, bytesRead)}` + const lines = content.split('\n') + cursor.offset = start + bytesRead + cursor.carry = lines.pop() ?? '' + if (skippedPrefix) { + lines.shift() + } + const records: JsonRecord[] = [] + for (const line of lines) { + if (Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES) { + continue + } + try { + const parsed = record(JSON.parse(line) as unknown) + if (parsed) { + records.push(parsed) + } + } catch { + // A malformed rollout line must not block later lifecycle events. + } + } + return records +} + +export function readTranscriptDirectory(directory: string): string[] { + let entries: string[] + try { + entries = readdirSync(directory) + } catch { + return [] + } + if (entries.length > TRANSCRIPT_DIRECTORY_MAX_ENTRIES) { + entries = entries.slice(-TRANSCRIPT_DIRECTORY_MAX_ENTRIES) + } + return entries +} diff --git a/src/shared/codex-subagent-reviewer.ts b/src/shared/codex-subagent-reviewer.ts new file mode 100644 index 00000000000..9fff4db175b --- /dev/null +++ b/src/shared/codex-subagent-reviewer.ts @@ -0,0 +1,91 @@ +import { extname, isAbsolute } from 'node:path' + +import { readJsonlCursor, type JsonRecord } from './codex-rollout-jsonl-cursor' +import type { CodexSubagentTranscriptState } from './codex-subagent-transcript' + +const REVIEWER_CURSOR_MAX_PATHS = 64 + +/** Codex's `approvals_reviewer`: `user` is a human, `auto_review` is Codex's own review agent. */ +export type CodexApprovalsReviewer = 'user' | 'auto_review' + +function normalizedTranscriptPath(transcriptPath: string | undefined): string | undefined { + const normalizedPath = transcriptPath?.trim() + return normalizedPath && isAbsolute(normalizedPath) && extname(normalizedPath) === '.jsonl' + ? normalizedPath + : undefined +} + +function record(value: unknown): JsonRecord | undefined { + return typeof value === 'object' && value !== null ? (value as JsonRecord) : undefined +} + +/** Latest reviewer evidence from turn or thread-settings records. */ +export function readApprovalsReviewer(records: JsonRecord[]): CodexApprovalsReviewer | undefined { + let reviewer: CodexApprovalsReviewer | undefined + for (const recordValue of records) { + const payload = record(recordValue.payload) + const candidate = + recordValue.type === 'turn_context' + ? payload?.approvals_reviewer + : recordValue.type === 'event_msg' && payload?.type === 'thread_settings_applied' + ? record(payload.thread_settings)?.approvals_reviewer + : undefined + const value = typeof candidate === 'string' ? candidate : '' + if (value === 'user' || value === 'auto_review') { + reviewer = value + } else if (value === 'guardian_subagent') { + // Codex still accepts this legacy spelling and normalizes it to auto_review. + reviewer = 'auto_review' + } + } + return reviewer +} + +/** Whether the transcript's own review agent resolves this permission request. */ +export function codexTurnApprovalsAreAutoReviewed( + state: CodexSubagentTranscriptState | undefined, + transcriptPath?: string +): boolean { + const normalizedPath = normalizedTranscriptPath(transcriptPath) + if (!state || !normalizedPath) { + return false + } + const reviewer = + normalizedPath === state.parent.filePath + ? state.approvalsReviewer + : state.reviewersByPath.get(normalizedPath) + return reviewer === 'auto_review' +} + +/** Reads reviewer ownership from a child rollout without replacing the parent lifecycle cursor. */ +export function reconcileCodexSubagentReviewer( + state: CodexSubagentTranscriptState, + transcriptPath: string | undefined +): void { + const normalizedPath = normalizedTranscriptPath(transcriptPath) + if (!normalizedPath) { + return + } + let cursor = state.reviewerCursorsByPath.get(normalizedPath) + if (!cursor) { + if (state.reviewerCursorsByPath.size >= REVIEWER_CURSOR_MAX_PATHS) { + const oldestPath = state.reviewerCursorsByPath.keys().next().value + if (typeof oldestPath === 'string') { + state.reviewerCursorsByPath.delete(oldestPath) + state.reviewersByPath.delete(oldestPath) + } + } + cursor = { filePath: normalizedPath, offset: 0, carry: '' } + state.reviewerCursorsByPath.set(normalizedPath, cursor) + } + const records = readJsonlCursor(cursor) + if (records === undefined) { + state.reviewerCursorsByPath.delete(normalizedPath) + state.reviewersByPath.delete(normalizedPath) + return + } + const reviewer = readApprovalsReviewer(records) + if (reviewer !== undefined) { + state.reviewersByPath.set(normalizedPath, reviewer) + } +} diff --git a/src/shared/codex-subagent-transcript.ts b/src/shared/codex-subagent-transcript.ts index b15254684ee..5ce841ca252 100644 --- a/src/shared/codex-subagent-transcript.ts +++ b/src/shared/codex-subagent-transcript.ts @@ -1,6 +1,16 @@ -import { closeSync, openSync, readSync, readdirSync, statSync, type Stats } from 'node:fs' import { basename, dirname, extname, isAbsolute, join } from 'node:path' +import { + readJsonlCursor, + readTranscriptDirectory, + record, + type JsonlCursor, + type JsonRecord +} from './codex-rollout-jsonl-cursor' + +import { readApprovalsReviewer } from './codex-subagent-reviewer' +import type { CodexApprovalsReviewer } from './codex-subagent-reviewer' + import { finishCodexSubagent, setCodexSubagentModel, @@ -8,19 +18,10 @@ import { type CodexSubagentRoster } from './codex-subagent-roster' -const TRANSCRIPT_READ_MAX_BYTES = 1024 * 1024 -const TRANSCRIPT_LINE_MAX_BYTES = 256 * 1024 -const TRANSCRIPT_DIRECTORY_MAX_ENTRIES = 4096 // Why: retire a child whose rollout stays unreadable this long, else a deleted/never-written file pins a phantom row forever. const CHILD_UNREADABLE_GRACE_MS = 60_000 const SAFE_THREAD_ID = /^[A-Za-z0-9-]{1,64}$/ -type JsonlCursor = { - filePath?: string - offset: number - carry: string -} - type TrackedTranscriptSubagent = JsonlCursor & { description?: string /** Latest model seen in the child's own rollout. Retained across polls @@ -34,86 +35,12 @@ type TrackedTranscriptSubagent = JsonlCursor & { export type CodexSubagentTranscriptState = { parent: JsonlCursor subagents: Map -} - -type JsonRecord = Record - -function record(value: unknown): JsonRecord | undefined { - return typeof value === 'object' && value !== null ? (value as JsonRecord) : undefined -} - -/** Returns undefined when the file is unreadable, distinguishing a vanished rollout from one with no new lines. */ -function readJsonlCursor(cursor: JsonlCursor): JsonRecord[] | undefined { - if (!cursor.filePath) { - return undefined - } - let stats: Stats - try { - stats = statSync(cursor.filePath) - } catch { - return undefined - } - if (!stats.isFile()) { - return undefined - } - if (stats.size < cursor.offset) { - cursor.offset = 0 - cursor.carry = '' - } - if (stats.size === cursor.offset) { - return [] - } - const bytesToRead = Math.min(stats.size - cursor.offset, TRANSCRIPT_READ_MAX_BYTES) - const start = stats.size - cursor.offset > bytesToRead ? stats.size - bytesToRead : cursor.offset - const buffer = Buffer.allocUnsafe(bytesToRead) - let bytesRead = 0 - let fd: number | undefined - try { - fd = openSync(cursor.filePath, 'r') - bytesRead = readSync(fd, buffer, 0, bytesToRead, start) - } catch { - return undefined - } finally { - if (fd !== undefined) { - closeSync(fd) - } - } - const skippedPrefix = start !== cursor.offset - const content = `${skippedPrefix ? '' : cursor.carry}${buffer.toString('utf8', 0, bytesRead)}` - const lines = content.split('\n') - cursor.offset = start + bytesRead - cursor.carry = lines.pop() ?? '' - if (skippedPrefix) { - lines.shift() - } - const records: JsonRecord[] = [] - for (const line of lines) { - if (Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES) { - continue - } - try { - const parsed = record(JSON.parse(line) as unknown) - if (parsed) { - records.push(parsed) - } - } catch { - // A malformed rollout line must not block later lifecycle events. - } - } - return records -} - -function readTranscriptDirectory(directory: string): string[] { - let entries: string[] - try { - entries = readdirSync(directory) - } catch { - return [] - } - if (entries.length > TRANSCRIPT_DIRECTORY_MAX_ENTRIES) { - entries = entries.slice(-TRANSCRIPT_DIRECTORY_MAX_ENTRIES) - } - return entries + /** Incremental reviewer cursors for child rollouts, which must not replace the parent cursor. */ + reviewerCursorsByPath: Map + /** Reviewer ownership discovered from child rollouts, keyed by their bounded cursor paths. */ + reviewersByPath: Map + /** Who resolves this turn's approvals in the parent rollout. */ + approvalsReviewer?: CodexApprovalsReviewer } // Why: Codex files each rollout under its OWN local start date, so a session running past midnight spawns children into a sibling day directory. @@ -222,6 +149,13 @@ function readChildModel(records: JsonRecord[]): string | undefined { return model } +function normalizedTranscriptPath(transcriptPath: string | undefined): string | undefined { + const normalizedPath = transcriptPath?.trim() + return normalizedPath && isAbsolute(normalizedPath) && extname(normalizedPath) === '.jsonl' + ? normalizedPath + : undefined +} + function childIsComplete(records: JsonRecord[]): boolean { let complete = false for (const recordValue of records) { @@ -241,7 +175,9 @@ function childIsComplete(records: JsonRecord[]): boolean { export function createCodexSubagentTranscriptState(): CodexSubagentTranscriptState { return { parent: { offset: 0, carry: '' }, - subagents: new Map() + subagents: new Map(), + reviewerCursorsByPath: new Map(), + reviewersByPath: new Map() } } @@ -256,8 +192,8 @@ export function reconcileCodexSubagentTranscript( roster: CodexSubagentRoster, transcriptPath: string | undefined ): void { - const normalizedPath = transcriptPath?.trim() - if (!normalizedPath || !isAbsolute(normalizedPath) || extname(normalizedPath) !== '.jsonl') { + const normalizedPath = normalizedTranscriptPath(transcriptPath) + if (!normalizedPath) { return } if (state.parent.filePath !== normalizedPath) { @@ -266,8 +202,18 @@ export function reconcileCodexSubagentTranscript( } state.parent = { filePath: normalizedPath, offset: 0, carry: '' } state.subagents.clear() + state.reviewerCursorsByPath.clear() + state.reviewersByPath.clear() + // Why: a different rollout is a different session, so its predecessor's reviewer is void. + state.approvalsReviewer = undefined } - for (const recordValue of readJsonlCursor(state.parent) ?? []) { + const parentRecords = readJsonlCursor(state.parent) + // A stale reviewer must never turn an unreadable rollout into a hidden prompt. + state.approvalsReviewer = + parentRecords === undefined + ? undefined + : (readApprovalsReviewer(parentRecords) ?? state.approvalsReviewer) + for (const recordValue of parentRecords ?? []) { const activity = readActivity(recordValue) if (!activity) { continue From a85e580e51943f7f650ea01b999d9f5d67a91ea5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:15:12 -0700 Subject: [PATCH 032/224] fix(orchestration): stop the sender-terminal refusal recommending another pane's handle (#21097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(orchestration): stop the sender-terminal refusal recommending another pane's handle The structured-session guard told callers to pass `--from `, but the explicit-flag branch returns before that guard runs — so following the advice succeeds, against a handle that necessarily belongs to a different pane, and the next `check` consumes that pane's unread mail. Both refusals now say what is actually true: no handle names a structured chat session, and a caller that does have one should pass its own. Also pins ORCA_STRUCTURED_SESSION in the gate CLI test, which until now decided which refusal it exercised from ambient environment. * fix(orchestration): route the lifecycle-send refusal to the structured message `orchestration send --type worker_done|heartbeat` refuses in the send handler before `resolveOrchestrationTerminalHandle` runs, so the structured guard never saw the case a structured session hits most: the canonical worker lifecycle report. That caller was still told to pass `--from` with "your own terminal's handle" — which it does not have, so any handle it picked would belong to another pane. `throwNoActiveSenderTerminal` now derives which refusal fits instead of each call site deciding: marker set AND no handle means no identity exists, so the structured refusal applies. A stale `ORCA_TERMINAL_HANDLE` is deliberately excluded — that caller does have an identity, it just went stale, and keeps the advice to re-run under a live one. Also corrects the guidance itself (`--agent` is a `worktree create` flag; `terminal create` has no such flag), aligns the SSH fallback wording with its local twin, and pins ORCA_STRUCTURED_SESSION in the send tests, which until now decided which refusal they exercised from ambient environment. --- .../handlers/orchestration-gate-cli.test.ts | 7 +++- src/cli/handlers/orchestration.test.ts | 33 +++++++++++++++++++ .../orchestration/terminal-identity.ts | 31 +++++++++++++---- src/main/ssh/ssh-remote-orchestration-send.ts | 3 +- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/cli/handlers/orchestration-gate-cli.test.ts b/src/cli/handlers/orchestration-gate-cli.test.ts index a2793ac9323..217e2f359f1 100644 --- a/src/cli/handlers/orchestration-gate-cli.test.ts +++ b/src/cli/handlers/orchestration-gate-cli.test.ts @@ -34,6 +34,9 @@ import { okFixture, queueFixtures } from '../test-fixtures' const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE const originalPaneKey = process.env.ORCA_PANE_KEY +// Why: a structured-session marker inherited from the runner diverts these cases to the +// structured refusal, so which branch they exercise would depend on who ran them. +const originalStructuredSession = process.env.ORCA_STRUCTURED_SESSION const restoreEnv = (name: string, value: string | undefined): void => { if (value === undefined) { @@ -54,6 +57,7 @@ describe('orchestration gate commands carry caller identity', () => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_PANE_KEY + delete process.env.ORCA_STRUCTURED_SESSION process.exitCode = 0 }) @@ -62,6 +66,7 @@ describe('orchestration gate commands carry caller identity', () => { errorSpy.mockRestore() restoreEnv('ORCA_TERMINAL_HANDLE', originalTerminalHandle) restoreEnv('ORCA_PANE_KEY', originalPaneKey) + restoreEnv('ORCA_STRUCTURED_SESSION', originalStructuredSession) process.exitCode = 0 }) @@ -192,7 +197,7 @@ describe('orchestration gate commands carry caller identity', () => { expect(process.exitCode).toBe(1) const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n') - expect(stderr).toContain('Pass --from ') + expect(stderr).toContain("Pass --from with your own terminal's handle") expect(callMock).not.toHaveBeenCalledWith('orchestration.gateCreate', expect.anything()) }) diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index d8cf528671d..e0a20541f91 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -4,6 +4,9 @@ const callMock = vi.fn() const getTerminalHandleMock = vi.hoisted(() => vi.fn()) const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE const originalPaneKey = process.env.ORCA_PANE_KEY +// Why: a structured-session marker inherited from the runner diverts these cases to the +// structured refusal, so which branch they exercise would depend on who ran them. +const originalStructuredSession = process.env.ORCA_STRUCTURED_SESSION function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { return `${type} messages belong to one exact Dispatch and cannot target a group address.` } @@ -28,6 +31,11 @@ afterEach(() => { } else { process.env.ORCA_PANE_KEY = originalPaneKey } + if (originalStructuredSession === undefined) { + delete process.env.ORCA_STRUCTURED_SESSION + } else { + process.env.ORCA_STRUCTURED_SESSION = originalStructuredSession + } }) describe('orchestration send structured payload flags', () => { @@ -36,6 +44,7 @@ describe('orchestration send structured payload flags', () => { getTerminalHandleMock.mockReset() delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_PANE_KEY + delete process.env.ORCA_STRUCTURED_SESSION }) const invokeSend = (flags: Map) => @@ -292,6 +301,29 @@ describe('orchestration send structured payload flags', () => { expect(callMock).not.toHaveBeenCalled() }) + it('refuses a structured session without naming a handle it could pass', async () => { + process.env.ORCA_STRUCTURED_SESSION = '1' + getTerminalHandleMock.mockResolvedValue('term_sibling_pane') + + // The refusal must not recommend --from: the explicit-flag branch returns before this guard, + // so the advice would succeed against a handle that necessarily belongs to another pane. + await expect( + invokeSend( + new Map([ + ['to', 'term_coord'], + ['subject', 'done'], + ['type', 'worker_done'], + ['outcome', 'succeeded'] + ]) + ) + ).rejects.toMatchObject({ + code: 'no_active_sender_terminal', + message: expect.not.stringContaining('Pass --from') + }) + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + }) + it.each(['worker_done', 'heartbeat'] as const)( 'does not resolve an identity-less %s sender from the active terminal', async (type) => { @@ -327,6 +359,7 @@ describe('orchestration timeout flag validation', () => { callMock.mockReset() delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_PANE_KEY + delete process.env.ORCA_STRUCTURED_SESSION }) const invokeCheck = (flags: Map) => diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index 505bfaac262..e693d99079d 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -36,11 +36,7 @@ export async function resolveOrchestrationTerminalHandle( // rightful worker never saw its mail. Refusing is the only honest answer: this child genuinely // cannot infer its own identity. if (isStructuredSessionWithoutIdentity()) { - throw new RuntimeClientError( - 'no_active_sender_terminal', - `This chat session has no orchestration identity of its own, so --${flagName} cannot be inferred. ` + - `Pass --${flagName} explicitly; guessing would act on another pane's mailbox.` - ) + throw structuredSessionRefusal(flagName) } if (flagName === 'from') { return await resolveImplicitOrchestrationSender(flags, cwd, client) @@ -188,10 +184,33 @@ async function resolveImplicitOrchestrationSender( } } +/** + * Why no flag is suggested: every caller reaches a refusal only after the explicit-flag branch has + * already returned, so `--from` advice would succeed — against a handle that necessarily belongs to + * another pane, whose unread mail the next `check` consumes. + */ +function structuredSessionRefusal(flagName: 'from' | 'terminal'): RuntimeClientError { + return new RuntimeClientError( + 'no_active_sender_terminal', + `This chat session has no orchestration identity of its own, so --${flagName} cannot be inferred, ` + + `and no terminal handle names it — every live handle belongs to a different pane, and passing one ` + + `would consume that pane's mailbox. Drive a worker directly instead: create a worktree with ` + + `--agent to launch one in its first terminal, then use terminal send and terminal read.` + ) +} + export function throwNoActiveSenderTerminal(): never { + // Lifecycle sends refuse here before the structured guard above ever runs, so this is the only + // place left that would tell an identity-less session to pass a handle it does not have. A stale + // ORCA_TERMINAL_HANDLE is a different case — that caller HAS an identity, so it keeps the advice + // to re-run under a live one. + if (isStructuredSessionWithoutIdentity() && !process.env.ORCA_TERMINAL_HANDLE) { + throw structuredSessionRefusal('from') + } throw new RuntimeClientError( 'no_active_sender_terminal', 'Could not determine the sender terminal for this orchestration command. ' + - 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' + "Pass --from with your own terminal's handle — another pane's handle would act on its mailbox — " + + 'or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' ) } diff --git a/src/main/ssh/ssh-remote-orchestration-send.ts b/src/main/ssh/ssh-remote-orchestration-send.ts index 391be6499df..bf03be2b867 100644 --- a/src/main/ssh/ssh-remote-orchestration-send.ts +++ b/src/main/ssh/ssh-remote-orchestration-send.ts @@ -27,7 +27,8 @@ export function resolveRemoteOrchestrationSender( throw new RemoteCliArgumentError( 'no_active_sender_terminal', 'Could not determine the sender terminal for this orchestration command. ' + - 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' + "Pass --from with your own terminal's handle — another pane's handle would act on its mailbox — " + + 'or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' ) } return explicit ?? envHandle ?? 'unknown' From 209d2d8df61000796115544e5de60572a68d152d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:58:42 -0400 Subject: [PATCH 033/224] build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) (#21475) * build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) The page bundled as one 8.16 MB script because every route was a static import. The route manifest now defers each screen behind `import()`, the build is esm with splitting on, and the document loads the entry as a module. What the browser parses before the first route can paint drops from 8.16 MB to 908 KiB; the whole page still weighs the same. Two budgets hold it: the chunk count, which catches a split running away, and the bytes the entry reaches by static import, which catches it collapsing back. The second is the one that matters, and it is measured from esbuild's metafile because only that says which import is static. The RequireContext stays synchronous, since expo-router reads keys() to build the route tree before anything renders. A lazy module cannot answer `unstable_settings` or `ErrorBoundary`, which expo-router reads off the namespace, so a test holds that no route in the subtree exports either. The render check now waits for the route's own text: the entry's mount signal lands while the route chunk is still being fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a route's synchronous exports from esbuild, not a regex `export { x as ErrorBoundary }`, `export class ErrorBoundary` and a re-export all reach the namespace without matching the declaration pattern the guard was matching, so the lazy manifest dropped the boundary and the page painted blank. A star re-export is now reported rather than read as clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say that the entry budget is not a per-route opt-out Measured: statically importing one route already breaks the 3 MiB bound for 5 of the 14. The hatch only works for a layout node, which is the only place expo-router reads a synchronous export from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the chunk ceiling from the route count 64 was three routes of headroom over the 53 chunks 14 routes measure, so C2's routes would have failed on a number measured before they existed. Four per route plus 16 tracks the measured slope; the entry-bytes bound stays the real budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the web entry's dead suspense boundary expo-router wraps every screen in its own, so this one never fires; all nine render checks stay green without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a client-side navigation fetches the next route's chunk Goes red with splitting off: the tasks screen paints out of the entry and no new script is fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name every bundle output by its bytes, not by esbuild's path hash esbuild's [hash] is over the metafile's input keys, which are paths relative to absWorkingDir, so a checkout at another depth or with node_modules as a symlink named a byte-identical chunk differently and shipped a different buildId for one commit. Outputs are now renamed leaves-first to the sha256 of their final bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build on a route the lazy manifest would strip The guard ran only in a test while the docstring said it failed the build. It now runs in bundleMobileWebApp and names the route and the export. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the asset ceiling from the chunk ceiling and the images A flat 128 stopped agreeing with the chunk ceiling at 18 routes, where the asset count would have failed first and named the count instead of the split. Chunks plus images plus the document keeps the chunk ceiling the one that trips. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the route-manifest tests out of the bundle builder's The builder's test file passed 600 lines. The route manifest, the synthesized RequireContext and the web entry are their own subject and move together. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the export guard the builder's route-source loaders Without .js as jsx the guard reported a React Native .js route carrying JSX as "JSX syntax extension is not enabled" instead of reading its exports. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the navigation fetches the tasks route's own chunk "some new script arrived" passed on any fetch. The builder now names the chunk each route lands in, read off the metafile, and the check asserts that exact path arrived and was not already loaded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): resolve a route's realpath before matching it to its chunk esbuild writes metafile input keys after resolving symlinks, so every scratch route tree under /var on macOS reached no output and failed the build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build when the asset ceiling outgrows the shell's map The derived ceiling had no upper bound, and the native shells return null for a manifest over their own 256 rather than truncating it. At 42 images the formula crosses that at 50 routes, inside what Phase C adds, so the build would stay green while the phone got nothing. The number is read from the contract through esbuild, not restated here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover the two hard stops in the content-addressed naming Both throws only ran through a whole bundle before, where neither can be provoked. A cycle and a route no output claims are now asserted directly; each test goes red when its throw is removed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): exit the app-bundle build on one line, not a stack The route-export guard fails this script by design, and a raw stack put the route and the export name under twelve frames of node internals. Mirrors the verifier's exit; the message is printed as thrown because every throw on this path already names its source. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../scripts/build-mobile-web-app-bundle.mjs | 243 +++++++-- .../build-mobile-web-app-bundle.test.mjs | 469 +++++++++++++----- config/scripts/mobile-web-app-render.test.mjs | 117 ++++- .../scripts/mobile-web-app-route-manifest.mjs | 98 +++- .../mobile-web-app-route-manifest.test.mjs | 242 +++++++++ .../scripts/verify-mobile-web-app-bundle.mjs | 142 +++++- mobile/web-entry/index.tsx | 2 + 7 files changed, 1111 insertions(+), 202 deletions(-) create mode 100644 config/scripts/mobile-web-app-route-manifest.test.mjs diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 3b311cce800..6ea2bc19663 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises' -import { basename, extname, join } from 'node:path' +import { realpathSync } from 'node:fs' +import { basename, extname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import * as esbuild from 'esbuild' import { @@ -13,6 +14,8 @@ import { contentTypeForExtension } from './build-mobile-web-bundle.mjs' import { + ROUTE_SOURCE_LOADERS, + assertRoutesCarryNoSynchronousExports, collectMobileWebAppRoutes, renderMobileWebAppRouteManifest } from './mobile-web-app-route-manifest.mjs' @@ -68,6 +71,9 @@ export const MOBILE_WEB_APP_SHIMS = [ const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest' const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider' +/** The entry output's name, so classifying the outputs never has to guess which one it is. */ +const ENTRY_CHUNK_NAME = 'entry' + // mobile/web-entry/route-manifest.ts is a real typed file rather than a virtual specifier, so the // entry typechecks and Metro can still resolve it; only its body is replaced here. function routeManifestPlugin(manifestSource) { @@ -104,12 +110,25 @@ export function mobileWebAppBuildOptions(routes) { // Virtual: write is false, so outdir only names the emitted files esbuild hands back. outdir: 'dist', write: false, - format: 'iife', + // esm, because `splitting` requires it and a per-route chunk is the point: with iife and + // static imports esbuild emitted one 8.16 MB script for all 14 routes. + format: 'esm', + splitting: true, + // esbuild's `[hash]` is over the metafile's input keys, which are paths relative to + // absWorkingDir, so this name is not a function of the bytes and differs between two + // checkouts of one commit. It is a placeholder: renameOutputsByContent replaces it below. + chunkNames: '[hash]', + // Pinned rather than defaulted, so the entry is found by name and not by elimination. + entryNames: ENTRY_CHUNK_NAME, target: ['es2022'], charset: 'utf8', legalComments: 'none', - // Why no sourcemap and no metafile: both embed absolute paths, which would break reproducibility. + // No sourcemap: it is an emitted file and would carry this checkout's absolute paths into the + // bundle. The metafile carries them too but is never written and never hashed; it is the only + // thing that says which output is the entry, which of its imports are static, and which + // outputs each one names. sourcemap: false, + metafile: true, logLevel: 'silent', jsx: 'automatic', // One React: resolve everything from mobile/node_modules, which is where the entry lives. @@ -131,7 +150,7 @@ export function mobileWebAppBuildOptions(routes) { // img-src 'self', which refuses data:. Content-hashed names keep the buildId reproducible. // A font would fail the build here rather than silently ship under font-src 'none'. loader: { - '.js': 'jsx', + ...ROUTE_SOURCE_LOADERS, '.png': 'file', '.jpg': 'file', '.jpeg': 'file', @@ -156,50 +175,187 @@ export function mobileWebAppBuildOptions(routes) { } } +/** + * What the browser must have before the first route can paint: the entry plus every chunk it + * reaches by static import, transitively. A dynamic import is what the split exists to defer, so + * it is where this stops. + * + * The bound the verifier holds is this number and not the entry file alone, because esbuild puts + * the code shared by entry and routes in a chunk the entry imports statically: budgeting the entry + * file on its own would fall as the shared chunk grew. + */ +export function entryStaticClosure(metafile, entryOutputPath) { + const reached = new Set([entryOutputPath]) + const queue = [entryOutputPath] + while (queue.length > 0) { + const current = queue.shift() + for (const imported of metafile.outputs[current]?.imports ?? []) { + if (imported.kind !== 'import-statement' || reached.has(imported.path)) { + continue + } + reached.add(imported.path) + queue.push(imported.path) + } + } + return reached +} + +/** + * Every emitted output, renamed to the sha256 of its own final bytes. + * + * esbuild's `[hash]` is computed over the metafile's input keys, and those keys are paths + * relative to absWorkingDir. A tree whose mobile/node_modules is a symlink keys most of its + * inputs as `../..//...`, a tree that holds a real directory keys them as + * `node_modules/...`, and a byte-identical chunk comes out under a different name in each. The + * name is embedded in every importer, so the difference cascades into a different buildId for one + * commit -- and every phone re-downloads a bundle whose bytes never changed. + * + * Renaming here is what removes the path from the output. Leaves first, so an importer is hashed + * only once the names written inside it are final: an image before the chunk that loads it, a + * chunk before the chunk that imports it, the entry last. The result is what `hashedAsset` would + * name each of these anyway, which is how the name inside the bytes and the manifest's own sha256 + * stay the same string. + */ +export function renameOutputsByContent(metafile, outputFiles) { + const emitted = new Map( + outputFiles.map((file) => [basename(file.path), Buffer.from(file.contents)]) + ) + const importsOf = new Map( + Object.entries(metafile.outputs).map(([output, { imports }]) => [ + basename(output), + (imports ?? []).map((entry) => basename(entry.path)).filter((name) => emitted.has(name)) + ]) + ) + const renamed = new Map() + const open = new Set() + function rename(name) { + const done = renamed.get(name) + if (done) { + return done + } + if (open.has(name)) { + // Two outputs naming each other have no content hash at all, so this is a hard stop rather + // than a fallback. esbuild's splitting emits a DAG; nothing in the tree has produced one. + throw new Error( + `[build-mobile-web-app-bundle] ${name} is in an output cycle and cannot be content-named` + ) + } + open.add(name) + let bytes = emitted.get(name) + for (const child of importsOf.get(name) ?? []) { + const { name: childName } = rename(child) + // publicPath already rewrote the specifier to this exact shape, and an esbuild output name + // is a token that appears nowhere else. + bytes = Buffer.from( + bytes.toString('utf8').split(`/assets/${child}`).join(`/assets/${childName}`), + 'utf8' + ) + } + open.delete(name) + const result = { name: `${sha256Hex(bytes)}${extname(name)}`, bytes } + renamed.set(name, result) + return result + } + for (const name of [...emitted.keys()].sort()) { + rename(name) + } + return renamed +} + +/** + * Which emitted chunk each route key's `import()` lands in. esbuild puts a route module in exactly + * one output, so the metafile's own inputs answer it; nothing downstream can, because by then + * every name is a hash of bytes and the route's source path is gone from the bundle. + */ +export function routeChunkNames(metafile, routes, renamed) { + const owner = new Map() + for (const [output, { inputs }] of Object.entries(metafile.outputs)) { + for (const input of Object.keys(inputs ?? {})) { + // Absolute, and through realpath on the lookup side below: esbuild writes its input keys + // relative to absWorkingDir after resolving symlinks, so a route reached through one (every + // scratch tree under /var on macOS) is keyed by a path the caller never spelled. + owner.set(resolve(mobileDir, input), basename(output)) + } + } + return Object.fromEntries( + routes.map(({ key, module }) => { + const emittedName = owner.get(realpathSync(module)) + if (!emittedName) { + throw new Error(`[build-mobile-web-app-bundle] ${key} reached no output`) + } + return [key, renamed.get(emittedName).name] + }) + ) +} + +const isScriptOutput = (path) => path.endsWith('.js') + // appDir is a seam for the tests, which bundle a scratch route tree; production always uses mobile/app. export async function bundleMobileWebApp({ appDir = defaultAppDir } = {}) { const routes = await collectMobileWebAppRoutes(appDir) + await assertRoutesCarryNoSynchronousExports(routes) const result = await esbuild.build(mobileWebAppBuildOptions(routes)) - const script = result.outputFiles.find((file) => file.path.endsWith('.js')) - if (!script) { - throw new Error('[build-mobile-web-app-bundle] esbuild emitted no script') + const entryOutputPath = Object.keys(result.metafile.outputs).find( + (path) => basename(path) === `${ENTRY_CHUNK_NAME}.js` + ) + if (!entryOutputPath) { + throw new Error('[build-mobile-web-app-bundle] esbuild emitted no entry script') } - const images = result.outputFiles - .filter((file) => file !== script) - .map((file) => ({ name: basename(file.path), bytes: Buffer.from(file.contents) })) - .sort((left, right) => (left.name < right.name ? -1 : 1)) + const renamed = renameOutputsByContent(result.metafile, result.outputFiles) + const entry = renamed.get(basename(entryOutputPath)) + const byName = (left, right) => (left.name < right.name ? -1 : 1) + const others = [...renamed.entries()] + .filter(([emittedName]) => emittedName !== basename(entryOutputPath)) + .map(([emittedName, output]) => ({ emittedName, ...output })) + // Chunks keep their new name into the served path: the entry imports them by it, and + // publicPath has already made that specifier /assets/. + const chunks = others.filter(({ emittedName }) => isScriptOutput(emittedName)).sort(byName) + const images = others.filter(({ emittedName }) => !isScriptOutput(emittedName)).sort(byName) + const closure = entryStaticClosure(result.metafile, entryOutputPath) return { - script: Buffer.from(script.contents), + script: entry.bytes, + chunks, images, - routeKeys: routes.map((route) => route.key) + // Counted off the renamed bytes rather than the metafile's own sizes, which are from before + // the names inside each output grew. Only the metafile knows which import is static; see + // entryStaticClosure. + entryStaticBytes: [...closure].reduce( + (total, path) => total + (renamed.get(basename(path))?.bytes.byteLength ?? 0), + 0 + ), + routeKeys: routes.map((route) => route.key), + routeChunks: routeChunkNames(result.metafile, routes, renamed) } } -export async function buildMobileWebAppBundle({ outDir = defaultOutDir } = {}) { - const [desktopVersion, protocolWindow, { script, images, routeKeys }] = await Promise.all([ +export async function buildMobileWebAppBundle({ appDir, outDir = defaultOutDir } = {}) { + const [ + desktopVersion, + protocolWindow, + { script, chunks, images, entryStaticBytes, routeChunks, routeKeys } + ] = await Promise.all([ readDesktopVersion(), readProtocolWindow(), - bundleMobileWebApp() + bundleMobileWebApp({ appDir }) ]) + // Every output is already named by its own bytes, and a name is written inside whatever imports + // it, so hashedAsset here reproduces the name rather than choosing one. const scriptAsset = hashedAsset(script, 'js') - // esbuild already named these by content hash; keep that name so the reference inside the - // script stays valid, and carry the sha256 in the manifest entry as every asset does. - const imageAssets = images.map(({ name, bytes }) => ({ - bytes, - path: `assets/${name}`, - sha256: sha256Hex(bytes), - byteLength: bytes.byteLength, - contentType: contentTypeForExtension(extname(name).slice(1)) - })) + const written = [ + scriptAsset, + ...[...chunks, ...images].map(({ name, bytes }) => hashedAsset(bytes, extname(name).slice(1))) + ] // Root-absolute, unlike the Phase A bootstrap's bare relative src: this document is served at // every route depth (/h//tasks), where a relative href resolves against the route and // 404s. A tag would be the other fix, but the shell's CSP sets base-uri 'none'. + // type="module", because the entry is esm and reaches its routes through import(). Same-origin + // module and chunk both load under the shell's script-src 'self'; the policy is unchanged. const html = '\n\n\n\n' + '\n' + 'Orca\n\n\n
\n' + - `\n\n\n` + `\n\n\n` const indexBytes = Buffer.from(html, 'utf8') const indexAsset = { bytes: indexBytes, @@ -211,18 +367,37 @@ export async function buildMobileWebAppBundle({ outDir = defaultOutDir } = {}) { const { manifest } = await writeMobileWebBundleTree({ outDir, - written: [indexAsset, scriptAsset, ...imageAssets], + written: [indexAsset, ...written], desktopVersion, protocolWindow }) - return { manifest, outDir, routeKeys } + return { + manifest, + outDir, + routeChunks, + routeKeys, + entryStaticBytes, + // The entry counts: it is a chunk the browser fetches, and the budget is about how many. + chunkCount: chunks.length + 1, + // Everything the routes import that is not a script, which is the rest of the asset budget. + imageCount: images.length + } } if (isDirectInvocation(import.meta.url, process.argv[1])) { - const { manifest, outDir, routeKeys } = await buildMobileWebAppBundle() - console.log( - `[build-mobile-web-app-bundle] OK — ${String(routeKeys.length)} route(s), ` + - `${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes, ` + - `buildId ${manifest.buildId} -> ${outDir}` - ) + try { + const { manifest, outDir, routeKeys, entryStaticBytes, chunkCount } = + await buildMobileWebAppBundle() + console.log( + `[build-mobile-web-app-bundle] OK — ${String(routeKeys.length)} route(s), ` + + `${String(chunkCount)} chunk(s), ${String(entryStaticBytes)} bytes before the first route, ` + + `${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes, ` + + `buildId ${manifest.buildId} -> ${outDir}` + ) + } catch (error) { + // The route guards fail here by design, and every throw on this path already names its + // source, so a stack only buries which route and which export. + console.error(error.message) + process.exit(1) + } } diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index a1446854d68..9e20952ed6a 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -7,19 +7,25 @@ import { MOBILE_WEB_APP_SHIMS, bundleMobileWebApp, buildMobileWebAppBundle, - mobileWebAppBuildOptions + entryStaticClosure, + mobileWebAppBuildOptions, + renameOutputsByContent, + routeChunkNames } from './build-mobile-web-app-bundle.mjs' import { MOBILE_WEB_APP_ROUTE_ROOT, - ROUTE_CONTEXT_SOURCE, + ROUTE_SOURCE_LOADERS, collectMobileWebAppRouteKeys, - collectMobileWebAppRoutes, - renderMobileWebAppRouteManifest + collectMobileWebAppRoutes } from './mobile-web-app-route-manifest.mjs' import { - MOBILE_WEB_APP_BUNDLE_MAX_ASSETS, + MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES, MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES, MOBILE_WEB_APP_SOURCE_DIRS, + assertAssetCeilingFitsShell, + mobileWebAppBundleMaxAssets, + mobileWebAppBundleMaxChunks, + readMobileWebBundleMaxAssets, verifyMobileWebAppBundle } from './verify-mobile-web-app-bundle.mjs' import { @@ -27,12 +33,16 @@ import { assertNoCarriageReturnsInSource } from './verify-mobile-web-bundle.mjs' import { + hashedAsset, readDesktopVersion, readProtocolWindow, sha256Hex, writeMobileWebBundleTree } from './build-mobile-web-bundle.mjs' -import { MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES } from '../../src/shared/mobile-web-bundle/manifest-contract.js' +import { + MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, + MOBILE_WEB_BUNDLE_MAX_ASSETS +} from '../../src/shared/mobile-web-bundle/manifest-contract.js' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' const projectDir = fileURLToPath(new URL('../..', import.meta.url)) @@ -44,6 +54,11 @@ const bundles = mobileWebAppDependenciesPresent() const describeBundling = bundles ? describe : describe.skip const itBundling = bundles ? it : it.skip +/** Every script the page loads. A route's code is in a chunk now, not in the entry. */ +function allScriptSource({ script, chunks }) { + return [script, ...chunks.map((chunk) => chunk.bytes)].map((bytes) => bytes.toString('utf8')) +} + async function withScratch(run) { const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-test-')) try { @@ -53,107 +68,6 @@ async function withScratch(run) { } } -describe('route manifest', () => { - it('collects the h/ subtree and nothing above it', async () => { - const keys = await collectMobileWebAppRouteKeys(appDir) - expect(keys.length).toBeGreaterThan(0) - for (const key of keys) { - expect(key.startsWith(`./${MOBILE_WEB_APP_ROUTE_ROOT}/`)).toBe(true) - } - // The native-only shell (pairing, settings, notifications) must not reach the page bundle. - expect(keys).not.toContain('./_layout.tsx') - expect(keys).not.toContain('./pair.tsx') - }) - - it('is sorted, so the generated module is a pure function of the tree', async () => { - const keys = await collectMobileWebAppRouteKeys(appDir) - expect(keys).toEqual([...keys].sort()) - }) - - it('excludes test files and API routes', async () => { - // mobile/app holds none of these today, so assert the rule against a tree that does. - await withScratch(async (scratch) => { - const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) - await mkdir(directory, { recursive: true }) - for (const name of [ - 'index.tsx', - 'index.test.tsx', - 'index.spec.tsx', - 'shape.d.ts', - '+api.ts', - 'tokens+api.ts', - '+middleware.ts', - 'notes.md' - ]) { - await writeFile(join(directory, name), 'export default null\n', 'utf8') - } - expect(await collectMobileWebAppRouteKeys(scratch)).toEqual(['./h/index.tsx']) - }) - expect(await collectMobileWebAppRouteKeys(appDir)).not.toContain('./h/_layout.test.tsx') - }) - - it('refuses an empty subtree rather than emitting a context with no routes', async () => { - await expect(collectMobileWebAppRouteKeys(appDir, 'does-not-exist')).rejects.toThrow() - }) - - it('emits one static import per key', async () => { - const source = renderMobileWebAppRouteManifest([ - { key: './h/index.tsx', module: '/app/h/index.tsx' }, - { key: './h/_layout.tsx', module: '/app/h/_layout.tsx' } - ]) - expect(source).toContain('import * as route0 from "/app/h/index.tsx"') - expect(source).toContain('import * as route1 from "/app/h/_layout.tsx"') - // A lazy getter would need a chunk fetch, which the page's script-src 'self' does not serve. - expect(source).not.toContain('import(') - }) - - it('imports a .web.tsx sibling under the native route key', async () => { - await withScratch(async (scratch) => { - const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'index.tsx'), 'export default function Route() {}\n') - expect(await collectMobileWebAppRoutes(scratch)).toEqual([ - { key: './h/index.tsx', module: join(directory, 'index.tsx') } - ]) - await writeFile(join(directory, 'index.web.tsx'), 'export default function Route() {}\n') - // The key is still the native filename, so the override changes the code and not the URL. - expect(await collectMobileWebAppRoutes(scratch)).toEqual([ - { key: './h/index.tsx', module: join(directory, 'index.web.tsx') } - ]) - }) - }) -}) - -describe('the synthesized RequireContext', () => { - const build = (modules) => - new Function('modules', `${ROUTE_CONTEXT_SOURCE}; return routeContext`)(modules) - - it('answers the four members expo-router reads', () => { - const context = build({ './h/index.tsx': { default: 'screen' } }) - expect(context.keys()).toEqual(['./h/index.tsx']) - expect(context('./h/index.tsx')).toEqual({ default: 'screen' }) - expect(context.resolve('./h/index.tsx')).toBe('./h/index.tsx') - expect(context.id).toBe('orca-mobile-web-app-routes') - }) - - it('hands out a copy of keys, so a caller cannot mutate the route tree', () => { - const context = build({ './h/index.tsx': {} }) - context.keys().push('./injected.tsx') - expect(context.keys()).toEqual(['./h/index.tsx']) - }) - - it('throws rather than returning undefined for an unknown key', () => { - const context = build({ './h/index.tsx': {} }) - expect(() => context('./missing.tsx')).toThrow('no route module') - expect(() => context.resolve('./missing.tsx')).toThrow('cannot resolve route') - }) - - it('does not answer inherited Object keys', () => { - const context = build({ './h/index.tsx': {} }) - expect(() => context('constructor')).toThrow('no route module') - }) -}) - describe('the CRLF pin', () => { it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => { const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8') @@ -172,13 +86,115 @@ describe('the CRLF pin', () => { describeBundling('the app bundle', () => { it('resolves react-native to react-native-web and leaves no require.context', async () => { - const { script } = await bundleMobileWebApp() - const source = script.toString('utf8') - expect(source).not.toContain('require.context') + const sources = allScriptSource(await bundleMobileWebApp()) + for (const source of sources) { + expect(source).not.toContain('require.context') + } // react-native-web's touch responder is proof the alias resolved rather than the native stub. - expect(source).toContain('ResponderTouchHistoryStore') + expect(sources.some((source) => source.includes('ResponderTouchHistoryStore'))).toBe(true) }, 120_000) + it('cuts the routes into chunks the entry does not load', async () => { + const { script, chunks, entryStaticBytes } = await bundleMobileWebApp() + expect(chunks.length).toBeGreaterThan(1) + // The entry's own bytes plus the chunks it imports statically, which is what the browser + // parses before any route paints. Every route chunk is outside it. + expect(entryStaticBytes).toBeGreaterThan(script.byteLength) + const allBytes = + script.byteLength + chunks.reduce((total, chunk) => total + chunk.bytes.byteLength, 0) + expect(entryStaticBytes).toBeLessThan(allBytes) + }, 120_000) + + it('names the chunk each route lands in', async () => { + const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp() + expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort()) + const emitted = new Set(chunks.map((chunk) => chunk.name)) + for (const [key, name] of Object.entries(routeChunks)) { + expect(emitted, key).toContain(name) + } + // One chunk per route, never the entry: that is what a client-side navigation fetches. + expect(new Set(Object.values(routeChunks)).size).toBe(routeKeys.length) + }, 120_000) + + it('counts only static imports into what loads before the first route', () => { + const metafile = { + outputs: { + 'dist/entry.js': { + bytes: 10, + imports: [ + { path: 'dist/shared.js', kind: 'import-statement' }, + { path: 'dist/route.js', kind: 'dynamic-import' } + ] + }, + 'dist/shared.js': { + bytes: 20, + imports: [{ path: 'dist/deep.js', kind: 'import-statement' }] + }, + 'dist/deep.js': { bytes: 30, imports: [] }, + 'dist/route.js': { bytes: 40, imports: [] } + } + } + expect([...entryStaticClosure(metafile, 'dist/entry.js')]).toEqual([ + 'dist/entry.js', + 'dist/shared.js', + 'dist/deep.js' + ]) + }) + + it('does not walk a chunk cycle forever', () => { + const metafile = { + outputs: { + 'dist/entry.js': { bytes: 1, imports: [{ path: 'dist/a.js', kind: 'import-statement' }] }, + 'dist/a.js': { bytes: 1, imports: [{ path: 'dist/entry.js', kind: 'import-statement' }] } + } + } + expect(entryStaticClosure(metafile, 'dist/entry.js').size).toBe(2) + }) + + itBundling( + 'refuses to build a route the lazy manifest would strip an export from', + async () => { + await withScratch(async (scratch) => { + const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, 'index.tsx'), + 'export default function Route() { return null }\n' + ) + await expect(bundleMobileWebApp({ appDir: scratch })).resolves.toBeTruthy() + await writeFile( + join(directory, 'settings.tsx'), + 'const anchor = { anchor: "index" }\nexport { anchor as unstable_settings }\nexport default function Route() { return null }\n' + ) + // The build is where this has to fail: the page it would otherwise emit mounts with the + // export silently gone, which is a blank screen on a phone and nothing in any log. + await expect(bundleMobileWebApp({ appDir: scratch })).rejects.toThrow( + /settings\.tsx.*unstable_settings/s + ) + }) + }, + 240_000 + ) + + itBundling( + 'refuses a route whose star re-export it cannot read', + async () => { + await withScratch(async (scratch) => { + const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'boundary.ts'), 'export const value = 1\n') + await writeFile( + join(directory, 'index.tsx'), + 'export * from "./boundary"\nexport default function Route() { return null }\n' + ) + await expect(bundleMobileWebApp({ appDir: scratch })).rejects.toThrow( + /index\.tsx.*boundary/s + ) + }) + }, + 240_000 + ) + it('bundles every route module', async () => { const { routeKeys } = await bundleMobileWebApp() expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir)) @@ -191,17 +207,90 @@ describeBundling('the app bundle', () => { const route = (marker) => `export default function Route() { return '${marker}' }\n` await writeFile(join(directory, 'index.tsx'), route('native-route-marker')) const before = await bundleMobileWebApp({ appDir: scratch }) - expect(before.script.toString('utf8')).toContain('native-route-marker') + const has = (bundle, marker) => + allScriptSource(bundle).some((source) => source.includes(marker)) + expect(has(before, 'native-route-marker')).toBe(true) await writeFile(join(directory, 'index.web.tsx'), route('web-route-marker')) const after = await bundleMobileWebApp({ appDir: scratch }) - expect(after.script.toString('utf8')).toContain('web-route-marker') - expect(after.script.toString('utf8')).not.toContain('native-route-marker') + expect(has(after, 'web-route-marker')).toBe(true) + expect(has(after, 'native-route-marker')).toBe(false) // Different script bytes means a different asset sha and so a different buildId. expect(after.script.equals(before.script)).toBe(false) }) }, 240_000) + /** + * The same route tree, bundled from two directories at different depths. esbuild's own `[hash]` + * is computed over the metafile's input keys, which are paths relative to absWorkingDir, so two + * checkouts of one commit -- at different depths, or one with mobile/node_modules as a symlink + * and one with it as a directory -- name a byte-identical chunk differently. The rename + * cascades through every importer into a different buildId, and every phone re-downloads a + * bundle whose bytes did not change. + */ + async function bundleFromDepth(root, depth) { + const nested = join(root, ...Array.from({ length: depth }, (_, index) => `d${String(index)}`)) + const directory = join(nested, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + // Two routes over one import, which is what makes esbuild emit a shared chunk to name. + await writeFile(join(directory, 'shared.ts'), 'export const marker = "shared-marker"\n') + for (const name of ['index.tsx', 'other.tsx']) { + await writeFile( + join(directory, name), + `import { marker } from "./shared"\nexport default function Route() { return marker + "${name}" }\n` + ) + } + return { appDir: nested, bundle: await bundleMobileWebApp({ appDir: nested }) } + } + + it('names every output by its bytes, so another checkout path builds the same bundle', async () => { + await withScratch(async (shallow) => { + await withScratch(async (deep) => { + const near = await bundleFromDepth(shallow, 1) + const far = await bundleFromDepth(deep, 5) + const names = ({ bundle }) => [...bundle.chunks, ...bundle.images].map((one) => one.name) + expect(names(far)).toEqual(names(near)) + expect(far.bundle.script.equals(near.bundle.script)).toBe(true) + // The whole point: the manifest the phone compares is the same document. + const buildIdFrom = async ({ appDir }) => + withScratch(async (out) => { + const { manifest } = await buildMobileWebAppBundle({ appDir, outDir: join(out, 'x') }) + return manifest.buildId + }) + expect(await buildIdFrom(far)).toBe(await buildIdFrom(near)) + }) + }) + }, 240_000) + + it("names an output the same way the manifest's own asset hash does", async () => { + const { script, chunks } = await bundleMobileWebApp() + // The name is embedded in the importer, so it cannot be recomputed later; this is what says + // the name inside the bytes and the manifest's sha256 of those bytes are the same string. + expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`) + for (const chunk of chunks) { + expect(chunk.name).toBe(`${sha256Hex(chunk.bytes)}.js`) + } + }, 120_000) + + it('asks esbuild for the split the budgets assume', async () => { + const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) + // Each of these is load-bearing for a budget below: esm and splitting are what make a route a + // chunk, and the metafile is the only thing that says which imports are static. + expect(options.format).toBe('esm') + expect(options.splitting).toBe(true) + expect(options.chunkNames).toBe('[hash]') + expect(options.metafile).toBe(true) + }) + + it('reads a route source the same way the export guard does', async () => { + const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) + // The guard parses each route on its own, outside this build. Sharing the table is what stops + // a loader the bundle relies on from being missing there and reported as a syntax error. + for (const [extension, loader] of Object.entries(ROUTE_SOURCE_LOADERS)) { + expect(options.loader[extension], extension).toBe(loader) + } + }) + it('applies every shim it names', async () => { const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) for (const shim of MOBILE_WEB_APP_SHIMS) { @@ -237,8 +326,11 @@ describeBundling('the app bundle', () => { }) it('embeds no absolute path from this checkout', async () => { - const { script } = await bundleMobileWebApp() - expect(script.toString('utf8')).not.toContain(projectDir) + // Every chunk, not only the entry: the route manifest names each route by absolute path, and + // the chunk that import resolves to is where such a path would survive. + for (const source of allScriptSource(await bundleMobileWebApp())) { + expect(source).not.toContain(projectDir) + } }, 120_000) it('builds the same buildId twice', async () => { @@ -251,6 +343,18 @@ describeBundling('the app bundle', () => { expect(first.manifest.buildId).toBe(second.manifest.buildId) }, 120_000) + it('loads the entry as a module, so its route imports resolve', async () => { + await withScratch(async (scratch) => { + const outDir = join(scratch, 'module-tag') + const { manifest } = await buildMobileWebAppBundle({ outDir }) + const html = await readFile(join(outDir, 'index.html'), 'utf8') + // import() in a classic script is a syntax error, so the tag and the format are one fact. + expect(html).toContain('\n\n\n` const indexBytes = Buffer.from(html, 'utf8') const indexAsset = { diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index 25b578f4b2f..6fc099ad576 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -4,6 +4,7 @@ import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { + MOBILE_WEB_APP_ROOT_RESET, MOBILE_WEB_APP_SHIMS, bundleMobileWebApp, buildMobileWebAppBundle, @@ -412,6 +413,32 @@ describeBundling('the app bundle', () => { }) }, 120_000) + it('carries the root reset, so the mounted tree has a height to be 1 of', async () => { + await withScratch(async (scratch) => { + const outDir = join(scratch, 'root-reset') + await buildMobileWebAppBundle({ outDir }) + const html = await readFile(join(outDir, 'index.html'), 'utf8') + expect(html).toContain(MOBILE_WEB_APP_ROOT_RESET) + // Literals rather than substrings taken off the constant, which would read it back against + // itself and follow any rule dropped from it. Every rule, because the chain is only as + // definite as its weakest link: a height on #root alone resolves against a body that has + // none, and percent of auto is auto. Named one by one so a failure says which rule went. + for (const rule of [ + 'html,body{height:100%}', + 'body{overflow:hidden}', + '#root{display:flex;height:100%;flex:1}' + ]) { + expect(MOBILE_WEB_APP_ROOT_RESET, rule).toContain(rule) + } + // The id travels with the rules: it is what marks this block as the template's reset rather + // than something the page grew its own copy of. + expect(MOBILE_WEB_APP_ROOT_RESET).toContain('' + return `${meta}${style}` } -/** Base64 back to the byte length it stands for, which is what the shell is handed. */ -function base64ByteLength(b64: string): number { - const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0 - return (b64.length / 4) * 3 - padding +/** + * A noise JPEG at the quality the pane ships, encoded by Chromium's screencast, in bytes. + * + * `Emulation.setDeviceMetricsOverride` here sizes the surface and nothing else. Its + * `deviceScaleFactor` is required by the command and inert to this measurement: headless Chromium + * composites at the DIP surface size whatever the factor says, so the sweep returns the same + * 0.543986 / 0.552964 to six decimals at a factor of 1 and at 3. Measured 2026-09-20; the frame is + * therefore requested at one device pixel per CSS pixel and the canvas painted at that same size, + * which is how every pixel of noise reaches the encoder unaveraged. + * + * What does guard the measurement is the document's viewport meta, without which the page lays out + * at Chromium's 980 px default, the canvas is scaled into the frame and the noise averages away. + * That is not left to this comment: the floor assertion in the sweep below is what catches it, and + * `reads far under the floor without the viewport meta, which is what the floor guards` is what + * proves the floor catches it. + * + * The quality is read, not retyped: at 90 every budgeted viewport posts over the cap. + */ +async function screencastNoiseJpegBytes( + frame: { width: number; height: number }, + seed: number, + target: { page: Page; cdp: CDPSession } | null = null +): Promise { + const resolved = target ?? (page !== null && cdp !== null ? { page, cdp } : null) + if (resolved === null) { + throw new Error('the sweep has no page') + } + const session = resolved.cdp + const sheet = resolved.page + await session.send('Emulation.setDeviceMetricsOverride', { + width: frame.width, + height: frame.height, + deviceScaleFactor: 1, + mobile: true + }) + await sheet.evaluate(({ width, height }) => { + const canvas = document.getElementById('noise') + if (!(canvas instanceof HTMLCanvasElement)) { + throw new Error('no noise canvas') + } + canvas.width = width + canvas.height = height + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + }, frame) + + const sizes: number[] = [] + const onFrame = (event: { data: string; sessionId: number }): void => { + sizes.push(Buffer.from(event.data, 'base64').length) + void session.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => {}) + } + session.on('Page.screencastFrame', onFrame) + let painted = 0 + try { + await session.send('Page.startScreencast', { + format: 'jpeg', + quality: sweep().BROWSER_FRAME_QUALITY, + maxWidth: frame.width, + maxHeight: frame.height, + everyNthFrame: 1 + }) + // The noise is painted after the screencast is running, and through this same CDP session, so + // the reply orders it against the frame events. Two animation frames are awaited inside it, so + // when it resolves the paint has been committed to the compositor and every later capture + // carries it. `painted` is how many frames had already arrived by then; only what comes after + // is a frame of the noise, which is what makes this a measurement of the canvas rather than of + // whatever the surface held when the capture began. + await session.send('Runtime.evaluate', { + awaitPromise: true, + expression: `(async () => { + const canvas = document.getElementById('noise') + const context = canvas.getContext('2d') + const image = context.createImageData(canvas.width, canvas.height) + let state = ${seed >>> 0} + for (let index = 0; index < image.data.length; index += 4) { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + image.data[index] = (state >>> 24) & 0xff + image.data[index + 1] = (state >>> 16) & 0xff + image.data[index + 2] = (state >>> 8) & 0xff + image.data[index + 3] = 255 + } + context.putImageData(image, 0, 0) + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + })()` + }) + painted = sizes.length + + // Nudged until a frame lands after that commit. A capture already in flight can still be the + // old surface, so two are taken and the larger is used: a blank frame is a fraction of a noise + // frame, so the maximum over the post-commit frames is the noise one whichever order they came. + const deadline = Date.now() + 20_000 + for (let nudge = 0; sizes.length - painted < 2 && Date.now() < deadline; nudge += 1) { + await session.send('Runtime.evaluate', { + expression: `document.documentElement.style.background = ${nudge % 2 === 0 ? "'#000'" : "'#111'"}` + }) + await sheet.waitForTimeout(80) + } + } finally { + await session.send('Page.stopScreencast').catch(() => {}) + session.off('Page.screencastFrame', onFrame) + } + const afterPaint = sizes.slice(painted) + if (afterPaint.length === 0) { + // Never fall back to a frame from before the paint: that is the understatement this exists to + // rule out, and a silent one would look like a cheaper encoder. + throw new Error( + `no screencast frame after the noise was committed for ${frame.width}x${frame.height}` + ) + } + return Math.max(...afterPaint) } function screencastFrame(image: Uint8Array, frame: { width: number; height: number }) { @@ -218,8 +308,10 @@ describeSweep('the frame budget across the viewport range', () => { let bestBytesPerPixel = 1 for (const viewport of VIEWPORTS.filter(withinBudget)) { const frame = budgetedFrame(viewport) - const b64 = await encodeNoiseJpeg(frame, viewport.width * 7_919 + viewport.height) - const imageBytes = base64ByteLength(b64) + const imageBytes = await screencastNoiseJpegBytes( + frame, + viewport.width * 7_919 + viewport.height + ) const bytesPerPixel = imageBytes / (frame.width * frame.height) worstBytesPerPixel = Math.max(worstBytesPerPixel, bytesPerPixel) bestBytesPerPixel = Math.min(bestBytesPerPixel, bytesPerPixel) @@ -231,9 +323,13 @@ describeSweep('the frame budget across the viewport range', () => { } } + console.log( + `[frame-budget-sweep] screencast bytes per pixel: max ${worstBytesPerPixel.toFixed(5)}, ` + + `min ${bestBytesPerPixel.toFixed(5)}` + ) expect(overCap).toEqual([]) // And the constant is above every cost that sweep just measured. Against the constant, not the - // 0.55351 measured on 2026-09-20 that its docstring records: the margin above that is what an + // 0.552964 measured on 2026-09-20 that its docstring records: the margin above that is what an // encoder drift may spend, and a drift inside it is not a budget failure. Without this the // assertion above passes by the budget being merely generous. expect(worstBytesPerPixel).toBeLessThanOrEqual(sweep().WORST_CASE_JPEG_BYTES_PER_PIXEL) @@ -253,8 +349,33 @@ describeSweep('the frame budget across the viewport range', () => { ) const frame = budgetedFrame(largest) expect(frame.scale).toBe(1) - const b64 = await encodeNoiseJpeg(frame, 1) - expect(postThroughShell(new Uint8Array(base64ByteLength(b64)), frame)).toBeNull() + const imageBytes = await screencastNoiseJpegBytes(frame, 1) + expect(postThroughShell(new Uint8Array(imageBytes), frame)).toBeNull() + }, 120_000) + + it('reads far under the floor without the viewport meta, which is what the floor guards', async () => { + if (browser === null) { + throw new Error('the sweep has no browser') + } + // The same frame, the same noise, the same encoder — one arm short of the meta. Chromium then + // lays the page out at its 980 px default and scales the canvas into the frame, so what the + // encoder sees is averaged noise rather than noise. Without a case saying so, the sweep could + // measure that and still pass every assertion above by being comfortably under the constant. + const frame = { width: 768, height: 1133 } + const context = await browser.newContext() + try { + const bare = await context.newPage() + const session = await context.newCDPSession(bare) + await bare.setContent(noiseDocument({ viewportMeta: false })) + const bytes = await screencastNoiseJpegBytes(frame, 4_242, { page: bare, cdp: session }) + const bytesPerPixel = bytes / (frame.width * frame.height) + + expect(bytesPerPixel).toBeLessThan(0.3) + // And the floor the sweep asserts is above it, so that assertion is what fails first. + expect(bytesPerPixel).toBeLessThan(0.5) + } finally { + await context.close() + } }, 120_000) it('never asks for more density than native, anywhere in the range', () => { diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index 0a4d2d2e93f..16ede2b6bb3 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -21,6 +21,7 @@ import { type PinchGesture } from './mobile-browser-frame-state' import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url' +import type { BrowserDialogState } from './mobile-browser-stream-events' import { browserGoBack, browserGoForward, @@ -64,11 +65,6 @@ type PanGesture = { offsetY: number } -type BrowserDialogState = { - dialogType: string - message: string -} - const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 } export function MobileBrowserPane({ diff --git a/mobile/src/browser/MobileBrowserPaneView.tsx b/mobile/src/browser/MobileBrowserPaneView.tsx index e36e7044837..d9a52600a1d 100644 --- a/mobile/src/browser/MobileBrowserPaneView.tsx +++ b/mobile/src/browser/MobileBrowserPaneView.tsx @@ -29,6 +29,7 @@ import type { } from './browser-touch-geometry' import type { MobileBrowserViewMode } from './browser-screencast-request' import type { MobileBrowserTab } from './MobileBrowserPane' +import type { BrowserDialogState } from './mobile-browser-stream-events' type MobileBrowserPaneViewProps = { addressFocused: boolean @@ -38,7 +39,7 @@ type MobileBrowserPaneViewProps = { browserViewMode: MobileBrowserViewMode busy: boolean controlsDisabled: boolean - dialog: { dialogType: string; message: string } | null + dialog: BrowserDialogState | null error: string | null frameGeometry: BrowserFrameGeometry | null frameLayerErrorHandler: (layer: FrameLayer) => () => void @@ -69,6 +70,16 @@ type MobileBrowserPaneViewProps = { zoom: BrowserZoomState } +/** + * The pane's chrome and the surface the frames paint into. + * + * "Never dark" holds only for a page that produces some frame that fits: with every frame over the + * cap this sits on its busy spinner over an unpainted viewport, which the C6.6 device proof + * measured with the area budget forced off — 299 dropped, 6 applied, the stream alive and no error + * state, and nothing to look at. That is C6 ruling 1 working as written, not a failure of it: a + * frame that does not fit is dropped rather than ending the stream. It is what the area budget + * exists to keep from happening. + */ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) { const { addressFocused, @@ -251,12 +262,16 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) { Browser Dialog {dialog.message} + {/* The page is still blocked, so the buttons stay live and the card says why. */} + {dialog.error ? {dialog.error} : null} {dialog.dialogType !== 'alert' ? ( [ styles.dialogButton, - pressed && styles.dialogButtonPressed + pressed && styles.dialogButtonPressed, + dialog.pending !== undefined && styles.dialogButtonDisabled ]} onPress={() => void sendDialogCommand('browser.dialogDismiss')} > @@ -264,10 +279,12 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) { ) : null} [ styles.dialogButton, styles.dialogButtonPrimary, - pressed && styles.dialogButtonPressed + pressed && styles.dialogButtonPressed, + dialog.pending !== undefined && styles.dialogButtonDisabled ]} onPress={() => void sendDialogCommand('browser.dialogAccept')} > diff --git a/mobile/src/browser/browser-screencast-request.web.ts b/mobile/src/browser/browser-screencast-request.web.ts index 3531b7f9e0e..5b6864a81b1 100644 --- a/mobile/src/browser/browser-screencast-request.web.ts +++ b/mobile/src/browser/browser-screencast-request.web.ts @@ -22,16 +22,27 @@ export type { * Uniform random noise at quality 72, which is the image JPEG compresses least and the ceiling * every real page sits under; photographic content measures near a tenth of it. * - * Swept 2026-09-20 over 143 viewports — widths 320 to 1400 and heights 480 to 1600 — each encoded - * by Chromium at the scale `budgetedMobileViewDeviceScaleFactor` picks for it. Across the 111 the - * budget fits, the measured cost ranged from 0.54470 to 0.55351 bytes per pixel. This is that - * maximum plus a margin of 0.00649, about 1.2%, for the encoder version it was not swept on. + * Swept 2026-09-20 over 143 viewports — widths 320 to 1400 and heights 480 to 1600 — each frame + * encoded at the scale `budgetedMobileViewDeviceScaleFactor` picks for it. Across the 111 the + * budget fits, `Page.startScreencast` measured 0.543986 to 0.552964 bytes per pixel. This is that + * maximum plus a margin of 0.007036, about 1.3%, for the encoder version it was not swept on. * - * It was 0.545 before that sweep, taken from one 2400x2160 frame. A single large frame is the + * Re-measured on the real encoder, which was the point of the exercise: the first sweep used + * `canvas.toDataURL` and read 0.54470 to 0.55351, while the product's frames come from + * `Page.startScreencast`. The two agree to within a thousandth of a byte per pixel, and the + * screencast is the marginally cheaper of them, so the encoder is not what makes a budgeted frame + * miss. `mobile-web-app-frame-budget-sweep.test.ts` now drives the screencast, so the number and + * the product share one encoder, and re-running it is how this number is changed. + * + * It was 0.545 before any sweep, taken from one 2400x2160 frame. A single large frame is the * cheapest per pixel in the whole range, so the number it gave was under 90 of those 143 viewports * and the budget it produced posted a frame over the cap on a phone. A worst case measured at one - * point is not a worst case; `mobile-web-app-frame-budget-sweep.test.ts` is what holds this one to - * the whole range, and re-running it is how this number is changed. + * point is not a worst case. + * + * What this margin does not cover: the C6.6 device proof, with the budget on, dropped 1 frame in + * 41 at 402x593, which needs about 0.5649 bytes per pixel — above everything either sweep has + * seen. Nothing here reproduces it, so it is not folded into this constant; a frame that still + * does not fit is C6 ruling 1's to drop. */ export const WORST_CASE_JPEG_BYTES_PER_PIXEL = 0.56 diff --git a/mobile/src/browser/browser-touch-geometry.test.ts b/mobile/src/browser/browser-touch-geometry.test.ts index d89a8b93fcb..50ef55f6d64 100644 --- a/mobile/src/browser/browser-touch-geometry.test.ts +++ b/mobile/src/browser/browser-touch-geometry.test.ts @@ -1,11 +1,35 @@ import { describe, expect, it } from 'vitest' import { + browserWheelDeltaFromScreen, clampBrowserZoomState, computeBrowserFrameGeometry, + computeBrowserTouchClickRadiusCss, mapScreenToBrowserPoint, readLocalTouchPoint } from './browser-touch-geometry' +const NO_ZOOM = { scale: 1, offsetX: 0, offsetY: 0 } + +/** + * What Chromium paints for `dialog.html`, the C6.6 fixture with no ``. + * + * Measured on Chromium 1217, 2026-09-20, under a 402x593 mobile emulation at device scale 1.91: + * the page lays out at 980 CSS px and the frame metadata comes back with `deviceWidth` 402 and + * `pageScaleFactor` 402/980. `Input.dispatchMouseEvent` takes page CSS coordinates, unscaled, and + * `scrollOffsetX/Y` must not be added to them — a click sent at `device / scale + scrollOffset` + * landed 300 px below its target on a scrolled page, and `device / scale` hit it. + */ +const NO_VIEWPORT_META = { + deviceWidth: 402, + deviceHeight: 593, + pageScaleFactor: 0.41020408272743225, + scrollOffsetX: 0, + scrollOffsetY: 300 +} + +/** The fixture's alert button, in the page's own CSS pixels. */ +const BUTTON = { left: 32, top: 112, right: 115, bottom: 162 } + describe('browser touch geometry', () => { it('maps the visual center of a letterboxed desktop frame to the browser center', () => { const layout = { width: 390, height: 700 } @@ -51,6 +75,96 @@ describe('browser touch geometry', () => { expect(mapScreenToBrowserPoint(screenX, screenY, layout, metadata, zoom)).toEqual(browserPoint) }) + it('maps a tap on a page with no viewport meta into the page CSS pixels', () => { + const layout = { width: 402, height: 593 } + const scale = NO_VIEWPORT_META.pageScaleFactor + // Where the button's centre is painted in the frame, which is where the finger goes. + const paintedX = ((BUTTON.left + BUTTON.right) / 2) * scale + const paintedY = ((BUTTON.top + BUTTON.bottom) / 2) * scale + + const point = mapScreenToBrowserPoint(paintedX, paintedY, layout, NO_VIEWPORT_META, NO_ZOOM)! + + expect(point.x).toBeGreaterThanOrEqual(BUTTON.left) + expect(point.x).toBeLessThanOrEqual(BUTTON.right) + expect(point.y).toBeGreaterThanOrEqual(BUTTON.top) + expect(point.y).toBeLessThanOrEqual(BUTTON.bottom) + // What the device proof recorded instead: the frame's own device space, 41% of the aim, on BODY. + expect(point).not.toEqual({ x: 30, y: 56 }) + }) + + it('divides the frame device space by the page scale and adds no scroll offset', () => { + const layout = { width: 400, height: 600 } + const metadata = { + deviceWidth: 400, + deviceHeight: 600, + pageScaleFactor: 0.5, + scrollOffsetX: 70, + scrollOffsetY: 300 + } + + expect(mapScreenToBrowserPoint(100, 200, layout, metadata, NO_ZOOM)).toEqual({ x: 200, y: 400 }) + }) + + it('leaves web view mode where it was, at a page scale of one', () => { + const layout = { width: 402, height: 593 } + const metadata = { deviceWidth: 402, deviceHeight: 593, pageScaleFactor: 1 } + + expect(mapScreenToBrowserPoint(120, 240, layout, metadata, NO_ZOOM)).toEqual({ x: 120, y: 240 }) + }) + + it('reads a missing or unusable page scale as one rather than dividing by it', () => { + const layout = { width: 402, height: 593 } + for (const pageScaleFactor of [undefined, 0, -1, Number.NaN]) { + expect( + mapScreenToBrowserPoint( + 120, + 240, + layout, + { deviceWidth: 402, deviceHeight: 593, pageScaleFactor }, + NO_ZOOM + ) + ).toEqual({ x: 120, y: 240 }) + } + }) + + it('grows the touch radius by the page scale, because a CSS pixel is smaller', () => { + const layout = { width: 400, height: 600 } + const metadata = { deviceWidth: 400, deviceHeight: 600, pageScaleFactor: 0.5 } + + expect(computeBrowserTouchClickRadiusCss(layout, metadata, NO_ZOOM, 14)).toBe(28) + expect( + computeBrowserTouchClickRadiusCss(layout, { ...metadata, pageScaleFactor: 1 }, NO_ZOOM, 14) + ).toBe(14) + }) + + it('scrolls the page by the page scale, not by the frame fit alone', () => { + const layout = { width: 402, height: 593 } + const geometry = computeBrowserFrameGeometry(layout, NO_VIEWPORT_META) + + // A 100 point flick up. The frame fits the pane one to one, so the only factor left is the + // page scale: 100 screen points span 100 / 0.41 CSS pixels of a page laid out at 980. + const delta = browserWheelDeltaFromScreen(0, 100, geometry, 1) + + expect(delta.dy).toBe(-244) + expect(delta.dx).toBe(0) + // What it sent before: the screen delta itself, 41% of the scroll asked for. + expect(delta.dy).not.toBe(-100) + }) + + it('leaves a web view scroll and a pinched one where they were', () => { + const layout = { width: 402, height: 593 } + const unscaled = computeBrowserFrameGeometry(layout, { + deviceWidth: 402, + deviceHeight: 593, + pageScaleFactor: 1 + }) + + expect(browserWheelDeltaFromScreen(0, 100, unscaled, 1)).toEqual({ dx: 0, dy: -100 }) + // A pinch to 2x halves what a screen point is worth, on top of whatever the page scale is. + expect(browserWheelDeltaFromScreen(0, 100, unscaled, 2)).toEqual({ dx: 0, dy: -50 }) + expect(browserWheelDeltaFromScreen(-40, 0, null, 1)).toEqual({ dx: 40, dy: 0 }) + }) + it('rejects page-level touch coordinates instead of mixing coordinate spaces', () => { expect(readLocalTouchPoint({ pageX: 120, pageY: 240 })).toBeNull() }) diff --git a/mobile/src/browser/browser-touch-geometry.ts b/mobile/src/browser/browser-touch-geometry.ts index 68b98e068a7..82a1fe94a4c 100644 --- a/mobile/src/browser/browser-touch-geometry.ts +++ b/mobile/src/browser/browser-touch-geometry.ts @@ -20,6 +20,15 @@ export type BrowserFrameGeometry = { offsetX: number offsetY: number scale: number + /** + * What the frame's device pixels divide by to reach the page's own CSS pixels. + * + * Mobile view emulates a phone viewport, and a page with no `` lays out at + * Chromium's 980 px default and is scaled into it, so the two spaces differ by this much. The + * browser's input commands take page CSS pixels, which is why nothing may be sent in the frame's + * space. One in web view mode, where no emulation is on. + */ + pageScale: number } export type BrowserZoomState = { @@ -44,6 +53,7 @@ export function computeBrowserFrameGeometry( const renderedWidth = sourceWidth * scale const renderedHeight = sourceHeight * scale return { + pageScale: getPositiveFiniteNumber(metadata?.pageScaleFactor) ?? 1, sourceWidth, sourceHeight, viewportWidth: layout.width, @@ -79,20 +89,58 @@ export function mapScreenToBrowserPoint( ) { return null } + // Why no scrollOffsetX/Y: the frame is the visual viewport and the input commands take + // viewport-relative CSS pixels, so adding the page's scroll would aim a screenful past the target. return { x: clamp( - Math.round((localX / geometry.renderedWidth) * geometry.sourceWidth), + Math.round(((localX / geometry.renderedWidth) * geometry.sourceWidth) / geometry.pageScale), 0, - geometry.sourceWidth + geometry.sourceWidth / geometry.pageScale ), y: clamp( - Math.round((localY / geometry.renderedHeight) * geometry.sourceHeight), + Math.round(((localY / geometry.renderedHeight) * geometry.sourceHeight) / geometry.pageScale), 0, - geometry.sourceHeight + geometry.sourceHeight / geometry.pageScale ) } } +/** A scroll the page should receive, in its own CSS pixels, the way a wheel reports one. */ +export type BrowserWheelDelta = { dx: number; dy: number } + +/** + * What one point of screen is worth in the page's own CSS pixels, or null when it cannot be read. + * + * Three factors, and every screen-space quantity the pane sends needs all three: the frame's fit + * into the pane, the pinch zoom on top of it, and the page scale the frame was painted at. A + * consumer that composes two of them is off by the third, which is how the wheel came to deliver + * 41% of the requested scroll on a page with no viewport meta. + */ +export function browserScreenToPageCssScale( + geometry: BrowserFrameGeometry | null, + zoomScale: number +): number | null { + const scale = geometry === null ? zoomScale : geometry.scale * zoomScale * geometry.pageScale + return Number.isFinite(scale) && scale > 0 ? scale : null +} + +/** A screen-space gesture delta as the page's own CSS pixels, inverted the way a wheel reports it. */ +export function browserWheelDeltaFromScreen( + screenDx: number, + screenDy: number, + geometry: BrowserFrameGeometry | null, + zoomScale: number +): BrowserWheelDelta { + const scale = browserScreenToPageCssScale(geometry, zoomScale) ?? 1 + return { dx: roundedWheelDelta(-screenDx / scale), dy: roundedWheelDelta(-screenDy / scale) } +} + +/** `Math.round` answers -0 for an axis that moved nothing; the wheel carries a plain zero. */ +function roundedWheelDelta(value: number): number { + const rounded = Math.round(value) + return rounded === 0 ? 0 : rounded +} + export function computeBrowserTouchClickRadiusCss( layout: BrowserTouchLayout | null, metadata: BrowserScreencastFrameMetadata | null, @@ -100,8 +148,8 @@ export function computeBrowserTouchClickRadiusCss( touchRadiusDip: number ): number { const geometry = computeBrowserFrameGeometry(layout, metadata) - const scale = geometry ? geometry.scale * zoom.scale : 1 - if (!Number.isFinite(scale) || scale <= 0) { + const scale = browserScreenToPageCssScale(geometry, zoom.scale) + if (scale === null) { return 10 } // Why: phone taps are finger-sized while CDP clicks are pixel exact. Convert a diff --git a/mobile/src/browser/mobile-browser-dialog-through-the-bridge.test.tsx b/mobile/src/browser/mobile-browser-dialog-through-the-bridge.test.tsx new file mode 100644 index 00000000000..19e3baff989 --- /dev/null +++ b/mobile/src/browser/mobile-browser-dialog-through-the-bridge.test.tsx @@ -0,0 +1,298 @@ +/** + * The pane's dialog card against a host that answers the way Chromium does. + * + * Measured on Chromium 1217, 2026-09-20: a page that opens a dialog runs nothing else until the + * dialog is answered, and `Page.javascriptDialogClosed` is what says it was. So the card is the + * page's block, not a local overlay — closing it on the press reports an answer the page never + * got, and hides a page that is still waiting. + */ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { createBridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness' +import { createFakeRpcClient, rpcSuccess } from '../mobile-web-shell/bridge-host-test-fakes' +import { MobileBrowserPane, type MobileBrowserTab } from './MobileBrowserPane' + +vi.mock('./use-browser-binary-screencast-grant', () => ({ + useBrowserBinaryScreencastGrant: vi.fn(() => true) +})) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) }, + Image: 'Image', + PanResponder: { create: () => ({ panHandlers: {} }) }, + PixelRatio: { get: () => 2 }, + Platform: { OS: 'android' }, + Pressable: 'Pressable', + StyleSheet: { + absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }, + create: (styles: unknown) => styles + }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', + Monitor: 'Monitor', + RefreshCw: 'RefreshCw', + Smartphone: 'Smartphone' +})) + +const TAB: MobileBrowserTab = { + type: 'browser', + id: 'tab-1', + title: 'Dialogs', + browserWorkspaceId: 'bw-1', + browserPageId: 'page-1', + url: 'https://dialogs.example/', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true +} + +/** + * A page that runs `alert('first')` and then `confirm('second')`, stopped at each one. + * + * `answer` is the only thing that moves it, which is the property the product has to hold: the + * card may not clear until this has run. + */ +function createBlockedPage(emit: (event: unknown) => void) { + let step = 0 + let confirmValue: boolean | null = null + const run = (): void => { + step += 1 + if (step === 1) { + emit({ type: 'dialog', dialogType: 'alert', message: 'first' }) + } else if (step === 2) { + emit({ type: 'dialog', dialogType: 'confirm', message: 'second' }) + } + } + return { + start: run, + answer: (accept: boolean): void => { + if (step === 2) { + confirmValue = accept + } + emit({ type: 'dialogClosed' }) + run() + }, + confirmValue: (): boolean | null => confirmValue + } +} + +/** Host components are strings under the react-native double, which `findAllByType` will not take. */ +function nodesOfType(root: ReactTestInstance, type: string): ReactTestInstance[] { + return root.findAll((node) => node.type === type) +} + +function cardMessages(renderer: ReactTestRenderer): string[] { + return nodesOfType(renderer.root, 'Text') + .map((node) => node.props.children) + .filter((child): child is string => typeof child === 'string') +} + +function findButton(renderer: ReactTestRenderer, label: string): ReactTestInstance { + const button = nodesOfType(renderer.root, 'Pressable').find((node) => + nodesOfType(node, 'Text').some((text) => text.props.children === label) + ) + if (!button) { + throw new Error(`no ${label} button on screen`) + } + return button +} + +function pressButton(renderer: ReactTestRenderer, label: string): void { + const button = findButton(renderer, label) + act(() => { + button.props.onPress() + }) +} + +async function openPaneOverTheBridge() { + const rpc = createFakeRpcClient() + const pair = createBridgePortPair({ rpc, routeGrants: ['screencastBinary'] }) + await pair.flush() + let renderer!: ReactTestRenderer + await act(async () => { + renderer = create( + createElement(MobileBrowserPane, { + client: pair.client, + worktreeId: 'worktree-1', + tab: TAB, + screencastSupported: true, + keyboardLift: 0, + bottomInset: 0, + onToast: () => {} + }), + { createNodeMock: () => ({ setNativeProps: () => {} }) } + ) + await Promise.resolve() + }) + const viewport = nodesOfType(renderer.root, 'View').find( + (node) => typeof node.props.onLayout === 'function' + ) + act(() => { + viewport?.props.onLayout({ nativeEvent: { layout: { width: 402, height: 593 } } }) + }) + await pair.flush() + const stream = rpc.streams.find((entry) => entry.method === 'browser.screencast') + if (!stream) { + throw new Error('the pane did not subscribe to browser.screencast through the bridge') + } + const page = createBlockedPage((event) => { + stream.emit(event) + }) + const flush = async (): Promise => { + await act(async () => { + await pair.flush() + }) + } + /** Settles the dialog reply the pane sent, the way a host that reached the stream would. */ + const answerFromTheHost = async (accept: boolean): Promise => { + const request = rpc.requests.at(-1) + if (!request || !request.method.startsWith('browser.dialog')) { + throw new Error(`the pane's last request was ${request?.method ?? 'nothing'}`) + } + await act(async () => { + request.resolve(rpcSuccess(`req-${rpc.requests.length}`, {})) + page.answer(accept) + await pair.flush() + }) + } + return { answerFromTheHost, flush, page, renderer, rpc } +} + +describe('the pane keeps its dialog card until the page is unblocked', () => { + it('raises the second dialog and answers the confirm with OK', async () => { + const pane = await openPaneOverTheBridge() + await act(async () => { + pane.page.start() + await pane.flush() + }) + expect(cardMessages(pane.renderer)).toContain('first') + + pressButton(pane.renderer, 'OK') + await pane.flush() + // The host has the reply and has not answered the page. The alert is still up on the page, + // so it is still up here. + expect(cardMessages(pane.renderer)).toContain('first') + + await pane.answerFromTheHost(true) + // The page moved on, so the card did too: the confirm, with a Cancel beside the OK. + expect(cardMessages(pane.renderer)).toContain('second') + expect(cardMessages(pane.renderer)).not.toContain('first') + expect(cardMessages(pane.renderer)).toContain('Cancel') + + pressButton(pane.renderer, 'OK') + await pane.flush() + await pane.answerFromTheHost(true) + expect(pane.page.confirmValue()).toBe(true) + expect(cardMessages(pane.renderer)).not.toContain('second') + }) + + it('keeps the card pressable and says so when the answer does not reach the page', async () => { + const pane = await openPaneOverTheBridge() + await act(async () => { + pane.page.start() + await pane.flush() + }) + expect(cardMessages(pane.renderer)).toContain('first') + + pressButton(pane.renderer, 'OK') + await pane.flush() + const refused = pane.rpc.requests.at(-1) + await act(async () => { + refused?.reject(new Error('the host refused')) + await pane.flush() + }) + + // The page never took the answer, so the alert is still up and the card says why. + expect(cardMessages(pane.renderer)).toContain('first') + expect(cardMessages(pane.renderer)).toContain('That answer did not reach the page.') + + // And the button still works: the retry reaches the host, which answers, and the page moves on. + pressButton(pane.renderer, 'OK') + await pane.flush() + await pane.answerFromTheHost(true) + expect(cardMessages(pane.renderer)).toContain('second') + expect(cardMessages(pane.renderer)).not.toContain('That answer did not reach the page.') + }) + + it('kills the card buttons while an answer is in flight, so a double tap sends one', async () => { + const pane = await openPaneOverTheBridge() + await act(async () => { + pane.page.start() + await pane.flush() + }) + + pressButton(pane.renderer, 'OK') + await pane.flush() + const inFlight = pane.rpc.requests.length + // The host takes one answer per dialog: a second would be refused, or would settle the page's + // next dialog unseen. The button is what stops the second tap from ever being sent. + expect(findButton(pane.renderer, 'OK').props.disabled).toBe(true) + + await pane.answerFromTheHost(true) + expect(pane.rpc.requests.length).toBe(inFlight) + // The next dialog arrives with live buttons of its own. + expect(cardMessages(pane.renderer)).toContain('second') + expect(findButton(pane.renderer, 'OK').props.disabled).toBe(false) + expect(findButton(pane.renderer, 'Cancel').props.disabled).toBe(false) + }) + + it('stamps the failure on the dialog that was answered, never on the one that replaced it', async () => { + const pane = await openPaneOverTheBridge() + await act(async () => { + pane.page.start() + await pane.flush() + }) + expect(cardMessages(pane.renderer)).toContain('first') + + pressButton(pane.renderer, 'OK') + await pane.flush() + const answeringFirst = pane.rpc.requests.at(-1) + + // The page moves on without the pane hearing about it: the alert was settled and the confirm + // raised, but the reply to that first answer is still out there. + await act(async () => { + pane.page.answer(true) + await pane.flush() + }) + expect(cardMessages(pane.renderer)).toContain('second') + + // Now it times out. It belongs to the alert, which is gone, so the confirm must not wear it. + await act(async () => { + answeringFirst?.reject(new Error('timed out after 5000ms')) + await pane.flush() + }) + + expect(cardMessages(pane.renderer)).toContain('second') + expect(cardMessages(pane.renderer)).not.toContain('That answer did not reach the page.') + expect(findButton(pane.renderer, 'OK').props.disabled).toBe(false) + expect(findButton(pane.renderer, 'Cancel').props.disabled).toBe(false) + }) + + it('answers the confirm with Cancel', async () => { + const pane = await openPaneOverTheBridge() + await act(async () => { + pane.page.start() + await pane.flush() + }) + pressButton(pane.renderer, 'OK') + await pane.flush() + await pane.answerFromTheHost(true) + + pressButton(pane.renderer, 'Cancel') + await pane.flush() + expect(pane.rpc.requests.at(-1)?.method).toBe('browser.dialogDismiss') + await pane.answerFromTheHost(false) + expect(pane.page.confirmValue()).toBe(false) + expect(cardMessages(pane.renderer)).not.toContain('second') + }) +}) diff --git a/mobile/src/browser/mobile-browser-pane-styles.ts b/mobile/src/browser/mobile-browser-pane-styles.ts index 4d417f505a4..364886ce38b 100644 --- a/mobile/src/browser/mobile-browser-pane-styles.ts +++ b/mobile/src/browser/mobile-browser-pane-styles.ts @@ -103,6 +103,15 @@ export const mobileBrowserPaneStyles = StyleSheet.create({ lineHeight: 20, marginTop: spacing.sm }, + dialogButtonDisabled: { + opacity: 0.5 + }, + dialogError: { + color: colors.statusRed, + fontSize: typography.metaSize, + lineHeight: 18, + marginTop: spacing.sm + }, dialogActions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/mobile/src/browser/mobile-browser-stream-events.ts b/mobile/src/browser/mobile-browser-stream-events.ts index 5b65b2987f6..da7a35fb385 100644 --- a/mobile/src/browser/mobile-browser-stream-events.ts +++ b/mobile/src/browser/mobile-browser-stream-events.ts @@ -2,7 +2,16 @@ import type { Dispatch, SetStateAction } from 'react' import { displayBrowserUrl } from './browser-url' import { shouldSurfaceBrowserError } from './mobile-browser-frame-state' -export type BrowserDialogState = { dialogType: string; message: string } +/** + * `error` is set when an answer did not reach the page, which leaves the page still blocked, and + * `pending` is the token of the answer in flight — the card's buttons are dead while one is. + */ +export type BrowserDialogState = { + dialogType: string + message: string + error?: string + pending?: number +} export type ScreencastEvent = { type?: string diff --git a/mobile/src/browser/use-mobile-browser-commands.ts b/mobile/src/browser/use-mobile-browser-commands.ts index 3aa1acd381f..5e4cd356a35 100644 --- a/mobile/src/browser/use-mobile-browser-commands.ts +++ b/mobile/src/browser/use-mobile-browser-commands.ts @@ -14,6 +14,7 @@ import { } from './mobile-browser-command-operations' import type { BrowserPageCommandSend, BrowserPageParams } from './use-mobile-browser-request' import { + browserWheelDeltaFromScreen, computeBrowserFrameGeometry, computeBrowserTouchClickRadiusCss, mapScreenToBrowserPoint, @@ -22,6 +23,7 @@ import { type BrowserZoomState } from './browser-touch-geometry' import type { BrowserPointerModifier } from './MobileBrowserPointerModifiers' +import type { BrowserDialogState } from './mobile-browser-stream-events' const TOUCH_CLICK_RADIUS_DIP = 14 type PendingWheelCommand = { @@ -45,7 +47,7 @@ type MobileBrowserCommandArgs = { pageParams: () => BrowserPageParams | null pointerModifiers: BrowserPointerModifier[] sendBrowserRequest: SendBrowserRequest - setDialog: Dispatch> + setDialog: Dispatch> setError: Dispatch> setKeyboardValue: Dispatch> setPointerModifiers: Dispatch> @@ -70,6 +72,7 @@ export function useMobileBrowserCommands(args: MobileBrowserCommandArgs) { } = args const pendingWheelCommandRef = useRef(null) + const dialogAnswerTokenRef = useRef(0) const wheelCommandInFlightRef = useRef(false) @@ -178,13 +181,8 @@ export function useMobileBrowserCommands(args: MobileBrowserCommandArgs) { if (!client || !base) { return } - const currentLayout = layoutRef.current - const geometry = computeBrowserFrameGeometry(currentLayout, frameMetadataRef.current) - const localZoom = zoomRef.current.scale - const scale = (geometry?.scale ?? 1) * localZoom - const cssDx = screenDx / scale - const cssDy = screenDy / scale - const delta = { dx: Math.round(-cssDx), dy: Math.round(-cssDy) } + const geometry = computeBrowserFrameGeometry(layoutRef.current, frameMetadataRef.current) + const delta = browserWheelDeltaFromScreen(screenDx, screenDy, geometry, zoomRef.current.scale) if (Math.abs(delta.dx) < 1 && Math.abs(delta.dy) < 1) { return } @@ -245,14 +243,33 @@ export function useMobileBrowserCommands(args: MobileBrowserCommandArgs) { [sendBrowserRequest] ) + // The card is the page's block, not an overlay of the pane's: the host's `dialogClosed` is what + // says the page took the answer, so clearing it on the press would report one it never got. const sendDialogCommand = useCallback( async (method: 'browser.dialogAccept' | 'browser.dialogDismiss') => { - setDialog(null) const command = method === 'browser.dialogAccept' ? browserDialogAccept : browserDialogDismiss - await sendBrowserRequest( + // The token marks which answer this is. The host takes one per dialog, so the card's buttons + // go dead while it is in flight, and only the answer that armed the card may write to it: + // a reply that lands after the page raised its next dialog belongs to neither. + const token = (dialogAnswerTokenRef.current += 1) + setDialog((current) => + current === null ? null : { ...current, error: undefined, pending: token } + ) + const result = await sendBrowserRequest( async (rpc, page, options) => command.interpret(await command.request(rpc, page, options)), { suppressError: true, timeoutMs: 5_000 } ) + // A refused or timed-out answer leaves the page blocked on the same dialog, so the card + // stays and says so rather than looking like a button that does nothing. + setDialog((current) => + current === null || current.pending !== token + ? current + : { + ...current, + pending: undefined, + ...(result === null ? { error: 'That answer did not reach the page.' } : {}) + } + ) }, [sendBrowserRequest] ) diff --git a/mobile/src/browser/use-mobile-browser-interactions.ts b/mobile/src/browser/use-mobile-browser-interactions.ts index b06c83e4866..0f09dc595c7 100644 --- a/mobile/src/browser/use-mobile-browser-interactions.ts +++ b/mobile/src/browser/use-mobile-browser-interactions.ts @@ -20,6 +20,7 @@ import { type BrowserZoomState } from './browser-touch-geometry' import type { BrowserPointerModifier } from './MobileBrowserPointerModifiers' +import type { BrowserDialogState } from './mobile-browser-stream-events' import type { BrowserPageCommandSend, BrowserPageParams } from './use-mobile-browser-request' import type { BrowserScreencastFrameMetadata } from '../transport/browser-screencast-protocol' @@ -50,7 +51,7 @@ type MobileBrowserInteractionArgs = { pinchRef: { current: PinchGesture | null } pointerModifiers: BrowserPointerModifier[] sendBrowserRequest: SendBrowserRequest - setDialog: Dispatch> + setDialog: Dispatch> setError: Dispatch> setKeyboardValue: Dispatch> scrollingRef: { current: boolean } diff --git a/src/main/browser/browser-screencast-cdp-events.ts b/src/main/browser/browser-screencast-cdp-events.ts index 0462a1d62e6..eb59bf6d875 100644 --- a/src/main/browser/browser-screencast-cdp-events.ts +++ b/src/main/browser/browser-screencast-cdp-events.ts @@ -19,6 +19,7 @@ type BrowserScreencastMessageHandlerDeps = { scheduleNavigationFrameCapture: () => void clearNavigationCaptureTimer: () => void bumpSnapshotGeneration: () => void + setDialogOpen: (open: boolean) => void } export function createBrowserScreencastMessageHandler( @@ -27,6 +28,7 @@ export function createBrowserScreencastMessageHandler( const { dbg, options, isClosed, isStopping, queueFrame, ackScreencastFrame } = deps const { scheduleNavigationFrameCapture, clearNavigationCaptureTimer, bumpSnapshotGeneration } = deps + const { setDialogOpen } = deps return (_event: unknown, method: string, params: unknown): void => { if (isClosed()) { @@ -38,6 +40,7 @@ export function createBrowserScreencastMessageHandler( if (method === 'Page.javascriptDialogOpening') { const payload = params && typeof params === 'object' ? (params as Record) : {} + setDialogOpen(true) options.onEvent?.({ type: 'dialog', dialogType: typeof payload.type === 'string' ? payload.type : 'alert', @@ -46,6 +49,7 @@ export function createBrowserScreencastMessageHandler( return } if (method === 'Page.javascriptDialogClosed') { + setDialogOpen(false) options.onEvent?.({ type: 'dialogClosed' }) return } diff --git a/src/main/browser/browser-screencast-dialog-settlement.test.ts b/src/main/browser/browser-screencast-dialog-settlement.test.ts new file mode 100644 index 00000000000..a47d4d3d28a --- /dev/null +++ b/src/main/browser/browser-screencast-dialog-settlement.test.ts @@ -0,0 +1,220 @@ +/** + * Settling the dialog the stream reported, on the session that reported it. + * + * Measured against Chromium 1217 on 2026-09-20: only the CDP session that received + * `Page.javascriptDialogOpening` can answer it — a second client that attaches afterwards gets + * `No dialog is showing`, and every renderer-bound command it sends (`Page.enable`, + * `Runtime.evaluate`, `DOM.getDocument`) hangs for as long as the dialog is up. So a reply that + * travels through a fresh session cannot land, and the page stays blocked with no second dialog. + */ +import { describe, expect, it, vi } from 'vitest' + +import { startBrowserScreencast } from './browser-screencast-stream' +import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double' + +const OPTIONS = { + format: 'jpeg' as const, + quality: 70, + maxWidth: 1440, + maxHeight: 1200, + everyNthFrame: 1, + minFrameIntervalMs: 0 +} + +function openDialog( + webContents: ReturnType, + type = 'alert', + message = 'first' +): void { + webContents.debugger.emit('message', {}, 'Page.javascriptDialogOpening', { type, message }) +} + +/** `sendDebuggerCommand` issues on a microtask, so a call is visible a turn after the caller ran. */ +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function dialogCalls(webContents: ReturnType): unknown[][] { + return webContents.debugger.sendCommand.mock.calls.filter( + (call) => call[0] === 'Page.handleJavaScriptDialog' + ) +} + +describe('the browser screencast settles the dialog it reported', () => { + it('answers the open dialog on its own debugger session', async () => { + const webContents = createMockScreencastWebContents() + const session = await startBrowserScreencast(webContents as never, { + ...OPTIONS, + onFrame: vi.fn(), + onEvent: vi.fn() + }) + + openDialog(webContents) + await expect(session.settleDialog(true)).resolves.toBe(true) + + expect(dialogCalls(webContents)).toEqual([['Page.handleJavaScriptDialog', { accept: true }]]) + + session.stop() + await session.done + }) + + it('carries a prompt answer and a dismissal', async () => { + const webContents = createMockScreencastWebContents() + const session = await startBrowserScreencast(webContents as never, { + ...OPTIONS, + onFrame: vi.fn() + }) + + openDialog(webContents, 'prompt', 'name?') + await expect(session.settleDialog(true, 'Ada')).resolves.toBe(true) + webContents.debugger.emit('message', {}, 'Page.javascriptDialogClosed', {}) + openDialog(webContents, 'confirm', 'sure?') + await expect(session.settleDialog(false)).resolves.toBe(true) + + expect(dialogCalls(webContents)).toEqual([ + ['Page.handleJavaScriptDialog', { accept: true, promptText: 'Ada' }], + ['Page.handleJavaScriptDialog', { accept: false }] + ]) + + session.stop() + await session.done + }) + + it('reports no dialog rather than answering one that is not open', async () => { + const webContents = createMockScreencastWebContents() + const session = await startBrowserScreencast(webContents as never, { + ...OPTIONS, + onFrame: vi.fn() + }) + + await expect(session.settleDialog(true)).resolves.toBe(false) + openDialog(webContents) + webContents.debugger.emit('message', {}, 'Page.javascriptDialogClosed', {}) + await expect(session.settleDialog(true)).resolves.toBe(false) + + expect(dialogCalls(webContents)).toEqual([]) + + session.stop() + await session.done + }) + + it('sends one command for two answers to the same dialog', async () => { + const webContents = createMockScreencastWebContents() + const session = await startBrowserScreencast(webContents as never, { + ...OPTIONS, + onFrame: vi.fn() + }) + + openDialog(webContents, 'confirm', 'twice?') + const [first, second] = await Promise.all([ + session.settleDialog(true), + session.settleDialog(true) + ]) + + await flushMicrotasks() + expect([first, second]).toEqual([true, true]) + // Two taps on a slow link are two calls. Chromium takes one answer per dialog: the second + // command is refused, or settles the page's next dialog unseen. + expect(dialogCalls(webContents)).toEqual([['Page.handleJavaScriptDialog', { accept: true }]]) + + session.stop() + await session.done + }) + + it("keeps a failed answer from clearing the next dialog's armed one", async () => { + const webContents = createMockScreencastWebContents() + const answers: { reject: (error: Error) => void }[] = [] + webContents.debugger.sendCommand.mockImplementation(async (method: string) => { + if (method !== 'Page.handleJavaScriptDialog') { + return {} + } + return new Promise((_resolve, reject) => { + answers.push({ reject }) + }) + }) + const session = await startBrowserScreencast(webContents as never, { + ...OPTIONS, + onFrame: vi.fn() + }) + + openDialog(webContents, 'confirm', 'A') + const answeringA = session.settleDialog(true) + webContents.debugger.emit('message', {}, 'Page.javascriptDialogClosed', {}) + openDialog(webContents, 'confirm', 'B') + const answeringB = session.settleDialog(true) + await flushMicrotasks() + expect(dialogCalls(webContents)).toHaveLength(2) + + // A's command fails after B has armed its own. If that cleared the slot, the next caller on B + // would send a third command against a dialog that already has an answer on the way. + answers[0]?.reject(new Error('No dialog is showing')) + await expect(answeringA).rejects.toThrow('No dialog is showing') + void session.settleDialog(true) + await flushMicrotasks() + expect(dialogCalls(webContents)).toHaveLength(2) + + answers[1]?.reject(new Error('stop')) + await expect(answeringB).rejects.toThrow('stop') + }) + + it('dismisses a dialog still open when the stream stops, so no later session inherits it', async () => { + const webContents = createMockScreencastWebContents() + const session = await startBrowserScreencast(webContents as never, { + ...OPTIONS, + onFrame: vi.fn() + }) + + openDialog(webContents, 'confirm', 'still up') + session.stop() + await session.done + + // Before `Page.stopScreencast`, because a stopped screencast is still an attached session and + // the order is what makes the answer land rather than race the teardown. + const methods = webContents.debugger.sendCommand.mock.calls.map((call) => call[0]) + expect(dialogCalls(webContents)).toEqual([['Page.handleJavaScriptDialog', { accept: false }]]) + expect(methods.indexOf('Page.handleJavaScriptDialog')).toBeLessThan( + methods.indexOf('Page.stopScreencast') + ) + }) + + it('leaves the page alone when it stops with no dialog open', async () => { + const webContents = createMockScreencastWebContents() + const session = await startBrowserScreencast(webContents as never, { + ...OPTIONS, + onFrame: vi.fn() + }) + + openDialog(webContents) + webContents.debugger.emit('message', {}, 'Page.javascriptDialogClosed', {}) + session.stop() + await session.done + + expect(dialogCalls(webContents)).toEqual([]) + }) + + it('raises every dialog of the subscription, not just the first', async () => { + const webContents = createMockScreencastWebContents() + const onEvent = vi.fn() + const session = await startBrowserScreencast(webContents as never, { + ...OPTIONS, + onFrame: vi.fn(), + onEvent + }) + + openDialog(webContents, 'alert', 'first') + await session.settleDialog(true) + webContents.debugger.emit('message', {}, 'Page.javascriptDialogClosed', {}) + openDialog(webContents, 'confirm', 'second') + await session.settleDialog(true) + + expect(onEvent.mock.calls.map(([event]) => event)).toEqual([ + { type: 'dialog', dialogType: 'alert', message: 'first' }, + { type: 'dialogClosed' }, + { type: 'dialog', dialogType: 'confirm', message: 'second' } + ]) + + session.stop() + await session.done + }) +}) diff --git a/src/main/browser/browser-screencast-lifecycle.test.ts b/src/main/browser/browser-screencast-lifecycle.test.ts index c97facbebc8..251b53fd469 100644 --- a/src/main/browser/browser-screencast-lifecycle.test.ts +++ b/src/main/browser/browser-screencast-lifecycle.test.ts @@ -143,7 +143,8 @@ describe('browser screencast lifecycle', () => { ackScreencastFrame, scheduleNavigationFrameCapture: vi.fn(), clearNavigationCaptureTimer: vi.fn(), - bumpSnapshotGeneration: vi.fn() + bumpSnapshotGeneration: vi.fn(), + setDialogOpen: vi.fn() }) handler({}, 'Page.screencastFrame', { sessionId: 42, data: '' }) diff --git a/src/main/browser/browser-screencast-stream-types.ts b/src/main/browser/browser-screencast-stream-types.ts index a692d32d025..c75f1b1bace 100644 --- a/src/main/browser/browser-screencast-stream-types.ts +++ b/src/main/browser/browser-screencast-stream-types.ts @@ -34,6 +34,14 @@ export type BrowserScreencastSession = { done: Promise updateViewport: (viewport: BrowserScreencastViewport) => Promise updateFrameBudget: (budget: BrowserScreencastFrameBudget) => Promise + /** + * Answers the dialog this stream reported, and says whether there was one to answer. + * + * Only the CDP session that received `Page.javascriptDialogOpening` may answer it; anything + * that attaches afterwards is told no dialog is showing, and every renderer-bound command it + * sends first blocks behind the dialog it is trying to clear. + */ + settleDialog: (accept: boolean, promptText?: string) => Promise } export type BrowserScreencastEvent = diff --git a/src/main/browser/browser-screencast-stream.test.ts b/src/main/browser/browser-screencast-stream.test.ts index d1fc838807d..79de252769c 100644 --- a/src/main/browser/browser-screencast-stream.test.ts +++ b/src/main/browser/browser-screencast-stream.test.ts @@ -1,32 +1,9 @@ import { Buffer } from 'node:buffer' -import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { decodeBrowserScreencastFrame } from '../../shared/browser-screencast-protocol' import { startBrowserScreencast } from './browser-screencast-stream' - -function createMockWebContents() { - let attached = false - const dbg = new EventEmitter() as EventEmitter & { - isAttached: ReturnType - attach: ReturnType - detach: ReturnType - sendCommand: ReturnType - } - dbg.isAttached = vi.fn(() => attached) - dbg.attach = vi.fn(() => { - attached = true - }) - dbg.detach = vi.fn(() => { - attached = false - }) - dbg.sendCommand = vi.fn(async () => ({})) - - return { - isDestroyed: vi.fn(() => false), - debugger: dbg - } -} +import { createMockScreencastWebContents as createMockWebContents } from './browser-screencast-web-contents-test-double' function jpegWithSize(width: number, height: number): Buffer { return Buffer.from([ diff --git a/src/main/browser/browser-screencast-stream.ts b/src/main/browser/browser-screencast-stream.ts index 2aa7df62aae..237c94852e8 100644 --- a/src/main/browser/browser-screencast-stream.ts +++ b/src/main/browser/browser-screencast-stream.ts @@ -34,6 +34,15 @@ export async function startBrowserScreencast( let closed = false let stopping = false + // The dialog this stream reported and has not seen closed. Only this session may answer it, so + // it is settled before the stream goes away rather than carried to one that cannot. + let dialogOpen = false + // The one answer in flight for that dialog, and which dialog it belongs to. Chromium takes a + // single `Page.handleJavaScriptDialog` per dialog: a second one is refused, or worse settles the + // next dialog unseen if the page has already raised it. A double tap on a slow link is exactly + // that, so duplicate callers get this promise instead of a second command. + let dialogSettlement: Promise | null = null + let dialogGeneration = 0 let resolveDone!: () => void // Serializes viewport and frame-budget changes against the snapshot capture they trigger. let pendingUpdate = Promise.resolve() @@ -64,7 +73,13 @@ export async function startBrowserScreencast( ackScreencastFrame: framePacer.ackFrame, scheduleNavigationFrameCapture: snapshotCapture.scheduleNavigationFrameCapture, clearNavigationCaptureTimer: snapshotCapture.clearNavigationCaptureTimer, - bumpSnapshotGeneration: snapshotCapture.bumpGeneration + bumpSnapshotGeneration: snapshotCapture.bumpGeneration, + setDialogOpen: (open: boolean) => { + dialogOpen = open + // A new dialog is a new answer, and a closed one leaves nothing to answer. + dialogGeneration += 1 + dialogSettlement = null + } }) const startScreencast = (): Promise => @@ -81,6 +96,8 @@ export async function startBrowserScreencast( return } closed = true + dialogOpen = false + dialogSettlement = null snapshotCapture.clearNavigationCaptureTimer() framePacer.clearPending() dbg.removeListener('message', handleMessage as never) @@ -115,6 +132,30 @@ export async function startBrowserScreencast( } return { + settleDialog: (accept: boolean, promptText?: string) => { + if (!dialogOpen) { + return Promise.resolve(false) + } + if (dialogSettlement !== null) { + return dialogSettlement + } + const generation = dialogGeneration + const settlement = sendDebuggerCommand(dbg, 'Page.handleJavaScriptDialog', { + accept, + ...(promptText === undefined ? {} : { promptText }) + }) + .then(() => true) + .catch((error: unknown) => { + // Only this dialog's own slot: by the time a failure lands the page may have raised the + // next one and armed its answer, and clearing that would let a second command through. + if (dialogGeneration === generation) { + dialogSettlement = null + } + throw error + }) + dialogSettlement = settlement + return settlement + }, updateViewport: (viewport: BrowserScreencastViewport) => { pendingUpdate = pendingUpdate .catch(() => {}) @@ -157,6 +198,22 @@ export async function startBrowserScreencast( try { void (async () => { await pendingUpdate.catch(() => {}) + // Why: only this CDP session may answer the dialog it reported, and a session that did + // not see it cannot even enable the Page domain while it is up — both measured on + // Chromium 1217, 2026-09-20. Leaving one outstanding would block the page for good, + // because the stream that replaces this one hangs on its own start. Dismissing is the + // conservative answer for every dialog type, and the automation path has taken it since + // `cdp-debugger-events.ts`. + if (dialogOpen) { + dialogOpen = false + const pending = dialogSettlement + dialogSettlement = null + // An answer already on its way settles it; a second command here would be the + // duplicate this session refuses to send anywhere else. + await ( + pending ?? sendDebuggerCommand(dbg, 'Page.handleJavaScriptDialog', { accept: false }) + ).catch(() => {}) + } await sendDebuggerCommand(dbg, 'Page.stopScreencast').catch(() => {}) if (deviceMetrics.isOverridden()) { await deviceMetrics.clear().catch(() => {}) diff --git a/src/main/browser/browser-screencast-web-contents-test-double.ts b/src/main/browser/browser-screencast-web-contents-test-double.ts new file mode 100644 index 00000000000..13811850c68 --- /dev/null +++ b/src/main/browser/browser-screencast-web-contents-test-double.ts @@ -0,0 +1,34 @@ +import { EventEmitter } from 'node:events' +import { vi } from 'vitest' + +export type MockScreencastDebugger = EventEmitter & { + isAttached: ReturnType + attach: ReturnType + detach: ReturnType + sendCommand: ReturnType +} + +export type MockScreencastWebContents = { + isDestroyed: ReturnType + debugger: MockScreencastDebugger +} + +/** A webContents whose debugger records every CDP command and replays events on demand. */ +export function createMockScreencastWebContents(): MockScreencastWebContents { + let attached = false + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the four fields are assigned on the next lines, before the value escapes. + const dbg = new EventEmitter() as MockScreencastDebugger + dbg.isAttached = vi.fn(() => attached) + dbg.attach = vi.fn(() => { + attached = true + }) + dbg.detach = vi.fn(() => { + attached = false + }) + dbg.sendCommand = vi.fn(async () => ({})) + + return { + isDestroyed: vi.fn(() => false), + debugger: dbg + } +} diff --git a/src/main/runtime/browser-dialog-settlement.test.ts b/src/main/runtime/browser-dialog-settlement.test.ts new file mode 100644 index 00000000000..7da2022a3e2 --- /dev/null +++ b/src/main/runtime/browser-dialog-settlement.test.ts @@ -0,0 +1,132 @@ +/** + * Where `browser.dialogAccept` and `browser.dialogDismiss` send their answer. + * + * The answer has to reach the CDP session that reported the dialog. The agent-browser path cannot + * be that session: it runs a fresh client whose bootstrap is renderer-bound, and a page sitting in + * a modal dialog answers no renderer-bound command until the dialog is gone. So with a stream live + * on the page, the stream settles it; with no stream, nothing changes. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + webContents: { fromId: () => ({ isDestroyed: () => false }) } +})) + +import { RuntimeBrowserCommandsWithBrowserSetHeaders } from './runtime-browser-commands-browser-set-headers' +import type { ActiveBrowserScreencastPage } from './runtime-browser-commands-browser-command-target-params' +import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types' + +const PAGE_ID = 'page-1' + +class DialogCommandsUnderTest extends RuntimeBrowserCommandsWithBrowserSetHeaders { + registerLiveScreencast(browserPageId: string, record: ActiveBrowserScreencastPage): void { + this.activeScreencastsByPageId.set(browserPageId, record) + } +} + +function streamingPage(session: BrowserScreencastSession): ActiveBrowserScreencastPage { + return { + format: 'jpeg', + session, + started: Promise.resolve(session), + stopping: false, + subscribers: new Map(), + viewportOwnerSubscriptionId: null, + appliedBudget: { + quality: 72, + maxWidth: 768, + maxHeight: 1133, + everyNthFrame: 1, + minFrameIntervalMs: 100 + } + } +} + +function createCommands(settleDialog: (accept: boolean, promptText?: string) => Promise) { + const bridge = { + getRegisteredTabs: () => new Map([[PAGE_ID, 7]]), + dialogAccept: vi.fn(async () => ({ via: 'bridge' })), + dialogDismiss: vi.fn(async () => ({ via: 'bridge' })) + } + const host = { getAgentBrowserBridge: () => bridge } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dialog path reads only getAgentBrowserBridge off the host. + const commands = new DialogCommandsUnderTest(host as never) + const registerStream = (): void => { + commands.registerLiveScreencast( + PAGE_ID, + streamingPage({ + stop: () => {}, + done: Promise.resolve(), + updateViewport: async () => {}, + updateFrameBudget: async () => {}, + settleDialog + }) + ) + } + return { bridge, commands, registerStream } +} + +describe('a dialog reply goes to the stream that reported it', () => { + it('accepts through the live stream instead of the agent-browser bridge', async () => { + const settleDialog = vi.fn(async () => true) + const { bridge, commands, registerStream } = createCommands(settleDialog) + registerStream() + + const result = await commands.browserDialogAccept({ page: PAGE_ID, text: 'Ada' }) + + expect(settleDialog).toHaveBeenCalledWith(true, 'Ada') + expect(bridge.dialogAccept).not.toHaveBeenCalled() + expect(result).toEqual({ accepted: true }) + }) + + it('dismisses through the live stream instead of the agent-browser bridge', async () => { + const settleDialog = vi.fn(async () => true) + const { bridge, commands, registerStream } = createCommands(settleDialog) + registerStream() + + const result = await commands.browserDialogDismiss({ page: PAGE_ID }) + + expect(settleDialog).toHaveBeenCalledWith(false, undefined) + expect(bridge.dialogDismiss).not.toHaveBeenCalled() + expect(result).toEqual({ accepted: false }) + }) + + it('falls back to the bridge when the stream has no dialog open', async () => { + const settleDialog = vi.fn(async () => false) + const { bridge, commands, registerStream } = createCommands(settleDialog) + registerStream() + + await commands.browserDialogAccept({ page: PAGE_ID }) + + expect(settleDialog).toHaveBeenCalledWith(true, undefined) + expect(bridge.dialogAccept).toHaveBeenCalledTimes(1) + }) + + it('falls back to the bridge when no stream is live on the page', async () => { + const settleDialog = vi.fn(async () => true) + const { bridge, commands } = createCommands(settleDialog) + + await commands.browserDialogDismiss({ page: PAGE_ID }) + + expect(settleDialog).not.toHaveBeenCalled() + expect(bridge.dialogDismiss).toHaveBeenCalledTimes(1) + }) + + it('answers one shape whichever path ran, so a viewer cannot change the reply', async () => { + const streamed = createCommands(vi.fn(async () => true)) + streamed.registerStream() + const bridged = createCommands(vi.fn(async () => false)) + bridged.registerStream() + + expect(await streamed.commands.browserDialogAccept({ page: PAGE_ID })).toEqual( + await bridged.commands.browserDialogAccept({ page: PAGE_ID }) + ) + expect(await streamed.commands.browserDialogDismiss({ page: PAGE_ID })).toEqual( + await bridged.commands.browserDialogDismiss({ page: PAGE_ID }) + ) + // The bridge answered on the second of each pair, so the bodies above came from both paths. + expect(bridged.bridge.dialogAccept).toHaveBeenCalledTimes(1) + expect(bridged.bridge.dialogDismiss).toHaveBeenCalledTimes(1) + expect(streamed.bridge.dialogAccept).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/browser-dialog-settlement.ts b/src/main/runtime/browser-dialog-settlement.ts new file mode 100644 index 00000000000..fc642cd5ce3 --- /dev/null +++ b/src/main/runtime/browser-dialog-settlement.ts @@ -0,0 +1,41 @@ +import type { ActiveBrowserScreencastPage } from './runtime-browser-commands-browser-command-target-params' + +/** + * What `browser.dialogAccept` and `browser.dialogDismiss` answer, on either path. + * + * One shape for both, because which path ran is an implementation detail of whether a pane happens + * to be streaming the page: `orca browser dialog accept --json` printed the agent-browser payload + * with no viewer and an empty object with one. The bridge's own body is not forwarded — it is + * whichever JSON that CLI version prints, which is the thing a caller cannot rely on. + */ +export type BrowserDialogResult = { accepted: boolean } + +export function browserDialogSettledResult(accepted: boolean): BrowserDialogResult { + return { accepted } +} + +/** + * Answers a page's modal dialog on the screencast that reported it, if one is streaming. + * + * Chromium hands `Page.javascriptDialogOpening` to one CDP session and takes the answer only from + * that session; a client attaching afterwards is told `No dialog is showing`. The agent-browser + * path the other browser commands take is always a later client, and its bootstrap is + * renderer-bound, so it blocks behind the very dialog it was sent to clear. `false` means nothing + * here owns a dialog and the caller should take that path anyway — an agent driving a page with no + * viewer still reaches Chromium's own dialog state through it. + */ +export async function settleBrowserDialogOnLiveScreencast( + activeScreencastsByPageId: ReadonlyMap, + browserPageId: string | undefined, + accept: boolean, + promptText?: string +): Promise { + if (browserPageId === undefined) { + return false + } + const session = activeScreencastsByPageId.get(browserPageId)?.session + if (!session) { + return false + } + return session.settleDialog(accept, promptText) +} diff --git a/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts b/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts index c7766703926..2e6a447b4f7 100644 --- a/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts +++ b/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts @@ -50,8 +50,8 @@ export type BrowserScreencastParams = { export type BrowserScreencastStartResult = { subscriptionId: string ready: Extract - // The frame budget belongs to the shared page, not to one subscriber's handle. - session: Omit + // The frame budget and the page's dialog belong to the shared page, not to one subscriber's handle. + session: Omit // Why: callers gate frames until they have emitted `ready`, and the snapshot captured // for a joining subscriber lands inside that window. This replays it once the gate opens. flushPendingFrame: () => void diff --git a/src/main/runtime/runtime-browser-commands-browser-set-headers.ts b/src/main/runtime/runtime-browser-commands-browser-set-headers.ts index f5bee7bd21f..251d29e900c 100644 --- a/src/main/runtime/runtime-browser-commands-browser-set-headers.ts +++ b/src/main/runtime/runtime-browser-commands-browser-set-headers.ts @@ -1,6 +1,11 @@ // @ts-nocheck -- mechanically split class members. import { RuntimeBrowserCommandsWithBrowserNetworkLog } from './runtime-browser-commands-browser-network-log' import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' +import { + browserDialogSettledResult, + settleBrowserDialogOnLiveScreencast, + type BrowserDialogResult +} from './browser-dialog-settlement' export class RuntimeBrowserCommandsWithBrowserSetHeaders extends RuntimeBrowserCommandsWithBrowserNetworkLog { async browserSetHeaders( @@ -66,18 +71,37 @@ export class RuntimeBrowserCommandsWithBrowserSetHeaders extends RuntimeBrowserC async browserDialogAccept( params: { text?: string } & BrowserCommandTargetParams - ): Promise { + ): Promise { const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogAccept( - params.text, - target.worktreeId, - target.browserPageId - ) + if ( + !(await settleBrowserDialogOnLiveScreencast( + this.activeScreencastsByPageId, + target.browserPageId, + true, + params.text + )) + ) { + await this.requireAgentBrowserBridge().dialogAccept( + params.text, + target.worktreeId, + target.browserPageId + ) + } + return browserDialogSettledResult(true) } - async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { + async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) + if ( + !(await settleBrowserDialogOnLiveScreencast( + this.activeScreencastsByPageId, + target.browserPageId, + false + )) + ) { + await this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) + } + return browserDialogSettledResult(false) } // ── Storage commands ── From 9fbdfc592cbbdbe03a0ac7172064580c59bb275d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:33:03 -0400 Subject: [PATCH 179/224] refactor(mobile): generate the terminal WebView document from typed modules (OTA phase C, C7.1) (#21804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `.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 3006d8dfdf is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the query-reply gate a module the page can import The second of the twelve groups, and the one that corrects the scope table's membership rule. `terminalDataRepliesEnabled` is written from four places, so the whole-script census counted it among the 57 variables that cannot stay free across modules. All four writes are in this group. Once the script is modules, a variable written only inside the module that declares it is that module's own state, not the document's, and it stays a `let` there. So the scope object holds what crosses a module boundary, and the 57 is an upper bound rather than the answer; the qualifier count the flip commit pins will be lower than the 641 measured over the single scope, and by how much is a function of where the boundaries fall. Two references do cross here and are qualified: the write-queue generation this group compares against, and the observer-disposal list it pushes onto. Counts pinned: two qualified references, one `var` to `let`, two one-statement `if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make reflow a module, and give the generator its own tests The third group, and the defect it found: esbuild wraps a long import list across lines, and the generator was skipping only the first of them, which left the remaining names loose in the emitted script. The document did not parse, and the equivalence check said so by name rather than throwing — which is what that refusal path was added for. Both lists, import and export, are now skipped to their closer instead of by their first line. The generator's own tests cover what the per-group comparisons cannot say on their own: an export is unmarked and indented into the document scope, a one-line import is dropped, a wrapped import is dropped whole, the trailing export block esbuild prints is dropped rather than left as a bare block statement, and types are erased without touching the program. Reflow's counts: eleven qualified references — the terminal ten times and the settled row count once — six locals that became `const`, and the two early returns braced. The row count is written from three groups, so unlike the query-reply flag it is the document's state rather than one module's. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the keyboard-avoidance metrics a module The fourth group, and the first that needed a non-null assertion. `lineHasVisibleContent` reads the terminal's column count with no guard of its own; the guard is in `computeContentBottomRow`, which is its only caller. Adding a guard would change the program, and optional chaining would change what happens when there is no terminal — the document throws there today. TypeScript erases a non-null assertion, so the emitted script is unchanged and the invariant is written down where the reader needs it. Reflow now imports the metrics call from this module rather than declaring it an external, which is the shape every group takes as its neighbours arrive. Counts: fourteen qualified references, nine locals rebound, ten one-statement bodies braced, and the two `catch (e) {}` clauses — the row scan and the alternate-screen probe — unbound. The scope table's rule is stated more precisely with it: a variable is this module's own only when the group both declares and assigns it. While the rest of the document is still strings, one the main slice declares stays shared even if every use is in one group, because emitting a second declaration beside the one the slice still carries would not be the same program. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make WebGL loss recovery a module The fifth group, and the first carrying a top-level statement rather than only declarations: the visibility listener it registers. In the document that runs when the IIFE reaches it; as a module it runs on import, which is the same single registration. The context-loss listener disposes the addon it is registered on, so it cannot run before that addon exists, but the assignment is to a `let` a closure captures and TypeScript will not carry the narrowing across it. A non-null assertion, erased by the compiler, keeps the emitted script identical and puts the invariant where the reader is. Counts: twenty-three qualified references across the terminal, the addon, its retry timer and the theme the host last sent; three locals rebound; twelve one-statement bodies braced; five of the six catch clauses unbound, the sixth keeping its binding because the attach failure reads the error into its diagnostic. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make indirect-pointer scroll a module, and count a fifth class The sixth group found a rule the four classes do not cover, so I measured the whole script rather than meeting them one at a time: linting all 2,757 lines as a module trips `curly` 279 times and `no-unused-vars` 38, both already counted, and then five further rules at 23 sites — `prefer-number-properties` 17, `prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1 and `no-unused-expressions` 1. Seventeen of those 23 are one rewrite: a global numeric function moved onto `Number`. It has the same token shape as the qualifier, so it is counted as its own class rather than folded into anything, and only the four numeric globals are admitted — anything else appearing under `Number` is refused, which a case pins. Every site is already behind a `typeof … === 'number'` check or is parsing a string, so the two forms are the same test. The remaining six sites are each a different shape and too few to be worth matching; they will surface as refusals in whichever group carries them, and I will report each rather than widen this. The scroll accumulator is the first declaration to move onto the scope: it is declared in this group but a touch scroll in another slice resets it, so the `var` becomes an assignment to the shared field and the class that exists for exactly that counts one. Counts: five qualified references, one declaration moved, four locals rebound, eight bodies braced, one `Number` rewrite. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal surface-swap group into a module The seventh named group. `surface` and the uncommitted terminal are read by other slices, so both move onto the scope; the two committed handles and the pending surface are declared and assigned only here and stay module locals. Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2, unbound catches 2, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): substitute build-time constants into the emitted document The document's script text is not all hand-written: parts of it are template literals interpolating real values, starting with the theme background. A module cannot interpolate and still be the same program, so the generator now derives an esbuild `define` from `document-constants.ts` and substitutes after the import lines are dropped, when the names are free again. The page imports the very same bindings, so there is one source either way. The fixture script's TypeScript loader moves beside it rather than being written twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal theme group into a module The eighth named group, and the first parameterised one: its background fallback comes from the mobile theme through `document-constants.ts`. Two sites carry a line-scoped lint disable rather than the rewrite the rule asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every normalisation class the equivalence instrument counts, so taking them would change the program the native document carries, which is the one thing this branch holds fixed. The reason is on the disable line. Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13, unbound catches 0, number properties 9. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal path-tap group into a module The ninth named group, and a pure query: it reads no shared state, so it has no qualifier sites at all. Two things this group forced. The generator now drops lint directive lines before the transform, because a directive inside an expression makes esbuild parenthesise that expression to keep the comment where it was, and those parentheses are tokens the document does not have. And the two regexes keep their `no-useless-escape` escapes behind a line-scoped disable, for the same reason the theme group keeps `Math.pow`. One name the document declares twice in one function stays `var`. Two block-scoped declarations would be two bindings where the document has one, and esbuild renames the inner one to say so. Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20, unbound catches 0, number properties 2. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal tap-dispatch group into a module The tenth named group, and the heaviest reader of shared state: the selection, its elements, its thresholds and both press origins are all declared by the overlay slice, which is still document text, so all of them move onto the scope with their declarations left where they are. Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11, unbound catches 0, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal mouse-click-drag group into a module The eleventh named group. The escape byte and both SGR mouse modes join the scope from the runtime slice; the gesture itself is declared here and never read outside, so it stays a module local. Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27, unbound catches 1, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal url-tap group into three modules The twelfth and last named group, and the second parameterised one: both candidate patterns and the length bound come through `document-constants.ts`. Three modules rather than one. At 303 lines it was over the file cap, and the document's own order interleaves the OSC 8 lookup with the file-URL parsing, so the split follows that order and the group's text is the three emissions joined. The test does the joining. Note for a later lane: `terminal-webview-url-tap.ts` and `terminal-file-url-tap.ts` already hold TypeScript twins of some of this, written for the React Native side and not identical to what the document carries. Collapsing the two is a behaviour change and does not belong in a branch whose whole claim is that the document did not move. Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25, unbound catches 6, number properties 4. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the mouse-mode DECSET scan slice into a module The first of the thirteen inline slices. Both control-sequence introducers, the straddling scan tail and all three mode fields are declared by the runtime-state slice, which is still document text, so they move onto the scope with their declarations left where they are. Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9, unbound catches 0, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal message-bridge slice into a module The script and the document end in the same slice, so the slice splits in two at the point where the IIFE closes: the script half becomes a module, the document half stays text. The byte pin proves the join is unchanged. The second catch keeps its binding: it names the error and reports it. Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0, unbound catches 1, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the document close its own slice file The previous commit put two exports in one slice file, which the slice-count guard reads as a mismatch: it derives the slice list from the composer's imports and cross-checks it against the composed entries, one per file. Five suites failed to load. Splitting the file rather than the constant is the better shape anyway. The file was called `message-bridge-and-document-close` because it carried two concerns; now each has its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal term-observers slice into modules This slice interpolates the already-extracted keyboard-avoidance group between its own two halves, so its text is three emissions joined in that order and the test does the joining. A sixth normalisation class, measured here rather than assumed: the printer writes `{ name: name }` back as shorthand, and qualifying the value makes the property name unavoidable again, so one baseline token faces four. It is counted on its own like the others, with its own acceptance case in the instrument's test, and every existing group's pin now carries a zero for it. Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12, unbound catches 6, number properties 0, shorthand properties 4. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the selection-state-and-eviction slice into a module The slice that declares most of the shared selection state: every threshold, every overlay element and the selection itself, twenty-two scope declarations in one place. The eviction counter is declared and assigned only here, so it stays a module local. Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the smooth-scroll and cell-geometry slice Two modules, not one: the slice carries the normal-buffer smooth scroll and then the cell-to-pixel geometry, and the split follows that order so the group's text is the two emissions joined. Four names stop being externals and become real imports. Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal write-queue slice into a module The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`, which belong to other concerns but sit here because emitted-document order pins them here; four names stop being externals as a result. The observer disposal keeps its guard-as-expression form behind a line-scoped disable: the rewrite the rule asks for is outside every counted class. Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10, unbound catches 1, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal fit-scale slice into a module The slice opens with the already-extracted theme group, so its text is two emissions joined. Four more names stop being externals. Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20, unbound catches 0, number properties 9, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal init-and-write slice into a module The slice opens with the already-extracted webgl-recovery group, so its text is two emissions joined. init() resets almost every field the document shares, which makes this the densest qualifier site in the script. The caret options were interpolated from the theme module, so they join `document-constants.ts` as four exports: a substitution is keyed by name, not by property path. One local the document declares and never reads keeps a line-scoped `no-unused-vars` disable. Removing it would be a different program, which is the one thing this branch does not do. Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18, unbound catches 7, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the runtime-state and text-scaling slice The document's declaration block, where almost everything it shares is declared, with the query-reply and surface-swap groups interpolated inside it. Three modules: the two declarations that come before the groups, the text scaling, and the viewport transform with the scroll indicator. Seven more names stop being externals. Two things this slice forced. The scope-declaration rule now counts each declarator of one `var`, because `var panX = 0, panY = 0` becomes two assignments onto the scope. It has its own acceptance case in the instrument's test. The two halves are compared against their own text rather than as one joined program. The declaration the slice opens with is shadowed by a parameter inside one of the interpolated groups, and printing the baseline as one program renames that parameter; qualifying the outer name removes the shadow, so the rename has nothing to correspond to. Splitting the slice on the group constants compares like with like, and those groups have their own tests. Build-time constants are now substituted textually rather than through an esbuild `define`: a `define` whose value is an object or an array is injected as a helper binding instead of being inlined. Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38, rebindings 25, braced bodies 13, unbound catches 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the two test files the last commit left unformatted Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the mouse-report and scroll-routing slice Two modules around the already-extracted mouse-report-cell group: the viewport cell lookup that precedes it, and the mouse input encoding and scroll routing that follow. Eight more names stop being externals, which leaves ten. Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42, unbound catches 3, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the host-message-router slice into modules Two modules after the already-extracted reflow group: the postMessage bridge with the engine error reporting that rides on it, and the router itself. `notify`, `handleMsg` and `reportEngineError` stop being externals, which leaves seven. The catch binding handed to the error reporter keeps a cast: a catch variable is `unknown` under strict mode, and the reporter reads only `message` before falling back to `String()`. The reason is on the line. Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12, unbound catches 2, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the selection-overlay slice into modules Two modules after the already-extracted path-tap and url-tap groups: the selection range with the xterm mirror, and the overlay positioning with the edge scroll. Six more names stop being externals, which leaves one. Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63, unbound catches 9, number properties 6, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the surface-touch-gestures slice into modules The last of the thirteen slices. Two modules after the three already-extracted groups: the selection menu's buttons, and the touch gestures with the pinch and the momentum scroll. `attachSurfaceEventHandlers` was the last external, so `document-externals.ts` is gone: every name the document uses now resolves to a module. The instrument reads both sides strict. A loose script has to defend Annex B's block-scoped function declarations, and the printer does that by hoisting a `var` and renaming the function, so one side carried a rename the other could not. Neither name escapes its block, so the two readings agree on behaviour and only the strict one can be compared. It has its own acceptance case. Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57, unbound catches 2, number properties 2, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the document's opening declarations into a module The document shell carried the IIFE opener and the eight declarations inside it, so it splits the way the message-bridge slice did: the shell keeps the HTML and the opener, a new slice file holds the declarations, and the byte pin proves the join is unchanged. With this every line of the document's script has a module behind it. Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the whole document script against the modules Every line of the script now has a module behind it, so the whole thing can be compared at once. This is the review of the move, as one number per class: qualifier 609 references + 73 declarations = 682 sites var rebindings 373, the document's 446 declarators less those 73 curly braces 279, the number measured before any of this started unbound catches 36 of 38; two name their error and report it Number properties 17, also measured up front shorthand properties 4, two SGR flags written twice each unshadowed names 7 A seventh class was needed and is counted like the others: a binding that shadowed a document variable stops being a shadow once that variable moves onto the scope, so the printer stops disambiguating it. It has its own acceptance case. The module order lives in one file that both this test and the generator read, so neither can drift from the other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): keep only the lint directives that do something Seventeen of the disables were inert: `typescript/no-non-null-assertion` is not enabled here, and a directive naming two rules on one line is not parsed at all, so the one rule that did apply was being ignored too. The changed-code quality gate reports an inert directive as a finding. The two that matter are back, one rule per line: the guard-as-expression in the observer disposal, and the local the document declares and never reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): generate the terminal document from its modules The WebView document is no longer a hand-written IIFE pasted into a template string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts` and the 36 modules under `src/terminal/document/` in document order, strips their imports, exports and line-scoped lint directives, substitutes the `document-constants.ts` exports textually, reprints each with esbuild and wraps the result in one IIFE. `terminal-webview-html.ts` composes the shell, that generated script and the close fragment. The artifact is gitignored and built by postinstall, like the two engine artifacts. The emitted document is token-equivalent to the old one under eight counted normalisation classes, each pinned as an exact number in `document/terminal-document-flip.test.ts` against the pre-flip text: qualifiedReferences 609 scopeFieldDeclarations 73 rebindings 373 bracedBodies 279 unboundCatches 36 numberProperties 17 shorthandProperties 4 unshadowedNames 7 Any other difference fails with the token index and both sides. The second case pins that the new document adds the scope object and nothing else. Ruling 17: the behavioural tests now grep the generated document through `XTERM_HTML`, never a module source, so every assertion still speaks about what the WebView runs. Every assertion stays and the `expect` count per file is unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22, keyboard-avoidance 18, query-reply 14. One control per file was run by deleting the module line the updated pattern guards; all seven red, and the tree restores green. Pattern changes, old -> new. terminal-webview-scroll-routing.test.ts var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y; smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY; var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); -> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH); 'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break }, { capture: true, passive: false }); -> { capture: true, passive: false } function momentumStep() -> let momentumStep = function() pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY; if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) { normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame( pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0; cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId); var writeQueueHead = 0; -> scope.writeQueueHead = 0; writeQueueHead++; -> scope.writeQueueHead++; writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead); surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale(" var FRICTION = 0.972; -> const FRICTION = 0.972; var MIN_VEL = 0.012; -> const MIN_VEL = 0.012; edgeScrollDir = dir; -> scope.edgeScrollDir = dir; term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir); // Latching document-level touch dispatcher -> function attachSurfaceEventHandlers( edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX; edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY; return mode !== 'none'; -> return mode !== "none"; var pixelX = cell.x; -> const pixelX = cell.x; var pixelY = cell.y; -> const pixelY = cell.y; ...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) { ...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) { if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress; if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress; if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press; if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return ""; document.addEventListener('touchend' -> document.addEventListener( "touchend" }, { capture: true, passive: true }); -> { capture: true, passive: true } notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...) document.addEventListener('touchstart' -> document.addEventListener( "touchstart" var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl }); notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput }); terminal-webview-text-zoom.test.ts var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT = var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT \n\n function enqueueWrite -> \n function enqueueWrite var terminalFontFamily = -> scope.terminalFontFamily = output = terminalFontFamily; -> output = scope.terminalFontFamily; String.fromCharCode(0x23fa) -> String.fromCharCode(9210) TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038) EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039) data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR ) writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data)) var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData) } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2) term.open(surface) -> scope.term.open(scope.surface) term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11" enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData) fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily fontWeight: '300' -> fontWeight: "300" fontWeightBold: '500' -> fontWeightBold: "500" terminal-webview-engine.test.ts var webglAddon = null; .. var webglRecoveryTimer = null; -> the refreshTerminalSurface()..init( block, with the scope preamble window.addEventListener('resize' -> window.addEventListener("resize" 'terminal init failed' -> "terminal init failed" 'terminal message failed' -> "terminal message failed" var everReady = false; -> scope.everReady = false; everReady = true; -> scope.everReady = true; fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady /fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/ if (msg.type === 'ping') -> if (msg.type === "ping") notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id }) terminal-webview-reflow.test.ts } else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2) var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20; if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) { flog('measure-skip-small-width' -> flog("measure-skip-small-width" notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... }) var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle" window.addEventListener('message' -> window.addEventListener("message" terminal-keyboard-avoidance-webview.test.ts \n // reflow() -> \n function reflow( } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") \n var panX -> \n scope.panX TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document terminal-webview-query-reply.test.ts attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2) term.attachCustomKeyEventHandler(function() { return false; }) -> term.attachCustomKeyEventHandler(function() { \n return false; \n }); term.textarea.readOnly = true -> term.textarea.readOnly = true; } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") terminal-webview-url-tap.test.ts notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl }); terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the generated document's digest, 730472 -> 723480 bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): delete the slice constants and injected fragments The document is generated from its modules now, so the strings it used to be pasted together from are dead. Deleted: the fourteen slice constants under `terminal-webview-html/` (host-message-router, message-bridge, mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants, runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction, smooth-scroll-and-cell-geometry, surface-touch-gestures, term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write, write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`, `document-close.ts` and `theme.ts` stay: the shell and close are still the document's HTML, and `theme.ts` is where `document-constants.ts` reads the palette from. Ruling 17, second commit. Tests that asserted the extraction mechanism itself went with it: they compared one module's emission against the slice text it was extracted from, and the flip test now pins the whole document against the whole pre-flip script with the same eight classes. Deleted, all under `document/`: fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge, mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing, mouse-report-cell, path-tap, query-reply, reflow, runtime-constants, runtime-state, selection-overlay, selection-state-and-eviction, smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures, tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery, wheel-scroll. `document/url-tap.test.ts` stays: it pins against `URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and still has a consumer. Tests that asserted behaviour through a deleted string now read the generated document. `document/generated-document-region.test-support.ts` is the one way in: `documentScopePreamble()` returns the scope object the document opens with, and `generatedDocumentModule(name)` re-emits a module and refuses unless the document carries that text verbatim, so an evaluated block is the WebView's own bytes. The two local copies of the preamble in the engine and text-zoom tests were folded into it. Moved, with every assertion kept and the `expect` count per file unchanged: terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34 terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14 terminal-webview-query-reply.test.ts 14 terminal-path-tap.test.ts 25 terminal-webview-url-tap.test.ts 33 terminal-keyboard-avoidance-webview.test.ts 18 terminal-webview-reflow.test.ts 22 terminal-webview-text-zoom.test.ts 59 terminal-webview-engine.test.ts 49 Pattern changes, old -> new. terminal-webview-reflow.test.ts if (!term || isAlternateBufferActive()) return; -> if (!scope.term || isAlternateBufferActive()) { term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows); var wasAtBottom = buffer.viewportY >= buffer.baseY; -> const wasAtBottom = buffer.viewportY >= buffer.baseY; term.scrollToBottom(); -> scope.term.scrollToBottom(); if (nextCols === term.cols && nextRows === term.rows) return; -> if (nextCols === scope.term.cols && nextRows === scope.term.rows) { The other eight files kept their patterns; only the text they read changed, from a deleted constant to the document block. The harnesses that evaluate a block now build the document's scope object instead of declaring the vars it replaced, and hand the terminal in as `scope.term`. Controls, one per file: the module line an updated pattern guards was removed, the document rebuilt, and the test run. All red, and the tree restores green. query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed path-tap const parsed = parsePathLineCol(...) -> path-tap test, red keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed terminal-theme scope.term.options.minimumContrastRatio = ... -> theme test, 4 failed write-queue scope.writeQueue[scope.writeQueueHead] = undefined -> write-queue test, 4 failed `document-scope.ts` docstrings named the slice each field belonged to; they name the owning module now. Three module comments pointed at deleted injected files and point at the modules instead. Neither changes the document: esbuild drops comments, and the byte pin is unmoved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the right number of counted classes The flip test's title still said seven; the table it asserts has eight. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the shape applyTerminalTheme writes through The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test. `applyTerminalTheme` touches exactly two slots on the terminal it is handed, so `terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and the test's parameter and both fixtures use it. The theme is optional on the way in because `applyTerminalTheme` is what writes it. No cast. The type is erased by the generator's transform, so the document is unchanged and the flip test's class table and the byte pin both still hold. Control: restoring the `object` parameter reproduces the finding at terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type it exits 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the dead URL-tap constant and two stale reflow guards Round 1 fixes, all three folded here. 1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with `document/url-tap.test.ts` deleted alongside it. The document is generated from its modules now, so that constant was a second copy of the URL-tap group with no consumer but its own tests. terminal-webview-url-tap.test.ts's resolver harness reads the document's own text instead, the path-tap, url-tap, osc-link-tap and surface-tap modules in document order through `generatedDocumentModule`, which refuses unless the document carries each verbatim. Its 33 expects all stay. One mechanism-only assertion went with the file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts` pin of the three emissions against the constant, which the flip test's whole-document pin already covers. The file's other exports stay. The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts concatenated terminal-webview-url-tap.ts into its `source`, and its `notify({ type: 'terminal-tap' });` assertion was matching the constant's single-quoted text, not the document. The read is dropped, since nothing else in that file needed it, and the assertion is the document's form: notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" }); Its 95 expects stay. Leaving the read in place would let a document assertion pass against a module source, which is the hazard this lane exists to remove. 2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer exists, so it could not fail: expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}') -> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1) Same intent against the generated document: the reflow module's emitted text is in the document exactly once. The case is renamed to say so and the comment above it describes the generator, not the deleted template. 3. Same file, the routine assertion still passed as a substring of the qualified call; qualified as line 30 already was: term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows); Its 22 expects stay. Controls, each verified to have changed the file first, all red, tree green after restore: osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed module order 'reflow' listed twice -> reflow test, expected 2 to be 1 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): retire the last module concatenator and guard the order list Round 2 fixes, all five folded here. 1. Deleted terminal-webview-html-source.test-support.ts. `readTerminalWebViewHtmlSource()` had no consumers left once the behavioural tests moved to the generated document, and it was the last thing that built a document-shaped string by concatenating module sources — its filter admitted `.test-support.ts` files too, so it could have grown one. Confirmed by grep that the only occurrence of either name in the repository was its own declaration. 2. New document-module-order.test.ts asserts both directions: the non-test, non-test-support `.ts` files under `document/` are exactly `{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`, and no name is listed twice. `document-constants` is the one exception because it is never emitted: its exports are substituted into the modules that import them as literals, so the document carries its values without carrying the module. A module added here and forgotten there would be dead code that reads as live; a name left after its file goes makes the generator throw at build time rather than at review time. 3. terminal-document-flip.test.ts's docstring now carries the retirement policy from ruling 18: the test is the proof of the flip and holds only while no module changes, the first lane that must change one retires it together with `terminal-document-pre-flip-script.txt`, and the standing pin from then on is `terminal-document-identity.test.ts`, whose fixture regeneration is a review event. Comment only. 4. terminal-document-equivalence.test-support.ts said 57 reassigned variables and "Four classes and no others". It now says 73 declaration sites and eight classes, with each class's measured figure named. Two doc comments sat above the wrong declaration and were moved onto what they describe: the `NUMBER_GLOBALS` one down to that constant, and the printing one down to `significantTokens`, with `STRICT_DIRECTIVE` given its own line. 5. build-terminal-document-script.mjs substituted constants with `replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a constant's value are read as replacement patterns. The substitution is now `substituteDocumentConstants`, exported so it can be tested directly, and replaces with a function. Controls, each verified to have changed its input first, all red, tree green after restore: plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module" drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll" revert to the string replacer -> 4 failed, "a $& b" became "a marker b" The `$n` case is deliberately absent from that table: the pattern has no capture group, so `$1` is already literal under either form and a case for it could not tell them apart. The document did not move. The byte golden, the digest and the flip test's class table are all unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the flip comparator refuse what it was accepting Round 2 items 6 and 7, both in the equivalence instrument. 6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving the two were the same binding, so an unrelated rename ending in a digit would have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`, an explicit list of pre-flip name, generated name and declaring module. The whole script has one entry: `term2` -> `term` in `query-reply`, which is the `term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven sites in all. That is stated in the docstring rather than encoded as a second pin, since the flip test already pins the total. 7. Brace absorption treated every unexpected `{` as a linter-added body and absorbed any later `}` while one was outstanding, so a bare block anywhere would have been swallowed. `isBraceableHeadBody` now requires the open to be the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its `(` and reading the keyword before it — and `matchingCloseIndex` records the index the close must appear at, so the absorbed `}` is that body's own. That check had to move ahead of the equality check. Wherever a braced body ends a block, the baseline's next token is a `}` as well, so pairing them would consume the wrong one and leave the counts right for the wrong reason. Both refusals are tested over snippets: function f() { return value2; } vs return value; -> token 6: expected name value2, generated name value let value = 1; use(value); vs { let value = 1; } use(value); -> token 0: expected name let, generated { and the braceable heads are tested one by one, `if`, `for`, `while`, `if`/`else` and `do`, so the new rule is shown to accept every shape the `curly` rule produces and not only the one the document happens to exercise. Controls: restoring the shape rule fails the first refusal case and nothing else; restoring the accept-any-brace rule fails the second and nothing else. The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7. Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The tightened rules put the file over the 300-line cap, and a `max-lines` disable is forbidden, so the token reader moved to its own module: that side answers what a script says, and says nothing about which differences between two of them are allowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- mobile/.gitignore | 1 + mobile/package.json | 2 +- .../build-terminal-document-fixture.mjs | 78 + .../build-terminal-document-script.mjs | 189 + .../build-terminal-document-script.test.ts | 105 + mobile/scripts/import-typescript-module.mjs | 21 + .../terminal-document-module-order.mjs | 48 + mobile/src/terminal/document/cell-geometry.ts | 46 + .../terminal/document/document-constants.ts | 46 + .../document/document-module-order.test.ts | 43 + .../src/terminal/document/document-scope.ts | 406 ++ mobile/src/terminal/document/fit-scale.ts | 146 + .../generated-document-region.test-support.ts | 51 + .../terminal/document/host-message-router.ts | 194 + mobile/src/terminal/document/host-notify.ts | 86 + .../document/keyboard-avoidance-metrics.ts | 70 + .../src/terminal/document/message-bridge.ts | 53 + .../src/terminal/document/mode-mirroring.ts | 46 + .../src/terminal/document/mouse-click-drag.ts | 283 ++ .../terminal/document/mouse-input-encoding.ts | 230 ++ .../document/mouse-mode-decset-scan.ts | 74 + .../terminal/document/mouse-report-cell.ts | 67 + .../document/normal-buffer-smooth-scroll.ts | 102 + mobile/src/terminal/document/osc-link-tap.ts | 221 ++ mobile/src/terminal/document/path-tap.ts | 222 ++ mobile/src/terminal/document/query-reply.ts | 70 + mobile/src/terminal/document/reflow.ts | 37 + .../terminal/document/runtime-constants.ts | 23 + .../document/selection-menu-buttons.ts | 36 + .../terminal/document/selection-overlay.ts | 141 + .../src/terminal/document/selection-range.ts | 115 + .../document/selection-state-and-eviction.ts | 82 + mobile/src/terminal/document/surface-swap.ts | 69 + mobile/src/terminal/document/surface-tap.ts | 59 + .../document/surface-touch-gestures.ts | 276 ++ mobile/src/terminal/document/tap-dispatch.ts | 248 ++ .../src/terminal/document/term-observers.ts | 40 + ...minal-document-equivalence.test-support.ts | 391 ++ .../terminal-document-equivalence.test.ts | 225 ++ .../document/terminal-document-flip.test.ts | 80 + .../terminal-document-tokens.test-support.ts | 94 + .../src/terminal/document/terminal-handle.ts | 10 + mobile/src/terminal/document/terminal-init.ts | 201 + .../src/terminal/document/terminal-theme.ts | 180 + mobile/src/terminal/document/text-scaling.ts | 94 + mobile/src/terminal/document/url-tap.ts | 55 + mobile/src/terminal/document/viewport-cell.ts | 36 + .../terminal/document/viewport-transform.ts | 142 + .../src/terminal/document/webgl-recovery.ts | 105 + mobile/src/terminal/document/wheel-scroll.ts | 75 + .../write-queue.test.ts | 61 +- mobile/src/terminal/document/write-queue.ts | 135 + .../src/terminal/terminal-document-golden.txt | 3290 +++++++++++++++++ .../terminal-document-identity.test.ts | 54 + .../terminal-document-pre-flip-script.txt | 2758 ++++++++++++++ ...nal-keyboard-avoidance-metrics-injected.ts | 43 - ...erminal-keyboard-avoidance-webview.test.ts | 38 +- .../terminal/terminal-path-tap-injected.ts | 134 - mobile/src/terminal/terminal-path-tap.test.ts | 6 +- .../terminal/terminal-webview-engine.test.ts | 43 +- ...rminal-webview-html-source.test-support.ts | 31 - mobile/src/terminal/terminal-webview-html.ts | 36 +- .../terminal-webview-html/document-close.ts | 5 + .../terminal-webview-html/document-shell.ts | 9 - .../host-message-router.ts | 189 - .../message-bridge-and-document-close.ts | 43 - .../mouse-mode-decset-scan.ts | 52 - .../mouse-report-and-scroll-routing.ts | 188 - .../runtime-state-and-text-scaling.ts | 181 - .../selection-overlay.ts | 195 - .../selection-state-and-eviction.ts | 71 - .../smooth-scroll-and-cell-geometry.ts | 110 - .../surface-touch-gestures.ts | 228 -- .../term-observers-and-mode-mirroring.ts | 67 - .../terminal-fit-scale.ts | 130 - .../terminal-init-and-write.ts | 139 - .../terminal-webview-html/write-queue.ts | 114 - ...minal-webview-mouse-click-drag-injected.ts | 198 - ...inal-webview-mouse-report-cell-injected.ts | 29 - .../terminal-webview-payload-hash.test.ts | 4 +- .../terminal-webview-query-reply-injected.ts | 44 - .../terminal-webview-query-reply.test.ts | 26 +- .../terminal-webview-reflow-injected.ts | 33 - .../terminal/terminal-webview-reflow.test.ts | 61 +- .../terminal-webview-scroll-routing.test.ts | 104 +- .../terminal-webview-surface-swap-injected.ts | 49 - .../terminal-webview-tap-dispatch-injected.ts | 188 - .../terminal-webview-text-zoom.test.ts | 70 +- .../terminal-webview-theme-injected.ts | 122 - ...test.ts => terminal-webview-theme.test.ts} | 85 +- .../terminal/terminal-webview-url-tap.test.ts | 27 +- .../src/terminal/terminal-webview-url-tap.ts | 209 -- ...erminal-webview-webgl-recovery-injected.ts | 62 - .../terminal-webview-wheel-scroll-injected.ts | 53 - 94 files changed, 12232 insertions(+), 3196 deletions(-) create mode 100644 mobile/scripts/build-terminal-document-fixture.mjs create mode 100644 mobile/scripts/build-terminal-document-script.mjs create mode 100644 mobile/scripts/build-terminal-document-script.test.ts create mode 100644 mobile/scripts/import-typescript-module.mjs create mode 100644 mobile/scripts/terminal-document-module-order.mjs create mode 100644 mobile/src/terminal/document/cell-geometry.ts create mode 100644 mobile/src/terminal/document/document-constants.ts create mode 100644 mobile/src/terminal/document/document-module-order.test.ts create mode 100644 mobile/src/terminal/document/document-scope.ts create mode 100644 mobile/src/terminal/document/fit-scale.ts create mode 100644 mobile/src/terminal/document/generated-document-region.test-support.ts create mode 100644 mobile/src/terminal/document/host-message-router.ts create mode 100644 mobile/src/terminal/document/host-notify.ts create mode 100644 mobile/src/terminal/document/keyboard-avoidance-metrics.ts create mode 100644 mobile/src/terminal/document/message-bridge.ts create mode 100644 mobile/src/terminal/document/mode-mirroring.ts create mode 100644 mobile/src/terminal/document/mouse-click-drag.ts create mode 100644 mobile/src/terminal/document/mouse-input-encoding.ts create mode 100644 mobile/src/terminal/document/mouse-mode-decset-scan.ts create mode 100644 mobile/src/terminal/document/mouse-report-cell.ts create mode 100644 mobile/src/terminal/document/normal-buffer-smooth-scroll.ts create mode 100644 mobile/src/terminal/document/osc-link-tap.ts create mode 100644 mobile/src/terminal/document/path-tap.ts create mode 100644 mobile/src/terminal/document/query-reply.ts create mode 100644 mobile/src/terminal/document/reflow.ts create mode 100644 mobile/src/terminal/document/runtime-constants.ts create mode 100644 mobile/src/terminal/document/selection-menu-buttons.ts create mode 100644 mobile/src/terminal/document/selection-overlay.ts create mode 100644 mobile/src/terminal/document/selection-range.ts create mode 100644 mobile/src/terminal/document/selection-state-and-eviction.ts create mode 100644 mobile/src/terminal/document/surface-swap.ts create mode 100644 mobile/src/terminal/document/surface-tap.ts create mode 100644 mobile/src/terminal/document/surface-touch-gestures.ts create mode 100644 mobile/src/terminal/document/tap-dispatch.ts create mode 100644 mobile/src/terminal/document/term-observers.ts create mode 100644 mobile/src/terminal/document/terminal-document-equivalence.test-support.ts create mode 100644 mobile/src/terminal/document/terminal-document-equivalence.test.ts create mode 100644 mobile/src/terminal/document/terminal-document-flip.test.ts create mode 100644 mobile/src/terminal/document/terminal-document-tokens.test-support.ts create mode 100644 mobile/src/terminal/document/terminal-handle.ts create mode 100644 mobile/src/terminal/document/terminal-init.ts create mode 100644 mobile/src/terminal/document/terminal-theme.ts create mode 100644 mobile/src/terminal/document/text-scaling.ts create mode 100644 mobile/src/terminal/document/url-tap.ts create mode 100644 mobile/src/terminal/document/viewport-cell.ts create mode 100644 mobile/src/terminal/document/viewport-transform.ts create mode 100644 mobile/src/terminal/document/webgl-recovery.ts create mode 100644 mobile/src/terminal/document/wheel-scroll.ts rename mobile/src/terminal/{terminal-webview-html => document}/write-queue.test.ts (80%) create mode 100644 mobile/src/terminal/document/write-queue.ts create mode 100644 mobile/src/terminal/terminal-document-golden.txt create mode 100644 mobile/src/terminal/terminal-document-identity.test.ts create mode 100644 mobile/src/terminal/terminal-document-pre-flip-script.txt delete mode 100644 mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts delete mode 100644 mobile/src/terminal/terminal-path-tap-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-html-source.test-support.ts create mode 100644 mobile/src/terminal/terminal-webview-html/document-close.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/host-message-router.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/selection-overlay.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/write-queue.ts delete mode 100644 mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-query-reply-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-reflow-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-surface-swap-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-theme-injected.ts rename mobile/src/terminal/{terminal-webview-theme-injected.test.ts => terminal-webview-theme.test.ts} (62%) delete mode 100644 mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts diff --git a/mobile/.gitignore b/mobile/.gitignore index 0428cbb65a9..7c30714f74c 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -1,5 +1,6 @@ node_modules/ src/terminal/terminal-webview-engine.generated.ts +src/terminal/terminal-webview-document-script.generated.ts src/components/pr-sidebar/mermaid-webview-engine.generated.ts .expo/ dist/ diff --git a/mobile/package.json b/mobile/package.json index c4a977cd459..d89f04ff36d 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,7 +7,7 @@ "start": "node scripts/start-expo.mjs", "android": "expo run:android", "ios": "expo run:ios", - "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs", + "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-terminal-document-script.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", diff --git a/mobile/scripts/build-terminal-document-fixture.mjs b/mobile/scripts/build-terminal-document-fixture.mjs new file mode 100644 index 00000000000..885aca358d7 --- /dev/null +++ b/mobile/scripts/build-terminal-document-fixture.mjs @@ -0,0 +1,78 @@ +import { writeFile } from 'node:fs/promises' +import path from 'node:path' +import { importTypeScriptModule } from './import-typescript-module.mjs' + +/** + * Writes the committed copy of the terminal WebView document that + * `terminal-document-identity.test.ts` diffs against. + * + * The document is a build artifact: fourteen source slices joined in a pinned order, with the + * generated xterm engine spliced into two of them. `terminal-webview-payload-hash.test.ts` already + * says *whether* it moved; what it cannot say is *where*, and a refactor whose whole claim is that + * the document did not move needs the diff, not the digest. + * + * The two generated engine strings are stored as placeholders rather than inline. They are already + * pinned by the hash test, they are regenerated by postinstall from whatever xterm version the + * lockfile holds, and inlining them would put 612 KiB of vendored bytes in the fixture and turn + * every xterm bump into an unreadable diff of the file that is supposed to isolate hand-written + * changes. + * + * Regenerating this fixture is a review event: it is only correct when the emitted document was + * meant to change, and the diff is the evidence for that. Run `node scripts/build-terminal-document-fixture.mjs` + * from `mobile/`. + */ +const mobileRoot = path.resolve(import.meta.dirname, '..') +const entry = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-html.ts') +const enginePath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts') + +export const TERMINAL_DOCUMENT_FIXTURE_PATH = path.join( + mobileRoot, + 'src', + 'terminal', + 'terminal-document-golden.txt' +) + +/** Chosen so the document cannot contain one by accident; asserted below and in the test. */ +export const ENGINE_JS_PLACEHOLDER = '__ORCA_TERMINAL_ENGINE_JS__' +export const ENGINE_CSS_PLACEHOLDER = '__ORCA_TERMINAL_ENGINE_CSS__' + +/** + * The document with both generated sections replaced by their placeholders. + * + * Exported so the test builds the same text the script writes, rather than restating the + * substitution and agreeing with a fixture that was written wrong. + */ +export function terminalDocumentFixture(document, engineJs, engineCss) { + for (const placeholder of [ENGINE_JS_PLACEHOLDER, ENGINE_CSS_PLACEHOLDER]) { + if (document.includes(placeholder)) { + throw new Error(`the document already contains ${placeholder}`) + } + } + for (const [name, value] of [ + ['XTERM_ENGINE_JS', engineJs], + ['XTERM_ENGINE_CSS', engineCss] + ]) { + if (document.split(value).length !== 2) { + throw new Error(`${name} does not appear exactly once in the document`) + } + } + return document + .replace(engineJs, ENGINE_JS_PLACEHOLDER) + .replace(engineCss, ENGINE_CSS_PLACEHOLDER) +} + +async function main() { + const [{ XTERM_HTML }, { XTERM_ENGINE_JS, XTERM_ENGINE_CSS }] = await Promise.all([ + importTypeScriptModule(entry), + importTypeScriptModule(enginePath) + ]) + const fixture = terminalDocumentFixture(XTERM_HTML, XTERM_ENGINE_JS, XTERM_ENGINE_CSS) + await writeFile(TERMINAL_DOCUMENT_FIXTURE_PATH, fixture) + console.log( + `[build-terminal-document-fixture] ${Buffer.byteLength(fixture, 'utf8')} bytes (document ${Buffer.byteLength(XTERM_HTML, 'utf8')})` + ) +} + +if (import.meta.filename === process.argv[1]) { + await main() +} diff --git a/mobile/scripts/build-terminal-document-script.mjs b/mobile/scripts/build-terminal-document-script.mjs new file mode 100644 index 00000000000..9fe3c9b0587 --- /dev/null +++ b/mobile/scripts/build-terminal-document-script.mjs @@ -0,0 +1,189 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import * as esbuild from 'esbuild' +import { importTypeScriptModule } from './import-typescript-module.mjs' +import { + TERMINAL_DOCUMENT_MODULE_ORDER, + TERMINAL_DOCUMENT_SCOPE_MODULE +} from './terminal-document-module-order.mjs' + +/** + * Turns one module of the in-WebView terminal document back into the script text the document + * carries. + * + * The document is a string the native WebView loads, so its parts cannot be imported by anything; + * the web page needs exactly those parts and must not re-implement them. So the parts are modules, + * and this is the other direction: the modules' declarations, with their imports removed and their + * exports unmarked, spliced into the one function scope the document has always been. + * + * Imports are dropped rather than resolved because inside the document every name is already in + * scope — that is what the single IIFE means. `document-externals.ts` declares the names that have + * not moved into modules yet, and it emits nothing at all. + * + * `esbuild` does the TypeScript, as it already does for the xterm engine beside this file. It is a + * transform and not a bundle: a bundler would order the output by its dependency graph, and the + * document's order is part of what the equivalence test holds fixed. + */ +const INDENT = ' ' + +const constantsPath = path.join( + import.meta.dirname, + '..', + 'src', + 'terminal', + 'document', + 'document-constants.ts' +) + +let substitutions = null + +/** + * `document-constants.ts` as the literal text each name stands for. + * + * Substitution happens after the import lines are dropped, when the names are free again, and it is + * textual rather than an esbuild `define` because a `define` whose value is an object or an array + * is injected as a helper binding instead of being inlined, which is not what the document carries. + * The names are exported for this purpose only and none of them appears inside a string. + */ +async function documentConstantSubstitutions() { + if (substitutions === null) { + const module = await importTypeScriptModule(constantsPath) + substitutions = Object.fromEntries( + Object.entries(module).map(([name, value]) => [name, JSON.stringify(value)]) + ) + } + return substitutions +} + +/** + * Replaces each constant's name with its literal. + * + * The replacement is a function, not the literal itself: as a string, `$&`, `` $` ``, `$'` and + * `$n` are replacement patterns, so a constant whose value contains one would be spliced with the + * match rather than written out. A function replacer has no such reading. + */ +export function substituteDocumentConstants(text, substitutions) { + let substituted = text + for (const [name, literal] of Object.entries(substitutions)) { + substituted = substituted.replaceAll(new RegExp(`\\b${name}\\b`, 'g'), () => literal) + } + return substituted +} + +/** + * Whether a line is a lint directive. + * + * These are removed before the transform, not after it: a directive inside an expression makes + * esbuild wrap that expression in parentheses to keep the comment where it was, and those + * parentheses are tokens the document does not have. They are tooling metadata about the source, + * not part of the program the WebView runs. + */ +function isLintDirectiveLine(line) { + return /^\s*\/\/\s*oxlint-disable/.test(line) +} + +/** Whether a line opens an import the document does not need. */ +function isImportLine(line) { + return /^import[\s{'"]/.test(line) +} + +/** Whether a statement that started on this line also ended on it. */ +function closesOnSameLine(line, closer) { + return line.includes(closer) +} + +/** + * The emitted text of one module: transpiled, unexported, un-imported and indented into the IIFE. + * + * Multi-line imports are handled by dropping through to the line that closes them, which esbuild's + * output makes safe: it prints one import per line. + */ +export async function emitTerminalDocumentModule(modulePath) { + const source = await readFile(modulePath, 'utf8') + const program = source + .split('\n') + .filter((line) => !isLintDirectiveLine(line)) + .join('\n') + const { code } = await esbuild.transform(program, { + loader: 'ts', + format: 'esm', + target: 'chrome74', + // The document is read by people as well as by a WebView, and the equivalence test compares + // tokens, so keeping the printer's own layout costs nothing and keeps the diff legible. + minify: false + }) + const kept = [] + // esbuild wraps a long import or export list across lines, so both are skipped to their closer + // rather than by their first line. An export list dropped by its keyword alone would leave a + // bare block statement in the document, and an import list would leave its names loose. + let skipUntil = null + for (const line of code.split('\n')) { + if (skipUntil !== null) { + if (closesOnSameLine(line, skipUntil)) { + skipUntil = null + } + continue + } + if (isImportLine(line)) { + skipUntil = closesOnSameLine(line, ' from ') || closesOnSameLine(line, ';') ? null : ' from ' + continue + } + if (line.startsWith('export {')) { + skipUntil = closesOnSameLine(line, '}') ? null : '}' + continue + } + kept.push(line.startsWith('export ') ? line.slice('export '.length) : line) + } + const text = substituteDocumentConstants(kept.join('\n'), await documentConstantSubstitutions()) + const substituted = await esbuild.transform(text, { + loader: 'js', + format: 'esm', + target: 'chrome74', + minify: false + }) + const body = substituted.code.trim() + return body + .split('\n') + .map((line) => (line.length === 0 ? line : `${INDENT}${line}`)) + .join('\n') +} + +const documentDirectory = path.join(import.meta.dirname, '..', 'src', 'terminal', 'document') + +export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join( + import.meta.dirname, + '..', + 'src', + 'terminal', + 'terminal-webview-document-script.generated.ts' +) + +/** + * The document's whole script: every module in the order the document had, inside the one function + * scope it has always been. + */ +export async function buildTerminalDocumentScript() { + const emitted = [] + // The scope object goes first: every module below reads it, and the document is one function + // scope, so it has to exist before any of them run. It is the only part of the emitted script + // the hand-written document did not have. + for (const name of [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]) { + emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`))) + } + return `(function() {\n${emitted.join('\n')}\n})();` +} + +async function main() { + const script = await buildTerminalDocumentScript() + await writeFile( + TERMINAL_DOCUMENT_SCRIPT_PATH, + `// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` + + `// The source is mobile/src/terminal/document/, in the order\n` + + `// scripts/terminal-document-module-order.mjs pins.\n` + + `export const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n` + ) +} + +if (process.argv[1] === import.meta.filename) { + await main() +} diff --git a/mobile/scripts/build-terminal-document-script.test.ts b/mobile/scripts/build-terminal-document-script.test.ts new file mode 100644 index 00000000000..f84e30de688 --- /dev/null +++ b/mobile/scripts/build-terminal-document-script.test.ts @@ -0,0 +1,105 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + emitTerminalDocumentModule, + substituteDocumentConstants +} from './build-terminal-document-script.mjs' +import { terminalBackgroundFallback } from '../src/terminal/document/document-constants' + +/** + * What the generator drops, what it keeps, and how it puts a module back into the document. + * + * The per-group tests compare a real module against the string the document carries, which says + * the two agree; these say why, on inputs small enough to read. The import and export cases are + * the ones that bit: esbuild wraps a long list across lines, and skipping only the first line + * leaves the rest of the names loose in the document. + */ +let directory: string + +async function emit(source: string): Promise { + const path = join(directory, `module-${Math.random().toString(36).slice(2)}.ts`) + await writeFile(path, source) + return emitTerminalDocumentModule(path) +} + +beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-terminal-document-')) +}) + +afterAll(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('emitting one terminal document module', () => { + it('unmarks an export and indents it into the document scope', async () => { + expect(await emit('export function f() {\n return 1\n}\n')).toBe( + ' function f() {\n return 1;\n }' + ) + }) + + it('drops an import that fits on one line', async () => { + expect(await emit("import { a } from './x'\nexport const b = 1\n")).toBe(' const b = 1;') + }) + + it('drops an import esbuild wrapped across lines', async () => { + // The case that produced an unparseable document: the names after the first line stayed. + const source = + "import { alpha, beta, gamma, delta, epsilon, zeta, eta, theta } from './document-externals'\n" + + 'export const b = alpha\n' + expect(await emit(source)).toBe(' const b = alpha;') + }) + + it('drops the trailing export block esbuild prints, not just its keyword', async () => { + // Left behind it is a bare block statement, which parses and does nothing. + const emitted = await emit('function f() {}\nfunction g() {}\nexport { f, g }\n') + expect(emitted).not.toContain('{ f, g }') + expect(emitted).toBe(' function f() {\n }\n function g() {\n }') + }) + + it('erases types without touching the program', async () => { + expect( + await emit( + 'export type T = { a: number }\nexport function f(v: T): number {\n return v.a\n}\n' + ) + ).toBe(' function f(v) {\n return v.a;\n }') + }) + + it('substitutes a build-time constant the document carries as a literal', async () => { + const emitted = await emit( + "import { terminalBackgroundFallback } from '../src/terminal/document/document-constants'\n" + + 'export function paint() {\n' + + ' return terminalBackgroundFallback\n' + + '}\n' + ) + expect(emitted).toContain(JSON.stringify(terminalBackgroundFallback)) + expect(emitted).not.toContain('terminalBackgroundFallback') + }) + + it('drops a lint directive rather than let it parenthesise the expression it guards', async () => { + expect( + await emit( + 'export const R =\n' + ' // oxlint-disable-next-line no-useless-escape\n' + ' /a/g\n' + ) + ).toBe(' const R = /a/g;') + }) +}) + +describe('substituting a build-time constant', () => { + it('writes a value containing a replacement pattern out as it stands', () => { + // `$&` is the matched text to `String.replaceAll`'s string form, which would splice the + // constant's own name in here and ship a document that says something else. + const literal = JSON.stringify('a $& b') + expect(substituteDocumentConstants('const v = marker;', { marker: literal })).toBe( + 'const v = "a $& b";' + ) + }) + + // `$n` is not listed: the pattern has no capture group, so it is already literal under either + // form and a case for it could not tell them apart. + it.each([['$&'], ["$'"], ['$`']])('is not read as the replacement pattern %s', (pattern) => { + const literal = JSON.stringify(`x${pattern}y`) + expect(substituteDocumentConstants('marker', { marker: literal })).toBe(literal) + }) +}) diff --git a/mobile/scripts/import-typescript-module.mjs b/mobile/scripts/import-typescript-module.mjs new file mode 100644 index 00000000000..54f84ab800b --- /dev/null +++ b/mobile/scripts/import-typescript-module.mjs @@ -0,0 +1,21 @@ +import * as esbuild from 'esbuild' + +/** + * Imports a TypeScript module from a build script, by bundling it to a data URL. + * + * Node cannot import TypeScript and these scripts run outside the app's bundler, so the values the + * document is built from — the theme, the URL limits, the caret options — would otherwise have to be + * restated here. Restating them is what the generator exists to avoid. + */ +export async function importTypeScriptModule(entryPoint) { + const result = await esbuild.build({ + entryPoints: [entryPoint], + bundle: true, + format: 'esm', + platform: 'node', + write: false, + logLevel: 'silent' + }) + const code = result.outputFiles[0].text + return import(`data:text/javascript;base64,${Buffer.from(code, 'utf8').toString('base64')}`) +} diff --git a/mobile/scripts/terminal-document-module-order.mjs b/mobile/scripts/terminal-document-module-order.mjs new file mode 100644 index 00000000000..7fa99fe5962 --- /dev/null +++ b/mobile/scripts/terminal-document-module-order.mjs @@ -0,0 +1,48 @@ +/** + * The order the document's modules are spliced back into the script, which is the order the + * hand-written document had. It is data, not a dependency graph: the document is one function + * scope, so declarations must land where they landed before. + * + * Both the generator and the equivalence test read this, so neither can drift from the other. + */ +/** The scope object, emitted ahead of everything else because everything else reads it. */ +export const TERMINAL_DOCUMENT_SCOPE_MODULE = 'document-scope' + +export const TERMINAL_DOCUMENT_MODULE_ORDER = [ + 'runtime-constants', + 'terminal-handle', + 'query-reply', + 'surface-swap', + 'text-scaling', + 'viewport-transform', + 'terminal-theme', + 'fit-scale', + 'mouse-mode-decset-scan', + 'write-queue', + 'webgl-recovery', + 'terminal-init', + 'reflow', + 'host-notify', + 'host-message-router', + 'selection-state-and-eviction', + 'mode-mirroring', + 'keyboard-avoidance-metrics', + 'term-observers', + 'viewport-cell', + 'mouse-report-cell', + 'mouse-input-encoding', + 'normal-buffer-smooth-scroll', + 'cell-geometry', + 'path-tap', + 'url-tap', + 'osc-link-tap', + 'surface-tap', + 'selection-range', + 'selection-overlay', + 'tap-dispatch', + 'wheel-scroll', + 'mouse-click-drag', + 'selection-menu-buttons', + 'surface-touch-gestures', + 'message-bridge' +] diff --git a/mobile/src/terminal/document/cell-geometry.ts b/mobile/src/terminal/document/cell-geometry.ts new file mode 100644 index 00000000000..d5f6e950912 --- /dev/null +++ b/mobile/src/terminal/document/cell-geometry.ts @@ -0,0 +1,46 @@ +import { getCellHeight } from './fit-scale' +import { getCellWidth, getTotalScale } from './viewport-transform' +import { scope } from './document-scope' + +export function cellToViewportPx(col: number, absRow: number) { + if (!scope.term) { + return { x: 0, y: 0 } + } + const cellW = getCellWidth() + const cellH = getCellHeight() + const viewportRow = absRow - scope.term.buffer.active.viewportY + const sx = col * cellW + const sy = viewportRow * cellH + const total = getTotalScale() + return { x: sx * total + scope.panX, y: sy * total + scope.panY } +} + +export function getLineText(absRow: number) { + if (!scope.term) { + return '' + } + const line = scope.term.buffer.active.getLine(absRow) + if (!line) { + return '' + } + return line.translateToString(false) +} + +// Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a +// tap's CELL column no longer equals the STRING index that url/path matchers use. +// Convert by measuring the string length up to the tapped cell (the count of +// string chars before it). Without this, taps on lines with a leading wide char +// (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss. +export function cellColToStringIndex(absRow: number, col: number) { + if (!scope.term) { + return col + } + const line = scope.term.buffer.active.getLine(absRow) + if (!line) { + return col + } + return line.translateToString(false, 0, col).length +} + +// File-path-under-tap detection (matchFilePathAtColumn). See path-tap.ts; +// mirrors the unit-tested terminal-path-tap.ts. diff --git a/mobile/src/terminal/document/document-constants.ts b/mobile/src/terminal/document/document-constants.ts new file mode 100644 index 00000000000..c5c67b29696 --- /dev/null +++ b/mobile/src/terminal/document/document-constants.ts @@ -0,0 +1,46 @@ +import { colors } from '../../theme/mobile-theme' +import { TERMINAL_TEXT_SCALES } from '../../storage/preferences' +import { + DEFAULT_TERMINAL_THEME, + MOBILE_TERMINAL_CARET_OPTIONS +} from '../terminal-webview-html/theme' +import { + TERMINAL_FILE_URL_REGEX_SOURCE, + TERMINAL_HTTP_URL_MAX_LENGTH, + TERMINAL_HTTP_URL_REGEX_SOURCE +} from '../terminal-webview-url-tap' + +/** + * The build-time values the document's script text carries as literals. + * + * The document is a string, so it cannot import: today each of these is interpolated into a + * template literal at the site that needs it. A module cannot do that and still be the same + * program, so the generator substitutes these exports into the text it emits, and the web page + * imports the very same bindings. One source either way. + * + * Every export must be JSON-serialisable, because a substitution is a JSON literal. + */ + +/** The page background before a theme arrives, and the fallback when a theme omits one. */ +export const terminalBackgroundFallback = colors.terminalBg + +/** The http(s) candidate pattern, as a string because the document builds the RegExp per call. */ +export const terminalHttpUrlRegexSource = TERMINAL_HTTP_URL_REGEX_SOURCE + +/** The file:// candidate pattern, same shape. */ +export const terminalFileUrlRegexSource = TERMINAL_FILE_URL_REGEX_SOURCE + +/** The longest candidate a tap will open, matching desktop. */ +export const terminalHttpUrlMaxLength = TERMINAL_HTTP_URL_MAX_LENGTH + +/** The caret options, one export each because a substitution is keyed by name. */ +export const terminalCursorBlink = MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink +export const terminalCursorStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle +export const terminalShowCursorImmediately = MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately +export const terminalCursorInactiveStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle + +/** The text-scale presets, as the document's own array literal. */ +export const terminalTextScalePresets = [...TERMINAL_TEXT_SCALES] + +/** The built-in theme, as the document's own object literal. */ +export const terminalDefaultTheme = DEFAULT_TERMINAL_THEME diff --git a/mobile/src/terminal/document/document-module-order.test.ts b/mobile/src/terminal/document/document-module-order.test.ts new file mode 100644 index 00000000000..7a66e9ad29a --- /dev/null +++ b/mobile/src/terminal/document/document-module-order.test.ts @@ -0,0 +1,43 @@ +import { readdirSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + TERMINAL_DOCUMENT_MODULE_ORDER, + TERMINAL_DOCUMENT_SCOPE_MODULE +} from '../../../scripts/terminal-document-module-order.mjs' + +/** + * Every module in this directory is in the document, and everything in the order list is here. + * + * The generator emits exactly what the order list names, so a module added here and forgotten + * there is dead code that reads as live, and a name left in the list after its file goes makes the + * generator throw at build time rather than at review time. Both directions are asserted. + * + * `document-constants` is the one file that is deliberately not emitted: its exports are + * substituted into the modules that import them as literals, so the document carries its values + * without carrying the module. + */ +const NOT_EMITTED = 'document-constants' + +function documentModuleNames(): string[] { + return readdirSync(new URL('.', import.meta.url)) + .filter((entry) => entry.endsWith('.ts')) + .filter((entry) => !entry.endsWith('.test.ts') && !entry.endsWith('.test-support.ts')) + .map((entry) => entry.slice(0, -'.ts'.length)) + .sort() +} + +describe('the document module order', () => { + it('names every module the directory holds, and only those', () => { + const expected = [ + NOT_EMITTED, + TERMINAL_DOCUMENT_SCOPE_MODULE, + ...TERMINAL_DOCUMENT_MODULE_ORDER + ].sort() + expect(documentModuleNames()).toEqual(expected) + }) + + it('names each module once, so the generator cannot emit one twice', () => { + const listed = [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER] + expect(listed).toHaveLength(new Set(listed).size) + }) +}) diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts new file mode 100644 index 00000000000..046ce3e708e --- /dev/null +++ b/mobile/src/terminal/document/document-scope.ts @@ -0,0 +1,406 @@ +import { terminalDefaultTheme, terminalTextScalePresets } from './document-constants' +import type { TerminalDocumentThemeMessage } from './terminal-theme' +/** + * The state the in-WebView terminal document shares across its parts. + * + * The document is one function scope: 2,758 lines around 100 `var` declarations, 57 of which are + * written from more than one place. Moving its parts into modules is what lets the web page import + * them instead of re-implementing them, and a variable assigned from another module cannot be an + * import — assigning an imported binding is a syntax error. So the written ones become fields here, + * and the group that owns each is named beside it. + * + * Two things keep a variable out of this table. One the script never assigns again is an ordinary + * local. One both declared and assigned inside a single group is that module's own state, however + * often it is written — `terminalDataRepliesEnabled` is written from four places and all four are + * in `query-reply`, so it stays a `let` there. + * + * Declared, not merely written: while the rest of the document is still strings, a variable the + * main slice declares is shared even when every use of it is in one group, because the declaration + * has nowhere else to live yet. `webglRecoveryTimer` is that case. Those can migrate out of this + * table when the flip makes the main slice modules too, and doing it before then would emit a + * second declaration beside the one the slice still carries. + * + * The table grows one group at a time as C7.1 extracts them; a field arrives with its group. + */ + +/** One cell of a buffer line, as the document inspects it. */ +/** xterm's OSC 8 link service, reached through internals and always guarded. */ +export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: string } | undefined } + +/** The xterm internals the OSC 8 lookup walks. */ +export type TerminalDocumentCore = { + _renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } } + _oscLinkService?: TerminalOscLinkService + _inputHandler?: { _oscLinkService?: TerminalOscLinkService } +} + +/** An OSC 8 link the host captured from scrollback before xterm replayed it. */ +export type TerminalInitialOscLink = { + uri?: string + row: number + startCol: number + endCol: number + text?: string +} + +export type TerminalDocumentCell = { + isBgDefault: () => boolean + extended?: { urlId?: number } + isInverse: () => boolean + isUnderline?: () => boolean + isStrikethrough?: () => boolean + isOverline?: () => boolean +} + +/** One buffer line, as the document inspects it. */ +export type TerminalDocumentLine = { + readonly length: number + translateToString: (trimRight: boolean, startColumn?: number, endColumn?: number) => string + getCell?: (x: number, cell?: TerminalDocumentCell | null) => TerminalDocumentCell | null +} + +/** One side of xterm's buffer, as the document reads it. */ +export type TerminalDocumentBuffer = { + readonly length: number + readonly viewportY: number + readonly baseY: number + readonly cursorY: number + readonly type: string + getNullCell?: () => TerminalDocumentCell + getLine: (index: number) => TerminalDocumentLine | undefined +} + +/** As much of xterm's terminal as the document's own code touches. */ +/** A terminal colour theme: xterm reads it as a flat map of slot to CSS colour. */ +export type TerminalDocumentTheme = Record + +/** The xterm options the document writes; each field is owned by the group that sets it. */ +export type TerminalDocumentTerminalOptions = { + theme: TerminalDocumentTheme + minimumContrastRatio: number + fontSize: number +} + +export type TerminalDocumentTerminal = { + readonly cols: number + readonly rows: number + readonly buffer: { readonly active: TerminalDocumentBuffer } + options: TerminalDocumentTerminalOptions + write: (data: string, callback?: () => void) => void + open: (element: HTMLElement) => void + scrollToLine: (line: number) => void + clear: () => void + reset: () => void + selectAll: () => void + getSelection?: () => string + select: (col: number, row: number, length: number) => void + clearSelection: () => void + readonly unicode: { activeVersion: string } + attachCustomKeyEventHandler: (handler: () => boolean) => void + onData: (listener: (data: string) => void) => TerminalDocumentDisposable + readonly textarea?: { + readOnly: boolean + tabIndex: number + setAttribute: (name: string, value: string) => void + } + readonly element?: HTMLElement + readonly _core?: TerminalDocumentCore + readonly modes?: { + bracketedPasteMode?: boolean + mouseTrackingMode?: string + applicationCursorKeysMode?: boolean + } + onLineFeed?: (listener: () => void) => TerminalDocumentDisposable + onScroll?: (listener: () => void) => TerminalDocumentDisposable + onWriteParsed?: (listener: () => void) => TerminalDocumentDisposable + resize: (cols: number, rows: number) => void + refresh: (start: number, end: number) => void + dispose: () => void + loadAddon: (addon: TerminalDocumentWebglAddon) => void + scrollToBottom: () => void + scrollLines: (amount: number) => void +} + +export type TerminalDocumentScope = { + /** `terminal-handle`: the live xterm terminal, or null before the first init. */ + term: TerminalDocumentTerminal | null + /** `viewport-transform`: the surface's pan offset, in viewport pixels. */ + panX: number + panY: number + /** `terminal-init`: bumped on every re-init, so a late callback can tell it is stale. */ + terminalGeneration: number + /** `term-observers`: xterm listener handles to dispose when the terminal is replaced. */ + termObserverDisposables: TerminalDocumentDisposable[] + /** `terminal-init`: the row count the last init or reflow settled on. */ + initRows: number + /** `webgl-recovery`: the loaded WebGL addon, or null on the DOM renderer. */ + webglAddon: TerminalDocumentWebglAddon | null + /** `webgl-recovery`: the pending single retry after a context loss. */ + webglRecoveryTimer: ReturnType | null + /** `terminal-theme`: the theme the host last sent, replayed on visibility. */ + terminalThemeInput: TerminalDocumentThemeMessage + /** `wheel-scroll`: sub-line wheel travel carried between events; reset by a touch scroll. */ + wheelAccumDeltaY: number + /** `terminal-theme`: the built-in theme, and the fallback for every slot a host theme omits. */ + defaultTheme: TerminalDocumentTheme + /** `terminal-theme`: the host theme normalised against the built-in one. */ + terminalTheme: TerminalDocumentTheme + /** `terminal-theme`: the contrast floor in force, published or derived from the background. */ + terminalMinimumContrastRatio: number + /** `selection-overlay`: OSC 8 links captured from scrollback before xterm replayed it. */ + initialOscLinks: TerminalInitialOscLink[] + /** `selection-overlay`: how far the captured rows have scrolled out of the buffer. */ + initialOscLinkRowOffset: number + /** `runtime-constants`: the escape byte every report is prefixed with. */ + ESC: string + /** `mode-mirroring`: the last mode set published to the host, to suppress repeats. */ + lastEmittedModes: TerminalDocumentModes + /** `terminal-init`: whether the terminal has ever reached ready. */ + everReady: boolean + /** `runtime-constants`: the C1 form of the control sequence introducer. */ + C1_CSI: string + /** `mouse-mode-decset-scan`: the tail of the last chunk, in case a DECSET straddles two writes. */ + mouseModeScanTail: string + /** `mouse-mode-decset-scan`: the mouse tracking mode the TUI last asked for. */ + trackedMouseTrackingMode: string + /** `mouse-mode-decset-scan`: whether the TUI asked for SGR (1006) mouse reports. */ + sgrMouseMode: boolean + /** `mouse-mode-decset-scan`: whether the TUI asked for SGR pixel (1016) mouse reports. */ + sgrMousePixelsMode: boolean + /** `text-scaling`: the scroll indicator's hide timer. */ + scrollIndicatorHideTimer: ReturnType | null + /** `text-scaling`: the narrowest grid a text-scale change will fit to. */ + MIN_FIT_COLS: number + /** `text-scaling`: the smallest text-scale preset. */ + MIN_TEXT_SCALE: number + /** `text-scaling`: the largest text-scale preset. */ + MAX_TEXT_SCALE: number + /** `viewport-transform`: host message ids already handled, to drop repeats. */ + handledMessageIds: number[] + /** `text-scaling`: the text scale the user picked, as a preset index. */ + currentTextScale: number + /** `text-scaling`: the font stack xterm renders with. */ + terminalFontFamily: string + /** `terminal-init`: whether the first live chunk since init is still pending. */ + firstDataPending: boolean + /** `terminal-init`: whether the replayed snapshot was an alternate screen. */ + activeAltScreenSnapshot: boolean + /** `fit-scale`: the fit scale the document committed. */ + currentScale: number + /** `text-scaling`: the pinch zoom the user applied on top of the fit scale. */ + userScale: number + /** `runtime-constants`: Claude's record dot, which iOS WebKit would otherwise promote to emoji. */ + CLAUDE_STATUS_DOT: string + /** `runtime-constants`: the variation selector that forces the text glyph. */ + TEXT_PRESENTATION_SELECTOR: string + /** `runtime-constants`: the variation selector that forces the emoji glyph. */ + EMOJI_PRESENTATION_SELECTOR: string + /** `runtime-constants`: the dot with any trailing selectors, as one pattern. */ + CLAUDE_STATUS_DOT_PATTERN: RegExp + /** `write-queue`: whether a chunk ended mid-selector, so the next one starts inside it. */ + statusDotPendingSelector: boolean + /** `write-queue`: how far a split DECSET may be carried before the scan gives up. */ + PRIVATE_MODE_SCAN_TAIL_LIMIT: number + /** `write-queue`: chunks and boundaries waiting for xterm. */ + writeQueue: TerminalWriteQueueEntry[] + /** `write-queue`: how far the queue has been consumed, before compaction. */ + writeQueueHead: number + /** `write-queue`: whether a write is parsing right now. */ + writesDraining: boolean + /** `write-queue`: callbacks waiting for the queue to empty. */ + afterDrainCallbacks: (() => void)[] + /** `terminal-init`: whether the terminal has been initialised. */ + ready: boolean + /** `normal-buffer-smooth-scroll`: sub-row scroll travel not yet committed to xterm. */ + smoothScrollOffsetY: number + /** `normal-buffer-smooth-scroll`: scroll travel waiting for the next frame. */ + pendingNormalScrollDeltaY: number + /** `normal-buffer-smooth-scroll`: the frame request that will apply it, if one is pending. */ + normalScrollFrameId: number | null + /** `selection-state-and-eviction`: what counts as one word for select-all and word seeding. */ + WORD_RE: RegExp + /** `selection-state-and-eviction`: how close to an edge a handle drag starts scrolling. */ + EDGE_SCROLL_PX: number + /** `selection-state-and-eviction`: the edge-scroll tick, in milliseconds. */ + EDGE_SCROLL_INTERVAL: number + /** `selection-state-and-eviction`: the menu pill element. */ + selMenu: HTMLElement | null + /** `selection-state-and-eviction`: the pill's copy button. */ + btnCopy: HTMLElement | null + /** `selection-state-and-eviction`: the pill's select-all button. */ + btnSelAll: HTMLElement | null + /** `selection-state-and-eviction`: the running edge-scroll timer. */ + edgeScrollTimer: ReturnType | null + /** `selection-state-and-eviction`: which way the edge scroll is going. */ + edgeScrollDir: number + /** `selection-state-and-eviction`: where the dragging finger last was. */ + edgeScrollClientX: number + /** `selection-state-and-eviction`: where the dragging finger last was. */ + edgeScrollClientY: number + /** `selection-state-and-eviction`: whether captured OSC 8 rows may start shifting with eviction. */ + initialOscLinkEvictionReady: boolean + /** `selection-overlay`: the press duration that starts a selection, in milliseconds. */ + LONG_PRESS_MS: number + /** `selection-overlay`: the travel that cancels a pending long press, in pixels. */ + LONG_PRESS_SLOP: number + /** `selection-overlay`: the travel that disqualifies a tap, in pixels. */ + TAP_SLOP: number + /** `selection-overlay`: the longest press still counted as a tap, in milliseconds. */ + TAP_MAX_MS: number + /** `selection-overlay`: the overlay element that carries the handles and the menu pill. */ + selectionOverlay: HTMLElement | null + /** `selection-overlay`: the selection's leading handle element. */ + handleStart: HTMLElement | null + /** `selection-overlay`: the selection's trailing handle element. */ + handleEnd: HTMLElement | null + /** `selection-overlay`: `navigate` or `select`. */ + selMode: string + /** `selection-overlay`: the live selection, or null when there is none. */ + sel: TerminalDocumentSelection | null + /** `selection-overlay`: the pending long-press timer. */ + longPressTimer: ReturnType | null + /** `selection-overlay`: where the pending long press started. */ + longPressOrigin: TerminalDocumentTouchOrigin | null + /** `selection-overlay`: the touch that may still resolve as a tap. */ + tapCandidate: TerminalDocumentTapCandidate | null + /** `surface-swap`: the element xterm is currently mounted on. */ + surface: HTMLElement | null + /** `surface-swap`: the terminal of a hidden replacement surface that has not committed. */ + pendingTerm: TerminalDocumentTerminal | null +} + +/** An xterm listener handle, as the document disposes of one. */ +/** The live selection; only the dragged handle is read outside the overlay slice. */ +export type TerminalDocumentSelection = { + anchor: { row: number; col: number } + focus: { row: number; col: number } + activeHandle: string | null +} + +/** Where a press began, and which finger began it. */ +export type TerminalDocumentTouchOrigin = { x: number; y: number; identifier: number } + +/** A touch that may still resolve as a tap: its origin, its start time and its finger. */ +export type TerminalDocumentTapCandidate = TerminalDocumentTouchOrigin & { t: number } + +/** The terminal modes the host mirrors. */ +export type TerminalDocumentModes = { + bracketedPasteMode: boolean + altScreen: boolean + mouseTrackingMode: string + sgrMouseMode: boolean + sgrMousePixelsMode: boolean +} + +/** One entry of the write queue: a chunk, a boundary callback, or a consumed slot. */ +export type TerminalWriteQueueEntry = string | (() => void) | undefined + +export type TerminalDocumentDisposable = { dispose?: () => void } + +/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */ +export type TerminalDocumentWebglAddon = { + onContextLoss?: (listener: () => void) => void + clearTextureAtlas?: () => void + dispose: () => void +} + +/** + * The initial values, which are the ones the document's own declarations carried. + * + * A factory rather than a shared literal so a second document — a test, or a page that remounts — + * starts from its own state instead of inheriting what the last one left. + */ +const textScalePresets = terminalTextScalePresets +const statusDot = String.fromCharCode(0x23fa) +const textPresentationSelector = String.fromCharCode(0xfe0e) +const emojiPresentationSelector = String.fromCharCode(0xfe0f) + +export function createTerminalDocumentScope(): TerminalDocumentScope { + return { + term: null, + panX: 0, + panY: 0, + terminalGeneration: 0, + termObserverDisposables: [], + initRows: 24, + webglAddon: null, + webglRecoveryTimer: null, + terminalThemeInput: null, + defaultTheme: terminalDefaultTheme, + terminalTheme: terminalDefaultTheme, + terminalMinimumContrastRatio: 3, + initialOscLinks: [], + initialOscLinkRowOffset: 0, + ESC: String.fromCharCode(27), + lastEmittedModes: { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }, + everReady: false, + C1_CSI: String.fromCharCode(155), + mouseModeScanTail: '', + trackedMouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false, + scrollIndicatorHideTimer: null, + MIN_FIT_COLS: 20, + MIN_TEXT_SCALE: textScalePresets[0], + MAX_TEXT_SCALE: textScalePresets[textScalePresets.length - 1], + handledMessageIds: [], + currentTextScale: 1, + terminalFontFamily: '', + firstDataPending: true, + activeAltScreenSnapshot: false, + currentScale: 1, + userScale: 1, + CLAUDE_STATUS_DOT: statusDot, + TEXT_PRESENTATION_SELECTOR: textPresentationSelector, + EMOJI_PRESENTATION_SELECTOR: emojiPresentationSelector, + CLAUDE_STATUS_DOT_PATTERN: new RegExp( + statusDot + '[' + textPresentationSelector + emojiPresentationSelector + ']*', + 'g' + ), + statusDotPendingSelector: false, + PRIVATE_MODE_SCAN_TAIL_LIMIT: 4096, + writeQueue: [], + writeQueueHead: 0, + writesDraining: false, + afterDrainCallbacks: [], + ready: false, + smoothScrollOffsetY: 0, + pendingNormalScrollDeltaY: 0, + normalScrollFrameId: null, + WORD_RE: /[\p{L}\p{N}_./:@~+=?&#%-]/u, + EDGE_SCROLL_PX: 40, + EDGE_SCROLL_INTERVAL: 60, + selMenu: null, + btnCopy: null, + btnSelAll: null, + edgeScrollTimer: null, + edgeScrollDir: 0, + edgeScrollClientX: 0, + edgeScrollClientY: 0, + initialOscLinkEvictionReady: false, + LONG_PRESS_MS: 500, + LONG_PRESS_SLOP: 10, + TAP_SLOP: 24, + TAP_MAX_MS: 700, + selectionOverlay: null, + handleStart: null, + handleEnd: null, + selMode: 'navigate', + sel: null, + longPressTimer: null, + longPressOrigin: null, + tapCandidate: null, + wheelAccumDeltaY: 0, + surface: null, + pendingTerm: null + } +} + +/** The document's own scope. The generator emits this declaration at the top of the script. */ +export const scope: TerminalDocumentScope = createTerminalDocumentScope() diff --git a/mobile/src/terminal/document/fit-scale.ts b/mobile/src/terminal/document/fit-scale.ts new file mode 100644 index 00000000000..9f9138ed88a --- /dev/null +++ b/mobile/src/terminal/document/fit-scale.ts @@ -0,0 +1,146 @@ +import { repositionOverlay } from './selection-overlay' +import { + computeFitScale, + flog, + getCellWidth, + getTotalScale, + updateTransform +} from './viewport-transform' +import { scope } from './document-scope' + +export function getCellHeight() { + if (!scope.term || !scope.term._core) { + return 15 + } + const core = scope.term._core + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 15 + } + return 15 +} + +// Why: clamp pan so the terminal content always covers the viewport +// when zoomed in. When content is smaller than viewport in a +// dimension, pin to top-left (no floating in the middle). +export function clampPan() { + if (!scope.term || !scope.term.element) { + return + } + const ts = getTotalScale() + const cw = scope.term.element.scrollWidth * ts + const ch = scope.term.element.scrollHeight * ts + const vpW = window.innerWidth + const vpH = window.innerHeight + if (cw > vpW) { + scope.panX = Math.min(0, Math.max(vpW - cw, scope.panX)) + } else { + scope.panX = 0 + } + if (ch > vpH) { + scope.panY = Math.min(0, Math.max(vpH - ch, scope.panY)) + } else { + scope.panY = 0 + } +} + +// Why: intentional no-op. Mobile replays a live PTY snapshot then applies +// live cursor-relative chunks from that same PTY; resizing only the WebView +// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or +// overlap. Kept as a no-op so its call sites stay legible. +export function adjustRowsForViewport() {} + +// Why: cold-start fit. After init() opens xterm, the renderer needs +// several frames before cell dimensions are computed. Reading too early +// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM +// not laid out), and computeFitScale returns 1 → no zoom. +// +// Gate: cellWidth × cols is the canonical "logical width" of the grid +// and reflects xterm's layout decision, independent of buffer content. +// We commit when cellWidth becomes positive (renderer ready). Fallback: +// if cellWidth never becomes available, gate on stable positive +// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) +// so a backgrounded WebView never spins forever. +const FIT_RETRY_MAX_FRAMES = 60 +let fitRetryToken = 0 +export function applyFitScale(reason: string) { + if (!scope.term || !scope.term.element) { + return + } + const token = ++fitRetryToken + let attempts = 0 + let lastScrollWidth = -1 + function attempt() { + if (token !== fitRetryToken) { + return + } + if (!scope.term || !scope.term.element) { + return + } + attempts++ + const cellW = getCellWidth() + if (cellW > 0 && scope.term.cols > 0) { + commitFitScale(reason, attempts, 'cellW') + return + } + const w = scope.term.element.scrollWidth + if (w > 0 && w === lastScrollWidth) { + commitFitScale(reason, attempts, 'stableSW') + return + } + lastScrollWidth = w + if (attempts >= FIT_RETRY_MAX_FRAMES) { + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: scope.term.cols + }) + commitFitScale(reason, attempts, 'timeout') + return + } + requestAnimationFrame(attempt) + } + requestAnimationFrame(attempt) +} + +export function commitFitScale(reason: string, attempts: number, gate: string) { + if (!scope.term || !scope.term.element) { + return + } + const preSnapScale = computeFitScale() + scope.currentScale = preSnapScale + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. + if (scope.currentScale >= 0.95) { + scope.currentScale = 1 + } + scope.userScale = 1 + scope.panX = 0 + scope.panY = 0 + scope.smoothScrollOffsetY = 0 + updateTransform() + adjustRowsForViewport() + + const cellW = getCellWidth() + const sw = scope.term.element.scrollWidth + const vpW = window.innerWidth + const expectedW = cellW * scope.term.cols + const suspect = scope.currentScale === 1 && scope.term.cols > 0 && expectedW > vpW + 1 // expected wider than viewport but no zoom + if (suspect) { + flog('commit-SUSPECT', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: scope.currentScale, + cellW: cellW, + cols: scope.term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW + }) + } + repositionOverlay() +} diff --git a/mobile/src/terminal/document/generated-document-region.test-support.ts b/mobile/src/terminal/document/generated-document-region.test-support.ts new file mode 100644 index 00000000000..f93efbfcb0e --- /dev/null +++ b/mobile/src/terminal/document/generated-document-region.test-support.ts @@ -0,0 +1,51 @@ +import { fileURLToPath } from 'node:url' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { XTERM_HTML } from '../terminal-webview-html' + +const SCOPE_OPEN = '(function() {\n' +// The first statement the document runs once the scope object exists. +const FIRST_STATEMENT_AFTER_SCOPE = ' scope.surface = document.getElementById' + +/** + * The scope object the document opens with. Every block below it reads and writes document state + * through this one object, so a test that evaluates a block has to build it first. + */ +export function documentScopePreamble(): string { + const start = XTERM_HTML.indexOf(SCOPE_OPEN) + const end = XTERM_HTML.indexOf(FIRST_STATEMENT_AFTER_SCOPE, start) + if (start === -1 || end <= start) { + throw new Error('the document does not open with the scope object') + } + return XTERM_HTML.slice(start + SCOPE_OPEN.length, end) +} + +/** + * One module's text as the document carries it. The module is re-emitted and then located in the + * document, so a test that evaluates the result is running the WebView's own bytes, not a + * parallel copy of them. + */ +export async function generatedDocumentModule(name: string): Promise { + const emitted = await emitTerminalDocumentModule( + fileURLToPath(new URL(`./${name}.ts`, import.meta.url)) + ) + if (!XTERM_HTML.includes(emitted)) { + throw new Error(`the document does not carry the ${name} module; rebuild the document script`) + } + return emitted +} + +/** + * A function the document's own text declared, read out of the context it was evaluated in. The + * name is checked to be callable, so only its parameter and return types are the caller's claim. + */ +export function documentDeclaredFunction unknown>( + context: Record, + name: string +): T { + const value = context[name] + if (typeof value !== 'function') { + throw new Error(`the document text did not declare ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: checked callable above. + return value as T +} diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts new file mode 100644 index 00000000000..b03bca2cead --- /dev/null +++ b/mobile/src/terminal/document/host-message-router.ts @@ -0,0 +1,194 @@ +import { scope } from './document-scope' +import { applyFitScale } from './fit-scale' +import { notify } from './host-notify' +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' +import { emitModesIfChanged } from './mode-mirroring' +import { reflow } from './reflow' +import { resumeTerminalDataReplyAuthority } from './query-reply' +import { repositionOverlay } from './selection-overlay' +import { cancelSelect } from './selection-range' +import { resetEvictionCounter } from './selection-state-and-eviction' +import { applyTerminalTheme } from './terminal-theme' +import { init, resize, write } from './terminal-init' +import { applyTextScale } from './text-scaling' +import { flog } from './viewport-transform' +import { resetWriteQueue } from './write-queue' + +/** One message from the host. Every field is optional because the router reads them by type. */ +export type TerminalHostMessage = { + id?: number + type?: string + cols?: number + rows?: number + initialData?: unknown + terminalTheme?: Parameters[0] + fontScale?: number + preserveScroll?: boolean + oscLinks?: unknown + data?: string + containerHeight?: number +} + +export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: number) { + if (typeof retriesLeft !== 'number') { + retriesLeft = 30 + } + // Why: init and measure are posted back-to-back from React, but + // init has an async rAF chain. A measure that runs synchronously + // after init can find term null, disposed, lacking element, or + // with cells size 0. Retry the whole gate for ~500ms. + const notReady = !scope.term || !scope.term.element + let cellWidth = 0 + let cellHeight = 0 + if (!notReady) { + const core = scope.term!._core + if (core && core._renderService && core._renderService.dimensions) { + cellWidth = core._renderService.dimensions.css.cell.width + cellHeight = core._renderService.dimensions.css.cell.height + } + } + if (notReady || cellWidth <= 0 || cellHeight <= 0) { + if (retriesLeft > 0) { + requestAnimationFrame(function () { + measureFitDimensions(containerHeightPx, retriesLeft - 1) + }) + return + } + flog('measure-fail', { + notReady: notReady, + cellWidth: cellWidth, + cellHeight: cellHeight, + retriesLeft: retriesLeft + }) + notify({ type: 'measure-result', cols: null, rows: null }) + return + } + const vpWidth = window.innerWidth + // Why: prefer the container height passed from React Native over + // window.innerHeight. The RN layout system knows the exact pixel + // height of the terminal frame after the accessory/input bars are + // subtracted, whereas innerHeight can overstate the visible area + // due to layout timing or safe-area insets. + const vpHeight = + typeof containerHeightPx === 'number' && containerHeightPx > 0 + ? containerHeightPx + : window.innerHeight + const cols = Math.floor(vpWidth / cellWidth) + if (cols < scope.MIN_FIT_COLS) { + flog('measure-skip-small-width', { + vpWidth: vpWidth, + cellWidth: cellWidth, + cols: cols + }) + notify({ type: 'measure-result', cols: null, rows: null }) + return + } + // Why: the rows we report become the PTY's actual row count after the + // server fits to viewport, and xterm renders exactly that many lines + // anchored top-left of the WebView. Subtracting rows here would leave + // dead xterm-background space at the bottom of the container and make + // the last PTY rows visually appear above an "invisible line." Any + // safety margin between the prompt and the accessory bar must come + // from RN layout (terminalFrame's flex bounds), not from undersizing + // the PTY. + const rows = Math.max(8, Math.floor(vpHeight / cellHeight)) + notify({ type: 'measure-result', cols: cols, rows: rows }) +} + +export function handleMsg(msg: TerminalHostMessage) { + if (typeof msg.id === 'number') { + // oxlint-disable-next-line unicorn/prefer-includes -- the document's text is pinned token for token; rewriting this changes the native program + if (scope.handledMessageIds.indexOf(msg.id) !== -1) { + return + } + scope.handledMessageIds.push(msg.id) + if (scope.handledMessageIds.length > 256) { + scope.handledMessageIds.shift() + } + } + if (msg.type === 'ping') { + notify({ type: 'pong', pingId: msg.id }) + } else if (msg.type === 'init') { + init( + msg.cols!, + msg.rows!, + msg.initialData, + msg.terminalTheme, + msg.fontScale, + msg.preserveScroll!, + msg.oscLinks + ) + } else if (msg.type === 'set-font-scale') { + // Why: ignore RN echoing back the value a pinch just set (msg.fontScale === + // currentTextScale) so the post-pinch state isn't reset; only apply changes. + if ( + typeof msg.fontScale === 'number' && + msg.fontScale > 0 && + msg.fontScale !== scope.currentTextScale + ) { + scope.userScale = 1 + scope.panX = 0 + scope.panY = 0 + applyTextScale(msg.fontScale) + } + } else if (msg.type === 'resize') { + resize(msg.cols!, msg.rows!) + } else if (msg.type === 'reflow') { + reflow(msg.cols!, msg.rows!) + } else if (msg.type === 'write') { + write(msg.data!) + } else if (msg.type === 'clear') { + scope.terminalGeneration++ + resetWriteQueue() + resumeTerminalDataReplyAuthority() // Why: clear drops the replay boundary. + scope.statusDotPendingSelector = false + scope.afterDrainCallbacks = [] + scope.writesDraining = false + scope.mouseModeScanTail = '' + scope.trackedMouseTrackingMode = 'none' + scope.sgrMouseMode = false + scope.sgrMousePixelsMode = false + scope.initialOscLinks = [] + scope.initialOscLinkRowOffset = 0 + scope.initialOscLinkEvictionReady = false + if (scope.term) { + scope.term.clear() + scope.term.reset() + } + emitModesIfChanged() + emitKeyboardAvoidanceMetrics() + resetEvictionCounter() + if (scope.selMode === 'select') { + notify({ type: 'selection-evicted' }) + cancelSelect() + } + } else if (msg.type === 'measure') { + measureFitDimensions(msg.containerHeight) + } else if (msg.type === 'reset-zoom') { + applyFitScale('reset-zoom-msg') + } else if (msg.type === 'set-theme') { + applyTerminalTheme(msg.terminalTheme) + } else if (msg.type === 'cancel-select') { + if (scope.selMode === 'select') { + cancelSelect() + } + } else if (msg.type === 'do-select-all') { + if (scope.term) { + try { + scope.term.selectAll() + const b = scope.term.buffer.active + if (scope.selMode !== 'select') { + scope.selMode = 'select' + scope.selectionOverlay!.classList.add('active') + notify({ type: 'set-select-mode', enabled: true }) + } + scope.sel = { + anchor: { col: 0, row: 0 }, + focus: { col: scope.term.cols - 1, row: b.length - 1 }, + activeHandle: null + } + repositionOverlay() + } catch {} + } + } +} diff --git a/mobile/src/terminal/document/host-notify.ts b/mobile/src/terminal/document/host-notify.ts new file mode 100644 index 00000000000..310a80e2b39 --- /dev/null +++ b/mobile/src/terminal/document/host-notify.ts @@ -0,0 +1,86 @@ +import { scope } from './document-scope' + +/** + * The postMessage bridge to the host, and the engine error reporting that rides on it. + * + * They are one module because the document declares them together, ahead of the message router + * that both serve. + */ + +declare global { + interface Window { + __engineErrors: string[] + } +} + +export function notify(msg: Record) { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify(msg)) + } +} + +/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */ +export type TerminalEngineError = string | null | undefined | { message?: unknown } + +export function engineErrorText(err: TerminalEngineError) { + if (!err) { + return '' + } + if (typeof err === 'string') { + return err + } + if (err && typeof err.message === 'string') { + return err.message + } + try { + return String(err) + } catch { + return '' + } +} + +export function chromeVersionText() { + const match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/) + return match ? 'Chrome ' + match[1] : 'Chrome version unknown' +} + +let nonFatalErrorNotifies = 0 + +export function reportEngineError(context: string, err: TerminalEngineError, fatal?: unknown) { + const isFatal = fatal === undefined ? !scope.everReady : !!fatal + if (!isFatal) { + // Why: a constructed-but-degraded engine can throw per frame; cap + // non-fatal notifies so RN isn't flooded. Fatal reports always emit. + nonFatalErrorNotifies++ + if (nonFatalErrorNotifies > 5) { + return + } + } + const parts = [context] + const errText = engineErrorText(err) + if (errText) { + parts.push(errText) + } + if (window.__engineErrors && window.__engineErrors.length) { + parts.push('captured: ' + window.__engineErrors.join(' | ')) + } + parts.push(chromeVersionText()) + notify({ + type: 'error', + fatal: isFatal, + message: parts.join(' - ') + }) +} + +window.onerror = function ( + msg: string | (Event & { message?: unknown }), + source, + line, + column, + err?: TerminalEngineError +) { + if (window.__engineErrors.length < 20) { + window.__engineErrors.push(String(msg)) + } + reportEngineError('terminal runtime error', err || msg) +} diff --git a/mobile/src/terminal/document/keyboard-avoidance-metrics.ts b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts new file mode 100644 index 00000000000..4c3520ca529 --- /dev/null +++ b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts @@ -0,0 +1,70 @@ +import { notify } from './host-notify' +import { scope, type TerminalDocumentCell, type TerminalDocumentLine } from './document-scope' + +export function lineHasVisibleContent( + line: TerminalDocumentLine, + cell: TerminalDocumentCell | null +) { + if (line.translateToString(true).trim().length > 0) { + return true + } + if (!cell || !line.getCell) { + return false + } + const limit = Math.min(scope.term!.cols || 0, line.length || 0) + for (let x = 0; x < limit; x++) { + const current = line.getCell(x, cell) + if (!current) { + continue + } + if (!current.isBgDefault() || current.isInverse()) { + return true + } + if (typeof current.isUnderline === 'function' && current.isUnderline()) { + return true + } + if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) { + return true + } + if (typeof current.isOverline === 'function' && current.isOverline()) { + return true + } + } + return false +} + +export function computeContentBottomRow() { + if (!scope.term || !scope.term.buffer || !scope.term.buffer.active) { + return 0 + } + const buffer = scope.term.buffer.active + const top = buffer.viewportY || 0 + const cell = buffer.getNullCell ? buffer.getNullCell() : null + for (let y = (scope.term.rows || 0) - 1; y >= 0; y--) { + try { + const line = buffer.getLine(top + y) + if (line && lineHasVisibleContent(line, cell)) { + return y + } + } catch {} + } + return 0 +} + +export function emitKeyboardAvoidanceMetrics() { + if (!scope.term) { + return + } + let alt = false + try { + alt = + scope.term.buffer && scope.term.buffer.active && scope.term.buffer.active.type === 'alternate' + } catch {} + notify({ + type: 'keyboard-avoidance-metrics', + cursorY: scope.term.buffer && scope.term.buffer.active ? scope.term.buffer.active.cursorY : 0, + contentBottomRow: alt ? 0 : computeContentBottomRow(), + rows: scope.term.rows || 0, + altScreen: alt + }) +} diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts new file mode 100644 index 00000000000..2141b7cfa3b --- /dev/null +++ b/mobile/src/terminal/document/message-bridge.ts @@ -0,0 +1,53 @@ +import { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale' +import { repositionOverlay } from './selection-overlay' +import { handleMsg, type TerminalHostMessage } from './host-message-router' +import { notify, reportEngineError, type TerminalEngineError } from './host-notify' +import { updateTransform } from './viewport-transform' +import { scope } from './document-scope' + +declare global { + interface Window { + Terminal?: unknown + } +} + +export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage | string }) { + let msg: TerminalHostMessage + try { + msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data + } catch { + return + } + try { + handleMsg(msg!) + } catch (ex) { + reportEngineError( + msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a catch binding is `unknown`; the reporter reads only `message` and falls back to String(). + ex as TerminalEngineError, + msg && msg.type === 'init' && !scope.everReady + ) + } +} + +window.addEventListener('message', handleIncomingMessage) + +document.addEventListener('message', handleIncomingMessage) + +window.addEventListener('resize', function () { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize') + adjustRowsForViewport() + repositionOverlay() + clampPan() + updateTransform() +}) + +if (window.Terminal) { + notify({ type: 'web-ready' }) +} else { + reportEngineError('terminal engine missing', 'xterm failed to load', true) +} diff --git a/mobile/src/terminal/document/mode-mirroring.ts b/mobile/src/terminal/document/mode-mirroring.ts new file mode 100644 index 00000000000..c5471bb8db4 --- /dev/null +++ b/mobile/src/terminal/document/mode-mirroring.ts @@ -0,0 +1,46 @@ +import { notify } from './host-notify' +import { getMouseTrackingMode } from './mouse-input-encoding' +import { scope } from './document-scope' + +export function emitModesIfChanged() { + if (!scope.term) { + return + } + const bp = !!(scope.term.modes && scope.term.modes.bracketedPasteMode) + let alt = false + const mouseTrackingMode = getMouseTrackingMode() + try { + alt = + scope.term.buffer && scope.term.buffer.active && scope.term.buffer.active.type === 'alternate' + } catch {} + if ( + bp !== scope.lastEmittedModes.bracketedPasteMode || + alt !== scope.lastEmittedModes.altScreen || + mouseTrackingMode !== scope.lastEmittedModes.mouseTrackingMode || + scope.sgrMouseMode !== scope.lastEmittedModes.sgrMouseMode || + scope.sgrMousePixelsMode !== scope.lastEmittedModes.sgrMousePixelsMode + ) { + scope.lastEmittedModes = { + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: scope.sgrMouseMode, + sgrMousePixelsMode: scope.sgrMousePixelsMode + } + notify({ + type: 'modes', + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: scope.sgrMouseMode, + sgrMousePixelsMode: scope.sgrMousePixelsMode + }) + } +} +scope.lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false +} diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts new file mode 100644 index 00000000000..aed11e99f5f --- /dev/null +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -0,0 +1,283 @@ +import { handleDragMove, repositionOverlay, stopEdgeScroll } from './selection-overlay' +import { applyXtermSelection, cancelSelect } from './selection-range' +import { notify } from './host-notify' +import { getMouseTrackingMode, isSafeSgrMouseCoordinate } from './mouse-input-encoding' +import { viewportToCell } from './viewport-cell' +import { scope } from './document-scope' +import { notifyTerminalSurfaceTap } from './surface-tap' +import { viewportToMouseReportCell } from './mouse-report-cell' +import { dispatcherShouldBlockSurface } from './tap-dispatch' + +/** A mouse press being tracked from pointerdown to pointerup. */ +export type TerminalMouseGesture = { + startX: number + startY: number + lastX: number + lastY: number + lastCellKey: string | null + moved: boolean + mode: string + dismissedSelection: boolean +} + +let mouseGesture: TerminalMouseGesture | null = null + +// One report per transition, built with the same encoding ladder as +// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' +// when the mode does not report this transition (x10 has no release, only +// drag/any report motion) or the cell is not encodable. +export function buildMouseButtonReport(kind: string, clientX: number, clientY: number) { + const mouseTrackingMode = getMouseTrackingMode() + if (mouseTrackingMode === 'none') { + return '' + } + if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') { + return '' + } + if (kind === 'release' && mouseTrackingMode === 'x10') { + return '' + } + const cell = viewportToMouseReportCell(clientX, clientY) + if (!cell) { + return '' + } + const sgrButton = kind === 'motion' ? 32 : 0 + const sgrFinal = kind === 'release' ? 'm' : 'M' + if (scope.sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) { + return '' + } + return scope.ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal + } + if (scope.sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + const sgrCol = cell.col + 1 + const sgrRow = cell.row + 1 + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) { + return '' + } + return scope.ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal + } + const button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32 + const col = cell.col + 1 + 32 + const row = cell.row + 1 + 32 + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path; drop instead of corrupting input. + if (col > 126 || row > 126) { + return '' + } + return ( + scope.ESC + + '[M' + + String.fromCharCode(button) + + String.fromCharCode(col) + + String.fromCharCode(row) + ) +} + +export function mouseReportCellKey(clientX: number, clientY: number) { + const cell = viewportToMouseReportCell(clientX, clientY) + return cell ? cell.col + ',' + cell.row : null +} + +export function abandonMouseGesture() { + const gesture = mouseGesture + mouseGesture = null + if (!gesture) { + return + } + if (gesture.mode === 'tracking') { + // Why: the press report already went to the TUI; a lost pointer must not + // leave the button latched down on the far side. + const release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY) + if (release) { + notify({ type: 'terminal-input', bytes: release }) + } + } else if (gesture.mode === 'selecting') { + if (scope.sel) { + scope.sel.activeHandle = null + } + stopEdgeScroll() + } +} + +export function beginMouseDrag(gesture: TerminalMouseGesture) { + gesture.moved = true + if (getMouseTrackingMode() !== 'none') { + gesture.mode = 'tracking' + gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY) + const press = buildMouseButtonReport('press', gesture.startX, gesture.startY) + if (press) { + notify({ type: 'terminal-input', bytes: press }) + } + return + } + const anchor = viewportToCell(gesture.startX, gesture.startY) + if (!anchor) { + gesture.mode = 'cancelled' + return + } + // Why: mouse drags select character-anchored ranges like desktop terminals, + // not the word-seeded long-press selection; reuse the touch handle-drag + // plumbing (edge scroll included) by acting as a live 'end' handle. + gesture.mode = 'selecting' + scope.selMode = 'select' + scope.sel = { anchor: anchor, focus: anchor, activeHandle: 'end' } + scope.selectionOverlay!.classList.add('active') + notify({ type: 'set-select-mode', enabled: true }) + applyXtermSelection() + repositionOverlay() +} + +export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { + targetSurface.addEventListener( + 'pointerdown', + function (e) { + if (e.pointerType !== 'mouse' || e.button !== 0) { + return + } + if (dispatcherShouldBlockSurface() || !scope.term) { + return + } + // Why: a pointerup lost outside the WebView must not leave the previous + // gesture latched (tracking press with no release) when the next one lands. + if (mouseGesture) { + abandonMouseGesture() + } + // Why: mouse pointers have no implicit capture; without it a drag that + // leaves the surface drops pointermove/pointerup and strands the gesture. + try { + if (targetSurface.setPointerCapture) { + targetSurface.setPointerCapture(e.pointerId) + } + } catch {} + mouseGesture = { + startX: e.clientX, + startY: e.clientY, + lastX: e.clientX, + lastY: e.clientY, + lastCellKey: null, + moved: false, + mode: 'pending', + dismissedSelection: false + } + if (scope.selMode === 'select') { + // Why: touch parity — pressing outside the pill dismisses the current + // selection; the same press may still start a new drag selection. + cancelSelect() + mouseGesture.dismissedSelection = true + } + }, + true + ) + + targetSurface.addEventListener( + 'pointermove', + function (e) { + const gesture = mouseGesture + if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') { + return + } + if (!scope.term) { + return + } + gesture.lastX = e.clientX + gesture.lastY = e.clientY + if ((e.buttons & 1) === 0) { + // Why: a pointerup lost outside the WebView (capture unavailable) must + // end the gesture here, or a tracked press stays latched at the TUI. + // Coordinates first, so the synthesized release lands where the + // pointer re-entered rather than at the previous cell. + abandonMouseGesture() + return + } + if (!gesture.moved) { + const dx = Math.abs(e.clientX - gesture.startX) + const dy = Math.abs(e.clientY - gesture.startY) + if (dx + dy <= scope.TAP_SLOP) { + return + } + beginMouseDrag(gesture) + } + if (gesture.mode === 'tracking') { + // Why: one motion report per cell keeps drags bounded by grid size, not + // by pointermove cadence, so the RN rate limiter is never the bottleneck. + const cellKey = mouseReportCellKey(e.clientX, e.clientY) + if (cellKey && cellKey !== gesture.lastCellKey) { + gesture.lastCellKey = cellKey + const motion = buildMouseButtonReport('motion', e.clientX, e.clientY) + if (motion) { + notify({ type: 'terminal-input', bytes: motion }) + } + } + } else if (gesture.mode === 'selecting') { + handleDragMove('end', e.clientX, e.clientY) + } + }, + true + ) + + targetSurface.addEventListener( + 'pointerup', + function (e) { + const gesture = mouseGesture + if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) { + return + } + mouseGesture = null + if (gesture.mode === 'cancelled' || !scope.term) { + return + } + if (gesture.mode === 'tracking') { + const release = buildMouseButtonReport('release', e.clientX, e.clientY) + if (release) { + notify({ type: 'terminal-input', bytes: release }) + } + return + } + if (gesture.mode === 'selecting') { + if (scope.sel) { + scope.sel.activeHandle = null + } + stopEdgeScroll() + repositionOverlay() + return + } + if (dispatcherShouldBlockSurface()) { + return + } + // Why: a dismissing tap only clears the selection (touch parity); it must + // not also open a link or focus the keyboard underneath. + if (gesture.dismissedSelection) { + return + } + // Pointer clicks keep their current link, file, TUI mouse, and focus priority. + notifyTerminalSurfaceTap(e.clientX, e.clientY, false) + }, + true + ) + + targetSurface.addEventListener( + 'pointercancel', + function (e) { + if (e.pointerType !== 'mouse') { + return + } + abandonMouseGesture() + }, + true + ) + + // Why: Android input injection can pair a mouse-flavored pointerdown with + // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, + // the document touch dispatcher owns the gesture. + targetSurface.addEventListener( + 'touchstart', + function () { + if (mouseGesture) { + abandonMouseGesture() + } + }, + true + ) +} diff --git a/mobile/src/terminal/document/mouse-input-encoding.ts b/mobile/src/terminal/document/mouse-input-encoding.ts new file mode 100644 index 00000000000..1ea8360cca3 --- /dev/null +++ b/mobile/src/terminal/document/mouse-input-encoding.ts @@ -0,0 +1,230 @@ +import { notify } from './host-notify' +import { scope } from './document-scope' +import { viewportToMouseReportCell } from './mouse-report-cell' + +export function isAlternateBufferActive() { + try { + return !!( + scope.term && + scope.term.buffer && + scope.term.buffer.active && + scope.term.buffer.active.type === 'alternate' + ) + } catch { + return false + } +} + +export function getMouseTrackingMode() { + try { + if (scope.term && scope.term.modes && typeof scope.term.modes.mouseTrackingMode === 'string') { + const mode = scope.term.modes.mouseTrackingMode + if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') { + return mode + } + return 'none' + } + } catch {} + if ( + scope.trackedMouseTrackingMode === 'x10' || + scope.trackedMouseTrackingMode === 'vt200' || + scope.trackedMouseTrackingMode === 'drag' || + scope.trackedMouseTrackingMode === 'any' + ) { + return scope.trackedMouseTrackingMode + } + return 'none' +} + +export function repeatSequence(sequence: string, count: number) { + let out = '' + for (let i = 0; i < count; i++) { + out += sequence + } + return out +} + +export function buildArrowScrollSequence(lines: number) { + let prefix = '[' + try { + if (scope.term && scope.term.modes && scope.term.modes.applicationCursorKeysMode) { + prefix = 'O' + } + } catch {} + return scope.ESC + prefix + (lines < 0 ? 'A' : 'B') +} + +export function buildMouseWheelSequence(lines: number, clientX: number, clientY: number) { + const cell = viewportToMouseReportCell(clientX, clientY) + if (!cell) { + return '' + } + const eventCode = lines < 0 ? 64 : 65 + if (scope.sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) { + return '' + } + return scope.ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M' + } + if (scope.sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + const sgrCol = cell.col + 1 + const sgrRow = cell.row + 1 + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) { + return '' + } + return scope.ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M' + } + // Why: xterm increments zero-based mouse cells before encoding reports. + const button = eventCode + 32 + const col = cell.col + 1 + 32 + const row = cell.row + 1 + 32 + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path. Fall back to keys for wide terminals. + if (button > 126 || col > 126 || row > 126) { + return '' + } + return ( + scope.ESC + + '[M' + + String.fromCharCode(button) + + String.fromCharCode(col) + + String.fromCharCode(row) + ) +} + +export function isSafeSgrMouseCoordinate(value: number) { + return Number.isInteger(value) && value >= 0 && value <= 9999 +} + +export function buildMouseClickInput(clientX: number, clientY: number) { + const mouseTrackingMode = getMouseTrackingMode() + if (!isClickMouseTrackingMode(mouseTrackingMode)) { + return '' + } + const cell = viewportToMouseReportCell(clientX, clientY) + if (!cell) { + return '' + } + if (scope.sgrMousePixelsMode) { + // Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions. + const pixelX = cell.x + const pixelY = cell.y + if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) { + return '' + } + const pixelPress = scope.ESC + '[<0;' + pixelX + ';' + pixelY + 'M' + if (mouseTrackingMode === 'x10') { + return pixelPress + } + return pixelPress + scope.ESC + '[<0;' + pixelX + ';' + pixelY + 'm' + } + if (scope.sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + const sgrCol = cell.col + 1 + const sgrRow = cell.row + 1 + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) { + return '' + } + const sgrPress = scope.ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M' + if (mouseTrackingMode === 'x10') { + return sgrPress + } + return sgrPress + scope.ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm' + } + // Why: non-SGR click coordinates use printable ASCII bytes on the mobile + // bridge; unsafe wide-terminal cells must not turn into corrupted input. + const col = cell.col + 1 + 32 + const row = cell.row + 1 + 32 + if (col > 126 || row > 126) { + return '' + } + const press = + scope.ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row) + if (mouseTrackingMode === 'x10') { + return press + } + return ( + press + + scope.ESC + + '[M' + + String.fromCharCode(35) + + String.fromCharCode(col) + + String.fromCharCode(row) + ) +} + +export function isClickMouseTrackingMode(mode: string) { + return mode !== 'none' +} + +export function isWheelMouseTrackingMode(mode: string) { + return mode !== 'none' && mode !== 'x10' +} + +export function shouldRouteScrollToTerminalInput() { + return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive() +} + +export function buildMouseWheelScrollInput(lines: number, clientX: number, clientY: number) { + const count = Math.min(Math.abs(lines), 32) + if (count === 0) { + return '' + } + const sequence = buildMouseWheelSequence(lines, clientX, clientY) + if (!sequence) { + return '' + } + return repeatSequence(sequence, count) +} + +export function buildTuiScrollInput(lines: number, clientX: number, clientY: number) { + const count = Math.min(Math.abs(lines), 32) + if (count === 0) { + return '' + } + const mouseTrackingMode = getMouseTrackingMode() + let sequence = '' + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + sequence = buildMouseWheelSequence(lines, clientX, clientY) + } + if (!sequence) { + sequence = buildArrowScrollSequence(lines) + } + return repeatSequence(sequence, count) +} + +export function routeScrollLines(lines: number, clientX: number, clientY: number) { + if (!scope.term || lines === 0) { + return + } + const mouseTrackingMode = getMouseTrackingMode() + const alternateBufferActive = isAlternateBufferActive() + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + // Why: xterm sends wheel events to mouse-aware TUIs before considering + // scrollback, even if the app stays on the normal buffer. + const mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY) + if (mouseInput) { + notify({ type: 'terminal-input', bytes: mouseInput }) + return + } + // Why: default mouse encoding can be unrepresentable in our ASCII-safe + // RPC path on wide terminals. Send bounded arrows instead of local + // scrollback/no-op while a mouse-aware app owns scroll gestures. + const fallbackInput = buildTuiScrollInput(lines, clientX, clientY) + if (fallbackInput) { + notify({ type: 'terminal-input', bytes: fallbackInput }) + } + return + } + if (alternateBufferActive) { + // Why: alternate-screen TUIs own their scroll state and xterm has no + // scrollback there, so mobile scroll gestures must become terminal input. + const input = buildTuiScrollInput(lines, clientX, clientY) + if (input) { + notify({ type: 'terminal-input', bytes: input }) + } + return + } + scope.term.scrollLines(lines) +} diff --git a/mobile/src/terminal/document/mouse-mode-decset-scan.ts b/mobile/src/terminal/document/mouse-mode-decset-scan.ts new file mode 100644 index 00000000000..f47370ab7e9 --- /dev/null +++ b/mobile/src/terminal/document/mouse-mode-decset-scan.ts @@ -0,0 +1,74 @@ +import { extractMouseModeScanTail } from './write-queue' +import { scope } from './document-scope' + +export function isAltScreenActive(data: unknown): data is string { + if (typeof data !== 'string') { + return false + } + const on = data.lastIndexOf(scope.ESC + '[?1049h') + const off = data.lastIndexOf(scope.ESC + '[?1049l') + return on !== -1 && on > off +} + +export function normalizeInitialData(data: unknown) { + if (!isAltScreenActive(data)) { + return data + } + const on = data.lastIndexOf(scope.ESC + '[?1049h') + // Why: SerializeAddon can include normal-buffer scrollback before the + // active alternate-screen snapshot. Replaying both into a fresh mobile + // xterm duplicates TUI frames and can flatten SGR attributes. + return on > 0 ? data.slice(on) : data +} + +export function updateMouseModeFromData(data: unknown) { + if (typeof data !== 'string' || data.length === 0) { + return + } + const input = scope.mouseModeScanTail + data + scope.mouseModeScanTail = extractMouseModeScanTail(input) + const re = new RegExp( + scope.ESC + 'c|' + scope.ESC + '\\[\\?([0-9;]+)([hl])|' + scope.C1_CSI + '\\?([0-9;]+)([hl])', + 'g' + ) + let match: RegExpExecArray | null + while ((match = re.exec(input)) !== null) { + if (match[0] === scope.ESC + 'c') { + scope.trackedMouseTrackingMode = 'none' + scope.sgrMouseMode = false + scope.sgrMousePixelsMode = false + continue + } + const enabled = (match[2] || match[4]) === 'h' + const params = (match[1] || match[3]).split(';') + for (let i = 0; i < params.length; i++) { + if (params[i] === '') { + continue + } + const param = Number(params[i]) + if (!Number.isInteger(param)) { + continue + } + if (param === 9) { + scope.trackedMouseTrackingMode = enabled ? 'x10' : 'none' + } + if (param === 1000) { + scope.trackedMouseTrackingMode = enabled ? 'vt200' : 'none' + } + if (param === 1002) { + scope.trackedMouseTrackingMode = enabled ? 'drag' : 'none' + } + if (param === 1003) { + scope.trackedMouseTrackingMode = enabled ? 'any' : 'none' + } + if (param === 1006) { + scope.sgrMouseMode = enabled + scope.sgrMousePixelsMode = false + } + if (param === 1016) { + scope.sgrMouseMode = false + scope.sgrMousePixelsMode = enabled + } + } + } +} diff --git a/mobile/src/terminal/document/mouse-report-cell.ts b/mobile/src/terminal/document/mouse-report-cell.ts new file mode 100644 index 00000000000..ca4bbaec3ed --- /dev/null +++ b/mobile/src/terminal/document/mouse-report-cell.ts @@ -0,0 +1,67 @@ +import { getCellHeight } from './fit-scale' +import { getCellWidth, getTotalScale } from './viewport-transform' +import { scope } from './document-scope' + +/** Where a viewport point lands in the terminal's cell grid, for an xterm mouse report. */ +export type MouseReportCell = { col: number; row: number; x: number; y: number } + +/** + * Maps a viewport point to a mouse-report cell, or null when there is no grid to map onto. + * + * Reads through the pan offset and the total scale rather than the element's box: the surface is a + * transformed layer, so its on-screen geometry is not the geometry xterm reports in. + */ +export function viewportToMouseReportCell( + clientX: number, + clientY: number +): MouseReportCell | null { + if (!scope.term) { + return null + } + const cellW = getCellWidth() + const cellH = getCellHeight() + if (cellW <= 0 || cellH <= 0) { + return null + } + if (typeof clientX !== 'number') { + clientX = window.innerWidth / 2 + } + if (typeof clientY !== 'number') { + clientY = window.innerHeight / 2 + } + let total = getTotalScale() + if (total <= 0) { + total = 1 + } + let sx = (clientX - scope.panX) / total + let sy = (clientY - scope.panY) / total + const maxX = Math.max(0, scope.term.cols * cellW - 1) + const maxY = Math.max(0, scope.term.rows * cellH - 1) + if (sx < 0) { + sx = 0 + } + if (sx > maxX) { + sx = maxX + } + if (sy < 0) { + sy = 0 + } + if (sy > maxY) { + sy = maxY + } + let col = Math.floor(sx / cellW) + let row = Math.floor(sy / cellH) + if (col < 0) { + col = 0 + } + if (col > scope.term.cols - 1) { + col = scope.term.cols - 1 + } + if (row < 0) { + row = 0 + } + if (row > scope.term.rows - 1) { + row = scope.term.rows - 1 + } + return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) } +} diff --git a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts new file mode 100644 index 00000000000..e1ee6dd63e3 --- /dev/null +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -0,0 +1,102 @@ +import { getCellHeight } from './fit-scale' +import { getTotalScale, updateScrollIndicator } from './viewport-transform' +import { scope } from './document-scope' + +export function clampNormalScrollLines(lines: number) { + if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) { + return 0 + } + const buffer = scope.term.buffer.active + if (lines > 0) { + return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY)) + } + return Math.max(lines, -buffer.viewportY) +} + +export function canScrollNormalBufferDelta(deltaY: number) { + if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || deltaY === 0) { + return false + } + const buffer = scope.term.buffer.active + if (deltaY > 0) { + return buffer.viewportY < buffer.baseY + } + return buffer.viewportY > 0 +} + +export function applyNormalBufferScrollDelta(deltaY: number) { + if (!scope.term || deltaY === 0) { + return false + } + const effectiveCellH = getCellHeight() * getTotalScale() + if (effectiveCellH <= 0) { + return false + } + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset() + return false + } + scope.smoothScrollOffsetY -= deltaY + const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH) + if (lines !== 0) { + const applied = clampNormalScrollLines(lines) + if (applied !== 0) { + scope.term.scrollLines(applied) + // Why: xterm's renderer is row-based. Buffer touch pixels and only + // commit whole rows so TUI canvas layers do not shimmer between + // fractional transforms and xterm repaints. + scope.smoothScrollOffsetY += applied * effectiveCellH + } + if (applied !== lines) { + scope.smoothScrollOffsetY = 0 + } + } + const limit = effectiveCellH - 1 + if (scope.smoothScrollOffsetY > limit) { + scope.smoothScrollOffsetY = limit + } + if (scope.smoothScrollOffsetY < -limit) { + scope.smoothScrollOffsetY = -limit + } + updateScrollIndicator(true) + return true +} + +export function enqueueNormalBufferScrollDelta(deltaY: number) { + if (!scope.term || deltaY === 0) { + return false + } + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset() + return false + } + scope.pendingNormalScrollDeltaY += deltaY + if (scope.normalScrollFrameId !== null) { + return true + } + // Why: dense terminal rows are expensive to repaint. Coalesce touchmove + // deltas into one xterm row-scroll per frame instead of repainting from + // the input event stream. + scope.normalScrollFrameId = requestAnimationFrame(function () { + scope.normalScrollFrameId = null + const delta = scope.pendingNormalScrollDeltaY + scope.pendingNormalScrollDeltaY = 0 + if (!applyNormalBufferScrollDelta(delta)) { + resetSmoothScrollOffset() + } + }) + return true +} + +export function resetSmoothScrollOffset() { + scope.pendingNormalScrollDeltaY = 0 + if (scope.normalScrollFrameId !== null) { + cancelAnimationFrame(scope.normalScrollFrameId) + scope.normalScrollFrameId = null + } + if (scope.smoothScrollOffsetY === 0) { + return + } + scope.smoothScrollOffsetY = 0 + updateScrollIndicator(false) +} diff --git a/mobile/src/terminal/document/osc-link-tap.ts b/mobile/src/terminal/document/osc-link-tap.ts new file mode 100644 index 00000000000..762c4596459 --- /dev/null +++ b/mobile/src/terminal/document/osc-link-tap.ts @@ -0,0 +1,221 @@ +import { cellColToStringIndex, getLineText } from './cell-geometry' +import { viewportToCell } from './viewport-cell' +import { + scope, + type TerminalDocumentLine, + type TerminalInitialOscLink, + type TerminalOscLinkService +} from './document-scope' +import { parsePathLineCol, type TerminalPathCandidate } from './path-tap' + +/** What a tapped OSC 8 link resolves to: a URL to open, or a file to reveal. */ +export type TerminalOscLinkTarget = + | { kind: 'url'; url: string } + | { kind: 'file'; fileTap: TerminalPathCandidate } + +// Why: OSC 8 links can render as labels like "#1234"; the URI lives in +// xterm's internal link service, so every access is guarded and falls through. +export function oscLinkService(): TerminalOscLinkService | null { + try { + const core = scope.term && scope.term._core + if (!core) { + return null + } + return ( + core._oscLinkService || (core._inputHandler && core._inputHandler._oscLinkService) || null + ) + } catch { + return null + } +} + +export function oscLinkAtViewportPoint(clientX: number, clientY: number) { + try { + const cell = viewportToCell(clientX, clientY) + if (!cell) { + return null + } + const line = scope.term!.buffer.active.getLine(cell.row) + if (!line) { + return null + } + const urlId = oscLinkIdAtCell(line, cell.col) + if (!urlId) { + return initialOscLinkAtCell(cell.row, cell.col) + } + const svc = oscLinkService() + if (!svc || !svc.getLinkData) { + return initialOscLinkAtCell(cell.row, cell.col) + } + const data = svc.getLinkData(urlId) + const uri = data && data.uri + return terminalOscLinkTarget(uri) + } catch { + return null + } +} + +export function initialOscLinkAtCell(row: number, col: number) { + for (let i = 0; i < scope.initialOscLinks.length; i++) { + const link = scope.initialOscLinks[i] + if (!link || typeof link.uri !== 'string') { + continue + } + if (link.row < scope.initialOscLinkRowOffset) { + continue + } + const shiftedRow = link.row - scope.initialOscLinkRowOffset + if ( + shiftedRow === row && + col >= link.startCol && + col < link.endCol && + initialOscLinkTextStillMatches(link, shiftedRow) + ) { + return terminalOscLinkTarget(link.uri) + } + } + return null +} + +export function terminalOscLinkTarget(uri: unknown): TerminalOscLinkTarget | null { + if (typeof uri !== 'string') { + return null + } + if (/^https?:/i.test(uri)) { + return { kind: 'url', url: uri } + } + const fileTap = resolveTerminalOscFileTap(uri) + return fileTap ? { kind: 'file', fileTap: fileTap } : null +} + +export function resolveTerminalOscFileTap(uri: string) { + return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri) +} + +export function resolveTerminalFileUrlTap(uri: string): TerminalPathCandidate | null { + let parsed: URL + try { + parsed = new URL(uri) + } catch { + return null + } + if (parsed.protocol !== 'file:') { + return null + } + let filePath: string + try { + filePath = decodeURIComponent(parsed.pathname || '') + } catch { + return null + } + if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) { + filePath = '//' + parsed.hostname + filePath + } else if (/^\/[A-Za-z]:\//.test(filePath)) { + filePath = filePath.slice(1) + } + if (!filePath) { + return null + } + const hashTarget = parseFileUrlLineHash(parsed.hash || '') + if (hashTarget) { + return { pathText: filePath, line: hashTarget.line, column: hashTarget.column } + } + if (/%3a/i.test(parsed.pathname || '')) { + return { pathText: filePath, line: null, column: null } + } + return ( + parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null } + ) +} + +export function isLocalFileUriHostname(hostname: string) { + const normalized = String(hostname).toLowerCase() + return ( + normalized === 'localhost' || + normalized === '127.0.0.1' || + normalized === '::1' || + normalized === '[::1]' + ) +} + +export function parseOscPathLikeTarget(value: string) { + if ( + !/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test( + value + ) + ) { + return null + } + return parsePathLineCol(value) +} + +export function parseFileUrlLineHash(hash: string) { + const match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash) + if (!match) { + return null + } + const line = Number.parseInt(match[1], 10) + const column = match[2] ? Number.parseInt(match[2], 10) : null + if (line < 1 || (column !== null && column < 1)) { + return null + } + return { line: line, column: column } +} + +export function parseFilePathTrailingLineTarget(filePath: string) { + const match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath) + if ( + !match || + !match[1] || + match[1].charAt(match[1].length - 1) === '/' || + match[1].charAt(match[1].length - 1) === '\\' + ) { + return null + } + const line = Number.parseInt(match[2], 10) + const column = match[3] ? Number.parseInt(match[3], 10) : null + if (line < 1 || (column !== null && column < 1)) { + return null + } + return { pathText: match[1], line: line, column: column } +} + +export function captureInitialOscLinkTexts() { + if (!Array.isArray(scope.initialOscLinks)) { + return + } + for (let i = 0; i < scope.initialOscLinks.length; i++) { + const link = scope.initialOscLinks[i] + if (!link || typeof link.text === 'string') { + continue + } + link.text = initialOscLinkTextAtRow(link, link.row) + } +} + +export function initialOscLinkTextStillMatches(link: TerminalInitialOscLink, row: number) { + if (typeof link.text !== 'string') { + return false + } + return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text +} + +export function initialOscLinkTextAtRow(link: TerminalInitialOscLink, row: number) { + try { + const lineText = getLineText(row) + const start = cellColToStringIndex(row, link.startCol) + const end = cellColToStringIndex(row, link.endCol) + return lineText.slice(start, end) + } catch { + return '' + } +} + +export function oscLinkIdAtCell(line: TerminalDocumentLine, col: number) { + try { + const bufCell = line.getCell!(col) + return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0 + } catch { + return 0 + } +} diff --git a/mobile/src/terminal/document/path-tap.ts b/mobile/src/terminal/document/path-tap.ts new file mode 100644 index 00000000000..42b0f25b839 --- /dev/null +++ b/mobile/src/terminal/document/path-tap.ts @@ -0,0 +1,222 @@ +import { cellColToStringIndex, getLineText } from './cell-geometry' +import { viewportToCell } from './viewport-cell' + +/** + * File-path-under-tap detection. + * + * Mirrors the unit-tested `terminal-path-tap.ts`; keep the two in sync. That module is the source + * of truth for the algorithm and has the regression tests. + * + * Matches both slash-bearing paths AND bare filenames with an extension (README.md, + * src/index.ts:5) — like desktop, we propose candidates and let the host's + * files.resolveTerminalPath existence check reject non-files. Agents often print a bare filename + * (the markdown link target is consumed, leaving only the label text), so requiring a slash would + * miss the common case. + */ + +/** A span of a rendered line, in string indices. */ +export type TerminalPathRange = { text: string; startIndex: number; endIndex: number } + +/** A path proposed to the host, with the line and column suffixes it carried. */ +export type TerminalPathCandidate = { + pathText: string + line: number | null + column: number | null +} + +const FILE_PATH_RE = + // oxlint-disable-next-line no-useless-escape -- the document's text is pinned token for token; rewriting this changes the native program + /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g +const SPACED_PATH_RE = + // oxlint-disable-next-line no-useless-escape -- the document's text is pinned token for token; rewriting this changes the native program + /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g +const PATH_LEADING_TRIM: Record = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 } +const PATH_TRAILING_TRIM: Record = { + ')': 1, + ']': 1, + '}': 1, + '"': 1, + "'": 1, + ',': 1, + ';': 1, + '.': 1 +} + +export function parsePathLineCol(value: string): TerminalPathCandidate | null { + const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value) + if (!m) { + return null + } + const pathText = m[1] + const last = pathText.charAt(pathText.length - 1) + if (!pathText || last === '/' || last === '\\') { + return null + } + const line = m[2] ? Number.parseInt(m[2], 10) : null + const column = m[3] ? Number.parseInt(m[3], 10) : null + if ((line !== null && line < 1) || (column !== null && column < 1)) { + return null + } + return { pathText: pathText, line: line, column: column } +} + +export function trimPathBoundaryPunctuation( + raw: string, + rawStart: number +): TerminalPathRange | null { + let start = 0, + end = raw.length + while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) { + start += 1 + } + while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) { + end -= 1 + } + if (start >= end) { + return null + } + return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end } +} + +export function hasSeparatorAfterWhitespace(text: string) { + let sawWhitespace = false + for (let i = 0; i < text.length; i++) { + const ch = text.charAt(i) + if (/\s/.test(ch)) { + sawWhitespace = true + continue + } + if (sawWhitespace && (ch === '/' || ch === '\\')) { + return true + } + } + return false +} + +export function trimSpacedPathTrailingProse( + range: TerminalPathRange, + col?: number +): TerminalPathRange | null { + // A line-end extension token only extends the span when the added segment + // is path-like (contains a separator) — prose must not be swallowed. + let selected: string | null = null + const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g + let match: RegExpExecArray | null + while ((match = extensionPrefixPattern.exec(range.text)) !== null) { + const end = match.index + match[0].length + // Why `var`: the document declares this name twice in one function, which is one binding; two + // block-scoped declarations would be a different program and esbuild renames the inner one. + var text = range.text.slice(0, end) + if (countPathStarts(text) > 1) { + continue + } + if ( + end < range.text.length || + selected === null || + /[\\/]/.test(range.text.slice(selected.length, end)) + ) { + selected = text + } + } + if (selected) { + if (col !== undefined && col >= range.startIndex + selected.length) { + return null + } + return { + text: selected, + startIndex: range.startIndex, + endIndex: range.startIndex + selected.length + } + } + var text = range.text.replace(/\s+$/, '') + return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length } +} + +export function countPathStarts(text: string) { + let count = 0 + const pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g + while (pathStartPattern.exec(text) !== null) { + count += 1 + } + return count +} + +export function hasSpacedPathExtension(text: string) { + const range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length }) + if (!range) { + return false + } + const trimmed = range.text.replace(/\s+$/, '') + return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed) +} + +export function matchSpacedFilePathAtColumn(lineText: string, col: number) { + SPACED_PATH_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = SPACED_PATH_RE.exec(lineText)) !== null) { + const trimmed = trimPathBoundaryPunctuation(match[0], match.index) + if ( + !trimmed || + (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text)) + ) { + continue + } + const candidate = trimSpacedPathTrailingProse(trimmed, col) + if (!candidate) { + continue + } + if (col < candidate.startIndex || col >= candidate.endIndex) { + continue + } + const parsed = parsePathLineCol(candidate.text) + if (parsed) { + return parsed + } + } + return null +} + +export function matchFilePathAtColumn(lineText: string, col: number) { + const spaced = matchSpacedFilePathAtColumn(lineText, col) + if (spaced) { + return spaced + } + FILE_PATH_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = FILE_PATH_RE.exec(lineText)) !== null) { + const raw = match[0] + if (raw.length === 0) { + FILE_PATH_RE.lastIndex += 1 + continue + } + const trimmed = trimPathBoundaryPunctuation(raw, match.index) + if (!trimmed) { + continue + } + if (col < trimmed.startIndex || col >= trimmed.endIndex) { + continue + } + const parsed = parsePathLineCol(trimmed.text) + if (parsed) { + return parsed + } + } + return null +} + +// Returns the path candidate under the tap, or null. Query-only so the tap +// handler can try file detection before forwarding a mouse click — which lets +// file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/ +// getLineText from the host script scope. +export function filePathAtViewportPoint(originX: number, originY: number) { + const tapCell = viewportToCell(originX, originY) + if (!tapCell) { + return null + } + // Map the cell column to a string index so wide chars (emoji/CJK) earlier on + // the line don't shift the match column off the tapped path. + return matchFilePathAtColumn( + getLineText(tapCell.row), + cellColToStringIndex(tapCell.row, tapCell.col) + ) +} diff --git a/mobile/src/terminal/document/query-reply.ts b/mobile/src/terminal/document/query-reply.ts new file mode 100644 index 00000000000..70509735b2c --- /dev/null +++ b/mobile/src/terminal/document/query-reply.ts @@ -0,0 +1,70 @@ +import { enqueueWriteBoundary } from './write-queue' +import { notify } from './host-notify' +import { scope, type TerminalDocumentDisposable } from './document-scope' + +/** + * The gate deciding when xterm's parser replies may reach the native host. + * + * One unit so the tests exercise the same replay and generation gate the document runs rather than + * a re-implementation of it — which was already the reason this was one injected string. + */ +export type QueryReplyTerminal = { + attachCustomKeyEventHandler: (handler: () => boolean) => void + textarea?: { + readOnly: boolean + tabIndex: number + setAttribute: (name: string, value: string) => void + } + onData: (listener: (data: string) => void) => TerminalDocumentDisposable +} + +// Written from four places, all of them here, so it is this module's state rather than the +// document's and stays a local. +let terminalDataRepliesEnabled = false + +export function resetTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = false +} + +export function resumeTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = true +} + +export function forwardTerminalDataReply(data: string) { + if (terminalDataRepliesEnabled) { + notify({ type: 'terminal-data', bytes: data }) + } +} + +export function enqueueTerminalDataReplyBoundary(gen: number) { + enqueueWriteBoundary(function () { + if (gen === scope.terminalGeneration) { + terminalDataRepliesEnabled = true + } + }) +} + +export function attachTerminalQueryReplyBridge(term: QueryReplyTerminal, gen: number) { + // Why: parser replies require stdin enabled, but mobile input is owned by + // native controls. Keep xterm's textarea inert for touch/hardware keys. + try { + term.attachCustomKeyEventHandler(function () { + return false + }) + if (term.textarea) { + term.textarea.readOnly = true + term.textarea.tabIndex = -1 + term.textarea.setAttribute('inputmode', 'none') + } + } catch {} + try { + scope.termObserverDisposables.push( + term.onData(function (data) { + forwardTerminalDataReply(data) + }) + ) + } catch {} + // Why: live output can queue before initial replay finishes. Enable replies + // at the replay boundary so those live queries are answered, never replayed ones. + enqueueTerminalDataReplyBoundary(gen) +} diff --git a/mobile/src/terminal/document/reflow.ts b/mobile/src/terminal/document/reflow.ts new file mode 100644 index 00000000000..d4f031557e3 --- /dev/null +++ b/mobile/src/terminal/document/reflow.ts @@ -0,0 +1,37 @@ +import { applyFitScale } from './fit-scale' +import { isAlternateBufferActive } from './mouse-input-encoding' +import { updateScrollIndicator } from './viewport-transform' +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' +import { scope } from './document-scope' + +// Why: rewrap the local xterm buffer (scrollback included) to a new width +// after a server PTY reflow. Skip the alternate screen: those snapshots are +// fully repainted by the PTY and a local resize there can drop SGR attributes +// (see init's alt-screen handling), which shows as white text. +export function reflow(cols: number, rows: number) { + if (!scope.term || isAlternateBufferActive()) { + return + } + const nextCols = cols || scope.term.cols + const nextRows = rows || scope.term.rows + if (nextCols === scope.term.cols && nextRows === scope.term.rows) { + return + } + const buffer = scope.term.buffer.active + // Why: anchor reflow on whether the user was pinned to the live bottom so + // their scroll position survives the rewrap — if they were scrolled up, + // hold the same distance from the bottom; if at the bottom, stay there. + const wasAtBottom = buffer.viewportY >= buffer.baseY + const distanceFromBottom = buffer.baseY - buffer.viewportY + scope.initRows = nextRows + scope.term.resize(nextCols, nextRows) + const rewrapped = scope.term.buffer.active + if (wasAtBottom) { + scope.term.scrollToBottom() + } else { + scope.term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY) + } + applyFitScale('reflow-msg') + updateScrollIndicator(false) + emitKeyboardAvoidanceMetrics() +} diff --git a/mobile/src/terminal/document/runtime-constants.ts b/mobile/src/terminal/document/runtime-constants.ts new file mode 100644 index 00000000000..a283c629ff8 --- /dev/null +++ b/mobile/src/terminal/document/runtime-constants.ts @@ -0,0 +1,23 @@ +import { scope } from './document-scope' + +/** + * The first declarations inside the document's IIFE. + * + * All eight are read by other parts of the script, so all eight are scope fields; the document + * shell opens the function they live in and `document-close.ts` closes it. + */ +scope.surface = document.getElementById('terminal-surface') +scope.ESC = String.fromCharCode(27) +scope.C1_CSI = String.fromCharCode(155) +scope.CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa) +scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) +scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) +scope.CLAUDE_STATUS_DOT_PATTERN = new RegExp( + scope.CLAUDE_STATUS_DOT + + '[' + + scope.TEXT_PRESENTATION_SELECTOR + + scope.EMOJI_PRESENTATION_SELECTOR + + ']*', + 'g' +) +scope.statusDotPendingSelector = false diff --git a/mobile/src/terminal/document/selection-menu-buttons.ts b/mobile/src/terminal/document/selection-menu-buttons.ts new file mode 100644 index 00000000000..9601cdb3bdf --- /dev/null +++ b/mobile/src/terminal/document/selection-menu-buttons.ts @@ -0,0 +1,36 @@ +import { scope } from './document-scope' +import { notify } from './host-notify' +import { cancelSelect } from './selection-range' +import { repositionOverlay } from './selection-overlay' + +scope.btnCopy!.addEventListener('click', function (e) { + e.preventDefault() + e.stopPropagation() + if (!scope.term) { + return + } + const text = scope.term.getSelection ? scope.term.getSelection() : '' + if (text && text.length > 0) { + notify({ type: 'selection', text: text }) + } else { + cancelSelect() + } +}) + +scope.btnSelAll!.addEventListener('click', function (e) { + e.preventDefault() + e.stopPropagation() + if (!scope.term) { + return + } + try { + scope.term.selectAll() + const b = scope.term.buffer.active + scope.sel = { + anchor: { col: 0, row: 0 }, + focus: { col: scope.term.cols - 1, row: b.length - 1 }, + activeHandle: null + } + repositionOverlay() + } catch {} +}) diff --git a/mobile/src/terminal/document/selection-overlay.ts b/mobile/src/terminal/document/selection-overlay.ts new file mode 100644 index 00000000000..24e0edcecbe --- /dev/null +++ b/mobile/src/terminal/document/selection-overlay.ts @@ -0,0 +1,141 @@ +import { cellToViewportPx } from './cell-geometry' +import { scope } from './document-scope' +import { getCellHeight } from './fit-scale' +import { notify } from './host-notify' +import { applyXtermSelection, selRange } from './selection-range' +import { viewportToCell } from './viewport-cell' +import { getTotalScale } from './viewport-transform' + +export function repositionOverlay() { + if (scope.selMode !== 'select' || !scope.sel || !scope.term) { + return + } + const r = selRange()! + const sPx = cellToViewportPx(r.start.col, r.start.row) + const ePx = cellToViewportPx(r.end.col + 1, r.end.row) + const cellH = getCellHeight() * getTotalScale() + // Why: native iOS pattern — start handle anchors at the TOP of the + // first selected cell (dot above, stem covers the cell going down); + // end handle anchors at the BOTTOM of the last selected cell (dot + // below, stem covers the cell going up). + scope.handleStart!.style.left = sPx.x + 'px' + scope.handleStart!.style.top = sPx.y + 'px' + scope.handleEnd!.style.left = ePx.x + 'px' + scope.handleEnd!.style.top = ePx.y + cellH + 'px' + const startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight + const endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight + scope.handleStart!.style.visibility = startVisible ? 'visible' : 'hidden' + scope.handleEnd!.style.visibility = endVisible ? 'visible' : 'hidden' + let menuCenterX: number, menuY: number, vTransform: string, marginTop: string + if (startVisible && sPx.y > 56) { + menuCenterX = sPx.x + menuY = sPx.y + vTransform = 'translateY(-100%)' + marginTop = '-12px' + } else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) { + menuCenterX = ePx.x + menuY = ePx.y + cellH + vTransform = 'translateY(0)' + marginTop = '12px' + } else { + // selection covers full viewport — pin to visible center + menuCenterX = window.innerWidth / 2 + menuY = window.innerHeight / 2 + vTransform = 'translateY(-50%)' + marginTop = '0' + } + // Why: clamp horizontally so the pill stays fully visible when the + // selection sits near a screen edge. We position via plain left + // (no horizontal translate) so the clamp math is straightforward. + scope.selMenu!.style.transform = vTransform + scope.selMenu!.style.marginTop = marginTop + scope.selMenu!.style.top = menuY + 'px' + scope.selMenu!.style.left = '0px' + const EDGE_MARGIN = 8 + const menuW = scope.selMenu!.offsetWidth || 0 + const minLeft = EDGE_MARGIN + const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN) + const desiredLeft = menuCenterX - menuW / 2 + const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft)) + scope.selMenu!.style.left = clampedLeft + 'px' +} + +export function syncSelectionHandleToViewportPoint( + handle: string, + clientX: number, + clientY: number +) { + const c = viewportToCell(clientX, clientY) + if (!c || !scope.sel) { + return false + } + if (handle === 'start') { + scope.sel.anchor = c + } else { + scope.sel.focus = c + } + applyXtermSelection() + return true +} + +export function syncEdgeScrollSelectionEndpoint() { + if (!scope.sel || !scope.sel.activeHandle) { + return false + } + // Why: WebView may not emit new touchmove events while a handle is held + // at the edge; resample the stored finger point after each viewport scroll. + return syncSelectionHandleToViewportPoint( + scope.sel.activeHandle, + scope.edgeScrollClientX, + scope.edgeScrollClientY + ) +} + +export function startEdgeScroll(dir: number) { + if (scope.edgeScrollDir === dir) { + return + } + stopEdgeScroll() + scope.edgeScrollDir = dir + scope.edgeScrollTimer = setInterval(function () { + if (!scope.term || scope.edgeScrollDir === 0) { + return + } + const beforeY = scope.term.buffer.active.viewportY + scope.term.scrollLines(scope.edgeScrollDir) + const afterY = scope.term.buffer.active.viewportY + if (beforeY === afterY) { + notify({ type: 'haptic', kind: 'edge-bump' }) + stopEdgeScroll() + return + } + syncEdgeScrollSelectionEndpoint() + repositionOverlay() + }, scope.EDGE_SCROLL_INTERVAL) +} + +export function stopEdgeScroll() { + if (scope.edgeScrollTimer) { + clearInterval(scope.edgeScrollTimer) + scope.edgeScrollTimer = null + } + scope.edgeScrollDir = 0 +} + +export function handleDragMove(handle: string, clientX: number, clientY: number) { + scope.edgeScrollClientX = clientX + scope.edgeScrollClientY = clientY + if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) { + return + } + repositionOverlay() + if (clientY < scope.EDGE_SCROLL_PX) { + startEdgeScroll(-1) + } else if (clientY > window.innerHeight - scope.EDGE_SCROLL_PX) { + startEdgeScroll(1) + } else { + stopEdgeScroll() + } +} + +// Latching document-level touch dispatcher: see tap-dispatch.ts. diff --git a/mobile/src/terminal/document/selection-range.ts b/mobile/src/terminal/document/selection-range.ts new file mode 100644 index 00000000000..0c7e77efdf4 --- /dev/null +++ b/mobile/src/terminal/document/selection-range.ts @@ -0,0 +1,115 @@ +import { getLineText } from './cell-geometry' +import { scope, type TerminalDocumentSelection } from './document-scope' +import { notify } from './host-notify' +import { repositionOverlay, stopEdgeScroll } from './selection-overlay' + +/** The ordered ends of the selection, whichever way the user dragged it. */ +export type TerminalSelectionRange = { + start: TerminalDocumentSelection['anchor'] + end: TerminalDocumentSelection['anchor'] +} + +export function seedWordSelection(col: number, absRow: number) { + const line = getLineText(absRow) + if (!line) { + scope.sel = { + anchor: { col: col, row: absRow }, + focus: { col: col, row: absRow }, + activeHandle: null + } + applyXtermSelection() + return + } + let s = col + let e = col + if (col >= 0 && col < line.length && scope.WORD_RE.test(line[col])) { + while (s > 0 && scope.WORD_RE.test(line[s - 1])) { + s-- + } + while (e < line.length - 1 && scope.WORD_RE.test(line[e + 1])) { + e++ + } + } + scope.sel = { + anchor: { col: s, row: absRow }, + focus: { col: e, row: absRow }, + activeHandle: null + } + applyXtermSelection() +} + +export function isStartFirst( + a: TerminalDocumentSelection['anchor'], + b: TerminalDocumentSelection['anchor'] +) { + if (a.row !== b.row) { + return a.row < b.row + } + return a.col <= b.col +} + +export function selRange(): TerminalSelectionRange | null { + if (!scope.sel) { + return null + } + if (isStartFirst(scope.sel.anchor, scope.sel.focus)) { + return { start: scope.sel.anchor, end: scope.sel.focus } + } + return { start: scope.sel.focus, end: scope.sel.anchor } +} + +export function applyXtermSelection() { + if (!scope.term || !scope.sel) { + return + } + const r = selRange() + if (!r) { + return + } + // Why: term.select(col, row, length) takes a buffer-absolute row, + // not a viewport-relative one. Subtracting viewportY here drifts the + // selection by the scrollback height — handles render where the user + // pressed (their math is independent), but xterm highlights an + // off-screen scrollback region and copies the wrong text. + let length: number + if (r.start.row === r.end.row) { + length = Math.max(1, r.end.col - r.start.col + 1) + } else { + const first = scope.term.cols - r.start.col + const middle = Math.max(0, r.end.row - r.start.row - 1) * scope.term.cols + const last = r.end.col + 1 + length = first + middle + last + } + try { + scope.term.select(r.start.col, r.start.row, length) + } catch {} +} + +export function cancelSelect() { + scope.selMode = 'navigate' + scope.sel = null + stopEdgeScroll() + if (scope.term) { + try { + scope.term.clearSelection() + } catch {} + // Why: some xterm renderers cache cells and skip repaint on + // clearSelection alone, leaving the previously-highlighted cells + // visually selected. Force a full refresh so the selection layer + // actually clears on screen. + try { + scope.term.refresh(0, scope.term.rows - 1) + } catch {} + } + scope.selectionOverlay!.classList.remove('active') + notify({ type: 'set-select-mode', enabled: false }) +} + +export function enterSelect(col: number, absRow: number) { + scope.selMode = 'select' + seedWordSelection(col, absRow) + scope.selectionOverlay!.classList.add('active') + notify({ type: 'set-select-mode', enabled: true }) + notify({ type: 'haptic', kind: 'selection' }) + repositionOverlay() +} diff --git a/mobile/src/terminal/document/selection-state-and-eviction.ts b/mobile/src/terminal/document/selection-state-and-eviction.ts new file mode 100644 index 00000000000..a97c1b9cb72 --- /dev/null +++ b/mobile/src/terminal/document/selection-state-and-eviction.ts @@ -0,0 +1,82 @@ +import { repositionOverlay } from './selection-overlay' +import { cancelSelect } from './selection-range' +import { notify } from './host-notify' +import { scope } from './document-scope' + +// ============================================================ +// SELECTION MODE (long-press → handles → Copy) +// ============================================================ +scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u +scope.LONG_PRESS_MS = 500 +scope.LONG_PRESS_SLOP = 10 +// Why: a tap that opens a link/path must survive small finger jitter. The +// long-press slop (10px) only cancels the press-to-select timer; reusing it +// to gate the tap dropped any URL/file tap that wandered >10px — at fit scale +// a few screen px of jitter is a normal tap. Use a wider, time-bounded tap +// window so deliberate scrolls/pans still don't fire a tap. +scope.TAP_SLOP = 24 +scope.TAP_MAX_MS = 700 +scope.EDGE_SCROLL_PX = 40 +scope.EDGE_SCROLL_INTERVAL = 60 + +scope.selectionOverlay = document.getElementById('selection-overlay') +scope.handleStart = document.getElementById('sel-handle-start') +scope.handleEnd = document.getElementById('sel-handle-end') +scope.selMenu = document.getElementById('sel-menu') +scope.btnCopy = document.getElementById('sel-menu-copy') +scope.btnSelAll = document.getElementById('sel-menu-all') + +// mode: 'navigate' | 'select' +scope.selMode = 'navigate' +scope.sel = null // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } +scope.longPressTimer = null +scope.longPressOrigin = null // {x,y, identifier} +// Why: tap detection is tracked separately from the long-press timer so a +// small jitter that cancels the press-to-select timer does not also cancel +// the tap (which opens links/paths). {x,y,t,identifier} or null once the +// gesture is disqualified as a tap (moved too far or held too long). +scope.tapCandidate = null +scope.edgeScrollTimer = null +scope.edgeScrollDir = 0 +scope.edgeScrollClientX = 0 +scope.edgeScrollClientY = 0 + +// Eviction watchdog: linesEverWritten counts onLineFeed since last init. +// Once buffer is full, every onLineFeed evicts the top row in xterm and +// we mirror that by decrementing stored absolute rows. +let linesEverWritten = 0 + +export function resetEvictionCounter() { + linesEverWritten = 0 +} + +export function isBufferFull() { + if (!scope.term) { + return false + } + return linesEverWritten >= 5000 + (scope.term.rows || 0) +} + +export function checkEviction() { + if (scope.selMode !== 'select' || !scope.sel) { + return + } + const oldest = Math.min(scope.sel.anchor.row, scope.sel.focus.row) + if (oldest < 0) { + notify({ type: 'selection-evicted' }) + cancelSelect() + } +} + +export function logFeedAndEvict() { + linesEverWritten++ + if (scope.initialOscLinkEvictionReady && isBufferFull()) { + scope.initialOscLinkRowOffset += 1 + } + if (scope.selMode === 'select' && scope.sel && isBufferFull()) { + scope.sel.anchor.row -= 1 + scope.sel.focus.row -= 1 + checkEviction() + repositionOverlay() + } +} diff --git a/mobile/src/terminal/document/surface-swap.ts b/mobile/src/terminal/document/surface-swap.ts new file mode 100644 index 00000000000..26082493cc3 --- /dev/null +++ b/mobile/src/terminal/document/surface-swap.ts @@ -0,0 +1,69 @@ +import { disposeTermObservers } from './write-queue' +import { attachSurfaceEventHandlers } from './surface-touch-gestures' +import { scope, type TerminalDocumentTerminal } from './document-scope' + +/** The surfaces and terminal a swap is replacing, handed back to whoever commits it. */ +export type TerminalSurfaceSwap = { + oldTerm: TerminalDocumentTerminal | null + oldSurface: HTMLElement | null + nextSurface: HTMLElement +} + +// Why: phone-fit startup can issue several init() calls before xterm finishes +// replaying. Track the last painted surface separately from its replacement. +let committedTerm: TerminalDocumentTerminal | null = null +let committedSurface = scope.surface +scope.pendingTerm = null +let pendingSurface: HTMLElement | null = null + +export function beginTerminalSurfaceSwap() { + // Why: a superseded hidden replacement must not remain between the last + // painted surface and the newest one, or the newest commits below the viewport. + if (pendingSurface) { + try { + pendingSurface.remove() + } catch {} + if (scope.pendingTerm) { + try { + scope.pendingTerm.dispose() + } catch {} + } + pendingSurface = null + scope.pendingTerm = null + } + const swap = { + oldTerm: committedTerm, + oldSurface: committedSurface, + nextSurface: document.createElement('div') + } + disposeTermObservers() + swap.nextSurface.id = 'terminal-surface' + swap.nextSurface.style.visibility = 'hidden' + swap.nextSurface.style.position = 'absolute' + swap.nextSurface.style.left = '0' + swap.nextSurface.style.top = '0' + document.getElementById('terminal-container')!.appendChild(swap.nextSurface) + scope.surface = swap.nextSurface + pendingSurface = swap.nextSurface + attachSurfaceEventHandlers(scope.surface) + swap.oldSurface!.removeAttribute('id') + return swap +} + +export function commitTerminalSurfaceSwap( + swap: TerminalSurfaceSwap, + nextTerm: TerminalDocumentTerminal +) { + swap.nextSurface.style.visibility = 'visible' + swap.nextSurface.style.position = '' + swap.nextSurface.style.left = '' + swap.nextSurface.style.top = '' + swap.oldSurface!.remove() + if (swap.oldTerm) { + swap.oldTerm.dispose() + } + committedTerm = nextTerm + committedSurface = swap.nextSurface + scope.pendingTerm = null + pendingSurface = null +} diff --git a/mobile/src/terminal/document/surface-tap.ts b/mobile/src/terminal/document/surface-tap.ts new file mode 100644 index 00000000000..475e3992648 --- /dev/null +++ b/mobile/src/terminal/document/surface-tap.ts @@ -0,0 +1,59 @@ +import { notify } from './host-notify' +import { + buildMouseClickInput, + getMouseTrackingMode, + isClickMouseTrackingMode +} from './mouse-input-encoding' +import { oscLinkAtViewportPoint, resolveTerminalFileUrlTap } from './osc-link-tap' +import { filePathAtViewportPoint } from './path-tap' +import { fileUrlAtViewportPoint, urlAtViewportPoint } from './url-tap' + +export function notifyTerminalSurfaceTap(originX: number, originY: number, focusKeyboard: boolean) { + const tappedOscLink = oscLinkAtViewportPoint(originX, originY) + if (tappedOscLink && tappedOscLink.kind === 'file') { + notify({ + type: 'terminal-file-tap', + pathText: tappedOscLink.fileTap.pathText, + line: tappedOscLink.fileTap.line, + column: tappedOscLink.fileTap.column + }) + return + } + const tappedFileUrl = fileUrlAtViewportPoint(originX, originY) + const tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null + if (tappedFileUrlPath) { + notify({ + type: 'terminal-file-tap', + pathText: tappedFileUrlPath.pathText, + line: tappedFileUrlPath.line, + column: tappedFileUrlPath.column + }) + return + } + const tappedUrl = + tappedOscLink && tappedOscLink.kind === 'url' + ? tappedOscLink.url + : urlAtViewportPoint(originX, originY) + if (tappedUrl) { + notify({ type: 'open-url', url: tappedUrl }) + return + } + const tappedPath = filePathAtViewportPoint(originX, originY) + if (tappedPath) { + notify({ + type: 'terminal-file-tap', + pathText: tappedPath.pathText, + line: tappedPath.line, + column: tappedPath.column + }) + return + } + const clickInput = buildMouseClickInput(originX, originY) + if (clickInput) { + notify({ type: 'terminal-input', bytes: clickInput }) + } + // Touch still needs native input focus after the TUI consumes its mouse click. + if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { + notify({ type: 'terminal-tap' }) + } +} diff --git a/mobile/src/terminal/document/surface-touch-gestures.ts b/mobile/src/terminal/document/surface-touch-gestures.ts new file mode 100644 index 00000000000..072186a14db --- /dev/null +++ b/mobile/src/terminal/document/surface-touch-gestures.ts @@ -0,0 +1,276 @@ +import { scope } from './document-scope' +import { clampPan, getCellHeight } from './fit-scale' +import { notify } from './host-notify' +import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag' +import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding' +import { + applyNormalBufferScrollDelta, + enqueueNormalBufferScrollDelta, + resetSmoothScrollOffset +} from './normal-buffer-smooth-scroll' +import { dispatcherShouldBlockSurface } from './tap-dispatch' +import { applyTextScale, snapToTextScalePreset } from './text-scaling' +import { getTotalScale, updateTransform } from './viewport-transform' +import { attachSurfaceWheelHandler } from './wheel-scroll' + +/** A surface that has already been wired, so a re-mount does not stack handlers. */ +type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean } + +/** The live touch gesture: the last point, the velocity, and the pinch it may be in. */ +type TerminalTouchState = { + lastX: number + lastY: number + lastTime: number + velY: number + accumDelta: number + momentumId: number | null + isPinching: boolean + pinchDist: number + pinchScale: number + pinchSurfX: number + pinchSurfY: number +} + +const ts: TerminalTouchState = { + lastX: 0, + lastY: 0, + lastTime: 0, + velY: 0, + accumDelta: 0, + momentumId: null, + isPinching: false, + pinchDist: 0, + pinchScale: 0, + pinchSurfX: 0, + pinchSurfY: 0 +} + +export function updateTouchVelocity(deltaY: number, dt: number) { + if (dt <= 0) { + return + } + const instantVelocity = deltaY / dt + if (!Number.isFinite(instantVelocity)) { + return + } + // Why: touchmove cadence is uneven in WebView. Blend recent samples so + // momentum launch doesn't inherit a one-frame spike or stall. + ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45 +} + +export function getDistance(a: Touch, b: Touch) { + const dx = a.clientX - b.clientX, + dy = a.clientY - b.clientY + return Math.sqrt(dx * dx + dy * dy) +} + +export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface) { + if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) { + return + } + targetSurface.__orcaSurfaceHandlersAttached = true + // Why: init() swaps in a new hidden surface to avoid flicker; each + // replacement needs gesture handlers or tab-switch replays stop scrolling. + targetSurface.addEventListener( + 'mousedown', + function (e) { + e.preventDefault() + e.stopPropagation() + }, + true + ) + targetSurface.addEventListener( + 'click', + function (e) { + e.preventDefault() + e.stopPropagation() + }, + true + ) + + attachSurfaceWheelHandler(targetSurface) + attachSurfaceMouseClickDragHandler(targetSurface) + + targetSurface.addEventListener( + 'touchstart', + function (e) { + if (dispatcherShouldBlockSurface()) { + return + } + if (ts.momentumId) { + cancelAnimationFrame(ts.momentumId) + ts.momentumId = null + } + if (e.touches.length === 2) { + ts.isPinching = true + scope.smoothScrollOffsetY = 0 + ts.pinchDist = getDistance(e.touches[0], e.touches[1]) + ts.pinchScale = scope.userScale + const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 + const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 + const total = getTotalScale() + ts.pinchSurfX = (mx - scope.panX) / total + ts.pinchSurfY = (my - scope.panY) / total + } else if (e.touches.length === 1) { + ts.isPinching = false + ts.lastX = e.touches[0].clientX + ts.lastY = e.touches[0].clientY + ts.lastTime = Date.now() + ts.velY = 0 + ts.accumDelta = 0 + } + }, + { capture: true, passive: true } + ) + + targetSurface.addEventListener( + 'touchmove', + function (e) { + if (dispatcherShouldBlockSurface()) { + return + } + if (!scope.term) { + return + } + e.preventDefault() + e.stopPropagation() + + if (e.touches.length === 2) { + ts.isPinching = true + const dist = getDistance(e.touches[0], e.touches[1]) + const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 + const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 + + const ratio = dist / ts.pinchDist + // Why: userScale is a CSS multiplier on the current font size; bound it so + // the resulting apparent size (currentTextScale × userScale) stays within + // the preset range, since release snaps to one of those presets. + const loScale = scope.MIN_TEXT_SCALE / scope.currentTextScale + const hiScale = scope.MAX_TEXT_SCALE / scope.currentTextScale + scope.userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio)) + const total = getTotalScale() + scope.panX = mx - ts.pinchSurfX * total + scope.panY = my - ts.pinchSurfY * total + clampPan() + updateTransform() + } else if (e.touches.length === 1 && !ts.isPinching) { + const x = e.touches[0].clientX, + y = e.touches[0].clientY + const now = Date.now(), + dt = now - ts.lastTime + + // Why: pan horizontally only when content overflows the viewport (larger + // than fit) — same check clampPan() uses. Vertical always drives buffer + // scroll so scrollback stays reachable at any text size; calling the + // never-defined contentWiderThanViewport() here threw and killed all + // single-finger scrolling, scrollback included. + if ( + scope.term.element && + scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1 + ) { + scope.panX += x - ts.lastX + clampPan() + updateTransform() + } + + const deltaY = ts.lastY - y + ts.lastTime = now + if (shouldRouteScrollToTerminalInput()) { + updateTouchVelocity(deltaY, dt) + resetSmoothScrollOffset() + const effectiveCellH = getCellHeight() * getTotalScale() + ts.accumDelta += deltaY + const lines = Math.trunc(ts.accumDelta / effectiveCellH) + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH + routeScrollLines(lines, x, y) + } + } else { + if (enqueueNormalBufferScrollDelta(deltaY)) { + updateTouchVelocity(deltaY, dt) + } else { + ts.velY = 0 + } + } + ts.lastX = x + ts.lastY = y + } + }, + { capture: true, passive: false } + ) + + targetSurface.addEventListener( + 'touchend', + function (e) { + if (dispatcherShouldBlockSurface()) { + return + } + if (!scope.term) { + return + } + + if (ts.isPinching && e.touches.length < 2) { + ts.isPinching = false + // Why: a finished pinch snaps to the nearest preset and becomes the new + // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way + // to set the text size. The CSS pinch zoom (userScale) is reset; the real + // size change reflows columns and RN persists + resizes the PTY to match. + const target = snapToTextScalePreset(scope.currentTextScale * scope.userScale) + const changed = target !== scope.currentTextScale + scope.userScale = 1 + scope.panX = 0 + scope.panY = 0 + applyTextScale(target) + updateTransform() + notify({ type: 'font-scale-changed', fontScale: target }) + if (changed) { + notify({ type: 'haptic', kind: 'selection' }) + } + if (e.touches.length === 1) { + ts.lastX = e.touches[0].clientX + ts.lastY = e.touches[0].clientY + ts.lastTime = Date.now() + ts.velY = 0 + ts.accumDelta = 0 + } + return + } + + if (e.touches.length === 0) { + let vel = ts.velY + const FRICTION = 0.972 + const MIN_VEL = 0.012 + function momentumStep() { + vel *= FRICTION + if (Math.abs(vel) < MIN_VEL) { + ts.momentumId = null + return + } + const delta = vel * 16 + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset() + const effectiveCellH = getCellHeight() * getTotalScale() + ts.accumDelta += delta + const lines = Math.trunc(ts.accumDelta / effectiveCellH) + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH + routeScrollLines(lines, ts.lastX, ts.lastY) + } + } else { + if (!applyNormalBufferScrollDelta(delta)) { + ts.momentumId = null + return + } + } + ts.momentumId = requestAnimationFrame(momentumStep) + } + if (Math.abs(vel) > MIN_VEL) { + ts.momentumId = requestAnimationFrame(momentumStep) + } + } + }, + { capture: true, passive: true } + ) +} + +attachSurfaceEventHandlers(scope.surface!) diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts new file mode 100644 index 00000000000..8e0b07c4bbd --- /dev/null +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -0,0 +1,248 @@ +import { handleDragMove, stopEdgeScroll } from './selection-overlay' +import { cancelSelect, enterSelect } from './selection-range' +import { notify } from './host-notify' +import { viewportToCell } from './viewport-cell' +import { scope } from './document-scope' +import { notifyTerminalSurfaceTap } from './surface-tap' + +// ============================================================ +// LATCHING TOUCH DISPATCHER (document-level) +// ============================================================ + +/** What the dispatcher has latched onto, and the fingers it is tracking. */ +export type TerminalTouchDispatch = { + mode: string + touchId: number | null + touchIds: number[] | null + longPressFingerInsideOverlay: boolean +} + +/** An element a target can be tested against; a method so a real element satisfies it. */ +type TerminalDocumentTargetContainer = { contains(other: EventTarget | null): boolean } + +const dispatch: TerminalTouchDispatch = { + mode: 'idle', + touchId: null, + touchIds: null, + longPressFingerInsideOverlay: false +} + +export function touchById(touches: TouchList, id: number | null) { + for (let i = 0; i < touches.length; i++) { + if (touches[i].identifier === id) { + return touches[i] + } + } + return null +} + +export function targetInside( + target: EventTarget | null, + el: TerminalDocumentTargetContainer | null +) { + if (!target || !el) { + return false + } + return el.contains(target) +} + +export function clearLongPress() { + if (scope.longPressTimer) { + clearTimeout(scope.longPressTimer) + scope.longPressTimer = null + } + scope.longPressOrigin = null +} + +export function armLongPress(touch: Touch) { + scope.longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier } + scope.longPressTimer = setTimeout(function () { + scope.longPressTimer = null + if (!scope.longPressOrigin) { + return + } + const c = viewportToCell(scope.longPressOrigin.x, scope.longPressOrigin.y) + if (!c) { + return + } + enterSelect(c.col, c.row) + }, scope.LONG_PRESS_MS) +} + +export function touchSlopExceeded(t: Touch) { + if (!scope.longPressOrigin) { + return false + } + const dx = Math.abs(t.clientX - scope.longPressOrigin.x) + const dy = Math.abs(t.clientY - scope.longPressOrigin.y) + return dx + dy > scope.LONG_PRESS_SLOP +} + +// Why: existing surface handlers stay attached to surface but we wrap +// their entry to no-op when the dispatcher latches into select-drag. +export function dispatcherShouldBlockSurface() { + return dispatch.mode === 'select-drag' +} + +document.addEventListener( + 'touchstart', + function (e) { + const t = e.touches[0] + const target = e.target + const onHandle = target === scope.handleStart || target === scope.handleEnd + const inOverlay = targetInside(target, scope.selectionOverlay) + const inSurface = targetInside(target, scope.surface) + // Why: clear any stale tap candidate up front; only a fresh single-finger + // surface touch (below) re-arms it, so handle drags / pinches / dismiss + // taps never resolve as a link tap on touchend. + scope.tapCandidate = null + + if (e.touches.length === 2) { + // pinch latch + if (scope.selMode === 'select') { + notify({ type: 'mobile-clip-cancel-by-pinch' }) + cancelSelect() + } + dispatch.mode = 'pinch' + dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier] + clearLongPress() + return + } + + if (onHandle && scope.selMode === 'select') { + // start handle drag + const handleName = target === scope.handleStart ? 'start' : 'end' + scope.sel!.activeHandle = handleName + dispatch.mode = 'select-drag' + dispatch.touchId = t.identifier + e.preventDefault() + return + } + + if (inOverlay) { + // tap on menu pill — let the buttons' own handlers fire + return + } + + if (inSurface && scope.selMode === 'select') { + // Why: tap-to-dismiss matches native iOS/Android — touching outside the + // selection clears it. We cancel immediately and latch to 'surface' so + // the same gesture still drives scroll/pan without a second touch. + cancelSelect() + dispatch.mode = 'surface' + dispatch.touchId = t.identifier + return + } + + if (inSurface) { + dispatch.mode = 'surface' + dispatch.touchId = t.identifier + scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier } + armLongPress(t) + } + }, + { capture: true, passive: false } +) + +document.addEventListener( + 'touchmove', + function (e) { + if (dispatch.mode === 'select-drag') { + const t = touchById(e.touches, dispatch.touchId) + if (!t || !scope.sel || !scope.sel.activeHandle) { + return + } + e.preventDefault() + handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY) + return + } + if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { + // long-press slop check + if (scope.longPressTimer && e.touches.length === 1) { + if (touchSlopExceeded(e.touches[0])) { + clearLongPress() + } + } + // Why: disqualify the tap only once the finger travels past TAP_SLOP + // (a scroll/pan), independent of the long-press timer — so a tap that + // jitters under TAP_SLOP still opens the link/path under the finger. + if (scope.tapCandidate && e.touches.length === 1) { + const mt = e.touches[0] + if (mt.identifier === scope.tapCandidate.identifier) { + const dx = Math.abs(mt.clientX - scope.tapCandidate.x) + const dy = Math.abs(mt.clientY - scope.tapCandidate.y) + if (dx + dy > scope.TAP_SLOP) { + scope.tapCandidate = null + } + } + } else if (e.touches.length !== 1) { + scope.tapCandidate = null + } + // existing surface handler will run from its own listener + } + }, + { capture: true, passive: false } +) + +document.addEventListener( + 'touchend', + function (e) { + if (dispatch.mode === 'select-drag') { + if (scope.sel) { + scope.sel.activeHandle = null + } + stopEdgeScroll() + dispatch.mode = 'idle' + dispatch.touchId = null + return + } + if (dispatch.mode === 'pinch') { + if (e.touches.length < 2) { + dispatch.mode = e.touches.length === 1 ? 'surface' : 'idle' + dispatch.touchIds = null + if (e.touches.length === 1) { + dispatch.touchId = e.touches[0].identifier + } + } + return + } + if (dispatch.mode === 'surface') { + // Why: fire the tap from the tap-candidate origin (survives jitter under + // TAP_SLOP) rather than longPressOrigin, which the press-to-select slop + // can null mid-tap — that was dropping URL/file taps that moved a few px. + if ( + e.touches.length === 0 && + scope.tapCandidate && + scope.selMode !== 'select' && + Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS + ) { + notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true) + } + clearLongPress() + scope.tapCandidate = null + if (e.touches.length === 0) { + dispatch.mode = 'idle' + dispatch.touchId = null + } + } + }, + { capture: true, passive: true } +) + +document.addEventListener( + 'touchcancel', + function () { + clearLongPress() + scope.tapCandidate = null + stopEdgeScroll() + if (dispatch.mode === 'select-drag') { + if (scope.sel) { + scope.sel.activeHandle = null + } + } + dispatch.mode = 'idle' + dispatch.touchId = null + dispatch.touchIds = null + }, + { capture: true, passive: true } +) diff --git a/mobile/src/terminal/document/term-observers.ts b/mobile/src/terminal/document/term-observers.ts new file mode 100644 index 00000000000..2e7cd4f12b3 --- /dev/null +++ b/mobile/src/terminal/document/term-observers.ts @@ -0,0 +1,40 @@ +import { afterWritesDrained, disposeTermObservers } from './write-queue' +import { updateScrollIndicator } from './viewport-transform' +import { scope } from './document-scope' +import { logFeedAndEvict } from './selection-state-and-eviction' +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' +import { emitModesIfChanged } from './mode-mirroring' + +export function attachTermObservers() { + if (!scope.term) { + return + } + disposeTermObservers() + try { + scope.termObserverDisposables.push(scope.term.onLineFeed!(logFeedAndEvict)) + } catch {} + try { + scope.termObserverDisposables.push( + scope.term.onScroll!(function () { + updateScrollIndicator(false) + }) + ) + } catch {} + // Why: emit modes on every parsed write so RN's mirror stays current + // without round-trip; covers \x1b[?2004h/l and alt-screen toggles. + try { + if (scope.term.onWriteParsed) { + scope.termObserverDisposables.push( + scope.term.onWriteParsed(function () { + emitModesIfChanged() + emitKeyboardAvoidanceMetrics() + }) + ) + } + } catch {} + // Initial emit once buffer settles. + afterWritesDrained(function () { + emitModesIfChanged() + emitKeyboardAvoidanceMetrics() + }) +} diff --git a/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts b/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts new file mode 100644 index 00000000000..4a799bb1f25 --- /dev/null +++ b/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts @@ -0,0 +1,391 @@ +import { + describeToken, + readScriptTokens, + type DocumentToken +} from './terminal-document-tokens.test-support' + +/** + * Whether two versions of the in-WebView document script are the same program, allowing only the + * scope qualifier that moving it into modules requires. + * + * C7.1 turns the document's one 2,758-line IIFE into modules the web page can import. A variable + * the script assigns across what became a module boundary cannot stay a free variable — assigning + * an imported binding is a syntax error — so those become fields of one scope object, 73 + * declaration sites in all, and every read and write of them gains a qualifier. Nothing else about + * the program may change. + * + * Byte comparison cannot make that claim once the source is formatter-owned: `oxfmt` writes the + * repository's style, which drops the semicolons the hand-written document carries, so the emitted + * text necessarily differs on almost every line for reasons that are not the refactor. Tokens are + * the level where the claim is exactly true. Semicolons are excluded for the same reason they moved + * — they are the formatter's, not the program's — and comments never reach the stream. + * + * This is deliberately stricter than "it still runs": a reordered statement, a changed literal, a + * dropped `!`, a renamed local, all diverge here and are reported with the token index and both + * sides, so the flip commit is reviewed by running this rather than by reading a 515-line diff. + */ +/** + * The differences moving the script into modules is allowed to make, each counted on its own. + * + * Eight classes and no others. Six are the repository's own rules and the printer rewriting the + * document's ES5 style the moment its source is a linted module — measured over the whole script, + * not assumed: `curly` braces 279 brace-less bodies, `no-unused-vars` unbinds 36 catch clauses, 373 + * `var` declarators become `const` or `let`, `unicorn/prefer-number-properties` moves 17 globals + * onto `Number`, the printer spells out 4 shorthand properties whose value gained a qualifier, and + * it stops renaming 7 bindings that are no longer shadows. The other two are the move itself: 609 + * qualified references and 73 declarations onto the scope. Semicolons and whitespace are the + * formatter's and never reach the token stream at all. + * + * Counted separately because the flip commit pins each number: a total would let one class absorb + * another, which is exactly the drift the pin exists to catch. + */ +export type TerminalDocumentNormalisations = { + /** `name` became `.name`; the declaration stayed where it was. */ + readonly qualifiedReferences: number + /** + * `var name` became `.name`; the declaration moved onto the scope object. A `var` + * with several declarators counts once per declarator, because each becomes its own assignment. + */ + readonly scopeFieldDeclarations: number + /** `var` became `const` or `let`, the binding staying local to the emitted script. */ + readonly rebindings: number + /** A brace-less `if`/`else`/`for`/`while` body gained its braces. */ + readonly bracedBodies: number + /** `catch (e)` became `catch`, the unused binding dropped. */ + readonly unboundCatches: number + /** A global numeric function became its `Number` property. */ + readonly numberProperties: number + /** `{ name: name }` was shorthand; qualifying the value spells the property out again. */ + readonly shorthandProperties: number + /** + * An inner binding that shadowed a document variable stopped being a shadow once that variable + * moved onto the scope, so the printer stopped renaming it. + */ + readonly unshadowedNames: number +} + +/** + * The bindings the printer renamed on the baseline and leaves alone in the modules, listed. + * + * A parameter named for a document variable shadowed it while both lived in one function scope, so + * the printer gave the inner one a decimal suffix; once the outer name is a scope field there is no + * shadow and the inner one keeps its own name. Listed rather than matched by shape: a rule that + * accepted any `name2` facing `name` would also accept an unrelated rename that happens to end in a + * digit, which is a changed program, not a normalisation. + * + * One entry covers all seven sites the whole script has: the `term` parameter of + * `attachTerminalQueryReplyBridge` in `query-reply.ts` and its six uses. + */ +const UNSHADOWED_RENAMES: readonly { + readonly baseline: string + readonly generated: string + readonly module: string +}[] = [{ baseline: 'term2', generated: 'term', module: 'query-reply' }] + +/** Whether this exact baseline-to-generated pair is one of the listed unshadowed renames. */ +function isListedUnshadowedRename(baseline: string, generated: string): boolean { + return UNSHADOWED_RENAMES.some( + (entry) => entry.baseline === baseline && entry.generated === generated + ) +} + +/** + * The globals `unicorn/prefer-number-properties` moves onto `Number`. + * + * Measured over the whole script: seventeen sites, and the rule is the only one of its kind that + * appears often enough to be worth matching. Each is equivalent here because every call is already + * behind a `typeof … === 'number'` check or is parsing a string, which is what the `Number` form + * does with no coercion of its own. + */ +const NUMBER_GLOBALS = new Set(['isFinite', 'isNaN', 'parseInt', 'parseFloat']) + +export type TerminalDocumentEquivalence = + | { readonly equivalent: true; readonly normalisations: TerminalDocumentNormalisations } + | { readonly equivalent: false; readonly reason: string } + +/** + * `baseline` is the script as it stood before the move, `candidate` the one the modules generate. + * + * The qualifier is read from `qualifier`, not assumed, so the test names the object it expects and + * a rename cannot quietly satisfy this. + */ +/** The statement heads `curly` braces: everything whose body may be a single unbraced statement. */ +const BRACEABLE_HEAD_KEYWORDS = new Set(['if', 'for', 'while']) + +/** + * Whether the `{` at `open` is the body of a braceable head rather than some other block. + * + * `else` and `do` are followed by their body directly. The rest put a parenthesised head first, so + * the `)` is walked back to its `(` and the keyword before that is what decides. Without this a + * bare block anywhere in the generated script would be absorbed as a linter-added body, when it is + * a statement the baseline does not have. + */ +function isBraceableHeadBody(tokens: readonly DocumentToken[], open: number): boolean { + const previous = tokens[open - 1] + if (previous === undefined) { + return false + } + if (previous.label === 'else' || previous.label === 'do') { + return true + } + if (previous.label !== ')') { + return false + } + let depth = 0 + for (let i = open - 1; i >= 0; i--) { + const label = tokens[i]?.label + if (label === ')') { + depth += 1 + continue + } + if (label === '(') { + depth -= 1 + if (depth === 0) { + return BRACEABLE_HEAD_KEYWORDS.has(tokens[i - 1]?.label ?? '') + } + } + } + return false +} + +/** The index of the `}` closing the `{` at `open`, or -1 when the generated script has none. */ +function matchingCloseIndex(tokens: readonly DocumentToken[], open: number): number { + let depth = 0 + for (let i = open; i < tokens.length; i++) { + const label = tokens[i]?.label + if (label === '{') { + depth += 1 + continue + } + if (label === '}') { + depth -= 1 + if (depth === 0) { + return i + } + } + } + return -1 +} + +export function compareTerminalDocumentScripts( + baseline: string, + candidate: string, + qualifier: string +): TerminalDocumentEquivalence { + const baselineTokens = readScriptTokens(baseline, 'the baseline') + if (!baselineTokens.ok) { + return { equivalent: false, reason: baselineTokens.reason } + } + const candidateTokens = readScriptTokens(candidate, 'the generated script') + if (!candidateTokens.ok) { + return { equivalent: false, reason: candidateTokens.reason } + } + const before = baselineTokens.tokens + const after = candidateTokens.tokens + let qualifiedReferences = 0 + let scopeFieldDeclarations = 0 + let rebindings = 0 + let bracedBodies = 0 + let unboundCatches = 0 + let numberProperties = 0 + let shorthandProperties = 0 + let unshadowedNames = 0 + // The generated index each inserted `{` expects its `}` at, innermost last. Recording the index + // rather than counting means an absorbed close is the one that closes that body and no other. + const insertedBraceCloses: number[] = [] + let lastMatched: DocumentToken | undefined + let left = 0 + let right = 0 + while (left < before.length && right < after.length) { + const expected = before[left] + const actual = after[right] + // Ahead of the equality check on purpose: the baseline's next token is a `}` too wherever a + // braced body ends a block, and this index is known to close the inserted body, so matching + // them as a pair would consume the wrong one and leave the counts right for the wrong reason. + if (actual.label === '}' && insertedBraceCloses.at(-1) === right) { + insertedBraceCloses.pop() + right += 1 + continue + } + if (expected.label === actual.label && expected.text === actual.text) { + lastMatched = expected + left += 1 + right += 1 + continue + } + // `term2` -> `term`: the printer disambiguated a shadowed binding on the baseline side, and + // qualifying the outer name removed the shadow, so the inner one keeps its own name. + if ( + expected.label === 'name' && + actual.label === 'name' && + isListedUnshadowedRename(expected.text, actual.text) + ) { + unshadowedNames += 1 + lastMatched = actual + left += 1 + right += 1 + continue + } + // `{ name }` -> `{ name: .name }`: the printer writes the baseline's shorthand back + // as one token, and qualifying the value makes the property name unavoidable again. + if ( + actual.label === ':' && + lastMatched?.label === 'name' && + after[right + 1]?.label === 'name' && + after[right + 1]?.text === qualifier && + after[right + 2]?.label === '.' && + after[right + 3]?.text === lastMatched.text + ) { + shorthandProperties += 1 + right += 4 + continue + } + // `name` -> `.name`, three tokens for one. + if (isQualified(after, right, expected, qualifier)) { + qualifiedReferences += 1 + left += 1 + right += 3 + continue + } + // `parseInt` -> `Number.parseInt`, the same shape under a different object. + if (NUMBER_GLOBALS.has(expected.text) && isQualified(after, right, expected, 'Number')) { + numberProperties += 1 + left += 1 + right += 3 + continue + } + // `var name` -> `.name`: the declaration itself moved onto the scope object. + if ( + expected.label === 'var' && + before[left + 1] !== undefined && + isQualified(after, right, before[left + 1], qualifier) + ) { + scopeFieldDeclarations += 1 + left += 2 + right += 3 + continue + } + // `var a = 1, b = 2` where both moved onto the scope: the comma introduces the second + // declaration, which is written as its own assignment. + if ( + expected.label === ',' && + before[left + 1] !== undefined && + isQualified(after, right, before[left + 1], qualifier) + ) { + scopeFieldDeclarations += 1 + left += 2 + right += 3 + continue + } + if (expected.label === 'var' && isBlockScopedKeyword(actual)) { + rebindings += 1 + lastMatched = actual + left += 1 + right += 1 + continue + } + // `catch (e) {` -> `catch {`: three baseline tokens the linted form does not carry. + if ( + lastMatched?.label === 'catch' && + expected.label === '(' && + before[left + 1]?.label === 'name' && + before[left + 2]?.label === ')' && + actual.label === '{' + ) { + unboundCatches += 1 + left += 3 + continue + } + // `if (a) b;` -> `if (a) { b; }`: the body the repository's `curly` rule braced. Only a + // braceable head's body qualifies, and only that body's own close is absorbed. + if (actual.label === '{' && isBraceableHeadBody(after, right)) { + const close = matchingCloseIndex(after, right) + if (close !== -1) { + bracedBodies += 1 + insertedBraceCloses.push(close) + right += 1 + continue + } + } + return { + equivalent: false, + reason: `token ${left}: expected ${describeToken(expected)}, generated ${describeToken(actual)}` + } + } + // A body braced at the very end of the script leaves its close after the baseline has run out. + while (insertedBraceCloses.at(-1) === right && after[right]?.label === '}') { + insertedBraceCloses.pop() + right += 1 + } + if (left !== before.length || right !== after.length) { + return { + equivalent: false, + reason: `length: ${before.length - left} token(s) left in the baseline, ${after.length - right} in the generated script` + } + } + if (insertedBraceCloses.length !== 0) { + return { + equivalent: false, + reason: `${insertedBraceCloses.length} inserted brace(s) never closed` + } + } + return { + equivalent: true, + normalisations: { + qualifiedReferences, + scopeFieldDeclarations, + rebindings, + bracedBodies, + unboundCatches, + numberProperties, + shorthandProperties, + unshadowedNames + } + } +} + +/** + * Whether a token is the `const` or `let` a `var` became. + * + * `let` is contextual outside strict mode, so acorn reports it as a name rather than as a keyword; + * matching on the label alone would refuse every `let` the linter introduced. + */ +function isBlockScopedKeyword(token: DocumentToken): boolean { + return token.label === 'const' || (token.label === 'name' && token.text === 'let') +} + +/** Whether the generated stream reads `.` where the baseline read `expected`. */ +function isQualified( + after: DocumentToken[], + right: number, + expected: DocumentToken, + qualifier: string +): boolean { + return ( + after[right]?.label === 'name' && + after[right]?.text === qualifier && + after[right + 1]?.label === '.' && + after[right + 2]?.label === expected.label && + after[right + 2]?.text === expected.text + ) +} + +/** + * The hand-written script out of the whole document, which is the part C7.1 moves. + * + * Read by locating the generated engine rather than by an index into the text, so a slice added + * above or below it does not silently shift what gets compared. + */ +export function readTerminalDocumentScript(document: string, engineJs: string): string { + const opener = `` + const start = document.indexOf(opener) + if (start === -1) { + throw new Error('the document does not carry the generated engine script') + } + const scriptStart = document.indexOf('') + if (scriptStart === -1 || scriptEnd <= scriptStart) { + throw new Error('the document does not carry a hand-written script after the engine') + } + return document.slice(scriptStart + ' + + + + +
+
+
+
+
+
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/mobile/src/terminal/terminal-document-identity.test.ts b/mobile/src/terminal/terminal-document-identity.test.ts new file mode 100644 index 00000000000..0f4bf152bd7 --- /dev/null +++ b/mobile/src/terminal/terminal-document-identity.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + ENGINE_CSS_PLACEHOLDER, + ENGINE_JS_PLACEHOLDER, + TERMINAL_DOCUMENT_FIXTURE_PATH, + terminalDocumentFixture +} from '../../scripts/build-terminal-document-fixture.mjs' +import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' +import { XTERM_HTML } from './terminal-webview-html' + +/** + * The emitted WebView document, byte for byte, against a committed copy of itself. + * + * `terminal-webview-payload-hash.test.ts` pins the same bytes as a digest, which answers whether + * the document moved. This answers where: the whole document is one assertion, so a slice that + * gained a character, lost an indent or changed order arrives as a diff of the line rather than as + * two hexadecimal strings. Both are kept — the digest also covers the generated engine, which this + * fixture deliberately does not. + * + * C7.1 moves the document's hand-written script into modules the web page can import, and a + * generator rebuilds the document from them. This is the instrument that says the native screen + * kept the document it had. Regenerate the fixture with + * `node scripts/build-terminal-document-fixture.mjs` only when the emitted document was meant to + * change; the diff in that commit is the evidence, and reviewing it is the point. + */ +const fixture = readFileSync(TERMINAL_DOCUMENT_FIXTURE_PATH, 'utf8') + +describe('the terminal WebView document', () => { + it('is byte for byte the document the fixture holds', () => { + // Rebuilt through the script's own substitution rather than a second copy of it: a fixture + // written by a different rule than the one that reads it agrees with itself and with nothing. + expect(terminalDocumentFixture(XTERM_HTML, XTERM_ENGINE_JS, XTERM_ENGINE_CSS)).toBe(fixture) + }) + + it('holds the generated engine as placeholders, so an xterm bump is not a diff here', () => { + // Without this the fixture could lose a placeholder — inlining the engine, or dropping the + // section entirely — and the assertion above would still pass against whatever it became. + for (const placeholder of [ENGINE_JS_PLACEHOLDER, ENGINE_CSS_PLACEHOLDER]) { + expect(fixture.split(placeholder)).toHaveLength(2) + } + expect(fixture).not.toContain(XTERM_ENGINE_JS) + expect(fixture).not.toContain(XTERM_ENGINE_CSS) + }) + + it('is the whole document once the engine is put back', () => { + // The placeholder round trip, which is what makes the first case a claim about the document + // and not only about the hand-written part of it. + const restored = fixture + .replace(ENGINE_JS_PLACEHOLDER, () => XTERM_ENGINE_JS) + .replace(ENGINE_CSS_PLACEHOLDER, () => XTERM_ENGINE_CSS) + expect(restored).toBe(XTERM_HTML) + }) +}) diff --git a/mobile/src/terminal/terminal-document-pre-flip-script.txt b/mobile/src/terminal/terminal-document-pre-flip-script.txt new file mode 100644 index 00000000000..1252cd0ac6b --- /dev/null +++ b/mobile/src/terminal/terminal-document-pre-flip-script.txt @@ -0,0 +1,2758 @@ + +(function() { + var surface = document.getElementById('terminal-surface'); + var ESC = String.fromCharCode(27); + var C1_CSI = String.fromCharCode(155); + var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa); + var TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e); + var EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f); + var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g'); + var statusDotPendingSelector = false; + var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; + var term = null; + var terminalDataRepliesEnabled = false; + + function resetTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = false; + } + + function resumeTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = true; + } + + function forwardTerminalDataReply(data) { + if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data }); + } + + function enqueueTerminalDataReplyBoundary(gen) { + enqueueWriteBoundary(function() { + if (gen === terminalGeneration) terminalDataRepliesEnabled = true; + }); + } + + function attachTerminalQueryReplyBridge(term, gen) { + // Why: parser replies require stdin enabled, but mobile input is owned by + // native controls. Keep xterm's textarea inert for touch/hardware keys. + try { + term.attachCustomKeyEventHandler(function() { return false; }); + if (term.textarea) { + term.textarea.readOnly = true; + term.textarea.tabIndex = -1; + term.textarea.setAttribute('inputmode', 'none'); + } + } catch (e) {} + try { + termObserverDisposables.push(term.onData(function(data) { + forwardTerminalDataReply(data); + })); + } catch (e) {} + // Why: live output can queue before initial replay finishes. Enable replies + // at the replay boundary so those live queries are answered, never replayed ones. + enqueueTerminalDataReplyBoundary(gen); + } + + + // Why: phone-fit startup can issue several init() calls before xterm finishes + // replaying. Track the last painted surface separately from its replacement. + var committedTerm = null; + var committedSurface = surface; + var pendingTerm = null; + var pendingSurface = null; + + function beginTerminalSurfaceSwap() { + // Why: a superseded hidden replacement must not remain between the last + // painted surface and the newest one, or the newest commits below the viewport. + if (pendingSurface) { + try { pendingSurface.remove(); } catch (e) {} + if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {} + pendingSurface = null; + pendingTerm = null; + } + var swap = { + oldTerm: committedTerm, + oldSurface: committedSurface, + nextSurface: document.createElement('div') + }; + disposeTermObservers(); + swap.nextSurface.id = 'terminal-surface'; + swap.nextSurface.style.visibility = 'hidden'; + swap.nextSurface.style.position = 'absolute'; + swap.nextSurface.style.left = '0'; + swap.nextSurface.style.top = '0'; + document.getElementById('terminal-container').appendChild(swap.nextSurface); + surface = swap.nextSurface; + pendingSurface = swap.nextSurface; + attachSurfaceEventHandlers(surface); + swap.oldSurface.removeAttribute('id'); + return swap; + } + + function commitTerminalSurfaceSwap(swap, nextTerm) { + swap.nextSurface.style.visibility = 'visible'; + swap.nextSurface.style.position = ''; + swap.nextSurface.style.left = ''; + swap.nextSurface.style.top = ''; + swap.oldSurface.remove(); + if (swap.oldTerm) swap.oldTerm.dispose(); + committedTerm = nextTerm; + committedSurface = swap.nextSurface; + pendingTerm = null; + pendingSurface = null; + } + + var scrollIndicator = document.getElementById('scroll-indicator'); + var scrollThumb = document.getElementById('scroll-thumb'); + var scrollIndicatorHideTimer = null; + var writeQueue = []; + var writeQueueHead = 0; + var writesDraining = false; + var afterDrainCallbacks = []; + var termObserverDisposables = []; + var ready = false; + // Why: init() flips ready false on every re-init (live width reflow included) + // while the old surface stays visible; a document-scoped latch drives the + // fatal/non-fatal decision so a transient reflow cannot blank a live terminal. + var everReady = false; + var currentScale = 1; + // Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a + // gesture only; it resets to 1 on release. The persistent "text size" is the + // real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it + // reflows the grid: a bigger cell means fewer columns fit, and RN re-measures + // and resizes the PTY (terminal.updateViewport) so the shell rewraps to the + // new width. A finished pinch snaps to the nearest preset and reports it to RN. + var userScale = 1; + var BASE_FONT_PX = 13; + var MIN_FONT_PX = 6; + var MIN_FIT_COLS = 20; + var currentTextScale = 1; + var TEXT_SCALE_PRESETS = [0.5,0.75,1,1.25,1.5,2]; + var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]; + var MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]; + function snapToTextScalePreset(value) { + var best = TEXT_SCALE_PRESETS[0], bestDelta = Infinity; + for (var i = 0; i < TEXT_SCALE_PRESETS.length; i++) { + var delta = Math.abs(TEXT_SCALE_PRESETS[i] - value); + if (delta < bestDelta) { bestDelta = delta; best = TEXT_SCALE_PRESETS[i]; } + } + return best; + } + function fontPxForScale(scale) { + return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale)); + } + function isIOSWebView() { + if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true; + return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; + } + // Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can + // fall to a non-monospace face; lead with the ui-monospace generic to avoid that. + var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace'; + var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS; + // Why: change the real font size, then resize the grid to fit the viewport at + // the new cell metrics so the text shows at its true size immediately. RN's + // refit (measure → updateViewport) then makes the server reflow the PTY to the + // same column count so the shell rewraps. cell metrics update on the frame + // after fontSize changes, so the resize/fit is deferred one rAF. + function applyTextScale(scale) { + currentTextScale = scale; + if (!term) return; + var px = fontPxForScale(scale); + if (term.options.fontSize === px) return; + term.options.fontSize = px; + requestAnimationFrame(function() { + if (!term) return; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW > 0 && cellH > 0) { + var cols = Math.floor(window.innerWidth / cellW); + if (cols < MIN_FIT_COLS) return; + var rows = Math.max(8, Math.floor(window.innerHeight / cellH)); + term.resize(cols, rows); + emitKeyboardAvoidanceMetrics(); + } + applyFitScale('text-scale'); + }); + } + var panX = 0, panY = 0; + var smoothScrollOffsetY = 0; + var pendingNormalScrollDeltaY = 0; + var normalScrollFrameId = null; + var initRows = 24; + var terminalGeneration = 0; + var defaultTheme = {"background":"#1a1b26","foreground":"#c0caf5","cursor":"#c0caf5","cursorAccent":"#1a1b26","selectionBackground":"#33467c","selectionForeground":"#c0caf5","black":"#15161e","red":"#f7768e","green":"#9ece6a","yellow":"#e0af68","blue":"#7aa2f7","magenta":"#bb9af7","cyan":"#7dcfff","white":"#a9b1d6","brightBlack":"#414868","brightRed":"#f7768e","brightGreen":"#9ece6a","brightYellow":"#e0af68","brightBlue":"#7aa2f7","brightMagenta":"#bb9af7","brightCyan":"#7dcfff","brightWhite":"#c0caf5"}; + var terminalThemeInput = null; + var terminalTheme = defaultTheme; + var terminalMinimumContrastRatio = 3; + var webglAddon = null; + var webglRecoveryTimer = null; + var activeAltScreenSnapshot = false; + var trackedMouseTrackingMode = 'none'; + var sgrMouseMode = false; + var sgrMousePixelsMode = false; + var initialOscLinks = [], initialOscLinkRowOffset = 0; + var initialOscLinkEvictionReady = false; + var mouseModeScanTail = ''; + var handledMessageIds = []; + // Why: after init() the initial scrollback applyFitScale may have run + // against an empty buffer (or one without the widest line yet). Re-fit + // once when the first live data chunk arrives so a wider line that pushes + // scrollWidth past the previously-measured value gets re-scaled to fit. + var firstDataPending = false; + + // Diagnostic logger — bridges WebView console.log to RN via postMessage. + // Tag with [fit] so it's easy to filter in the Expo/Metro logs. + function flog(tag, payload) { + try { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'log', tag: '[fit]' + tag, payload: payload + })); + } + } catch (e) {} + } + + function getCellWidth() { + if (!term || !term._core) return 0; + var core = term._core; + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.width || 0; + } + return 0; + } + + // Why: width measurement strategy. + // 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses + // to lay out and is independent of buffer content. It's the "logical + // width" of the terminal grid. + // 2. Fall back to term.element.scrollWidth — the actual rendered DOM + // width — only when cellWidth isn't available yet (renderer not + // initialized). This is content-dependent (reflects widest row), + // but better than nothing. + // 3. If both are 0, return 1 (no scale change). The retry loop in + // applyFitScale will keep trying until one is positive. + function computeFitScale() { + if (!term) return 1; + var cellW = getCellWidth(); + var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0); + if (termWidth <= 0) return 1; + var vpWidth = window.innerWidth; + return Math.min(1, vpWidth / termWidth); + } + + function getTotalScale() { return currentScale * userScale; } + + function updateTransform() { + surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')'; + updateScrollIndicator(false); + if (selMode === 'select') repositionOverlay(); + } + + function updateScrollIndicator(reveal) { + if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return; + var buffer = term.buffer.active; + var maxViewportY = buffer.baseY || 0; + if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) { + scrollIndicator.classList.remove('visible'); + return; + } + var trackHeight = Math.max(0, window.innerHeight - 8); + var totalRows = maxViewportY + (term.rows || 0); + if (trackHeight <= 0 || totalRows <= 0) return; + var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows); + var maxTop = Math.max(0, trackHeight - thumbHeight); + var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0; + scrollThumb.style.height = thumbHeight + 'px'; + scrollThumb.style.transform = 'translateY(' + top + 'px)'; + if (!reveal) return; + scrollIndicator.classList.add('visible'); + if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer); + scrollIndicatorHideTimer = setTimeout(function() { + scrollIndicator.classList.remove('visible'); + scrollIndicatorHideTimer = null; + }, 550); + } + + + var DARK_BG_MIN_CONTRAST = 3; + var LIGHT_BG_MIN_CONTRAST = 4.5; + // Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark). + var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 }; + + function parseTerminalBackgroundRgba(value) { + if (typeof value !== 'string') return null; + var v = value.trim().toLowerCase(); + if (!v) return null; + if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 }; + if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 }; + if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }; + var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + var h = hex[1]; + var ch; + if (h.length === 3 || h.length === 4) { + ch = h.split('').map(function (p) { return parseInt(p + p, 16); }); + } else { + ch = []; + for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16)); + } + return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 }; + } + var rgb = v.match(/^rgba?\(([^)]+)\)$/); + if (!rgb) return null; + var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\s/]+/); + parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; }); + if (parts.length < 3) return null; + var channel = function (p) { + var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p); + return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null; + }; + var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]); + if (r === null || g === null || b === null) return null; + var a = 1; + if (parts[3] !== undefined) { + var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]); + a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1; + } + return { r: r, g: g, b: b, a: a }; + } + + function terminalRelativeLuminance(rgb) { + var lin = function (c) { + var n = c / 255; + return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); + }; + return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b); + } + + function terminalContrastRatio(a, b) { + var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); + } + + // Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override". + function normalizeTerminalContrastOverride(value) { + if (typeof value !== 'number' || !isFinite(value)) return null; + return Math.min(21, Math.max(1, value)); + } + + // Pick the xterm minimumContrastRatio floor from the composed terminal background. + // Unparseable input defaults to the dark floor so agent output never stays invisible. + function resolveTerminalContrastFloor(background) { + var color = parseTerminalBackgroundRgba(background); + if (!color) return DARK_BG_MIN_CONTRAST; + var composited = color.a < 1 + ? { + r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)), + g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)), + b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a)) + } + : color; + var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >= + terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited); + return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST; + } + + function normalizeTerminalTheme(input) { + var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object' + ? input.theme + : null; + if (!source) return defaultTheme; + var next = {}; + var keys = Object.keys(defaultTheme); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (typeof source[key] === 'string') next[key] = source[key]; + } + return Object.assign({}, defaultTheme, next); + } + + function applyTerminalTheme(input) { + terminalThemeInput = input; + terminalTheme = normalizeTerminalTheme(input); + var background = terminalTheme.background || '#1a1b26'; + document.documentElement.style.background = background; + document.body.style.background = background; + // Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754); + // an older host omits the field and the luminance gate stays authoritative. + var publishedFloor = normalizeTerminalContrastOverride( + input && typeof input === 'object' ? input.minimumContrastRatio : undefined + ); + terminalMinimumContrastRatio = + publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor; + if (term) { + term.options.theme = terminalTheme; + term.options.minimumContrastRatio = terminalMinimumContrastRatio; + } + } + + + function getCellHeight() { + if (!term || !term._core) return 15; + var core = term._core; + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 15; + } + return 15; + } + + // Why: clamp pan so the terminal content always covers the viewport + // when zoomed in. When content is smaller than viewport in a + // dimension, pin to top-left (no floating in the middle). + function clampPan() { + if (!term || !term.element) return; + var ts = getTotalScale(); + var cw = term.element.scrollWidth * ts; + var ch = term.element.scrollHeight * ts; + var vpW = window.innerWidth; + var vpH = window.innerHeight; + if (cw > vpW) { + panX = Math.min(0, Math.max(vpW - cw, panX)); + } else { + panX = 0; + } + if (ch > vpH) { + panY = Math.min(0, Math.max(vpH - ch, panY)); + } else { + panY = 0; + } + } + + // Why: intentional no-op. Mobile replays a live PTY snapshot then applies + // live cursor-relative chunks from that same PTY; resizing only the WebView + // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or + // overlap. Kept as a no-op so its call sites stay legible. + function adjustRowsForViewport() {} + + // Why: cold-start fit. After init() opens xterm, the renderer needs + // several frames before cell dimensions are computed. Reading too early + // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM + // not laid out), and computeFitScale returns 1 → no zoom. + // + // Gate: cellWidth × cols is the canonical "logical width" of the grid + // and reflects xterm's layout decision, independent of buffer content. + // We commit when cellWidth becomes positive (renderer ready). Fallback: + // if cellWidth never becomes available, gate on stable positive + // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) + // so a backgrounded WebView never spins forever. + var FIT_RETRY_MAX_FRAMES = 60; + var fitRetryToken = 0; + function applyFitScale(reason) { + if (!term || !term.element) return; + var token = ++fitRetryToken; + var attempts = 0; + var lastScrollWidth = -1; + function attempt() { + if (token !== fitRetryToken) return; + if (!term || !term.element) return; + attempts++; + var cellW = getCellWidth(); + if (cellW > 0 && term.cols > 0) { + commitFitScale(reason, attempts, 'cellW'); + return; + } + var w = term.element.scrollWidth; + if (w > 0 && w === lastScrollWidth) { + commitFitScale(reason, attempts, 'stableSW'); + return; + } + lastScrollWidth = w; + if (attempts >= FIT_RETRY_MAX_FRAMES) { + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: term.cols + }); + commitFitScale(reason, attempts, 'timeout'); + return; + } + requestAnimationFrame(attempt); + } + requestAnimationFrame(attempt); + } + + function commitFitScale(reason, attempts, gate) { + if (!term || !term.element) return; + var preSnapScale = computeFitScale(); + currentScale = preSnapScale; + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. + if (currentScale >= 0.95) currentScale = 1; + userScale = 1; + panX = 0; + panY = 0; + smoothScrollOffsetY = 0; + updateTransform(); + adjustRowsForViewport(); + + var cellW = getCellWidth(); + var sw = term.element.scrollWidth; + var vpW = window.innerWidth; + var expectedW = cellW * term.cols; + var suspect = + currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom + if (suspect) { + flog('commit-SUSPECT', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: currentScale, + cellW: cellW, + cols: term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW + }); + } + repositionOverlay(); + } + + function isAltScreenActive(data) { + if (typeof data !== 'string') return false; + var on = data.lastIndexOf(ESC + '[?1049h'); + var off = data.lastIndexOf(ESC + '[?1049l'); + return on !== -1 && on > off; + } + + function normalizeInitialData(data) { + if (!isAltScreenActive(data)) return data; + var on = data.lastIndexOf(ESC + '[?1049h'); + // Why: SerializeAddon can include normal-buffer scrollback before the + // active alternate-screen snapshot. Replaying both into a fresh mobile + // xterm duplicates TUI frames and can flatten SGR attributes. + return on > 0 ? data.slice(on) : data; + } + + function updateMouseModeFromData(data) { + if (typeof data !== 'string' || data.length === 0) return; + var input = mouseModeScanTail + data; + mouseModeScanTail = extractMouseModeScanTail(input); + var re = new RegExp(ESC + 'c|' + ESC + '\\[\\?([0-9;]+)([hl])|' + C1_CSI + '\\?([0-9;]+)([hl])', 'g'); + var match; + while ((match = re.exec(input)) !== null) { + if (match[0] === ESC + 'c') { + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + continue; + } + var enabled = (match[2] || match[4]) === 'h'; + var params = (match[1] || match[3]).split(';'); + for (var i = 0; i < params.length; i++) { + if (params[i] === '') continue; + var param = Number(params[i]); + if (!Number.isInteger(param)) continue; + if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; + if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; + if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; + if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; + if (param === 1006) { + sgrMouseMode = enabled; + sgrMousePixelsMode = false; + } + if (param === 1016) { + sgrMouseMode = false; + sgrMousePixelsMode = enabled; + } + } + } + } + + function resetWriteQueue() { + writeQueue = []; + writeQueueHead = 0; + } + + function isStatusDotPresentationSelector(value) { + return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; + } + + function endsWithStatusDotPresentationSequence(data) { + var i = data.length - 1; + while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; + return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; + } + + // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. + function normalizeStatusDotPresentation(data) { + if (typeof data !== 'string' || data.length === 0) return data; + if (statusDotPendingSelector) { + statusDotPendingSelector = false; + var strippedPendingSelectors = false; + while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); + strippedPendingSelectors = data.length === 0; + if (strippedPendingSelectors) { + statusDotPendingSelector = true; + return ''; + } + } + var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); + statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); + return normalized; + } + + function enqueueWrite(data) { + writeQueue.push(normalizeStatusDotPresentation(data)); + } + + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + + function nextQueuedWrite() { + if (writeQueueHead >= writeQueue.length) { + resetWriteQueue(); + return undefined; + } + var next = writeQueue[writeQueueHead]; + writeQueue[writeQueueHead] = undefined; + writeQueueHead++; + // Why: high-throughput terminals can enqueue faster than xterm parses; + // compact consumed slots so drain work stays O(1) without retaining old chunks. + if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { + writeQueue = writeQueue.slice(writeQueueHead); + writeQueueHead = 0; + } + return next; + } + + function disposeTermObservers() { + var disposables = termObserverDisposables; + termObserverDisposables = []; + for (var i = 0; i < disposables.length; i++) { + try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} + } + } + + function extractMouseModeScanTail(input) { + var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); + if (start === -1) return ''; + var tail = input.slice(start); + // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. + // Keep parser state far beyond normal mode lists while still bounding memory. + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; + if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; + if (tail.indexOf(ESC + '[?') === 0) { + return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; + } + if (tail.indexOf(C1_CSI + '?') === 0) { + return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; + } + return ''; + } + + function pumpWrites(gen) { + if (!ready || !term || writesDraining || gen !== terminalGeneration) return; + var next = nextQueuedWrite(); + if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); + var callbacks = afterDrainCallbacks; + afterDrainCallbacks = []; + for (var i = 0; i < callbacks.length; i++) callbacks[i](); + return; + } + writesDraining = true; + // Why: xterm.write() parses asynchronously. Row adjustment/resizing must + // wait until replayed SGR attributes have landed in the buffer. + term.write(next, function() { + if (gen !== terminalGeneration) return; + writesDraining = false; + pumpWrites(gen); + }); + } + + function afterWritesDrained(callback) { + afterDrainCallbacks.push(callback); + pumpWrites(terminalGeneration); + } + + + function refreshTerminalSurface() { + if (!term) return; + try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {} + } + + function cancelWebglContextRecovery() { + if (!webglRecoveryTimer) return; + clearTimeout(webglRecoveryTimer); + webglRecoveryTimer = null; + } + + function attachWebglAddon(allowRecovery) { + if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false; + var addon = null; + try { + addon = new window.WebglAddon.WebglAddon(); + webglAddon = addon; + if (addon.onContextLoss) addon.onContextLoss(function() { + if (webglAddon !== addon) return; + flog('webgl-context-loss', { retry: allowRecovery }); + webglAddon = null; + try { addon.dispose(); } catch (e) {} + refreshTerminalSurface(); + if (!allowRecovery) return; + // Why: one delayed retry handles transient iOS context loss without + // entering a GPU crash loop; a second loss stays on the DOM renderer. + cancelWebglContextRecovery(); + var recoveryTerm = term; + var recoveryGeneration = terminalGeneration; + webglRecoveryTimer = setTimeout(function() { + webglRecoveryTimer = null; + if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return; + attachWebglAddon(false); + }, 100); + }); + term.loadAddon(addon); + if (!allowRecovery) { + try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {} + refreshTerminalSurface(); + } + return true; + } catch (e) { + flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) }); + if (webglAddon === addon) webglAddon = null; + try { if (addon) addon.dispose(); } catch (disposeError) {} + refreshTerminalSurface(); + return false; + } + } + + document.addEventListener('visibilitychange', function() { + if (document.visibilityState !== 'visible') return; + // Why: iOS may restore the xterm model while discarding GPU pixels/theme + // paint state, so visibility must rebuild the atlas and repaint every row. + applyTerminalTheme(terminalThemeInput); + try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {} + refreshTerminalSurface(); + }); + + + function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { + if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; + // Why: a width-reflow re-stream rewraps the same content at new cols. + // Distance-from-bottom (rows) is the only stable anchor across reflow, + // since line counts and cell positions change. null = stay pinned to bottom. + var prevB = preserveScroll && term && term.buffer && term.buffer.active ? term.buffer.active : null; + var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; + terminalGeneration++; + var gen = terminalGeneration; + // Why: snapshot replay can contain old queries whose replies must never + // re-enter the live PTY. Each replacement terminal earns authority anew. + resetTerminalDataReplyAuthority(); + cancelWebglContextRecovery(); + webglAddon = null; + ready = false; + resetWriteQueue(); + statusDotPendingSelector = false; + writesDraining = false; + afterDrainCallbacks = []; + initRows = rows || 24; + firstDataPending = true; + smoothScrollOffsetY = 0; + wheelAccumDeltaY = 0; + mouseModeScanTail = ''; + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; + var replayData = normalizeInitialData(initialData); + // Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the + // mirrored modes aligned with exactly what this mobile xterm replays. + updateMouseModeFromData(replayData); + activeAltScreenSnapshot = isAltScreenActive(replayData); + initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []; + initialOscLinkRowOffset = 0; + initialOscLinkEvictionReady = false; + var surfaceSwap = beginTerminalSurfaceSwap(); + var nextSurface = surfaceSwap.nextSurface; + + applyTerminalTheme(nextTheme); + term = new Terminal({ + cols: cols || 80, + rows: rows || 24, + theme: terminalTheme, + minimumContrastRatio: terminalMinimumContrastRatio, + fontFamily: terminalFontFamily, + fontSize: fontPxForScale(currentTextScale), + fontWeight: '300', + fontWeightBold: '500', + scrollback: 5000, + // Why: xterm suppresses parser-generated query replies when disableStdin + // is true. Native accepts only validated reply grammars from onData. + disableStdin: false, + cursorBlink: false, + cursorStyle: "bar", + // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. + showCursorImmediately: true, + // A full inactive cell remains visible under the terminal's phone-fit scale. + cursorInactiveStyle: "block", + convertEol: false, + allowProposedApi: true + }); + var nextTerm = term; + pendingTerm = nextTerm; + term.open(surface); + attachWebglAddon(true); + if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} + if (typeof replayData === 'string' && replayData.length > 0) { + // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output. + enqueueWrite(ESC + '[0m' + replayData); + } + + // Why: reset eviction tracking + attach observers for the new term. + resetEvictionCounter(); + cancelSelect(); + attachTermObservers(); + attachTerminalQueryReplyBridge(term, gen); + + requestAnimationFrame(function() { + if (gen !== terminalGeneration) return; + ready = true; + everReady = true; + afterWritesDrained(function() { + if (gen !== terminalGeneration) return; + commitTerminalSurfaceSwap(surfaceSwap, nextTerm); + // Why: restore the reader's place after the rewrapped buffer replays. + // Replay lands at bottom, so only act when they were scrolled up (rows>0). + if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) { + try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {} + } + captureInitialOscLinkTexts(); + initialOscLinkRowOffset = 0; + initialOscLinkEvictionReady = true; + applyFitScale('init-replay'); + notify({ type: 'ready', cols: cols, rows: rows }); + }); + }); + } + + function write(data) { + updateMouseModeFromData(data); + enqueueWrite(data); + pumpWrites(terminalGeneration); + // Why: first live data chunk after init may widen the buffer past + // what the post-replay applyFitScale measured. Re-fit once after this + // chunk drains to catch the wider line. Subsequent chunks don't re-fit + // (the user's manual zoom is sticky after that). + if (firstDataPending) { + firstDataPending = false; + var gen = terminalGeneration; + afterWritesDrained(function() { + if (gen !== terminalGeneration) return; + applyFitScale('first-data'); + }); + } + } + + function resize(cols, rows) { + if (!term) return; + initRows = rows || initRows; + term.resize(cols || term.cols, rows || term.rows); + emitKeyboardAvoidanceMetrics(); + applyFitScale('resize-msg'); + notify({ type: 'ready', cols: cols, rows: rows }); + } + + // reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines). + + // Why: rewrap the local xterm buffer (scrollback included) to a new width + // after a server PTY reflow. Skip the alternate screen: those snapshots are + // fully repainted by the PTY and a local resize there can drop SGR attributes + // (see init's alt-screen handling), which shows as white text. + function reflow(cols, rows) { + if (!term || isAlternateBufferActive()) return; + var nextCols = cols || term.cols; + var nextRows = rows || term.rows; + if (nextCols === term.cols && nextRows === term.rows) return; + var buffer = term.buffer.active; + // Why: anchor reflow on whether the user was pinned to the live bottom so + // their scroll position survives the rewrap — if they were scrolled up, + // hold the same distance from the bottom; if at the bottom, stay there. + var wasAtBottom = buffer.viewportY >= buffer.baseY; + var distanceFromBottom = buffer.baseY - buffer.viewportY; + initRows = nextRows; + term.resize(nextCols, nextRows); + var rewrapped = term.buffer.active; + if (wasAtBottom) { + term.scrollToBottom(); + } else { + term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY); + } + applyFitScale('reflow-msg'); + updateScrollIndicator(false); + emitKeyboardAvoidanceMetrics(); + } + + + function notify(msg) { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify(msg)); + } + } + + function engineErrorText(err) { + if (!err) return ''; + if (typeof err === 'string') return err; + if (err && typeof err.message === 'string') return err.message; + try { return String(err); } catch (e) { return ''; } + } + + function chromeVersionText() { + var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/); + return match ? 'Chrome ' + match[1] : 'Chrome version unknown'; + } + + var nonFatalErrorNotifies = 0; + + function reportEngineError(context, err, fatal) { + var isFatal = fatal === undefined ? !everReady : !!fatal; + if (!isFatal) { + // Why: a constructed-but-degraded engine can throw per frame; cap + // non-fatal notifies so RN isn't flooded. Fatal reports always emit. + nonFatalErrorNotifies++; + if (nonFatalErrorNotifies > 5) return; + } + var parts = [context]; + var errText = engineErrorText(err); + if (errText) parts.push(errText); + if (window.__engineErrors && window.__engineErrors.length) { + parts.push('captured: ' + window.__engineErrors.join(' | ')); + } + parts.push(chromeVersionText()); + notify({ + type: 'error', + fatal: isFatal, + message: parts.join(' - ') + }); + } + + window.onerror = function(msg, source, line, column, err) { + if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg)); + reportEngineError('terminal runtime error', err || msg); + }; + + function measureFitDimensions(containerHeightPx, retriesLeft) { + if (typeof retriesLeft !== 'number') retriesLeft = 30; + // Why: init and measure are posted back-to-back from React, but + // init has an async rAF chain. A measure that runs synchronously + // after init can find term null, disposed, lacking element, or + // with cells size 0. Retry the whole gate for ~500ms. + var notReady = !term || !term.element; + var cellWidth = 0; + var cellHeight = 0; + if (!notReady) { + var core = term._core; + if (core && core._renderService && core._renderService.dimensions) { + cellWidth = core._renderService.dimensions.css.cell.width; + cellHeight = core._renderService.dimensions.css.cell.height; + } + } + if (notReady || cellWidth <= 0 || cellHeight <= 0) { + if (retriesLeft > 0) { + requestAnimationFrame(function() { + measureFitDimensions(containerHeightPx, retriesLeft - 1); + }); + return; + } + flog('measure-fail', { + notReady: notReady, + cellWidth: cellWidth, + cellHeight: cellHeight, + retriesLeft: retriesLeft + }); + notify({ type: 'measure-result', cols: null, rows: null }); + return; + } + var vpWidth = window.innerWidth; + // Why: prefer the container height passed from React Native over + // window.innerHeight. The RN layout system knows the exact pixel + // height of the terminal frame after the accessory/input bars are + // subtracted, whereas innerHeight can overstate the visible area + // due to layout timing or safe-area insets. + var vpHeight = (typeof containerHeightPx === 'number' && containerHeightPx > 0) + ? containerHeightPx + : window.innerHeight; + var cols = Math.floor(vpWidth / cellWidth); + if (cols < MIN_FIT_COLS) { + flog('measure-skip-small-width', { + vpWidth: vpWidth, + cellWidth: cellWidth, + cols: cols + }); + notify({ type: 'measure-result', cols: null, rows: null }); + return; + } + // Why: the rows we report become the PTY's actual row count after the + // server fits to viewport, and xterm renders exactly that many lines + // anchored top-left of the WebView. Subtracting rows here would leave + // dead xterm-background space at the bottom of the container and make + // the last PTY rows visually appear above an "invisible line." Any + // safety margin between the prompt and the accessory bar must come + // from RN layout (terminalFrame's flex bounds), not from undersizing + // the PTY. + var rows = Math.max(8, Math.floor(vpHeight / cellHeight)); + notify({ type: 'measure-result', cols: cols, rows: rows }); + } + + function handleMsg(msg) { + if (typeof msg.id === 'number') { + if (handledMessageIds.indexOf(msg.id) !== -1) return; + handledMessageIds.push(msg.id); + if (handledMessageIds.length > 256) handledMessageIds.shift(); + } + if (msg.type === 'ping') { + notify({ type: 'pong', pingId: msg.id }); + } else if (msg.type === 'init') { + init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks); + } else if (msg.type === 'set-font-scale') { + // Why: ignore RN echoing back the value a pinch just set (msg.fontScale === + // currentTextScale) so the post-pinch state isn't reset; only apply changes. + if (typeof msg.fontScale === 'number' && msg.fontScale > 0 && msg.fontScale !== currentTextScale) { + userScale = 1; + panX = 0; + panY = 0; + applyTextScale(msg.fontScale); + } + } else if (msg.type === 'resize') { + resize(msg.cols, msg.rows); + } else if (msg.type === 'reflow') { reflow(msg.cols, msg.rows); + } else if (msg.type === 'write') { + write(msg.data); + } else if (msg.type === 'clear') { + terminalGeneration++; + resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary. + statusDotPendingSelector = false; + afterDrainCallbacks = []; + writesDraining = false; + mouseModeScanTail = ''; + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + initialOscLinks = []; + initialOscLinkRowOffset = 0; + initialOscLinkEvictionReady = false; + if (term) { term.clear(); term.reset(); } + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + resetEvictionCounter(); + if (selMode === 'select') { + notify({ type: 'selection-evicted' }); + cancelSelect(); + } + } else if (msg.type === 'measure') { + measureFitDimensions(msg.containerHeight); + } else if (msg.type === 'reset-zoom') { + applyFitScale('reset-zoom-msg'); + } else if (msg.type === 'set-theme') { + applyTerminalTheme(msg.terminalTheme); + } else if (msg.type === 'cancel-select') { + if (selMode === 'select') cancelSelect(); + } else if (msg.type === 'do-select-all') { + if (term) { + try { + term.selectAll(); + var b = term.buffer.active; + if (selMode !== 'select') { + selMode = 'select'; + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + } + sel = { + anchor: { col: 0, row: 0 }, + focus: { col: term.cols - 1, row: b.length - 1 }, + activeHandle: null + }; + repositionOverlay(); + } catch (e) {} + } + } + } + + // ============================================================ + // SELECTION MODE (long-press → handles → Copy) + // ============================================================ + var WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u; + var LONG_PRESS_MS = 500; + var LONG_PRESS_SLOP = 10; + // Why: a tap that opens a link/path must survive small finger jitter. The + // long-press slop (10px) only cancels the press-to-select timer; reusing it + // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale + // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap + // window so deliberate scrolls/pans still don't fire a tap. + var TAP_SLOP = 24; + var TAP_MAX_MS = 700; + var EDGE_SCROLL_PX = 40; + var EDGE_SCROLL_INTERVAL = 60; + + var selectionOverlay = document.getElementById('selection-overlay'); + var handleStart = document.getElementById('sel-handle-start'); + var handleEnd = document.getElementById('sel-handle-end'); + var selMenu = document.getElementById('sel-menu'); + var btnCopy = document.getElementById('sel-menu-copy'); + var btnSelAll = document.getElementById('sel-menu-all'); + + // mode: 'navigate' | 'select' + var selMode = 'navigate'; + var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } + var longPressTimer = null; + var longPressOrigin = null; // {x,y, identifier} + // Why: tap detection is tracked separately from the long-press timer so a + // small jitter that cancels the press-to-select timer does not also cancel + // the tap (which opens links/paths). {x,y,t,identifier} or null once the + // gesture is disqualified as a tap (moved too far or held too long). + var tapCandidate = null; + var edgeScrollTimer = null; + var edgeScrollDir = 0; + var edgeScrollClientX = 0; + var edgeScrollClientY = 0; + + // Eviction watchdog: linesEverWritten counts onLineFeed since last init. + // Once buffer is full, every onLineFeed evicts the top row in xterm and + // we mirror that by decrementing stored absolute rows. + var linesEverWritten = 0; + + function resetEvictionCounter() { linesEverWritten = 0; } + + function isBufferFull() { + if (!term) return false; + return linesEverWritten >= 5000 + (term.rows || 0); + } + + function checkEviction() { + if (selMode !== 'select' || !sel) return; + var oldest = Math.min(sel.anchor.row, sel.focus.row); + if (oldest < 0) { + notify({ type: 'selection-evicted' }); + cancelSelect(); + } + } + + function logFeedAndEvict() { + linesEverWritten++; + if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; + if (selMode === 'select' && sel && isBufferFull()) { + sel.anchor.row -= 1; + sel.focus.row -= 1; + checkEviction(); + repositionOverlay(); + } + } + + function emitModesIfChanged() { + if (!term) return; + var bp = !!(term.modes && term.modes.bracketedPasteMode); + var alt = false; + var mouseTrackingMode = getMouseTrackingMode(); + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + if ( + bp !== lastEmittedModes.bracketedPasteMode || + alt !== lastEmittedModes.altScreen || + mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || + sgrMouseMode !== lastEmittedModes.sgrMouseMode || + sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode + ) { + lastEmittedModes = { + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }; + notify({ + type: 'modes', + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }); + } + } + var lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; + + + function lineHasVisibleContent(line, cell) { + if (line.translateToString(true).trim().length > 0) return true; + if (!cell || !line.getCell) return false; + var limit = Math.min(term.cols || 0, line.length || 0); + for (var x = 0; x < limit; x++) { + var current = line.getCell(x, cell); + if (!current) continue; + if (!current.isBgDefault() || current.isInverse()) return true; + if (typeof current.isUnderline === 'function' && current.isUnderline()) return true; + if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true; + if (typeof current.isOverline === 'function' && current.isOverline()) return true; + } + return false; + } + + function computeContentBottomRow() { + if (!term || !term.buffer || !term.buffer.active) return 0; + var buffer = term.buffer.active; + var top = buffer.viewportY || 0; + var cell = buffer.getNullCell ? buffer.getNullCell() : null; + for (var y = (term.rows || 0) - 1; y >= 0; y--) { + try { + var line = buffer.getLine(top + y); + if (line && lineHasVisibleContent(line, cell)) return y; + } catch (e) {} + } + return 0; + } + + function emitKeyboardAvoidanceMetrics() { + if (!term) return; + var alt = false; + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + notify({ + type: 'keyboard-avoidance-metrics', + cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, + contentBottomRow: alt ? 0 : computeContentBottomRow(), + rows: term.rows || 0, + altScreen: alt + }); + } + + + function attachTermObservers() { + if (!term) return; + disposeTermObservers(); + try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} + try { + termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); + } catch (e) {} + // Why: emit modes on every parsed write so RN's mirror stays current + // without round-trip; covers \x1b[?2004h/l and alt-screen toggles. + try { + if (term.onWriteParsed) { + termObserverDisposables.push(term.onWriteParsed(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + })); + } + } catch (e) {} + // Initial emit once buffer settles. + afterWritesDrained(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + }); + } + + function viewportToCell(clientX, clientY) { + if (!term) return null; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW <= 0 || cellH <= 0) return null; + var total = getTotalScale(); + if (total <= 0) total = 1; + var sx = (clientX - panX) / total; + var sy = (clientY - panY) / total; + var col = Math.floor(sx / cellW); + var viewportRow = Math.floor(sy / cellH); + if (col < 0) col = 0; + if (col > term.cols - 1) col = term.cols - 1; + if (viewportRow < 0) viewportRow = 0; + if (viewportRow > term.rows - 1) viewportRow = term.rows - 1; + var viewportY = term.buffer.active.viewportY; + return { col: col, row: viewportRow + viewportY }; + } + + + function viewportToMouseReportCell(clientX, clientY) { + if (!term) return null; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW <= 0 || cellH <= 0) return null; + if (typeof clientX !== 'number') clientX = window.innerWidth / 2; + if (typeof clientY !== 'number') clientY = window.innerHeight / 2; + var total = getTotalScale(); + if (total <= 0) total = 1; + var sx = (clientX - panX) / total; + var sy = (clientY - panY) / total; + var maxX = Math.max(0, term.cols * cellW - 1); + var maxY = Math.max(0, term.rows * cellH - 1); + if (sx < 0) sx = 0; + if (sx > maxX) sx = maxX; + if (sy < 0) sy = 0; + if (sy > maxY) sy = maxY; + var col = Math.floor(sx / cellW); + var row = Math.floor(sy / cellH); + if (col < 0) col = 0; + if (col > term.cols - 1) col = term.cols - 1; + if (row < 0) row = 0; + if (row > term.rows - 1) row = term.rows - 1; + return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; + } + + + function isAlternateBufferActive() { + try { + return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'); + } catch (e) { + return false; + } + } + + function getMouseTrackingMode() { + try { + if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') { + var mode = term.modes.mouseTrackingMode; + if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode; + return 'none'; + } + } catch (e) {} + if ( + trackedMouseTrackingMode === 'x10' || + trackedMouseTrackingMode === 'vt200' || + trackedMouseTrackingMode === 'drag' || + trackedMouseTrackingMode === 'any' + ) { + return trackedMouseTrackingMode; + } + return 'none'; + } + + function repeatSequence(sequence, count) { + var out = ''; + for (var i = 0; i < count; i++) out += sequence; + return out; + } + + function buildArrowScrollSequence(lines) { + var prefix = '['; + try { + if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O'; + } catch (e) {} + return ESC + prefix + (lines < 0 ? 'A' : 'B'); + } + + function buildMouseWheelSequence(lines, clientX, clientY) { + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + var eventCode = lines < 0 ? 64 : 65; + if (sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; + return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M'; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + return ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M'; + } + // Why: xterm increments zero-based mouse cells before encoding reports. + var button = eventCode + 32; + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path. Fall back to keys for wide terminals. + if (button > 126 || col > 126 || row > 126) return ''; + return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isSafeSgrMouseCoordinate(value) { + return Number.isInteger(value) && value >= 0 && value <= 9999; + } + + function buildMouseClickInput(clientX, clientY) { + var mouseTrackingMode = getMouseTrackingMode(); + if (!isClickMouseTrackingMode(mouseTrackingMode)) return ''; + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + if (sgrMousePixelsMode) { + // Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions. + var pixelX = cell.x; + var pixelY = cell.y; + if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) return ''; + var pixelPress = ESC + '[<0;' + pixelX + ';' + pixelY + 'M'; + if (mouseTrackingMode === 'x10') return pixelPress; + return pixelPress + ESC + '[<0;' + pixelX + ';' + pixelY + 'm'; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; + if (mouseTrackingMode === 'x10') return sgrPress; + return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; + } + // Why: non-SGR click coordinates use printable ASCII bytes on the mobile + // bridge; unsafe wide-terminal cells must not turn into corrupted input. + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + if (col > 126 || row > 126) return ''; + var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); + if (mouseTrackingMode === 'x10') return press; + return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isClickMouseTrackingMode(mode) { + return mode !== 'none'; + } + + function isWheelMouseTrackingMode(mode) { + return mode !== 'none' && mode !== 'x10'; + } + + function shouldRouteScrollToTerminalInput() { + return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); + } + + function buildMouseWheelScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var sequence = buildMouseWheelSequence(lines, clientX, clientY); + if (!sequence) return ''; + return repeatSequence(sequence, count); + } + + function buildTuiScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var mouseTrackingMode = getMouseTrackingMode(); + var sequence = ''; + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + sequence = buildMouseWheelSequence(lines, clientX, clientY); + } + if (!sequence) sequence = buildArrowScrollSequence(lines); + return repeatSequence(sequence, count); + } + + function routeScrollLines(lines, clientX, clientY) { + if (!term || lines === 0) return; + var mouseTrackingMode = getMouseTrackingMode(); + var alternateBufferActive = isAlternateBufferActive(); + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + // Why: xterm sends wheel events to mouse-aware TUIs before considering + // scrollback, even if the app stays on the normal buffer. + var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); + if (mouseInput) { + notify({ type: 'terminal-input', bytes: mouseInput }); + return; + } + // Why: default mouse encoding can be unrepresentable in our ASCII-safe + // RPC path on wide terminals. Send bounded arrows instead of local + // scrollback/no-op while a mouse-aware app owns scroll gestures. + var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); + if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); + return; + } + if (alternateBufferActive) { + // Why: alternate-screen TUIs own their scroll state and xterm has no + // scrollback there, so mobile scroll gestures must become terminal input. + var input = buildTuiScrollInput(lines, clientX, clientY); + if (input) notify({ type: 'terminal-input', bytes: input }); + return; + } + term.scrollLines(lines); + } + + function clampNormalScrollLines(lines) { + if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; + var buffer = term.buffer.active; + if (lines > 0) { + return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY)); + } + return Math.max(lines, -buffer.viewportY); + } + + function canScrollNormalBufferDelta(deltaY) { + if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false; + var buffer = term.buffer.active; + if (deltaY > 0) return buffer.viewportY < buffer.baseY; + return buffer.viewportY > 0; + } + + function applyNormalBufferScrollDelta(deltaY) { + if (!term || deltaY === 0) return false; + var effectiveCellH = getCellHeight() * getTotalScale(); + if (effectiveCellH <= 0) return false; + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset(); + return false; + } + smoothScrollOffsetY -= deltaY; + var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); + if (lines !== 0) { + var applied = clampNormalScrollLines(lines); + if (applied !== 0) { + term.scrollLines(applied); + // Why: xterm's renderer is row-based. Buffer touch pixels and only + // commit whole rows so TUI canvas layers do not shimmer between + // fractional transforms and xterm repaints. + smoothScrollOffsetY += applied * effectiveCellH; + } + if (applied !== lines) smoothScrollOffsetY = 0; + } + var limit = effectiveCellH - 1; + if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit; + if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit; + updateScrollIndicator(true); + return true; + } + + function enqueueNormalBufferScrollDelta(deltaY) { + if (!term || deltaY === 0) return false; + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset(); + return false; + } + pendingNormalScrollDeltaY += deltaY; + if (normalScrollFrameId !== null) return true; + // Why: dense terminal rows are expensive to repaint. Coalesce touchmove + // deltas into one xterm row-scroll per frame instead of repainting from + // the input event stream. + normalScrollFrameId = requestAnimationFrame(function() { + normalScrollFrameId = null; + var delta = pendingNormalScrollDeltaY; + pendingNormalScrollDeltaY = 0; + if (!applyNormalBufferScrollDelta(delta)) { + resetSmoothScrollOffset(); + } + }); + return true; + } + + function resetSmoothScrollOffset() { + pendingNormalScrollDeltaY = 0; + if (normalScrollFrameId !== null) { + cancelAnimationFrame(normalScrollFrameId); + normalScrollFrameId = null; + } + if (smoothScrollOffsetY === 0) return; + smoothScrollOffsetY = 0; + updateScrollIndicator(false); + } + + function cellToViewportPx(col, absRow) { + if (!term) return { x: 0, y: 0 }; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + var viewportRow = absRow - term.buffer.active.viewportY; + var sx = col * cellW; + var sy = viewportRow * cellH; + var total = getTotalScale(); + return { x: sx * total + panX, y: sy * total + panY }; + } + + function getLineText(absRow) { + if (!term) return ''; + var line = term.buffer.active.getLine(absRow); + if (!line) return ''; + return line.translateToString(false); + } + + // Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a + // tap's CELL column no longer equals the STRING index that url/path matchers use. + // Convert by measuring the string length up to the tapped cell (the count of + // string chars before it). Without this, taps on lines with a leading wide char + // (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss. + function cellColToStringIndex(absRow, col) { + if (!term) return col; + var line = term.buffer.active.getLine(absRow); + if (!line) return col; + return line.translateToString(false, 0, col).length; + } + + // File-path-under-tap detection (matchFilePathAtColumn). See + // terminal-path-tap-injected.ts; mirrors the unit-tested terminal-path-tap.ts. + + var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g; + var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g; + var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 }; + var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 }; + + function parsePathLineCol(value) { + var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value); + if (!m) return null; + var pathText = m[1]; + var last = pathText.charAt(pathText.length - 1); + if (!pathText || last === '/' || last === '\\') return null; + var line = m[2] ? parseInt(m[2], 10) : null; + var column = m[3] ? parseInt(m[3], 10) : null; + if ((line !== null && line < 1) || (column !== null && column < 1)) return null; + return { pathText: pathText, line: line, column: column }; + } + + function trimPathBoundaryPunctuation(raw, rawStart) { + var start = 0, end = raw.length; + while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1; + while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1; + if (start >= end) return null; + return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }; + } + + function hasSeparatorAfterWhitespace(text) { + var sawWhitespace = false; + for (var i = 0; i < text.length; i++) { + var ch = text.charAt(i); + if (/\s/.test(ch)) { sawWhitespace = true; continue; } + if (sawWhitespace && (ch === '/' || ch === '\\')) return true; + } + return false; + } + + function trimSpacedPathTrailingProse(range, col) { + // A line-end extension token only extends the span when the added segment + // is path-like (contains a separator) — prose must not be swallowed. + var selected = null; + var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g; + var match; + while ((match = extensionPrefixPattern.exec(range.text)) !== null) { + var end = match.index + match[0].length; + var text = range.text.slice(0, end); + if (countPathStarts(text) > 1) continue; + if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) { + selected = text; + } + } + if (selected) { + if (col !== undefined && col >= range.startIndex + selected.length) return null; + return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length }; + } + var text = range.text.replace(/\s+$/, ''); + return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length }; + } + + function countPathStarts(text) { + var count = 0; + var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g; + while (pathStartPattern.exec(text) !== null) count += 1; + return count; + } + + function hasSpacedPathExtension(text) { + var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length }); + if (!range) return false; + var trimmed = range.text.replace(/\s+$/, ''); + return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed); + } + + function matchSpacedFilePathAtColumn(lineText, col) { + SPACED_PATH_RE.lastIndex = 0; + var match; + while ((match = SPACED_PATH_RE.exec(lineText)) !== null) { + var trimmed = trimPathBoundaryPunctuation(match[0], match.index); + if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue; + var candidate = trimSpacedPathTrailingProse(trimmed, col); + if (!candidate) continue; + if (col < candidate.startIndex || col >= candidate.endIndex) continue; + var parsed = parsePathLineCol(candidate.text); + if (parsed) return parsed; + } + return null; + } + + function matchFilePathAtColumn(lineText, col) { + var spaced = matchSpacedFilePathAtColumn(lineText, col); + if (spaced) return spaced; + FILE_PATH_RE.lastIndex = 0; + var match; + while ((match = FILE_PATH_RE.exec(lineText)) !== null) { + var raw = match[0]; + if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; } + var trimmed = trimPathBoundaryPunctuation(raw, match.index); + if (!trimmed) continue; + if (col < trimmed.startIndex || col >= trimmed.endIndex) continue; + var parsed = parsePathLineCol(trimmed.text); + if (parsed) return parsed; + } + return null; + } + + // Returns the path candidate under the tap, or null. Query-only so the tap + // handler can try file detection before forwarding a mouse click — which lets + // file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/ + // getLineText from the host script scope. + function filePathAtViewportPoint(originX, originY) { + var tapCell = viewportToCell(originX, originY); + if (!tapCell) return null; + // Map the cell column to a string index so wide chars (emoji/CJK) earlier on + // the line don't shift the match column off the tapped path. + return matchFilePathAtColumn( + getLineText(tapCell.row), + cellColToStringIndex(tapCell.row, tapCell.col) + ); + } + + + var URL_TAP_RE_SOURCE = "\\bhttps?:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"':,.!?{}|\\\\^~[\\]`()<>]"; + var FILE_URL_TAP_RE_SOURCE = "\\bfile:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"',!?{}|\\\\^~[\\]`()<>]"; + var URL_TAP_MAX_LENGTH = 2048; + function findUrlAtColumn(lineText, col) { + return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE); + } + function findFileUrlAtColumn(lineText, col) { + return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE); + } + function findTerminalUrlAtColumn(lineText, col, source) { + if (typeof lineText !== 'string' || lineText.length === 0) return null; + var re = new RegExp(source, 'gi'); + var match; + while ((match = re.exec(lineText)) !== null) { + var end = match.index + match[0].length; + if (match[0].length <= URL_TAP_MAX_LENGTH && col >= match.index && col < end) return match[0]; + if (match[0].length === 0) re.lastIndex++; + } + return null; + } + function fileUrlAtViewportPoint(clientX, clientY) { + var cell = viewportToCell(clientX, clientY); + if (!cell) return null; + return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); + } + function urlAtViewportPoint(clientX, clientY) { + var cell = viewportToCell(clientX, clientY); + if (!cell) return null; + // Map the cell column to a string index so wide chars earlier on the line + // don't shift the match column off the tapped URL. + return findUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); + } + + // Why: OSC 8 links can render as labels like "#1234"; the URI lives in + // xterm's internal link service, so every access is guarded and falls through. + function oscLinkService() { + try { + var core = term && term._core; + if (!core) return null; + return core._oscLinkService + || (core._inputHandler && core._inputHandler._oscLinkService) + || null; + } catch (e) { return null; } + } + function oscLinkAtViewportPoint(clientX, clientY) { + try { + var cell = viewportToCell(clientX, clientY); + if (!cell) return null; + var line = term.buffer.active.getLine(cell.row); + if (!line) return null; + var urlId = oscLinkIdAtCell(line, cell.col); + if (!urlId) return initialOscLinkAtCell(cell.row, cell.col); + var svc = oscLinkService(); + if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col); + var data = svc.getLinkData(urlId); + var uri = data && data.uri; + return terminalOscLinkTarget(uri); + } catch (e) { return null; } + } + function initialOscLinkAtCell(row, col) { + for (var i = 0; i < initialOscLinks.length; i++) { + var link = initialOscLinks[i]; + if (!link || typeof link.uri !== 'string') continue; + if (link.row < initialOscLinkRowOffset) continue; + var shiftedRow = link.row - initialOscLinkRowOffset; + if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri); + } + return null; + } + function terminalOscLinkTarget(uri) { + if (typeof uri !== 'string') return null; + if (/^https?:/i.test(uri)) return { kind: 'url', url: uri }; + var fileTap = resolveTerminalOscFileTap(uri); + return fileTap ? { kind: 'file', fileTap: fileTap } : null; + } + function resolveTerminalOscFileTap(uri) { + return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri); + } + function resolveTerminalFileUrlTap(uri) { + var parsed; + try { + parsed = new URL(uri); + } catch (e) { + return null; + } + if (parsed.protocol !== 'file:') return null; + var filePath; + try { + filePath = decodeURIComponent(parsed.pathname || ''); + } catch (e) { + return null; + } + if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) { + filePath = '//' + parsed.hostname + filePath; + } else if (/^\/[A-Za-z]:\//.test(filePath)) { + filePath = filePath.slice(1); + } + if (!filePath) return null; + var hashTarget = parseFileUrlLineHash(parsed.hash || ''); + if (hashTarget) { + return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }; + } + if (/%3a/i.test(parsed.pathname || '')) { + return { pathText: filePath, line: null, column: null }; + } + return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null }; + } + function isLocalFileUriHostname(hostname) { + var normalized = String(hostname).toLowerCase(); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]'; + } + function parseOscPathLikeTarget(value) { + if (!/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(value)) return null; + return parsePathLineCol(value); + } + function parseFileUrlLineHash(hash) { + var match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash); + if (!match) return null; + var line = parseInt(match[1], 10); + var column = match[2] ? parseInt(match[2], 10) : null; + if (line < 1 || (column !== null && column < 1)) return null; + return { line: line, column: column }; + } + function parseFilePathTrailingLineTarget(filePath) { + var match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath); + if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\') return null; + var line = parseInt(match[2], 10); + var column = match[3] ? parseInt(match[3], 10) : null; + if (line < 1 || (column !== null && column < 1)) return null; + return { pathText: match[1], line: line, column: column }; + } + function captureInitialOscLinkTexts() { + if (!Array.isArray(initialOscLinks)) return; + for (var i = 0; i < initialOscLinks.length; i++) { + var link = initialOscLinks[i]; + if (!link || typeof link.text === 'string') continue; + link.text = initialOscLinkTextAtRow(link, link.row); + } + } + function initialOscLinkTextStillMatches(link, row) { + if (typeof link.text !== 'string') return false; + return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text; + } + function initialOscLinkTextAtRow(link, row) { + try { + var lineText = getLineText(row); + var start = cellColToStringIndex(row, link.startCol); + var end = cellColToStringIndex(row, link.endCol); + return lineText.slice(start, end); + } catch (e) { + return ''; + } + } + function oscLinkIdAtCell(line, col) { + try { + var bufCell = line.getCell(col); + return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0; + } catch (e) { return 0; } + } + + function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) { + var tappedOscLink = oscLinkAtViewportPoint(originX, originY); + if (tappedOscLink && tappedOscLink.kind === 'file') { + notify({ + type: 'terminal-file-tap', + pathText: tappedOscLink.fileTap.pathText, + line: tappedOscLink.fileTap.line, + column: tappedOscLink.fileTap.column + }); + return; + } + var tappedFileUrl = fileUrlAtViewportPoint(originX, originY); + var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null; + if (tappedFileUrlPath) { + notify({ + type: 'terminal-file-tap', + pathText: tappedFileUrlPath.pathText, + line: tappedFileUrlPath.line, + column: tappedFileUrlPath.column + }); + return; + } + var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY); + if (tappedUrl) { + notify({ type: 'open-url', url: tappedUrl }); + return; + } + var tappedPath = filePathAtViewportPoint(originX, originY); + if (tappedPath) { + notify({ + type: 'terminal-file-tap', + pathText: tappedPath.pathText, + line: tappedPath.line, + column: tappedPath.column + }); + return; + } + var clickInput = buildMouseClickInput(originX, originY); + if (clickInput) { + notify({ type: 'terminal-input', bytes: clickInput }); + } + // Touch still needs native input focus after the TUI consumes its mouse click. + if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { + notify({ type: 'terminal-tap' }); + } + } + + + function seedWordSelection(col, absRow) { + var line = getLineText(absRow); + if (!line) { + sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null }; + applyXtermSelection(); + return; + } + var s = col; + var e = col; + if (col >= 0 && col < line.length && WORD_RE.test(line[col])) { + while (s > 0 && WORD_RE.test(line[s - 1])) s--; + while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++; + } + sel = { + anchor: { col: s, row: absRow }, + focus: { col: e, row: absRow }, + activeHandle: null + }; + applyXtermSelection(); + } + + function isStartFirst(a, b) { + if (a.row !== b.row) return a.row < b.row; + return a.col <= b.col; + } + + function selRange() { + if (!sel) return null; + if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus }; + return { start: sel.focus, end: sel.anchor }; + } + + function applyXtermSelection() { + if (!term || !sel) return; + var r = selRange(); + if (!r) return; + // Why: term.select(col, row, length) takes a buffer-absolute row, + // not a viewport-relative one. Subtracting viewportY here drifts the + // selection by the scrollback height — handles render where the user + // pressed (their math is independent), but xterm highlights an + // off-screen scrollback region and copies the wrong text. + var length; + if (r.start.row === r.end.row) { + length = Math.max(1, r.end.col - r.start.col + 1); + } else { + var first = term.cols - r.start.col; + var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols; + var last = r.end.col + 1; + length = first + middle + last; + } + try { term.select(r.start.col, r.start.row, length); } catch (e) {} + } + + function cancelSelect() { + selMode = 'navigate'; + sel = null; + stopEdgeScroll(); + if (term) { + try { term.clearSelection(); } catch (e) {} + // Why: some xterm renderers cache cells and skip repaint on + // clearSelection alone, leaving the previously-highlighted cells + // visually selected. Force a full refresh so the selection layer + // actually clears on screen. + try { term.refresh(0, term.rows - 1); } catch (e) {} + } + selectionOverlay.classList.remove('active'); + notify({ type: 'set-select-mode', enabled: false }); + } + + function enterSelect(col, absRow) { + selMode = 'select'; + seedWordSelection(col, absRow); + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + notify({ type: 'haptic', kind: 'selection' }); + repositionOverlay(); + } + + function repositionOverlay() { + if (selMode !== 'select' || !sel || !term) return; + var r = selRange(); + var sPx = cellToViewportPx(r.start.col, r.start.row); + var ePx = cellToViewportPx(r.end.col + 1, r.end.row); + var cellH = getCellHeight() * getTotalScale(); + // Why: native iOS pattern — start handle anchors at the TOP of the + // first selected cell (dot above, stem covers the cell going down); + // end handle anchors at the BOTTOM of the last selected cell (dot + // below, stem covers the cell going up). + handleStart.style.left = sPx.x + 'px'; + handleStart.style.top = sPx.y + 'px'; + handleEnd.style.left = ePx.x + 'px'; + handleEnd.style.top = (ePx.y + cellH) + 'px'; + var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight; + var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight; + handleStart.style.visibility = startVisible ? 'visible' : 'hidden'; + handleEnd.style.visibility = endVisible ? 'visible' : 'hidden'; + var menuCenterX, menuY, vTransform, marginTop; + if (startVisible && sPx.y > 56) { + menuCenterX = sPx.x; menuY = sPx.y; + vTransform = 'translateY(-100%)'; + marginTop = '-12px'; + } else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) { + menuCenterX = ePx.x; menuY = ePx.y + cellH; + vTransform = 'translateY(0)'; + marginTop = '12px'; + } else { + // selection covers full viewport — pin to visible center + menuCenterX = window.innerWidth / 2; + menuY = window.innerHeight / 2; + vTransform = 'translateY(-50%)'; + marginTop = '0'; + } + // Why: clamp horizontally so the pill stays fully visible when the + // selection sits near a screen edge. We position via plain left + // (no horizontal translate) so the clamp math is straightforward. + selMenu.style.transform = vTransform; + selMenu.style.marginTop = marginTop; + selMenu.style.top = menuY + 'px'; + selMenu.style.left = '0px'; + var EDGE_MARGIN = 8; + var menuW = selMenu.offsetWidth || 0; + var minLeft = EDGE_MARGIN; + var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN); + var desiredLeft = menuCenterX - menuW / 2; + var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft)); + selMenu.style.left = clampedLeft + 'px'; + } + + function syncSelectionHandleToViewportPoint(handle, clientX, clientY) { + var c = viewportToCell(clientX, clientY); + if (!c || !sel) return false; + if (handle === 'start') sel.anchor = c; + else sel.focus = c; + applyXtermSelection(); + return true; + } + + function syncEdgeScrollSelectionEndpoint() { + if (!sel || !sel.activeHandle) return false; + // Why: WebView may not emit new touchmove events while a handle is held + // at the edge; resample the stored finger point after each viewport scroll. + return syncSelectionHandleToViewportPoint( + sel.activeHandle, + edgeScrollClientX, + edgeScrollClientY + ); + } + + function startEdgeScroll(dir) { + if (edgeScrollDir === dir) return; + stopEdgeScroll(); + edgeScrollDir = dir; + edgeScrollTimer = setInterval(function() { + if (!term || edgeScrollDir === 0) return; + var beforeY = term.buffer.active.viewportY; + term.scrollLines(edgeScrollDir); + var afterY = term.buffer.active.viewportY; + if (beforeY === afterY) { + notify({ type: 'haptic', kind: 'edge-bump' }); + stopEdgeScroll(); + return; + } + syncEdgeScrollSelectionEndpoint(); + repositionOverlay(); + }, EDGE_SCROLL_INTERVAL); + } + + function stopEdgeScroll() { + if (edgeScrollTimer) { + clearInterval(edgeScrollTimer); + edgeScrollTimer = null; + } + edgeScrollDir = 0; + } + + function handleDragMove(handle, clientX, clientY) { + edgeScrollClientX = clientX; + edgeScrollClientY = clientY; + if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) return; + repositionOverlay(); + if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1); + else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1); + else stopEdgeScroll(); + } + + // Latching document-level touch dispatcher: see + // terminal-webview-tap-dispatch-injected.ts (extracted for max-lines). + + // ============================================================ + // LATCHING TOUCH DISPATCHER (document-level) + // ============================================================ + var dispatch = { mode: 'idle', touchId: null, touchIds: null, longPressFingerInsideOverlay: false }; + + function touchById(touches, id) { + for (var i = 0; i < touches.length; i++) { + if (touches[i].identifier === id) return touches[i]; + } + return null; + } + + function targetInside(target, el) { + if (!target || !el) return false; + return el.contains(target); + } + + function clearLongPress() { + if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } + longPressOrigin = null; + } + + function armLongPress(touch) { + longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier }; + longPressTimer = setTimeout(function() { + longPressTimer = null; + if (!longPressOrigin) return; + var c = viewportToCell(longPressOrigin.x, longPressOrigin.y); + if (!c) return; + enterSelect(c.col, c.row); + }, LONG_PRESS_MS); + } + + function touchSlopExceeded(t) { + if (!longPressOrigin) return false; + var dx = Math.abs(t.clientX - longPressOrigin.x); + var dy = Math.abs(t.clientY - longPressOrigin.y); + return (dx + dy) > LONG_PRESS_SLOP; + } + + // Why: existing surface handlers stay attached to surface but we wrap + // their entry to no-op when the dispatcher latches into select-drag. + function dispatcherShouldBlockSurface() { + return dispatch.mode === 'select-drag'; + } + + document.addEventListener('touchstart', function(e) { + var t = e.touches[0]; + var target = e.target; + var onHandle = target === handleStart || target === handleEnd; + var inOverlay = targetInside(target, selectionOverlay); + var inSurface = targetInside(target, surface); + // Why: clear any stale tap candidate up front; only a fresh single-finger + // surface touch (below) re-arms it, so handle drags / pinches / dismiss + // taps never resolve as a link tap on touchend. + tapCandidate = null; + + if (e.touches.length === 2) { + // pinch latch + if (selMode === 'select') { + notify({ type: 'mobile-clip-cancel-by-pinch' }); + cancelSelect(); + } + dispatch.mode = 'pinch'; + dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]; + clearLongPress(); + return; + } + + if (onHandle && selMode === 'select') { + // start handle drag + var handleName = (target === handleStart) ? 'start' : 'end'; + sel.activeHandle = handleName; + dispatch.mode = 'select-drag'; + dispatch.touchId = t.identifier; + e.preventDefault(); + return; + } + + if (inOverlay) { + // tap on menu pill — let the buttons' own handlers fire + return; + } + + if (inSurface && selMode === 'select') { + // Why: tap-to-dismiss matches native iOS/Android — touching outside the + // selection clears it. We cancel immediately and latch to 'surface' so + // the same gesture still drives scroll/pan without a second touch. + cancelSelect(); + dispatch.mode = 'surface'; + dispatch.touchId = t.identifier; + return; + } + + if (inSurface) { + dispatch.mode = 'surface'; + dispatch.touchId = t.identifier; + tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }; + armLongPress(t); + } + }, { capture: true, passive: false }); + + document.addEventListener('touchmove', function(e) { + if (dispatch.mode === 'select-drag') { + var t = touchById(e.touches, dispatch.touchId); + if (!t || !sel || !sel.activeHandle) return; + e.preventDefault(); + handleDragMove(sel.activeHandle, t.clientX, t.clientY); + return; + } + if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { + // long-press slop check + if (longPressTimer && e.touches.length === 1) { + if (touchSlopExceeded(e.touches[0])) clearLongPress(); + } + // Why: disqualify the tap only once the finger travels past TAP_SLOP + // (a scroll/pan), independent of the long-press timer — so a tap that + // jitters under TAP_SLOP still opens the link/path under the finger. + if (tapCandidate && e.touches.length === 1) { + var mt = e.touches[0]; + if (mt.identifier === tapCandidate.identifier) { + var dx = Math.abs(mt.clientX - tapCandidate.x); + var dy = Math.abs(mt.clientY - tapCandidate.y); + if (dx + dy > TAP_SLOP) tapCandidate = null; + } + } else if (e.touches.length !== 1) { + tapCandidate = null; + } + // existing surface handler will run from its own listener + } + }, { capture: true, passive: false }); + + document.addEventListener('touchend', function(e) { + if (dispatch.mode === 'select-drag') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + dispatch.mode = 'idle'; + dispatch.touchId = null; + return; + } + if (dispatch.mode === 'pinch') { + if (e.touches.length < 2) { + dispatch.mode = (e.touches.length === 1) ? 'surface' : 'idle'; + dispatch.touchIds = null; + if (e.touches.length === 1) dispatch.touchId = e.touches[0].identifier; + } + return; + } + if (dispatch.mode === 'surface') { + // Why: fire the tap from the tap-candidate origin (survives jitter under + // TAP_SLOP) rather than longPressOrigin, which the press-to-select slop + // can null mid-tap — that was dropping URL/file taps that moved a few px. + if ( + e.touches.length === 0 && + tapCandidate && + selMode !== 'select' && + Date.now() - tapCandidate.t <= TAP_MAX_MS + ) { + notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); + } + clearLongPress(); + tapCandidate = null; + if (e.touches.length === 0) { + dispatch.mode = 'idle'; + dispatch.touchId = null; + } + } + }, { capture: true, passive: true }); + + document.addEventListener('touchcancel', function() { + clearLongPress(); + tapCandidate = null; + stopEdgeScroll(); + if (dispatch.mode === 'select-drag') { + if (sel) sel.activeHandle = null; + } + dispatch.mode = 'idle'; + dispatch.touchId = null; + dispatch.touchIds = null; + }, { capture: true, passive: true }); + + + // External mouse / trackpad scroll: see + // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). + + var wheelAccumDeltaY = 0; + + function wheelEventPixelDeltaY(e) { + var delta = e.deltaY; + if (typeof delta !== 'number' || !isFinite(delta) || delta === 0) return 0; + // DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas + // for external mouse wheels, iOS trackpads report pixels. + if (e.deltaMode === 1) return delta * getCellHeight() * getTotalScale(); + if (e.deltaMode === 2) return delta * window.innerHeight; + return delta; + } + + function attachSurfaceWheelHandler(targetSurface) { + targetSurface.addEventListener('wheel', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + // Why: xterm's own wheel handler scrolls its hidden viewport or emits + // cursor keys through onData, which the mobile query-reply gate drops. + // Claim the event so indirect pointers share the touch scroll router. + e.preventDefault(); + e.stopPropagation(); + + // Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than + // firing cursor keys at the TUI; two-finger pinch still drives text size. + if (e.ctrlKey) return; + + var deltaY = wheelEventPixelDeltaY(e); + if (deltaY === 0) return; + + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + if (!(effectiveCellH > 0)) return; + wheelAccumDeltaY += deltaY; + var lines = Math.trunc(wheelAccumDeltaY / effectiveCellH); + if (lines !== 0) { + wheelAccumDeltaY -= lines * effectiveCellH; + routeScrollLines(lines, e.clientX, e.clientY); + } + return; + } + wheelAccumDeltaY = 0; + enqueueNormalBufferScrollDelta(deltaY); + }, { capture: true, passive: false }); + } + + + // External mouse click/drag: see + // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). + + var mouseGesture = null; + + // One report per transition, built with the same encoding ladder as + // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' + // when the mode does not report this transition (x10 has no release, only + // drag/any report motion) or the cell is not encodable. + function buildMouseButtonReport(kind, clientX, clientY) { + var mouseTrackingMode = getMouseTrackingMode(); + if (mouseTrackingMode === 'none') return ''; + if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return ''; + if (kind === 'release' && mouseTrackingMode === 'x10') return ''; + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + var sgrButton = kind === 'motion' ? 32 : 0; + var sgrFinal = kind === 'release' ? 'm' : 'M'; + if (sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; + return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal; + } + var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32; + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path; drop instead of corrupting input. + if (col > 126 || row > 126) return ''; + return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function mouseReportCellKey(clientX, clientY) { + var cell = viewportToMouseReportCell(clientX, clientY); + return cell ? cell.col + ',' + cell.row : null; + } + + function abandonMouseGesture() { + var gesture = mouseGesture; + mouseGesture = null; + if (!gesture) return; + if (gesture.mode === 'tracking') { + // Why: the press report already went to the TUI; a lost pointer must not + // leave the button latched down on the far side. + var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY); + if (release) notify({ type: 'terminal-input', bytes: release }); + } else if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + } + } + + function beginMouseDrag(gesture) { + gesture.moved = true; + if (getMouseTrackingMode() !== 'none') { + gesture.mode = 'tracking'; + gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY); + var press = buildMouseButtonReport('press', gesture.startX, gesture.startY); + if (press) notify({ type: 'terminal-input', bytes: press }); + return; + } + var anchor = viewportToCell(gesture.startX, gesture.startY); + if (!anchor) { + gesture.mode = 'cancelled'; + return; + } + // Why: mouse drags select character-anchored ranges like desktop terminals, + // not the word-seeded long-press selection; reuse the touch handle-drag + // plumbing (edge scroll included) by acting as a live 'end' handle. + gesture.mode = 'selecting'; + selMode = 'select'; + sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }; + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + applyXtermSelection(); + repositionOverlay(); + } + + function attachSurfaceMouseClickDragHandler(targetSurface) { + targetSurface.addEventListener('pointerdown', function(e) { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + if (dispatcherShouldBlockSurface() || !term) return; + // Why: a pointerup lost outside the WebView must not leave the previous + // gesture latched (tracking press with no release) when the next one lands. + if (mouseGesture) abandonMouseGesture(); + // Why: mouse pointers have no implicit capture; without it a drag that + // leaves the surface drops pointermove/pointerup and strands the gesture. + try { + if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId); + } catch (err) {} + mouseGesture = { + startX: e.clientX, startY: e.clientY, + lastX: e.clientX, lastY: e.clientY, + lastCellKey: null, + moved: false, + mode: 'pending', + dismissedSelection: false + }; + if (selMode === 'select') { + // Why: touch parity — pressing outside the pill dismisses the current + // selection; the same press may still start a new drag selection. + cancelSelect(); + mouseGesture.dismissedSelection = true; + } + }, true); + + targetSurface.addEventListener('pointermove', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; + if (!term) return; + gesture.lastX = e.clientX; + gesture.lastY = e.clientY; + if ((e.buttons & 1) === 0) { + // Why: a pointerup lost outside the WebView (capture unavailable) must + // end the gesture here, or a tracked press stays latched at the TUI. + // Coordinates first, so the synthesized release lands where the + // pointer re-entered rather than at the previous cell. + abandonMouseGesture(); + return; + } + if (!gesture.moved) { + var dx = Math.abs(e.clientX - gesture.startX); + var dy = Math.abs(e.clientY - gesture.startY); + if (dx + dy <= TAP_SLOP) return; + beginMouseDrag(gesture); + } + if (gesture.mode === 'tracking') { + // Why: one motion report per cell keeps drags bounded by grid size, not + // by pointermove cadence, so the RN rate limiter is never the bottleneck. + var cellKey = mouseReportCellKey(e.clientX, e.clientY); + if (cellKey && cellKey !== gesture.lastCellKey) { + gesture.lastCellKey = cellKey; + var motion = buildMouseButtonReport('motion', e.clientX, e.clientY); + if (motion) notify({ type: 'terminal-input', bytes: motion }); + } + } else if (gesture.mode === 'selecting') { + handleDragMove('end', e.clientX, e.clientY); + } + }, true); + + targetSurface.addEventListener('pointerup', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return; + mouseGesture = null; + if (gesture.mode === 'cancelled' || !term) return; + if (gesture.mode === 'tracking') { + var release = buildMouseButtonReport('release', e.clientX, e.clientY); + if (release) notify({ type: 'terminal-input', bytes: release }); + return; + } + if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + repositionOverlay(); + return; + } + if (dispatcherShouldBlockSurface()) return; + // Why: a dismissing tap only clears the selection (touch parity); it must + // not also open a link or focus the keyboard underneath. + if (gesture.dismissedSelection) return; + // Pointer clicks keep their current link, file, TUI mouse, and focus priority. + notifyTerminalSurfaceTap(e.clientX, e.clientY, false); + }, true); + + targetSurface.addEventListener('pointercancel', function(e) { + if (e.pointerType !== 'mouse') return; + abandonMouseGesture(); + }, true); + + // Why: Android input injection can pair a mouse-flavored pointerdown with + // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, + // the document touch dispatcher owns the gesture. + targetSurface.addEventListener('touchstart', function() { + if (mouseGesture) abandonMouseGesture(); + }, true); + } + + + btnCopy.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if (!term) return; + var text = term.getSelection ? term.getSelection() : ''; + if (text && text.length > 0) { + notify({ type: 'selection', text: text }); + } else { + cancelSelect(); + } + }); + + btnSelAll.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if (!term) return; + try { + term.selectAll(); + var b = term.buffer.active; + sel = { + anchor: { col: 0, row: 0 }, + focus: { col: term.cols - 1, row: b.length - 1 }, + activeHandle: null + }; + repositionOverlay(); + } catch (err) {} + }); + + var ts = { + lastX: 0, lastY: 0, lastTime: 0, velY: 0, + accumDelta: 0, momentumId: null, isPinching: false, + pinchDist: 0, pinchScale: 0, pinchSurfX: 0, pinchSurfY: 0 + }; + + function updateTouchVelocity(deltaY, dt) { + if (dt <= 0) return; + var instantVelocity = deltaY / dt; + if (!isFinite(instantVelocity)) return; + // Why: touchmove cadence is uneven in WebView. Blend recent samples so + // momentum launch doesn't inherit a one-frame spike or stall. + ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45; + } + + function getDistance(a, b) { + var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + function attachSurfaceEventHandlers(targetSurface) { + if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return; + targetSurface.__orcaSurfaceHandlersAttached = true; + // Why: init() swaps in a new hidden surface to avoid flicker; each + // replacement needs gesture handlers or tab-switch replays stop scrolling. + targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true); + targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); + + attachSurfaceWheelHandler(targetSurface); + attachSurfaceMouseClickDragHandler(targetSurface); + + targetSurface.addEventListener('touchstart', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (ts.momentumId) { + cancelAnimationFrame(ts.momentumId); + ts.momentumId = null; + } + if (e.touches.length === 2) { + ts.isPinching = true; + smoothScrollOffsetY = 0; + ts.pinchDist = getDistance(e.touches[0], e.touches[1]); + ts.pinchScale = userScale; + var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; + var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; + var total = getTotalScale(); + ts.pinchSurfX = (mx - panX) / total; + ts.pinchSurfY = (my - panY) / total; + } else if (e.touches.length === 1) { + ts.isPinching = false; + ts.lastX = e.touches[0].clientX; + ts.lastY = e.touches[0].clientY; + ts.lastTime = Date.now(); + ts.velY = 0; + ts.accumDelta = 0; + } + }, { capture: true, passive: true }); + + targetSurface.addEventListener('touchmove', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + e.preventDefault(); + e.stopPropagation(); + + if (e.touches.length === 2) { + ts.isPinching = true; + var dist = getDistance(e.touches[0], e.touches[1]); + var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; + var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; + + var ratio = dist / ts.pinchDist; + // Why: userScale is a CSS multiplier on the current font size; bound it so + // the resulting apparent size (currentTextScale × userScale) stays within + // the preset range, since release snaps to one of those presets. + var loScale = MIN_TEXT_SCALE / currentTextScale; + var hiScale = MAX_TEXT_SCALE / currentTextScale; + userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio)); + + var total = getTotalScale(); + panX = mx - ts.pinchSurfX * total; + panY = my - ts.pinchSurfY * total; + clampPan(); + updateTransform(); + + } else if (e.touches.length === 1 && !ts.isPinching) { + var x = e.touches[0].clientX, y = e.touches[0].clientY; + var now = Date.now(), dt = now - ts.lastTime; + + // Why: pan horizontally only when content overflows the viewport (larger + // than fit) — same check clampPan() uses. Vertical always drives buffer + // scroll so scrollback stays reachable at any text size; calling the + // never-defined contentWiderThanViewport() here threw and killed all + // single-finger scrolling, scrollback included. + if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { + panX += x - ts.lastX; + clampPan(); + updateTransform(); + } + + var deltaY = ts.lastY - y; + ts.lastTime = now; + if (shouldRouteScrollToTerminalInput()) { + updateTouchVelocity(deltaY, dt); + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += deltaY; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, x, y); + } + } else { + if (enqueueNormalBufferScrollDelta(deltaY)) { + updateTouchVelocity(deltaY, dt); + } else { + ts.velY = 0; + } + } + ts.lastX = x; + ts.lastY = y; + } + }, { capture: true, passive: false }); + + targetSurface.addEventListener('touchend', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + + if (ts.isPinching && e.touches.length < 2) { + ts.isPinching = false; + // Why: a finished pinch snaps to the nearest preset and becomes the new + // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way + // to set the text size. The CSS pinch zoom (userScale) is reset; the real + // size change reflows columns and RN persists + resizes the PTY to match. + var target = snapToTextScalePreset(currentTextScale * userScale); + var changed = target !== currentTextScale; + userScale = 1; + panX = 0; panY = 0; + applyTextScale(target); + updateTransform(); + notify({ type: 'font-scale-changed', fontScale: target }); + if (changed) notify({ type: 'haptic', kind: 'selection' }); + if (e.touches.length === 1) { + ts.lastX = e.touches[0].clientX; + ts.lastY = e.touches[0].clientY; + ts.lastTime = Date.now(); + ts.velY = 0; + ts.accumDelta = 0; + } + return; + } + + if (e.touches.length === 0) { + var vel = ts.velY; + var FRICTION = 0.972; + var MIN_VEL = 0.012; + function momentumStep() { + vel *= FRICTION; + if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } + var delta = vel * 16; + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += delta; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, ts.lastX, ts.lastY); + } + } else { + if (!applyNormalBufferScrollDelta(delta)) { + ts.momentumId = null; + return; + } + } + ts.momentumId = requestAnimationFrame(momentumStep); + } + if (Math.abs(vel) > MIN_VEL) { + ts.momentumId = requestAnimationFrame(momentumStep); + } + } + }, { capture: true, passive: true }); + } + + attachSurfaceEventHandlers(surface); + + function handleIncomingMessage(e) { + var msg; + try { + msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; + } catch (ex) { + return; + } + try { + handleMsg(msg); + } catch(ex) { + reportEngineError( + msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', + ex, + msg && msg.type === 'init' && !everReady + ); + } + } + + window.addEventListener('message', handleIncomingMessage); + + document.addEventListener('message', handleIncomingMessage); + + window.addEventListener('resize', function() { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize'); + adjustRowsForViewport(); + repositionOverlay(); + clampPan(); + updateTransform(); + }); + + if (window.Terminal) { + notify({ type: 'web-ready' }); + } else { + reportEngineError('terminal engine missing', 'xterm failed to load', true); + } +})(); diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts b/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts deleted file mode 100644 index a3a29ac61f9..00000000000 --- a/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts +++ /dev/null @@ -1,43 +0,0 @@ -export const TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS = ` - function lineHasVisibleContent(line, cell) { - if (line.translateToString(true).trim().length > 0) return true; - if (!cell || !line.getCell) return false; - var limit = Math.min(term.cols || 0, line.length || 0); - for (var x = 0; x < limit; x++) { - var current = line.getCell(x, cell); - if (!current) continue; - if (!current.isBgDefault() || current.isInverse()) return true; - if (typeof current.isUnderline === 'function' && current.isUnderline()) return true; - if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true; - if (typeof current.isOverline === 'function' && current.isOverline()) return true; - } - return false; - } - - function computeContentBottomRow() { - if (!term || !term.buffer || !term.buffer.active) return 0; - var buffer = term.buffer.active; - var top = buffer.viewportY || 0; - var cell = buffer.getNullCell ? buffer.getNullCell() : null; - for (var y = (term.rows || 0) - 1; y >= 0; y--) { - try { - var line = buffer.getLine(top + y); - if (line && lineHasVisibleContent(line, cell)) return y; - } catch (e) {} - } - return 0; - } - - function emitKeyboardAvoidanceMetrics() { - if (!term) return; - var alt = false; - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - notify({ - type: 'keyboard-avoidance-metrics', - cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, - contentBottomRow: alt ? 0 : computeContentBottomRow(), - rows: term.rows || 0, - altScreen: alt - }); - } -` diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts index 203db63bc65..1b028395003 100644 --- a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts +++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts @@ -1,16 +1,16 @@ -import { readFileSync } from 'node:fs' import { Script } from 'node:vm' import { Terminal } from '@xterm/xterm' import { describe, expect, it, vi } from 'vitest' -import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected' +import { + documentScopePreamble, + generatedDocumentModule +} from './document/generated-document-region.test-support' import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' +import { XTERM_HTML } from './terminal-webview-html' -const terminalHtmlSource = readTerminalWebViewHtmlSource() -const reflowSource = readFileSync( - new URL('./terminal-webview-reflow-injected.ts', import.meta.url), - 'utf8' -) +const terminalHtmlSource = XTERM_HTML +// The scope object plus the metrics block, exactly as the document carries them. +const keyboardAvoidanceMetricsScript = `${documentScopePreamble()}\nscope.term = term;\n${await generatedDocumentModule('keyboard-avoidance-metrics')}` type Cell = { isBgDefault: () => boolean; isInverse: () => number } type MetricsNotification = { @@ -48,17 +48,15 @@ function runMetrics(lines: (ReturnType | undefined)[], altScree notify: (message: Record) => notifications.push(message), term: { buffer: { active: buffer }, cols: 10, rows: lines.length } } - new Script( - `${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();` - ).runInNewContext(context) + new Script(`${keyboardAvoidanceMetricsScript}\nemitKeyboardAvoidanceMetrics();`).runInNewContext( + context + ) return notifications[0] as MetricsNotification } function runTerminalMetrics(term: Terminal) { const notifications: Record[] = [] - new Script( - `${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();` - ).runInNewContext({ + new Script(`${keyboardAvoidanceMetricsScript}\nemitKeyboardAvoidanceMetrics();`).runInNewContext({ notify: (message: Record) => notifications.push(message), term }) @@ -193,17 +191,19 @@ describe('terminal keyboard-avoidance WebView metrics', () => { it('refreshes metrics after every buffer geometry reset', () => { const resizeStart = terminalHtmlSource.indexOf(' function resize(cols, rows)') - const resizeEnd = terminalHtmlSource.indexOf('\n // reflow()', resizeStart) - const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {") - const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart) + const resizeEnd = terminalHtmlSource.indexOf('\n function reflow(', resizeStart) + const clearStart = terminalHtmlSource.indexOf('} else if (msg.type === "clear") {') + const clearEnd = terminalHtmlSource.indexOf('} else if (msg.type === "measure")', clearStart) const textScaleStart = terminalHtmlSource.indexOf(' function applyTextScale(scale)') - const textScaleEnd = terminalHtmlSource.indexOf('\n var panX', textScaleStart) + const textScaleEnd = terminalHtmlSource.indexOf('\n scope.panX', textScaleStart) + const reflowStart = terminalHtmlSource.indexOf(' function reflow(cols, rows)') + const reflowEnd = terminalHtmlSource.indexOf('\n function notify(', reflowStart) for (const block of [ terminalHtmlSource.slice(resizeStart, resizeEnd), terminalHtmlSource.slice(clearStart, clearEnd), terminalHtmlSource.slice(textScaleStart, textScaleEnd), - reflowSource + terminalHtmlSource.slice(reflowStart, reflowEnd) ]) { expect(block.indexOf('emitKeyboardAvoidanceMetrics()')).toBeGreaterThan( block.includes('term.resize') ? block.indexOf('term.resize') : block.indexOf('term.reset') diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts deleted file mode 100644 index 766c381ea22..00000000000 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Plain-JS file-path-under-tap detection, injected verbatim into the terminal -// WebView's xterm script (XTERM_HTML). It is interpolated with ${...}, so the -// regex backslashes here are single (the real runtime form) — not the doubled -// form a backtick template literal would otherwise require. -// -// This mirrors the unit-tested mobile/src/terminal/terminal-path-tap.ts; keep -// the two in sync. The TS module is the source of truth for the algorithm and -// has the regression tests; this string only exists because the WebView can't -// import RN modules. -// -// Matches both slash-bearing paths AND bare filenames with an extension -// (README.md, src/index.ts:5) — like desktop, we propose candidates and let the -// host's files.resolveTerminalPath existence check reject non-files. Agents -// often print a bare filename (the markdown link target is consumed, leaving -// only the label text), so requiring a slash would miss the common case. -export const TERMINAL_PATH_TAP_JS = String.raw` - var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g; - var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g; - var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 }; - var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 }; - - function parsePathLineCol(value) { - var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value); - if (!m) return null; - var pathText = m[1]; - var last = pathText.charAt(pathText.length - 1); - if (!pathText || last === '/' || last === '\\') return null; - var line = m[2] ? parseInt(m[2], 10) : null; - var column = m[3] ? parseInt(m[3], 10) : null; - if ((line !== null && line < 1) || (column !== null && column < 1)) return null; - return { pathText: pathText, line: line, column: column }; - } - - function trimPathBoundaryPunctuation(raw, rawStart) { - var start = 0, end = raw.length; - while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1; - while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1; - if (start >= end) return null; - return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }; - } - - function hasSeparatorAfterWhitespace(text) { - var sawWhitespace = false; - for (var i = 0; i < text.length; i++) { - var ch = text.charAt(i); - if (/\s/.test(ch)) { sawWhitespace = true; continue; } - if (sawWhitespace && (ch === '/' || ch === '\\')) return true; - } - return false; - } - - function trimSpacedPathTrailingProse(range, col) { - // A line-end extension token only extends the span when the added segment - // is path-like (contains a separator) — prose must not be swallowed. - var selected = null; - var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g; - var match; - while ((match = extensionPrefixPattern.exec(range.text)) !== null) { - var end = match.index + match[0].length; - var text = range.text.slice(0, end); - if (countPathStarts(text) > 1) continue; - if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) { - selected = text; - } - } - if (selected) { - if (col !== undefined && col >= range.startIndex + selected.length) return null; - return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length }; - } - var text = range.text.replace(/\s+$/, ''); - return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length }; - } - - function countPathStarts(text) { - var count = 0; - var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g; - while (pathStartPattern.exec(text) !== null) count += 1; - return count; - } - - function hasSpacedPathExtension(text) { - var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length }); - if (!range) return false; - var trimmed = range.text.replace(/\s+$/, ''); - return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed); - } - - function matchSpacedFilePathAtColumn(lineText, col) { - SPACED_PATH_RE.lastIndex = 0; - var match; - while ((match = SPACED_PATH_RE.exec(lineText)) !== null) { - var trimmed = trimPathBoundaryPunctuation(match[0], match.index); - if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue; - var candidate = trimSpacedPathTrailingProse(trimmed, col); - if (!candidate) continue; - if (col < candidate.startIndex || col >= candidate.endIndex) continue; - var parsed = parsePathLineCol(candidate.text); - if (parsed) return parsed; - } - return null; - } - - function matchFilePathAtColumn(lineText, col) { - var spaced = matchSpacedFilePathAtColumn(lineText, col); - if (spaced) return spaced; - FILE_PATH_RE.lastIndex = 0; - var match; - while ((match = FILE_PATH_RE.exec(lineText)) !== null) { - var raw = match[0]; - if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; } - var trimmed = trimPathBoundaryPunctuation(raw, match.index); - if (!trimmed) continue; - if (col < trimmed.startIndex || col >= trimmed.endIndex) continue; - var parsed = parsePathLineCol(trimmed.text); - if (parsed) return parsed; - } - return null; - } - - // Returns the path candidate under the tap, or null. Query-only so the tap - // handler can try file detection before forwarding a mouse click — which lets - // file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/ - // getLineText from the host script scope. - function filePathAtViewportPoint(originX, originY) { - var tapCell = viewportToCell(originX, originY); - if (!tapCell) return null; - // Map the cell column to a string index so wide chars (emoji/CJK) earlier on - // the line don't shift the match column off the tapped path. - return matchFilePathAtColumn( - getLineText(tapCell.row), - cellColToStringIndex(tapCell.row, tapCell.col) - ); - } -` diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts index ebb8e0b9ec3..417d7ca24cc 100644 --- a/mobile/src/terminal/terminal-path-tap.test.ts +++ b/mobile/src/terminal/terminal-path-tap.test.ts @@ -4,9 +4,11 @@ import { TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES, columnForTerminalFileLinkTap } from '../../../src/shared/terminal-file-link-conformance' -import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' +import { generatedDocumentModule } from './document/generated-document-region.test-support' import { matchFilePathAtColumn, parsePathWithOptionalLineColumn } from './terminal-path-tap' +const pathTapSource = await generatedDocumentModule('path-tap') + type InjectedPathMatcher = typeof matchFilePathAtColumn // Returns the column of the first occurrence of `needle` in `line` (+offset). @@ -182,7 +184,7 @@ describe('injected matchFilePathAtColumn', () => { function createInjectedPathMatcher(): InjectedPathMatcher { const context = createContext({}) new Script( - `${TERMINAL_PATH_TAP_JS}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;` + `${pathTapSource}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;` ).runInContext(context) return (context as { __matchFilePathAtColumn: InjectedPathMatcher }).__matchFilePathAtColumn } diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index ac415922851..08058f3e6f1 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -2,22 +2,18 @@ import { Script } from 'node:vm' import { parse } from 'acorn' import { describe, expect, it, vi } from 'vitest' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' +import { documentScopePreamble } from './document/generated-document-region.test-support' import { XTERM_HTML } from './terminal-webview-html' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' -import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' // Assert against the assembled document so extracted fragments cannot silently // disappear from the WebView while source-level checks still pass. -const terminalHtmlSource = readTerminalWebViewHtmlSource() +const terminalHtmlSource = XTERM_HTML function createWebglRecoveryHarness(failSecondAttach = false) { - const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;') - const variablesEnd = terminalHtmlSource.indexOf( - '\n', - terminalHtmlSource.indexOf(' var webglRecoveryTimer = null;') - ) - expect(variablesStart).toBeGreaterThanOrEqual(0) - expect(variablesEnd).toBeGreaterThan(variablesStart) + const recoveryStart = terminalHtmlSource.indexOf(' function refreshTerminalSurface()') + const recoveryEnd = terminalHtmlSource.indexOf(' function init(', recoveryStart) + expect(recoveryStart).toBeGreaterThanOrEqual(0) + expect(recoveryEnd).toBeGreaterThan(recoveryStart) const timers: Array<() => void> = [] const addons: Array<{ @@ -74,8 +70,11 @@ function createWebglRecoveryHarness(failSecondAttach = false) { terminalThemeInput, window: { WebglAddon: { WebglAddon } } } - new Script(`${terminalHtmlSource.slice(variablesStart, variablesEnd)} -${TERMINAL_WEBGL_RECOVERY_JS} + new Script(`${documentScopePreamble()} +scope.term = term; +scope.terminalGeneration = terminalGeneration; +scope.terminalThemeInput = terminalThemeInput; +${terminalHtmlSource.slice(recoveryStart, recoveryEnd)} attachWebglAddon(true);`).runInNewContext(context) return { addons, @@ -154,14 +153,14 @@ describe('terminal WebView bundled engine', () => { it('reports WebView message handler failures instead of swallowing them', () => { const start = terminalHtmlSource.indexOf('function handleIncomingMessage') - const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start) + const end = terminalHtmlSource.indexOf('window.addEventListener("resize"', start) expect(start).toBeGreaterThanOrEqual(0) expect(end).toBeGreaterThan(start) const handlerSource = terminalHtmlSource.slice(start, end) expect(handlerSource).toContain('reportEngineError(') - expect(handlerSource).toContain("'terminal init failed'") - expect(handlerSource).toContain("'terminal message failed'") + expect(handlerSource).toContain('"terminal init failed"') + expect(handlerSource).toContain('"terminal message failed"') expect(handlerSource).not.toContain('catch(ex) {}') }) @@ -170,11 +169,11 @@ describe('terminal WebView bundled engine', () => { // old surface visible meanwhile), so the fatal default and the init-catch must // key off `everReady` — otherwise a transient reflow error blanks a live // terminal behind the fatal overlay. The latch stays set for the document. - expect(terminalHtmlSource).toContain('var everReady = false;') - expect(terminalHtmlSource).toContain('everReady = true;') - expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal') - expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady") - expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/) + expect(terminalHtmlSource).toContain('scope.everReady = false;') + expect(terminalHtmlSource).toContain('scope.everReady = true;') + expect(terminalHtmlSource).toContain('fatal === void 0 ? !scope.everReady : !!fatal') + expect(terminalHtmlSource).toContain('msg.type === "init" && !scope.everReady') + expect(terminalHtmlSource).not.toMatch(/fatal === void 0 \? !scope\.ready\b/) }) it('bounds error capture and non-fatal reporting on a degraded engine', () => { @@ -235,7 +234,7 @@ describe('terminal WebView bundled engine', () => { }) it('answers native readiness probes from the live document', () => { - expect(terminalHtmlSource).toContain("if (msg.type === 'ping')") - expect(terminalHtmlSource).toContain("notify({ type: 'pong', pingId: msg.id })") + expect(terminalHtmlSource).toContain('if (msg.type === "ping")') + expect(terminalHtmlSource).toContain('notify({ type: "pong", pingId: msg.id })') }) }) diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts deleted file mode 100644 index 19a9cfc07ba..00000000000 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { readFileSync } from 'node:fs' - -const COMPOSER_FILE = './terminal-webview-html.ts' -const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm -const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm - -function readSource(relativePath: string): string { - return readFileSync(new URL(relativePath, import.meta.url), 'utf8') -} - -/** - * Reads the TypeScript source that assembles the in-WebView document. - * - * Why: the slice list is derived from the composer's own imports rather than duplicated, so a - * new slice cannot join the emitted document while staying invisible to the tests that search - * this source. The count cross-check catches an import shape the regex cannot see. - */ -export function readTerminalWebViewHtmlSource(): string { - const composer = readSource(COMPOSER_FILE) - const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`) - const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length - if (composedCount === 0) { - throw new Error('no composed WebView document slices found') - } - if (slices.length !== composedCount) { - throw new Error( - `WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})` - ) - } - return [composer, ...slices.map(readSource)].join('\n') -} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 17fadd4d26c..54769545deb 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,38 +1,16 @@ +import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated' +import { TERMINAL_HTML_DOCUMENT_CLOSE } from './terminal-webview-html/document-close' import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell' -import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling' -import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale' -import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan' -import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue' -import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write' -import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router' -import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction' -import { TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING } from './terminal-webview-html/term-observers-and-mode-mirroring' -import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webview-html/mouse-report-and-scroll-routing' -import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry' -import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay' -import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures' -import { TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE } from './terminal-webview-html/message-bridge-and-document-close' export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' -// Why: keep the document source stable while each script/style concern remains independently -// reviewable. Boundaries can only fall where the emitted document allows, so a few modules -// carry a second concern noted at the top of the file. +// Why: the script the WebView runs is generated from `src/terminal/document/`, the same modules the +// web page imports, so there is one source for both. The shell and the close are still text: they +// are markup, not program. export const XTERM_HTML = [ TERMINAL_HTML_DOCUMENT_SHELL, - TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, - TERMINAL_HTML_FIT_SCALE, - TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN, - TERMINAL_HTML_WRITE_QUEUE, - TERMINAL_HTML_INIT_AND_WRITE, - TERMINAL_HTML_HOST_MESSAGE_ROUTER, - TERMINAL_HTML_SELECTION_STATE_AND_EVICTION, - TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING, - TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING, - TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY, - TERMINAL_HTML_SELECTION_OVERLAY, - TERMINAL_HTML_SURFACE_TOUCH_GESTURES, - TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE + TERMINAL_DOCUMENT_SCRIPT, + TERMINAL_HTML_DOCUMENT_CLOSE ].join('') export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } diff --git a/mobile/src/terminal/terminal-webview-html/document-close.ts b/mobile/src/terminal/terminal-webview-html/document-close.ts new file mode 100644 index 00000000000..693edf2201e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/document-close.ts @@ -0,0 +1,5 @@ +// Closes the document after the generated script. +export const TERMINAL_HTML_DOCUMENT_CLOSE = ` + + +` diff --git a/mobile/src/terminal/terminal-webview-html/document-shell.ts b/mobile/src/terminal/terminal-webview-html/document-shell.ts index d6e733cdd77..7dd869b1df9 100644 --- a/mobile/src/terminal/terminal-webview-html/document-shell.ts +++ b/mobile/src/terminal/terminal-webview-html/document-shell.ts @@ -161,13 +161,4 @@ window.onerror = function(msg) {
- -` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts deleted file mode 100644 index 6f0685df87e..00000000000 --- a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts +++ /dev/null @@ -1,52 +0,0 @@ -export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) { - if (typeof data !== 'string') return false; - var on = data.lastIndexOf(ESC + '[?1049h'); - var off = data.lastIndexOf(ESC + '[?1049l'); - return on !== -1 && on > off; - } - - function normalizeInitialData(data) { - if (!isAltScreenActive(data)) return data; - var on = data.lastIndexOf(ESC + '[?1049h'); - // Why: SerializeAddon can include normal-buffer scrollback before the - // active alternate-screen snapshot. Replaying both into a fresh mobile - // xterm duplicates TUI frames and can flatten SGR attributes. - return on > 0 ? data.slice(on) : data; - } - - function updateMouseModeFromData(data) { - if (typeof data !== 'string' || data.length === 0) return; - var input = mouseModeScanTail + data; - mouseModeScanTail = extractMouseModeScanTail(input); - var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); - var match; - while ((match = re.exec(input)) !== null) { - if (match[0] === ESC + 'c') { - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - continue; - } - var enabled = (match[2] || match[4]) === 'h'; - var params = (match[1] || match[3]).split(';'); - for (var i = 0; i < params.length; i++) { - if (params[i] === '') continue; - var param = Number(params[i]); - if (!Number.isInteger(param)) continue; - if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; - if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; - if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; - if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; - if (param === 1006) { - sgrMouseMode = enabled; - sgrMousePixelsMode = false; - } - if (param === 1016) { - sgrMouseMode = false; - sgrMousePixelsMode = enabled; - } - } - } - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts deleted file mode 100644 index 3b7e7f24cf3..00000000000 --- a/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { TERMINAL_MOUSE_REPORT_CELL_JS } from '../terminal-webview-mouse-report-cell-injected' - -export const TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING = ` function viewportToCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var col = Math.floor(sx / cellW); - var viewportRow = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (viewportRow < 0) viewportRow = 0; - if (viewportRow > term.rows - 1) viewportRow = term.rows - 1; - var viewportY = term.buffer.active.viewportY; - return { col: col, row: viewportRow + viewportY }; - } - - ${TERMINAL_MOUSE_REPORT_CELL_JS} - - function isAlternateBufferActive() { - try { - return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'); - } catch (e) { - return false; - } - } - - function getMouseTrackingMode() { - try { - if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') { - var mode = term.modes.mouseTrackingMode; - if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode; - return 'none'; - } - } catch (e) {} - if ( - trackedMouseTrackingMode === 'x10' || - trackedMouseTrackingMode === 'vt200' || - trackedMouseTrackingMode === 'drag' || - trackedMouseTrackingMode === 'any' - ) { - return trackedMouseTrackingMode; - } - return 'none'; - } - - function repeatSequence(sequence, count) { - var out = ''; - for (var i = 0; i < count; i++) out += sequence; - return out; - } - - function buildArrowScrollSequence(lines) { - var prefix = '['; - try { - if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O'; - } catch (e) {} - return ESC + prefix + (lines < 0 ? 'A' : 'B'); - } - - function buildMouseWheelSequence(lines, clientX, clientY) { - var cell = viewportToMouseReportCell(clientX, clientY); - if (!cell) return ''; - var eventCode = lines < 0 ? 64 : 65; - if (sgrMousePixelsMode) { - if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; - return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M'; - } - if (sgrMouseMode) { - // Why: xterm increments zero-based mouse cells before encoding reports. - var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - return ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M'; - } - // Why: xterm increments zero-based mouse cells before encoding reports. - var button = eventCode + 32; - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - // Why: non-SGR mouse bytes above ASCII are not preserved reliably through - // the mobile JSON/RPC string path. Fall back to keys for wide terminals. - if (button > 126 || col > 126 || row > 126) return ''; - return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isSafeSgrMouseCoordinate(value) { - return Number.isInteger(value) && value >= 0 && value <= 9999; - } - - function buildMouseClickInput(clientX, clientY) { - var mouseTrackingMode = getMouseTrackingMode(); - if (!isClickMouseTrackingMode(mouseTrackingMode)) return ''; - var cell = viewportToMouseReportCell(clientX, clientY); - if (!cell) return ''; - if (sgrMousePixelsMode) { - // Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions. - var pixelX = cell.x; - var pixelY = cell.y; - if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) return ''; - var pixelPress = ESC + '[<0;' + pixelX + ';' + pixelY + 'M'; - if (mouseTrackingMode === 'x10') return pixelPress; - return pixelPress + ESC + '[<0;' + pixelX + ';' + pixelY + 'm'; - } - if (sgrMouseMode) { - // Why: xterm increments zero-based mouse cells before encoding reports. - var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; - if (mouseTrackingMode === 'x10') return sgrPress; - return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; - } - // Why: non-SGR click coordinates use printable ASCII bytes on the mobile - // bridge; unsafe wide-terminal cells must not turn into corrupted input. - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - if (col > 126 || row > 126) return ''; - var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); - if (mouseTrackingMode === 'x10') return press; - return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isClickMouseTrackingMode(mode) { - return mode !== 'none'; - } - - function isWheelMouseTrackingMode(mode) { - return mode !== 'none' && mode !== 'x10'; - } - - function shouldRouteScrollToTerminalInput() { - return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); - } - - function buildMouseWheelScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var sequence = buildMouseWheelSequence(lines, clientX, clientY); - if (!sequence) return ''; - return repeatSequence(sequence, count); - } - - function buildTuiScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var mouseTrackingMode = getMouseTrackingMode(); - var sequence = ''; - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - sequence = buildMouseWheelSequence(lines, clientX, clientY); - } - if (!sequence) sequence = buildArrowScrollSequence(lines); - return repeatSequence(sequence, count); - } - - function routeScrollLines(lines, clientX, clientY) { - if (!term || lines === 0) return; - var mouseTrackingMode = getMouseTrackingMode(); - var alternateBufferActive = isAlternateBufferActive(); - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - // Why: xterm sends wheel events to mouse-aware TUIs before considering - // scrollback, even if the app stays on the normal buffer. - var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); - if (mouseInput) { - notify({ type: 'terminal-input', bytes: mouseInput }); - return; - } - // Why: default mouse encoding can be unrepresentable in our ASCII-safe - // RPC path on wide terminals. Send bounded arrows instead of local - // scrollback/no-op while a mouse-aware app owns scroll gestures. - var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); - if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); - return; - } - if (alternateBufferActive) { - // Why: alternate-screen TUIs own their scroll state and xterm has no - // scrollback there, so mobile scroll gestures must become terminal input. - var input = buildTuiScrollInput(lines, clientX, clientY); - if (input) notify({ type: 'terminal-input', bytes: input }); - return; - } - term.scrollLines(lines); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts deleted file mode 100644 index 44ce1d39042..00000000000 --- a/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { TERMINAL_QUERY_REPLY_JS } from '../terminal-webview-query-reply-injected' -import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injected' -import { TERMINAL_TEXT_SCALES } from '../../storage/preferences' -import { DEFAULT_TERMINAL_THEME } from './theme' - -// Also carries the scroll-indicator painter, which reads the scale state declared here. -export const TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; - var term = null; ${TERMINAL_QUERY_REPLY_JS} - ${TERMINAL_SURFACE_SWAP_JS} - var scrollIndicator = document.getElementById('scroll-indicator'); - var scrollThumb = document.getElementById('scroll-thumb'); - var scrollIndicatorHideTimer = null; - var writeQueue = []; - var writeQueueHead = 0; - var writesDraining = false; - var afterDrainCallbacks = []; - var termObserverDisposables = []; - var ready = false; - // Why: init() flips ready false on every re-init (live width reflow included) - // while the old surface stays visible; a document-scoped latch drives the - // fatal/non-fatal decision so a transient reflow cannot blank a live terminal. - var everReady = false; - var currentScale = 1; - // Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a - // gesture only; it resets to 1 on release. The persistent "text size" is the - // real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it - // reflows the grid: a bigger cell means fewer columns fit, and RN re-measures - // and resizes the PTY (terminal.updateViewport) so the shell rewraps to the - // new width. A finished pinch snaps to the nearest preset and reports it to RN. - var userScale = 1; - var BASE_FONT_PX = 13; - var MIN_FONT_PX = 6; - var MIN_FIT_COLS = 20; - var currentTextScale = 1; - var TEXT_SCALE_PRESETS = ${JSON.stringify([...TERMINAL_TEXT_SCALES])}; - var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]; - var MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]; - function snapToTextScalePreset(value) { - var best = TEXT_SCALE_PRESETS[0], bestDelta = Infinity; - for (var i = 0; i < TEXT_SCALE_PRESETS.length; i++) { - var delta = Math.abs(TEXT_SCALE_PRESETS[i] - value); - if (delta < bestDelta) { bestDelta = delta; best = TEXT_SCALE_PRESETS[i]; } - } - return best; - } - function fontPxForScale(scale) { - return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale)); - } - function isIOSWebView() { - if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true; - return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; - } - // Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can - // fall to a non-monospace face; lead with the ui-monospace generic to avoid that. - var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace'; - var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS; - // Why: change the real font size, then resize the grid to fit the viewport at - // the new cell metrics so the text shows at its true size immediately. RN's - // refit (measure → updateViewport) then makes the server reflow the PTY to the - // same column count so the shell rewraps. cell metrics update on the frame - // after fontSize changes, so the resize/fit is deferred one rAF. - function applyTextScale(scale) { - currentTextScale = scale; - if (!term) return; - var px = fontPxForScale(scale); - if (term.options.fontSize === px) return; - term.options.fontSize = px; - requestAnimationFrame(function() { - if (!term) return; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW > 0 && cellH > 0) { - var cols = Math.floor(window.innerWidth / cellW); - if (cols < MIN_FIT_COLS) return; - var rows = Math.max(8, Math.floor(window.innerHeight / cellH)); - term.resize(cols, rows); - emitKeyboardAvoidanceMetrics(); - } - applyFitScale('text-scale'); - }); - } - var panX = 0, panY = 0; - var smoothScrollOffsetY = 0; - var pendingNormalScrollDeltaY = 0; - var normalScrollFrameId = null; - var initRows = 24; - var terminalGeneration = 0; - var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)}; - var terminalThemeInput = null; - var terminalTheme = defaultTheme; - var terminalMinimumContrastRatio = 3; - var webglAddon = null; - var webglRecoveryTimer = null; - var activeAltScreenSnapshot = false; - var trackedMouseTrackingMode = 'none'; - var sgrMouseMode = false; - var sgrMousePixelsMode = false; - var initialOscLinks = [], initialOscLinkRowOffset = 0; - var initialOscLinkEvictionReady = false; - var mouseModeScanTail = ''; - var handledMessageIds = []; - // Why: after init() the initial scrollback applyFitScale may have run - // against an empty buffer (or one without the widest line yet). Re-fit - // once when the first live data chunk arrives so a wider line that pushes - // scrollWidth past the previously-measured value gets re-scaled to fit. - var firstDataPending = false; - - // Diagnostic logger — bridges WebView console.log to RN via postMessage. - // Tag with [fit] so it's easy to filter in the Expo/Metro logs. - function flog(tag, payload) { - try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'log', tag: '[fit]' + tag, payload: payload - })); - } - } catch (e) {} - } - - function getCellWidth() { - if (!term || !term._core) return 0; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.width || 0; - } - return 0; - } - - // Why: width measurement strategy. - // 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses - // to lay out and is independent of buffer content. It's the "logical - // width" of the terminal grid. - // 2. Fall back to term.element.scrollWidth — the actual rendered DOM - // width — only when cellWidth isn't available yet (renderer not - // initialized). This is content-dependent (reflects widest row), - // but better than nothing. - // 3. If both are 0, return 1 (no scale change). The retry loop in - // applyFitScale will keep trying until one is positive. - function computeFitScale() { - if (!term) return 1; - var cellW = getCellWidth(); - var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0); - if (termWidth <= 0) return 1; - var vpWidth = window.innerWidth; - return Math.min(1, vpWidth / termWidth); - } - - function getTotalScale() { return currentScale * userScale; } - - function updateTransform() { - surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')'; - updateScrollIndicator(false); - if (selMode === 'select') repositionOverlay(); - } - - function updateScrollIndicator(reveal) { - if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return; - var buffer = term.buffer.active; - var maxViewportY = buffer.baseY || 0; - if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) { - scrollIndicator.classList.remove('visible'); - return; - } - var trackHeight = Math.max(0, window.innerHeight - 8); - var totalRows = maxViewportY + (term.rows || 0); - if (trackHeight <= 0 || totalRows <= 0) return; - var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows); - var maxTop = Math.max(0, trackHeight - thumbHeight); - var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0; - scrollThumb.style.height = thumbHeight + 'px'; - scrollThumb.style.transform = 'translateY(' + top + 'px)'; - if (!reveal) return; - scrollIndicator.classList.add('visible'); - if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer); - scrollIndicatorHideTimer = setTimeout(function() { - scrollIndicator.classList.remove('visible'); - scrollIndicatorHideTimer = null; - }, 550); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/selection-overlay.ts b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts deleted file mode 100644 index 335407af51a..00000000000 --- a/mobile/src/terminal/terminal-webview-html/selection-overlay.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { TERMINAL_PATH_TAP_JS } from '../terminal-path-tap-injected' -import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap' - -// Opens with the path/url tap matchers: they land at this point in the emitted document. -export const TERMINAL_HTML_SELECTION_OVERLAY = ` ${TERMINAL_PATH_TAP_JS} - ${URL_TAP_WEBVIEW_JS} - - function seedWordSelection(col, absRow) { - var line = getLineText(absRow); - if (!line) { - sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null }; - applyXtermSelection(); - return; - } - var s = col; - var e = col; - if (col >= 0 && col < line.length && WORD_RE.test(line[col])) { - while (s > 0 && WORD_RE.test(line[s - 1])) s--; - while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++; - } - sel = { - anchor: { col: s, row: absRow }, - focus: { col: e, row: absRow }, - activeHandle: null - }; - applyXtermSelection(); - } - - function isStartFirst(a, b) { - if (a.row !== b.row) return a.row < b.row; - return a.col <= b.col; - } - - function selRange() { - if (!sel) return null; - if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus }; - return { start: sel.focus, end: sel.anchor }; - } - - function applyXtermSelection() { - if (!term || !sel) return; - var r = selRange(); - if (!r) return; - // Why: term.select(col, row, length) takes a buffer-absolute row, - // not a viewport-relative one. Subtracting viewportY here drifts the - // selection by the scrollback height — handles render where the user - // pressed (their math is independent), but xterm highlights an - // off-screen scrollback region and copies the wrong text. - var length; - if (r.start.row === r.end.row) { - length = Math.max(1, r.end.col - r.start.col + 1); - } else { - var first = term.cols - r.start.col; - var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols; - var last = r.end.col + 1; - length = first + middle + last; - } - try { term.select(r.start.col, r.start.row, length); } catch (e) {} - } - - function cancelSelect() { - selMode = 'navigate'; - sel = null; - stopEdgeScroll(); - if (term) { - try { term.clearSelection(); } catch (e) {} - // Why: some xterm renderers cache cells and skip repaint on - // clearSelection alone, leaving the previously-highlighted cells - // visually selected. Force a full refresh so the selection layer - // actually clears on screen. - try { term.refresh(0, term.rows - 1); } catch (e) {} - } - selectionOverlay.classList.remove('active'); - notify({ type: 'set-select-mode', enabled: false }); - } - - function enterSelect(col, absRow) { - selMode = 'select'; - seedWordSelection(col, absRow); - selectionOverlay.classList.add('active'); - notify({ type: 'set-select-mode', enabled: true }); - notify({ type: 'haptic', kind: 'selection' }); - repositionOverlay(); - } - - function repositionOverlay() { - if (selMode !== 'select' || !sel || !term) return; - var r = selRange(); - var sPx = cellToViewportPx(r.start.col, r.start.row); - var ePx = cellToViewportPx(r.end.col + 1, r.end.row); - var cellH = getCellHeight() * getTotalScale(); - // Why: native iOS pattern — start handle anchors at the TOP of the - // first selected cell (dot above, stem covers the cell going down); - // end handle anchors at the BOTTOM of the last selected cell (dot - // below, stem covers the cell going up). - handleStart.style.left = sPx.x + 'px'; - handleStart.style.top = sPx.y + 'px'; - handleEnd.style.left = ePx.x + 'px'; - handleEnd.style.top = (ePx.y + cellH) + 'px'; - var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight; - var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight; - handleStart.style.visibility = startVisible ? 'visible' : 'hidden'; - handleEnd.style.visibility = endVisible ? 'visible' : 'hidden'; - var menuCenterX, menuY, vTransform, marginTop; - if (startVisible && sPx.y > 56) { - menuCenterX = sPx.x; menuY = sPx.y; - vTransform = 'translateY(-100%)'; - marginTop = '-12px'; - } else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) { - menuCenterX = ePx.x; menuY = ePx.y + cellH; - vTransform = 'translateY(0)'; - marginTop = '12px'; - } else { - // selection covers full viewport — pin to visible center - menuCenterX = window.innerWidth / 2; - menuY = window.innerHeight / 2; - vTransform = 'translateY(-50%)'; - marginTop = '0'; - } - // Why: clamp horizontally so the pill stays fully visible when the - // selection sits near a screen edge. We position via plain left - // (no horizontal translate) so the clamp math is straightforward. - selMenu.style.transform = vTransform; - selMenu.style.marginTop = marginTop; - selMenu.style.top = menuY + 'px'; - selMenu.style.left = '0px'; - var EDGE_MARGIN = 8; - var menuW = selMenu.offsetWidth || 0; - var minLeft = EDGE_MARGIN; - var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN); - var desiredLeft = menuCenterX - menuW / 2; - var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft)); - selMenu.style.left = clampedLeft + 'px'; - } - - function syncSelectionHandleToViewportPoint(handle, clientX, clientY) { - var c = viewportToCell(clientX, clientY); - if (!c || !sel) return false; - if (handle === 'start') sel.anchor = c; - else sel.focus = c; - applyXtermSelection(); - return true; - } - - function syncEdgeScrollSelectionEndpoint() { - if (!sel || !sel.activeHandle) return false; - // Why: WebView may not emit new touchmove events while a handle is held - // at the edge; resample the stored finger point after each viewport scroll. - return syncSelectionHandleToViewportPoint( - sel.activeHandle, - edgeScrollClientX, - edgeScrollClientY - ); - } - - function startEdgeScroll(dir) { - if (edgeScrollDir === dir) return; - stopEdgeScroll(); - edgeScrollDir = dir; - edgeScrollTimer = setInterval(function() { - if (!term || edgeScrollDir === 0) return; - var beforeY = term.buffer.active.viewportY; - term.scrollLines(edgeScrollDir); - var afterY = term.buffer.active.viewportY; - if (beforeY === afterY) { - notify({ type: 'haptic', kind: 'edge-bump' }); - stopEdgeScroll(); - return; - } - syncEdgeScrollSelectionEndpoint(); - repositionOverlay(); - }, EDGE_SCROLL_INTERVAL); - } - - function stopEdgeScroll() { - if (edgeScrollTimer) { - clearInterval(edgeScrollTimer); - edgeScrollTimer = null; - } - edgeScrollDir = 0; - } - - function handleDragMove(handle, clientX, clientY) { - edgeScrollClientX = clientX; - edgeScrollClientY = clientY; - if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) return; - repositionOverlay(); - if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1); - else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1); - else stopEdgeScroll(); - } - - // Latching document-level touch dispatcher: see - // terminal-webview-tap-dispatch-injected.ts (extracted for max-lines). -` diff --git a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts deleted file mode 100644 index 48c05b6ad85..00000000000 --- a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts +++ /dev/null @@ -1,71 +0,0 @@ -export const TERMINAL_HTML_SELECTION_STATE_AND_EVICTION = ` // ============================================================ - // SELECTION MODE (long-press → handles → Copy) - // ============================================================ - var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; - var LONG_PRESS_MS = 500; - var LONG_PRESS_SLOP = 10; - // Why: a tap that opens a link/path must survive small finger jitter. The - // long-press slop (10px) only cancels the press-to-select timer; reusing it - // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale - // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap - // window so deliberate scrolls/pans still don't fire a tap. - var TAP_SLOP = 24; - var TAP_MAX_MS = 700; - var EDGE_SCROLL_PX = 40; - var EDGE_SCROLL_INTERVAL = 60; - - var selectionOverlay = document.getElementById('selection-overlay'); - var handleStart = document.getElementById('sel-handle-start'); - var handleEnd = document.getElementById('sel-handle-end'); - var selMenu = document.getElementById('sel-menu'); - var btnCopy = document.getElementById('sel-menu-copy'); - var btnSelAll = document.getElementById('sel-menu-all'); - - // mode: 'navigate' | 'select' - var selMode = 'navigate'; - var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } - var longPressTimer = null; - var longPressOrigin = null; // {x,y, identifier} - // Why: tap detection is tracked separately from the long-press timer so a - // small jitter that cancels the press-to-select timer does not also cancel - // the tap (which opens links/paths). {x,y,t,identifier} or null once the - // gesture is disqualified as a tap (moved too far or held too long). - var tapCandidate = null; - var edgeScrollTimer = null; - var edgeScrollDir = 0; - var edgeScrollClientX = 0; - var edgeScrollClientY = 0; - - // Eviction watchdog: linesEverWritten counts onLineFeed since last init. - // Once buffer is full, every onLineFeed evicts the top row in xterm and - // we mirror that by decrementing stored absolute rows. - var linesEverWritten = 0; - - function resetEvictionCounter() { linesEverWritten = 0; } - - function isBufferFull() { - if (!term) return false; - return linesEverWritten >= 5000 + (term.rows || 0); - } - - function checkEviction() { - if (selMode !== 'select' || !sel) return; - var oldest = Math.min(sel.anchor.row, sel.focus.row); - if (oldest < 0) { - notify({ type: 'selection-evicted' }); - cancelSelect(); - } - } - - function logFeedAndEvict() { - linesEverWritten++; - if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; - if (selMode === 'select' && sel && isBufferFull()) { - sel.anchor.row -= 1; - sel.focus.row -= 1; - checkEviction(); - repositionOverlay(); - } - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts deleted file mode 100644 index de9db152fbf..00000000000 --- a/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts +++ /dev/null @@ -1,110 +0,0 @@ -export const TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY = ` function clampNormalScrollLines(lines) { - if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; - var buffer = term.buffer.active; - if (lines > 0) { - return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY)); - } - return Math.max(lines, -buffer.viewportY); - } - - function canScrollNormalBufferDelta(deltaY) { - if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false; - var buffer = term.buffer.active; - if (deltaY > 0) return buffer.viewportY < buffer.baseY; - return buffer.viewportY > 0; - } - - function applyNormalBufferScrollDelta(deltaY) { - if (!term || deltaY === 0) return false; - var effectiveCellH = getCellHeight() * getTotalScale(); - if (effectiveCellH <= 0) return false; - if (!canScrollNormalBufferDelta(deltaY)) { - resetSmoothScrollOffset(); - return false; - } - smoothScrollOffsetY -= deltaY; - var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); - if (lines !== 0) { - var applied = clampNormalScrollLines(lines); - if (applied !== 0) { - term.scrollLines(applied); - // Why: xterm's renderer is row-based. Buffer touch pixels and only - // commit whole rows so TUI canvas layers do not shimmer between - // fractional transforms and xterm repaints. - smoothScrollOffsetY += applied * effectiveCellH; - } - if (applied !== lines) smoothScrollOffsetY = 0; - } - var limit = effectiveCellH - 1; - if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit; - if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit; - updateScrollIndicator(true); - return true; - } - - function enqueueNormalBufferScrollDelta(deltaY) { - if (!term || deltaY === 0) return false; - if (!canScrollNormalBufferDelta(deltaY)) { - resetSmoothScrollOffset(); - return false; - } - pendingNormalScrollDeltaY += deltaY; - if (normalScrollFrameId !== null) return true; - // Why: dense terminal rows are expensive to repaint. Coalesce touchmove - // deltas into one xterm row-scroll per frame instead of repainting from - // the input event stream. - normalScrollFrameId = requestAnimationFrame(function() { - normalScrollFrameId = null; - var delta = pendingNormalScrollDeltaY; - pendingNormalScrollDeltaY = 0; - if (!applyNormalBufferScrollDelta(delta)) { - resetSmoothScrollOffset(); - } - }); - return true; - } - - function resetSmoothScrollOffset() { - pendingNormalScrollDeltaY = 0; - if (normalScrollFrameId !== null) { - cancelAnimationFrame(normalScrollFrameId); - normalScrollFrameId = null; - } - if (smoothScrollOffsetY === 0) return; - smoothScrollOffsetY = 0; - updateScrollIndicator(false); - } - - function cellToViewportPx(col, absRow) { - if (!term) return { x: 0, y: 0 }; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - var viewportRow = absRow - term.buffer.active.viewportY; - var sx = col * cellW; - var sy = viewportRow * cellH; - var total = getTotalScale(); - return { x: sx * total + panX, y: sy * total + panY }; - } - - function getLineText(absRow) { - if (!term) return ''; - var line = term.buffer.active.getLine(absRow); - if (!line) return ''; - return line.translateToString(false); - } - - // Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a - // tap's CELL column no longer equals the STRING index that url/path matchers use. - // Convert by measuring the string length up to the tapped cell (the count of - // string chars before it). Without this, taps on lines with a leading wide char - // (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss. - function cellColToStringIndex(absRow, col) { - if (!term) return col; - var line = term.buffer.active.getLine(absRow); - if (!line) return col; - return line.translateToString(false, 0, col).length; - } - - // File-path-under-tap detection (matchFilePathAtColumn). See - // terminal-path-tap-injected.ts; mirrors the unit-tested terminal-path-tap.ts. -` diff --git a/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts deleted file mode 100644 index 515d7fd9d26..00000000000 --- a/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injected' -import { TERMINAL_WHEEL_SCROLL_JS } from '../terminal-webview-wheel-scroll-injected' -import { TERMINAL_MOUSE_CLICK_DRAG_JS } from '../terminal-webview-mouse-click-drag-injected' - -// Also wires the selection menu's Copy/Select All buttons, which sit here in the emitted document. -export const TERMINAL_HTML_SURFACE_TOUCH_GESTURES = ` ${TERMINAL_TAP_DISPATCH_JS} - - // External mouse / trackpad scroll: see - // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). - ${TERMINAL_WHEEL_SCROLL_JS} - - // External mouse click/drag: see - // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). - ${TERMINAL_MOUSE_CLICK_DRAG_JS} - - btnCopy.addEventListener('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - if (!term) return; - var text = term.getSelection ? term.getSelection() : ''; - if (text && text.length > 0) { - notify({ type: 'selection', text: text }); - } else { - cancelSelect(); - } - }); - - btnSelAll.addEventListener('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - if (!term) return; - try { - term.selectAll(); - var b = term.buffer.active; - sel = { - anchor: { col: 0, row: 0 }, - focus: { col: term.cols - 1, row: b.length - 1 }, - activeHandle: null - }; - repositionOverlay(); - } catch (err) {} - }); - - var ts = { - lastX: 0, lastY: 0, lastTime: 0, velY: 0, - accumDelta: 0, momentumId: null, isPinching: false, - pinchDist: 0, pinchScale: 0, pinchSurfX: 0, pinchSurfY: 0 - }; - - function updateTouchVelocity(deltaY, dt) { - if (dt <= 0) return; - var instantVelocity = deltaY / dt; - if (!isFinite(instantVelocity)) return; - // Why: touchmove cadence is uneven in WebView. Blend recent samples so - // momentum launch doesn't inherit a one-frame spike or stall. - ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45; - } - - function getDistance(a, b) { - var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; - return Math.sqrt(dx * dx + dy * dy); - } - - function attachSurfaceEventHandlers(targetSurface) { - if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return; - targetSurface.__orcaSurfaceHandlersAttached = true; - // Why: init() swaps in a new hidden surface to avoid flicker; each - // replacement needs gesture handlers or tab-switch replays stop scrolling. - targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true); - targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); - - attachSurfaceWheelHandler(targetSurface); - attachSurfaceMouseClickDragHandler(targetSurface); - - targetSurface.addEventListener('touchstart', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (ts.momentumId) { - cancelAnimationFrame(ts.momentumId); - ts.momentumId = null; - } - if (e.touches.length === 2) { - ts.isPinching = true; - smoothScrollOffsetY = 0; - ts.pinchDist = getDistance(e.touches[0], e.touches[1]); - ts.pinchScale = userScale; - var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; - var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; - var total = getTotalScale(); - ts.pinchSurfX = (mx - panX) / total; - ts.pinchSurfY = (my - panY) / total; - } else if (e.touches.length === 1) { - ts.isPinching = false; - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; - } - }, { capture: true, passive: true }); - - targetSurface.addEventListener('touchmove', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - e.preventDefault(); - e.stopPropagation(); - - if (e.touches.length === 2) { - ts.isPinching = true; - var dist = getDistance(e.touches[0], e.touches[1]); - var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; - var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; - - var ratio = dist / ts.pinchDist; - // Why: userScale is a CSS multiplier on the current font size; bound it so - // the resulting apparent size (currentTextScale × userScale) stays within - // the preset range, since release snaps to one of those presets. - var loScale = MIN_TEXT_SCALE / currentTextScale; - var hiScale = MAX_TEXT_SCALE / currentTextScale; - userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio)); - - var total = getTotalScale(); - panX = mx - ts.pinchSurfX * total; - panY = my - ts.pinchSurfY * total; - clampPan(); - updateTransform(); - - } else if (e.touches.length === 1 && !ts.isPinching) { - var x = e.touches[0].clientX, y = e.touches[0].clientY; - var now = Date.now(), dt = now - ts.lastTime; - - // Why: pan horizontally only when content overflows the viewport (larger - // than fit) — same check clampPan() uses. Vertical always drives buffer - // scroll so scrollback stays reachable at any text size; calling the - // never-defined contentWiderThanViewport() here threw and killed all - // single-finger scrolling, scrollback included. - if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { - panX += x - ts.lastX; - clampPan(); - updateTransform(); - } - - var deltaY = ts.lastY - y; - ts.lastTime = now; - if (shouldRouteScrollToTerminalInput()) { - updateTouchVelocity(deltaY, dt); - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += deltaY; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, x, y); - } - } else { - if (enqueueNormalBufferScrollDelta(deltaY)) { - updateTouchVelocity(deltaY, dt); - } else { - ts.velY = 0; - } - } - ts.lastX = x; - ts.lastY = y; - } - }, { capture: true, passive: false }); - - targetSurface.addEventListener('touchend', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false; - // Why: a finished pinch snaps to the nearest preset and becomes the new - // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way - // to set the text size. The CSS pinch zoom (userScale) is reset; the real - // size change reflows columns and RN persists + resizes the PTY to match. - var target = snapToTextScalePreset(currentTextScale * userScale); - var changed = target !== currentTextScale; - userScale = 1; - panX = 0; panY = 0; - applyTextScale(target); - updateTransform(); - notify({ type: 'font-scale-changed', fontScale: target }); - if (changed) notify({ type: 'haptic', kind: 'selection' }); - if (e.touches.length === 1) { - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; - } - return; - } - - if (e.touches.length === 0) { - var vel = ts.velY; - var FRICTION = 0.972; - var MIN_VEL = 0.012; - function momentumStep() { - vel *= FRICTION; - if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } - var delta = vel * 16; - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += delta; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, ts.lastX, ts.lastY); - } - } else { - if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null; - return; - } - } - ts.momentumId = requestAnimationFrame(momentumStep); - } - if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep); - } - } - }, { capture: true, passive: true }); - } - - attachSurfaceEventHandlers(surface); - -` diff --git a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts deleted file mode 100644 index b69547affb5..00000000000 --- a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' - -export const TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING = ` function emitModesIfChanged() { - if (!term) return; - var bp = !!(term.modes && term.modes.bracketedPasteMode); - var alt = false; - var mouseTrackingMode = getMouseTrackingMode(); - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - if ( - bp !== lastEmittedModes.bracketedPasteMode || - alt !== lastEmittedModes.altScreen || - mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || - sgrMouseMode !== lastEmittedModes.sgrMouseMode || - sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode - ) { - lastEmittedModes = { - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }; - notify({ - type: 'modes', - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }); - } - } - var lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - - ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} - - function attachTermObservers() { - if (!term) return; - disposeTermObservers(); - try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} - try { - termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); - } catch (e) {} - // Why: emit modes on every parsed write so RN's mirror stays current - // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. - try { - if (term.onWriteParsed) { - termObserverDisposables.push(term.onWriteParsed(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - })); - } - } catch (e) {} - // Initial emit once buffer settles. - afterWritesDrained(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - }); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts deleted file mode 100644 index b756bcb550c..00000000000 --- a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' - -// Opens with the injected theme block: it lands at this point in the emitted document. -export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS} - - function getCellHeight() { - if (!term || !term._core) return 15; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.height || 15; - } - return 15; - } - - // Why: clamp pan so the terminal content always covers the viewport - // when zoomed in. When content is smaller than viewport in a - // dimension, pin to top-left (no floating in the middle). - function clampPan() { - if (!term || !term.element) return; - var ts = getTotalScale(); - var cw = term.element.scrollWidth * ts; - var ch = term.element.scrollHeight * ts; - var vpW = window.innerWidth; - var vpH = window.innerHeight; - if (cw > vpW) { - panX = Math.min(0, Math.max(vpW - cw, panX)); - } else { - panX = 0; - } - if (ch > vpH) { - panY = Math.min(0, Math.max(vpH - ch, panY)); - } else { - panY = 0; - } - } - - // Why: intentional no-op. Mobile replays a live PTY snapshot then applies - // live cursor-relative chunks from that same PTY; resizing only the WebView - // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or - // overlap. Kept as a no-op so its call sites stay legible. - function adjustRowsForViewport() {} - - // Why: cold-start fit. After init() opens xterm, the renderer needs - // several frames before cell dimensions are computed. Reading too early - // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM - // not laid out), and computeFitScale returns 1 → no zoom. - // - // Gate: cellWidth × cols is the canonical "logical width" of the grid - // and reflects xterm's layout decision, independent of buffer content. - // We commit when cellWidth becomes positive (renderer ready). Fallback: - // if cellWidth never becomes available, gate on stable positive - // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) - // so a backgrounded WebView never spins forever. - var FIT_RETRY_MAX_FRAMES = 60; - var fitRetryToken = 0; - function applyFitScale(reason) { - if (!term || !term.element) return; - var token = ++fitRetryToken; - var attempts = 0; - var lastScrollWidth = -1; - function attempt() { - if (token !== fitRetryToken) return; - if (!term || !term.element) return; - attempts++; - var cellW = getCellWidth(); - if (cellW > 0 && term.cols > 0) { - commitFitScale(reason, attempts, 'cellW'); - return; - } - var w = term.element.scrollWidth; - if (w > 0 && w === lastScrollWidth) { - commitFitScale(reason, attempts, 'stableSW'); - return; - } - lastScrollWidth = w; - if (attempts >= FIT_RETRY_MAX_FRAMES) { - flog('commit-timeout', { - reason: reason, - attempts: attempts, - cellW: cellW, - scrollWidth: w, - cols: term.cols - }); - commitFitScale(reason, attempts, 'timeout'); - return; - } - requestAnimationFrame(attempt); - } - requestAnimationFrame(attempt); - } - - function commitFitScale(reason, attempts, gate) { - if (!term || !term.element) return; - var preSnapScale = computeFitScale(); - currentScale = preSnapScale; - // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar - // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents - // a second applyFitScale from observing a "no-op needed" state. - if (currentScale >= 0.95) currentScale = 1; - userScale = 1; - panX = 0; - panY = 0; - smoothScrollOffsetY = 0; - updateTransform(); - adjustRowsForViewport(); - - var cellW = getCellWidth(); - var sw = term.element.scrollWidth; - var vpW = window.innerWidth; - var expectedW = cellW * term.cols; - var suspect = - currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom - if (suspect) { - flog('commit-SUSPECT', { - reason: reason, - attempts: attempts, - gate: gate, - preSnapScale: preSnapScale, - finalScale: currentScale, - cellW: cellW, - cols: term.cols, - expectedW: expectedW, - scrollWidth: sw, - vpWidth: vpW - }); - } - repositionOverlay(); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts deleted file mode 100644 index 90dba7cbdbc..00000000000 --- a/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected' -import { MOBILE_TERMINAL_CARET_OPTIONS } from './theme' - -export const TERMINAL_HTML_INIT_AND_WRITE = `${TERMINAL_WEBGL_RECOVERY_JS} - - function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { - if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; - // Why: a width-reflow re-stream rewraps the same content at new cols. - // Distance-from-bottom (rows) is the only stable anchor across reflow, - // since line counts and cell positions change. null = stay pinned to bottom. - var prevB = preserveScroll && term && term.buffer && term.buffer.active ? term.buffer.active : null; - var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; - terminalGeneration++; - var gen = terminalGeneration; - // Why: snapshot replay can contain old queries whose replies must never - // re-enter the live PTY. Each replacement terminal earns authority anew. - resetTerminalDataReplyAuthority(); - cancelWebglContextRecovery(); - webglAddon = null; - ready = false; - resetWriteQueue(); - statusDotPendingSelector = false; - writesDraining = false; - afterDrainCallbacks = []; - initRows = rows || 24; - firstDataPending = true; - smoothScrollOffsetY = 0; - wheelAccumDeltaY = 0; - mouseModeScanTail = ''; - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - var replayData = normalizeInitialData(initialData); - // Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the - // mirrored modes aligned with exactly what this mobile xterm replays. - updateMouseModeFromData(replayData); - activeAltScreenSnapshot = isAltScreenActive(replayData); - initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []; - initialOscLinkRowOffset = 0; - initialOscLinkEvictionReady = false; - var surfaceSwap = beginTerminalSurfaceSwap(); - var nextSurface = surfaceSwap.nextSurface; - - applyTerminalTheme(nextTheme); - term = new Terminal({ - cols: cols || 80, - rows: rows || 24, - theme: terminalTheme, - minimumContrastRatio: terminalMinimumContrastRatio, - fontFamily: terminalFontFamily, - fontSize: fontPxForScale(currentTextScale), - fontWeight: '300', - fontWeightBold: '500', - scrollback: 5000, - // Why: xterm suppresses parser-generated query replies when disableStdin - // is true. Native accepts only validated reply grammars from onData. - disableStdin: false, - cursorBlink: ${MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink}, - cursorStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle)}, - // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. - showCursorImmediately: ${MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately}, - // A full inactive cell remains visible under the terminal's phone-fit scale. - cursorInactiveStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle)}, - convertEol: false, - allowProposedApi: true - }); - var nextTerm = term; - pendingTerm = nextTerm; - term.open(surface); - attachWebglAddon(true); - if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} - if (typeof replayData === 'string' && replayData.length > 0) { - // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output. - enqueueWrite(ESC + '[0m' + replayData); - } - - // Why: reset eviction tracking + attach observers for the new term. - resetEvictionCounter(); - cancelSelect(); - attachTermObservers(); - attachTerminalQueryReplyBridge(term, gen); - - requestAnimationFrame(function() { - if (gen !== terminalGeneration) return; - ready = true; - everReady = true; - afterWritesDrained(function() { - if (gen !== terminalGeneration) return; - commitTerminalSurfaceSwap(surfaceSwap, nextTerm); - // Why: restore the reader's place after the rewrapped buffer replays. - // Replay lands at bottom, so only act when they were scrolled up (rows>0). - if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) { - try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {} - } - captureInitialOscLinkTexts(); - initialOscLinkRowOffset = 0; - initialOscLinkEvictionReady = true; - applyFitScale('init-replay'); - notify({ type: 'ready', cols: cols, rows: rows }); - }); - }); - } - - function write(data) { - updateMouseModeFromData(data); - enqueueWrite(data); - pumpWrites(terminalGeneration); - // Why: first live data chunk after init may widen the buffer past - // what the post-replay applyFitScale measured. Re-fit once after this - // chunk drains to catch the wider line. Subsequent chunks don't re-fit - // (the user's manual zoom is sticky after that). - if (firstDataPending) { - firstDataPending = false; - var gen = terminalGeneration; - afterWritesDrained(function() { - if (gen !== terminalGeneration) return; - applyFitScale('first-data'); - }); - } - } - - function resize(cols, rows) { - if (!term) return; - initRows = rows || initRows; - term.resize(cols || term.cols, rows || term.rows); - emitKeyboardAvoidanceMetrics(); - applyFitScale('resize-msg'); - notify({ type: 'ready', cols: cols, rows: rows }); - } - - // reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines). -` diff --git a/mobile/src/terminal/terminal-webview-html/write-queue.ts b/mobile/src/terminal/terminal-webview-html/write-queue.ts deleted file mode 100644 index cdb45bb0b91..00000000000 --- a/mobile/src/terminal/terminal-webview-html/write-queue.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to -// other concerns, but emitted-document order pins them inside this queue. -// nextQueuedWrite() clears each slot before advancing the head; otherwise consumed slots keep -// already-submitted chunks reachable until compaction, which is up to half a backlog away. -// Kept out of the template literal below: anything inside it ships to every device. -export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() { - writeQueue = []; - writeQueueHead = 0; - } - - function isStatusDotPresentationSelector(value) { - return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; - } - - function endsWithStatusDotPresentationSequence(data) { - var i = data.length - 1; - while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; - return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; - } - - // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. - function normalizeStatusDotPresentation(data) { - if (typeof data !== 'string' || data.length === 0) return data; - if (statusDotPendingSelector) { - statusDotPendingSelector = false; - var strippedPendingSelectors = false; - while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); - strippedPendingSelectors = data.length === 0; - if (strippedPendingSelectors) { - statusDotPendingSelector = true; - return ''; - } - } - var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); - statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); - return normalized; - } - - function enqueueWrite(data) { - writeQueue.push(normalizeStatusDotPresentation(data)); - } - - function enqueueWriteBoundary(callback) { - writeQueue.push(callback); - } - - function nextQueuedWrite() { - if (writeQueueHead >= writeQueue.length) { - resetWriteQueue(); - return undefined; - } - var next = writeQueue[writeQueueHead]; - writeQueue[writeQueueHead] = undefined; - writeQueueHead++; - // Why: high-throughput terminals can enqueue faster than xterm parses; - // compact consumed slots so drain work stays O(1) without retaining old chunks. - if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { - writeQueue = writeQueue.slice(writeQueueHead); - writeQueueHead = 0; - } - return next; - } - - function disposeTermObservers() { - var disposables = termObserverDisposables; - termObserverDisposables = []; - for (var i = 0; i < disposables.length; i++) { - try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} - } - } - - function extractMouseModeScanTail(input) { - var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); - if (start === -1) return ''; - var tail = input.slice(start); - // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. - // Keep parser state far beyond normal mode lists while still bounding memory. - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; - if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; - if (tail.indexOf(ESC + '[?') === 0) { - return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; - } - if (tail.indexOf(C1_CSI + '?') === 0) { - return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; - } - return ''; - } - - function pumpWrites(gen) { - if (!ready || !term || writesDraining || gen !== terminalGeneration) return; - var next = nextQueuedWrite(); - if (typeof next !== 'string') { - if (typeof next === 'function') return next(), pumpWrites(gen); - var callbacks = afterDrainCallbacks; - afterDrainCallbacks = []; - for (var i = 0; i < callbacks.length; i++) callbacks[i](); - return; - } - writesDraining = true; - // Why: xterm.write() parses asynchronously. Row adjustment/resizing must - // wait until replayed SGR attributes have landed in the buffer. - term.write(next, function() { - if (gen !== terminalGeneration) return; - writesDraining = false; - pumpWrites(gen); - }); - } - - function afterWritesDrained(callback) { - afterDrainCallbacks.push(callback); - pumpWrites(terminalGeneration); - } - -` diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts deleted file mode 100644 index 54ee8d430a4..00000000000 --- a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts +++ /dev/null @@ -1,198 +0,0 @@ -// Indirect-pointer (external mouse / trackpad) click and drag for the terminal -// surface, injected into XTERM_HTML. Extracted from terminal-webview-html.ts to -// keep that file within its max-lines budget. Companion to -// terminal-webview-wheel-scroll-injected.ts, which owns the wheel half (#11247); -// this owns the click/drag half of #8818. Closes over host-IIFE state/functions: -// term, ESC, sel, selMode, selectionOverlay, TAP_SLOP, getMouseTrackingMode, -// viewportToCell, viewportToMouseReportCell, isSafeSgrMouseCoordinate, -// sgrMouseMode, sgrMousePixelsMode, notify, notifyTerminalSurfaceTap, -// cancelSelect, applyXtermSelection, repositionOverlay, handleDragMove, -// stopEdgeScroll, and dispatcherShouldBlockSurface. -// -// Why pointer events: a hardware mouse on Android/iPadOS raises pointer events -// with pointerType 'mouse' and NO touch events, while a finger raises -// pointerType 'touch' plus the touch events the document dispatcher owns. The -// capture-phase mousedown/click suppression in attachSurfaceEventHandlers stays: -// it is what keeps xterm's own mouse handling inert (its onData output is -// dropped by the mobile bridge), and pointer events are unaffected by it. -export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` - var mouseGesture = null; - - // One report per transition, built with the same encoding ladder as - // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' - // when the mode does not report this transition (x10 has no release, only - // drag/any report motion) or the cell is not encodable. - function buildMouseButtonReport(kind, clientX, clientY) { - var mouseTrackingMode = getMouseTrackingMode(); - if (mouseTrackingMode === 'none') return ''; - if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return ''; - if (kind === 'release' && mouseTrackingMode === 'x10') return ''; - var cell = viewportToMouseReportCell(clientX, clientY); - if (!cell) return ''; - var sgrButton = kind === 'motion' ? 32 : 0; - var sgrFinal = kind === 'release' ? 'm' : 'M'; - if (sgrMousePixelsMode) { - if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; - return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal; - } - if (sgrMouseMode) { - // Why: xterm increments zero-based mouse cells before encoding reports. - var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal; - } - var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32; - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - // Why: non-SGR mouse bytes above ASCII are not preserved reliably through - // the mobile JSON/RPC string path; drop instead of corrupting input. - if (col > 126 || row > 126) return ''; - return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function mouseReportCellKey(clientX, clientY) { - var cell = viewportToMouseReportCell(clientX, clientY); - return cell ? cell.col + ',' + cell.row : null; - } - - function abandonMouseGesture() { - var gesture = mouseGesture; - mouseGesture = null; - if (!gesture) return; - if (gesture.mode === 'tracking') { - // Why: the press report already went to the TUI; a lost pointer must not - // leave the button latched down on the far side. - var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY); - if (release) notify({ type: 'terminal-input', bytes: release }); - } else if (gesture.mode === 'selecting') { - if (sel) sel.activeHandle = null; - stopEdgeScroll(); - } - } - - function beginMouseDrag(gesture) { - gesture.moved = true; - if (getMouseTrackingMode() !== 'none') { - gesture.mode = 'tracking'; - gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY); - var press = buildMouseButtonReport('press', gesture.startX, gesture.startY); - if (press) notify({ type: 'terminal-input', bytes: press }); - return; - } - var anchor = viewportToCell(gesture.startX, gesture.startY); - if (!anchor) { - gesture.mode = 'cancelled'; - return; - } - // Why: mouse drags select character-anchored ranges like desktop terminals, - // not the word-seeded long-press selection; reuse the touch handle-drag - // plumbing (edge scroll included) by acting as a live 'end' handle. - gesture.mode = 'selecting'; - selMode = 'select'; - sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }; - selectionOverlay.classList.add('active'); - notify({ type: 'set-select-mode', enabled: true }); - applyXtermSelection(); - repositionOverlay(); - } - - function attachSurfaceMouseClickDragHandler(targetSurface) { - targetSurface.addEventListener('pointerdown', function(e) { - if (e.pointerType !== 'mouse' || e.button !== 0) return; - if (dispatcherShouldBlockSurface() || !term) return; - // Why: a pointerup lost outside the WebView must not leave the previous - // gesture latched (tracking press with no release) when the next one lands. - if (mouseGesture) abandonMouseGesture(); - // Why: mouse pointers have no implicit capture; without it a drag that - // leaves the surface drops pointermove/pointerup and strands the gesture. - try { - if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId); - } catch (err) {} - mouseGesture = { - startX: e.clientX, startY: e.clientY, - lastX: e.clientX, lastY: e.clientY, - lastCellKey: null, - moved: false, - mode: 'pending', - dismissedSelection: false - }; - if (selMode === 'select') { - // Why: touch parity — pressing outside the pill dismisses the current - // selection; the same press may still start a new drag selection. - cancelSelect(); - mouseGesture.dismissedSelection = true; - } - }, true); - - targetSurface.addEventListener('pointermove', function(e) { - var gesture = mouseGesture; - if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; - if (!term) return; - gesture.lastX = e.clientX; - gesture.lastY = e.clientY; - if ((e.buttons & 1) === 0) { - // Why: a pointerup lost outside the WebView (capture unavailable) must - // end the gesture here, or a tracked press stays latched at the TUI. - // Coordinates first, so the synthesized release lands where the - // pointer re-entered rather than at the previous cell. - abandonMouseGesture(); - return; - } - if (!gesture.moved) { - var dx = Math.abs(e.clientX - gesture.startX); - var dy = Math.abs(e.clientY - gesture.startY); - if (dx + dy <= TAP_SLOP) return; - beginMouseDrag(gesture); - } - if (gesture.mode === 'tracking') { - // Why: one motion report per cell keeps drags bounded by grid size, not - // by pointermove cadence, so the RN rate limiter is never the bottleneck. - var cellKey = mouseReportCellKey(e.clientX, e.clientY); - if (cellKey && cellKey !== gesture.lastCellKey) { - gesture.lastCellKey = cellKey; - var motion = buildMouseButtonReport('motion', e.clientX, e.clientY); - if (motion) notify({ type: 'terminal-input', bytes: motion }); - } - } else if (gesture.mode === 'selecting') { - handleDragMove('end', e.clientX, e.clientY); - } - }, true); - - targetSurface.addEventListener('pointerup', function(e) { - var gesture = mouseGesture; - if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return; - mouseGesture = null; - if (gesture.mode === 'cancelled' || !term) return; - if (gesture.mode === 'tracking') { - var release = buildMouseButtonReport('release', e.clientX, e.clientY); - if (release) notify({ type: 'terminal-input', bytes: release }); - return; - } - if (gesture.mode === 'selecting') { - if (sel) sel.activeHandle = null; - stopEdgeScroll(); - repositionOverlay(); - return; - } - if (dispatcherShouldBlockSurface()) return; - // Why: a dismissing tap only clears the selection (touch parity); it must - // not also open a link or focus the keyboard underneath. - if (gesture.dismissedSelection) return; - // Pointer clicks keep their current link, file, TUI mouse, and focus priority. - notifyTerminalSurfaceTap(e.clientX, e.clientY, false); - }, true); - - targetSurface.addEventListener('pointercancel', function(e) { - if (e.pointerType !== 'mouse') return; - abandonMouseGesture(); - }, true); - - // Why: Android input injection can pair a mouse-flavored pointerdown with - // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, - // the document touch dispatcher owns the gesture. - targetSurface.addEventListener('touchstart', function() { - if (mouseGesture) abandonMouseGesture(); - }, true); - } -` diff --git a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts deleted file mode 100644 index 14baeeaf325..00000000000 --- a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Mouse-report coordinate mapping injected into XTERM_HTML. Closes over term, -// panX/panY, getCellWidth/Height, and getTotalScale. -export const TERMINAL_MOUSE_REPORT_CELL_JS = ` - function viewportToMouseReportCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - if (typeof clientX !== 'number') clientX = window.innerWidth / 2; - if (typeof clientY !== 'number') clientY = window.innerHeight / 2; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var maxX = Math.max(0, term.cols * cellW - 1); - var maxY = Math.max(0, term.rows * cellH - 1); - if (sx < 0) sx = 0; - if (sx > maxX) sx = maxX; - if (sy < 0) sy = 0; - if (sy > maxY) sy = maxY; - var col = Math.floor(sx / cellW); - var row = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (row < 0) row = 0; - if (row > term.rows - 1) row = term.rows - 1; - return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; - } -` diff --git a/mobile/src/terminal/terminal-webview-payload-hash.test.ts b/mobile/src/terminal/terminal-webview-payload-hash.test.ts index 8c23de0ac3d..6d4380ba168 100644 --- a/mobile/src/terminal/terminal-webview-payload-hash.test.ts +++ b/mobile/src/terminal/terminal-webview-payload-hash.test.ts @@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html' // uncovered region ships silently. A diff here means the emitted WebView source changed — // update these values only when that change is deliberate, and only after checking the // document still runs. Refactors that merely move slice boundaries must leave them alone. -const EXPECTED_SHA256 = '25b800f342c972f0b8eaba54367bd8b02b7518e9ea6a25e04ab89b3a2ad7d21b' -const EXPECTED_LENGTH = 730472 +const EXPECTED_SHA256 = 'c84ce5fc7343546427ad875aeebea90e54560579a1d18b3b700076a1c4b4623f' +const EXPECTED_LENGTH = 723480 describe('terminal WebView payload', () => { it('composes the expected document', () => { diff --git a/mobile/src/terminal/terminal-webview-query-reply-injected.ts b/mobile/src/terminal/terminal-webview-query-reply-injected.ts deleted file mode 100644 index a4ac34e5809..00000000000 --- a/mobile/src/terminal/terminal-webview-query-reply-injected.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Kept as one injectable unit so tests execute the same replay/generation gate -// that the WebView document runs, rather than a TypeScript reimplementation. -export const TERMINAL_QUERY_REPLY_JS = ` - var terminalDataRepliesEnabled = false; - - function resetTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = false; - } - - function resumeTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = true; - } - - function forwardTerminalDataReply(data) { - if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data }); - } - - function enqueueTerminalDataReplyBoundary(gen) { - enqueueWriteBoundary(function() { - if (gen === terminalGeneration) terminalDataRepliesEnabled = true; - }); - } - - function attachTerminalQueryReplyBridge(term, gen) { - // Why: parser replies require stdin enabled, but mobile input is owned by - // native controls. Keep xterm's textarea inert for touch/hardware keys. - try { - term.attachCustomKeyEventHandler(function() { return false; }); - if (term.textarea) { - term.textarea.readOnly = true; - term.textarea.tabIndex = -1; - term.textarea.setAttribute('inputmode', 'none'); - } - } catch (e) {} - try { - termObserverDisposables.push(term.onData(function(data) { - forwardTerminalDataReply(data); - })); - } catch (e) {} - // Why: live output can queue before initial replay finishes. Enable replies - // at the replay boundary so those live queries are answered, never replayed ones. - enqueueTerminalDataReplyBoundary(gen); - } -` diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts index 4e8add33f2a..9d3b780a530 100644 --- a/mobile/src/terminal/terminal-webview-query-reply.test.ts +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' +import { + documentScopePreamble, + generatedDocumentModule +} from './document/generated-document-region.test-support' import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' -import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' + +const queryReplySource = await generatedDocumentModule('query-reply') type QueryReplyGate = { forward: (data: string) => void @@ -15,17 +20,18 @@ function createQueryReplyGate(notify: (message: unknown) => void): { queuedBoundaries: Array<() => void> } { const queuedBoundaries: Array<() => void> = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the body's return literal names exactly the five entries below. const factory = new Function( 'notify', 'enqueueWriteBoundary', - `var terminalGeneration = 0; - ${TERMINAL_QUERY_REPLY_JS} + `${documentScopePreamble()} + ${queryReplySource} return { forward: forwardTerminalDataReply, queueBoundary: enqueueTerminalDataReplyBoundary, reset: resetTerminalDataReplyAuthority, resume: resumeTerminalDataReplyAuthority, - setGeneration: function(next) { terminalGeneration = next; } + setGeneration: function(next) { scope.terminalGeneration = next; } };` ) as ( notify: (message: unknown) => void, @@ -39,7 +45,7 @@ describe('mobile terminal query replies', () => { it('forwards xterm-generated data only after initial replay drains', () => { const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)') const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( - 'attachTerminalQueryReplyBridge(term, gen)', + 'attachTerminalQueryReplyBridge(scope.term, gen)', listenerIndex ) const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( @@ -52,9 +58,9 @@ describe('mobile terminal query replies', () => { expect(notifyIndex).toBeGreaterThan(listenerIndex) expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false') expect(XTERM_WEBVIEW_SOURCE.html).toContain( - 'term.attachCustomKeyEventHandler(function() { return false; })' + 'term.attachCustomKeyEventHandler(function() {\n return false;\n });' ) - expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true') + expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true;') }) it('mutes a replacement terminal until its own replay drains', () => { @@ -64,7 +70,7 @@ describe('mobile terminal query replies', () => { initIndex ) const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( - 'attachTerminalQueryReplyBridge(term, gen)', + 'attachTerminalQueryReplyBridge(scope.term, gen)', disableIndex ) @@ -114,9 +120,9 @@ describe('mobile terminal query replies', () => { gate.forward('\x1b[3;4R') expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }]) - const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {") + const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf('} else if (msg.type === "clear") {') const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf( - "} else if (msg.type === 'measure')", + '} else if (msg.type === "measure")', clearStart ) expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain( diff --git a/mobile/src/terminal/terminal-webview-reflow-injected.ts b/mobile/src/terminal/terminal-webview-reflow-injected.ts deleted file mode 100644 index ef704529e4b..00000000000 --- a/mobile/src/terminal/terminal-webview-reflow-injected.ts +++ /dev/null @@ -1,33 +0,0 @@ -// In-WebView reflow routine, injected into XTERM_HTML. Extracted from -// terminal-webview-html.ts to keep that file within its max-lines budget. -// Closes over term / isAlternateBufferActive / applyFitScale / -// updateScrollIndicator / initRows defined in the host IIFE. -export const TERMINAL_REFLOW_JS = ` - // Why: rewrap the local xterm buffer (scrollback included) to a new width - // after a server PTY reflow. Skip the alternate screen: those snapshots are - // fully repainted by the PTY and a local resize there can drop SGR attributes - // (see init's alt-screen handling), which shows as white text. - function reflow(cols, rows) { - if (!term || isAlternateBufferActive()) return; - var nextCols = cols || term.cols; - var nextRows = rows || term.rows; - if (nextCols === term.cols && nextRows === term.rows) return; - var buffer = term.buffer.active; - // Why: anchor reflow on whether the user was pinned to the live bottom so - // their scroll position survives the rewrap — if they were scrolled up, - // hold the same distance from the bottom; if at the bottom, stay there. - var wasAtBottom = buffer.viewportY >= buffer.baseY; - var distanceFromBottom = buffer.baseY - buffer.viewportY; - initRows = nextRows; - term.resize(nextCols, nextRows); - var rewrapped = term.buffer.active; - if (wasAtBottom) { - term.scrollToBottom(); - } else { - term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY); - } - applyFitScale('reflow-msg'); - updateScrollIndicator(false); - emitKeyboardAvoidanceMetrics(); - } -` diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts index 90112740d1d..be24fbb3819 100644 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ b/mobile/src/terminal/terminal-webview-reflow.test.ts @@ -1,17 +1,14 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { generatedDocumentModule } from './document/generated-document-region.test-support' import { XTERM_HTML } from './terminal-webview-html' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' -// The reflow logic lives as injected in-WebView JS; the message dispatch and -// handle wiring live in terminal-webview-html.ts / TerminalWebView.tsx. Assert -// the load-bearing invariants from source, mirroring the other tests here. -const reflowSource = readFileSync( - new URL('./terminal-webview-reflow-injected.ts', import.meta.url), - 'utf8' -) -// Use the assembled document so the test covers the fragments that run in the WebView. -const htmlSource = readTerminalWebViewHtmlSource() +// The reflow logic runs inside the WebView document; the message dispatch and handle wiring live +// in terminal-webview-html.ts / TerminalWebView.tsx. Assert the load-bearing invariants from the +// document the WebView runs, mirroring the other tests here. +const reflowSource = await generatedDocumentModule('reflow') +// Use the assembled document so the test covers what the WebView actually runs. +const htmlSource = XTERM_HTML const handleSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') function reflowFnBody(): string { @@ -24,57 +21,53 @@ describe('terminal WebView reflow', () => { it('skips the alternate screen so TUI snapshots are not mutated', () => { // Why: alt-screen snapshots are repainted by the PTY; a local resize there // can drop SGR attributes (white text). Reflow must early-return. - expect(reflowFnBody()).toContain('if (!term || isAlternateBufferActive()) return;') + expect(reflowFnBody()).toContain('if (!scope.term || isAlternateBufferActive()) {') }) it('rewraps the local buffer via term.resize to the new cols', () => { - expect(reflowFnBody()).toContain('term.resize(nextCols, nextRows);') + expect(reflowFnBody()).toContain('scope.term.resize(nextCols, nextRows);') }) it('preserves the user scroll position across the rewrap', () => { const body = reflowFnBody() // At the live bottom -> stay pinned; scrolled up -> hold distance-from-bottom. - expect(body).toContain('var wasAtBottom = buffer.viewportY >= buffer.baseY;') - expect(body).toContain('term.scrollToBottom();') + expect(body).toContain('const wasAtBottom = buffer.viewportY >= buffer.baseY;') + expect(body).toContain('scope.term.scrollToBottom();') expect(body).toContain('rewrapped.baseY - distanceFromBottom - rewrapped.viewportY') }) it('is no-op when the dimensions are unchanged', () => { expect(reflowFnBody()).toContain( - 'if (nextCols === term.cols && nextRows === term.rows) return;' + 'if (nextCols === scope.term.cols && nextRows === scope.term.rows) {' ) }) it('is dispatched by the reflow WebView message and exposed on the handle', () => { - expect(htmlSource).toContain("} else if (msg.type === 'reflow') {") + expect(htmlSource).toContain('} else if (msg.type === "reflow") {') expect(htmlSource).toContain('reflow(msg.cols, msg.rows);') expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows })") }) it('does not locally resize hidden WebViews to a one-column grid', () => { - expect(htmlSource).toContain('var MIN_FIT_COLS = 20;') - expect(htmlSource).toContain('if (cols < MIN_FIT_COLS) return;') - expect(htmlSource).toContain("flog('measure-skip-small-width'") - expect(htmlSource).toContain("notify({ type: 'measure-result', cols: null, rows: null });") + expect(htmlSource).toContain('scope.MIN_FIT_COLS = 20;') + expect(htmlSource).toContain('if (cols < scope.MIN_FIT_COLS) {') + expect(htmlSource).toContain('flog("measure-skip-small-width"') + expect(htmlSource).toContain('notify({ type: "measure-result", cols: null, rows: null });') }) - // Why: the raw-source assertions above pass even if the reflow module is - // dropped from the XTERM_HTML concatenation (a broken/removed import or an - // emptied TERMINAL_REFLOW_JS leaves the `${...}` placeholder in the template - // but never injects the routine). That was the regression class reported when - // a sibling refactor extracted the tap dispatcher next to the reflow inject. - // Guard the *assembled* document so the routine and its dispatch are really - // present in what the WebView runs. + // Why: the assertions above read the reflow module's own emission, which still reads whole if + // the generator drops the module from the document or emits it twice. That was the regression + // class reported when a sibling refactor extracted the tap dispatcher next to reflow. Guard the + // assembled document so the routine, once, and its dispatch are really in what the WebView runs. describe('assembled XTERM_HTML', () => { - it('still injects the reflow routine (placeholder fully expanded)', () => { + it('carries the reflow routine exactly once', () => { expect(XTERM_HTML).toContain('function reflow(cols, rows) {') - expect(XTERM_HTML).toContain('term.resize(nextCols, nextRows);') - // No unexpanded template placeholder for the injected reflow JS. - expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}') + expect(XTERM_HTML).toContain('scope.term.resize(nextCols, nextRows);') + expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1) }) it('still routes the reflow message to the injected routine', () => { - expect(XTERM_HTML).toContain("} else if (msg.type === 'reflow') {") + expect(XTERM_HTML).toContain('} else if (msg.type === "reflow") {') expect(XTERM_HTML).toContain('reflow(msg.cols, msg.rows);') }) @@ -84,8 +77,8 @@ describe('terminal WebView reflow', () => { // between them; if its IIFE-time code threw, the listener below would // never bind and reflow messages would silently no-op. const reflowAt = XTERM_HTML.indexOf('function reflow(cols, rows) {') - const dispatchAt = XTERM_HTML.indexOf("var dispatch = { mode: 'idle'") - const listenerAt = XTERM_HTML.indexOf("window.addEventListener('message'") + const dispatchAt = XTERM_HTML.indexOf('const dispatch = {\n mode: "idle"') + const listenerAt = XTERM_HTML.indexOf('window.addEventListener("message"') expect(reflowAt).toBeGreaterThanOrEqual(0) expect(dispatchAt).toBeGreaterThan(reflowAt) expect(listenerAt).toBeGreaterThan(dispatchAt) diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 9218e5d6ad9..53d580cf196 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -1,15 +1,13 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' +import { XTERM_HTML } from './terminal-webview-html' -// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in -// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file. +// The RN wrapper and the pending-message queue are TypeScript; everything the WebView runs is the +// generated document. Concatenated so assertions resolve regardless of file. const source = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') + - readTerminalWebViewHtmlSource() + XTERM_HTML const sessionSource = readFileSync( new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), 'utf8' @@ -33,9 +31,11 @@ describe('TerminalWebView scroll routing', () => { }) it('maps a downward pull at the bottom to older scrollback rows', () => { - expect(source).toContain('var deltaY = ts.lastY - y;') - expect(source).toContain('smoothScrollOffsetY -= deltaY;') - expect(source).toContain('var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);') + expect(source).toContain('const deltaY = ts.lastY - y;') + expect(source).toContain('scope.smoothScrollOffsetY -= deltaY;') + expect(source).toContain( + 'const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);' + ) const nextViewportY = simulateNormalBufferPull({ baseY: 120, @@ -54,15 +54,18 @@ describe('TerminalWebView scroll routing', () => { ) const touchMoveBlock = sliceBetween( - "targetSurface.addEventListener('touchmove'", - '}, { capture: true, passive: false });' + 'targetSurface.addEventListener(\n "touchmove"', + '{ capture: true, passive: false }' ) expect(touchMoveBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan( touchMoveBlock.indexOf('if (enqueueNormalBufferScrollDelta(deltaY))') ) expect(touchMoveBlock).toContain('routeScrollLines(lines, x, y);') - const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)') + const momentumBlock = sliceBetween( + 'let momentumStep = function()', + 'if (Math.abs(vel) > MIN_VEL)' + ) expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan( momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))') ) @@ -81,13 +84,16 @@ describe('TerminalWebView scroll routing', () => { expect(smoothScrollBlock).toContain('return true;') const touchMoveBlock = sliceBetween( - "targetSurface.addEventListener('touchmove'", - '}, { capture: true, passive: false });' + 'targetSurface.addEventListener(\n "touchmove"', + '{ capture: true, passive: false }' ) expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(deltaY))') expect(touchMoveBlock).toContain('ts.velY = 0;') - const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)') + const momentumBlock = sliceBetween( + 'let momentumStep = function()', + 'if (Math.abs(vel) > MIN_VEL)' + ) expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))') expect(momentumBlock).toContain('ts.momentumId = null;') }) @@ -97,24 +103,24 @@ describe('TerminalWebView scroll routing', () => { 'function enqueueNormalBufferScrollDelta(deltaY)', 'function resetSmoothScrollOffset()' ) - expect(enqueueBlock).toContain('pendingNormalScrollDeltaY += deltaY;') - expect(enqueueBlock).toContain('if (normalScrollFrameId !== null) return true;') - expect(enqueueBlock).toContain('normalScrollFrameId = requestAnimationFrame(function()') + expect(enqueueBlock).toContain('scope.pendingNormalScrollDeltaY += deltaY;') + expect(enqueueBlock).toContain('if (scope.normalScrollFrameId !== null) {') + expect(enqueueBlock).toContain('scope.normalScrollFrameId = requestAnimationFrame(function()') expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(delta)') const resetBlock = sliceBetween( 'function resetSmoothScrollOffset()', 'function cellToViewportPx' ) - expect(resetBlock).toContain('pendingNormalScrollDeltaY = 0;') - expect(resetBlock).toContain('cancelAnimationFrame(normalScrollFrameId);') + expect(resetBlock).toContain('scope.pendingNormalScrollDeltaY = 0;') + expect(resetBlock).toContain('cancelAnimationFrame(scope.normalScrollFrameId);') }) it('drains terminal writes without shifting the queued array', () => { - expect(source).toContain('var writeQueueHead = 0;') + expect(source).toContain('scope.writeQueueHead = 0;') expect(source).toContain('function nextQueuedWrite()') - expect(source).toContain('writeQueueHead++;') - expect(source).toContain('writeQueue = writeQueue.slice(writeQueueHead);') + expect(source).toContain('scope.writeQueueHead++;') + expect(source).toContain('scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);') expect(source).not.toContain('writeQueue.shift()') }) @@ -159,67 +165,67 @@ describe('TerminalWebView scroll routing', () => { 'function updateScrollIndicator(reveal)' ) expect(updateTransformBlock).toContain( - "surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';" + 'scope.surface.style.transform = "translate(" + scope.panX + "px," + scope.panY + "px) scale(" + getTotalScale() + ")"' ) expect(source).not.toContain("querySelector('.xterm-screen')") expect(source).not.toContain('updateTerminalScreenTransform') - expect(updateTransformBlock).not.toContain("getVisualPanY() + 'px) scale('") + expect(updateTransformBlock).not.toContain('getVisualPanY() + "px) scale("') expect(updateTransformBlock).not.toContain('smoothScrollOffsetY') }) it('smooths velocity samples and uses lower friction for mobile momentum', () => { expect(source).toContain('function updateTouchVelocity(deltaY, dt)') expect(source).toContain('ts.velY * 0.55 + instantVelocity * 0.45') - expect(source).toContain('var FRICTION = 0.972;') - expect(source).toContain('var MIN_VEL = 0.012;') + expect(source).toContain('const FRICTION = 0.972;') + expect(source).toContain('const MIN_VEL = 0.012;') }) it('keeps selection edge autoscroll active and extends the dragged endpoint', () => { const startBlock = sliceBetween('function startEdgeScroll(dir)', 'function stopEdgeScroll()') expect(startBlock.indexOf('stopEdgeScroll();')).toBeLessThan( - startBlock.indexOf('edgeScrollDir = dir;') + startBlock.indexOf('scope.edgeScrollDir = dir;') ) - expect(startBlock.indexOf('term.scrollLines(edgeScrollDir);')).toBeLessThan( + expect(startBlock.indexOf('scope.term.scrollLines(scope.edgeScrollDir);')).toBeLessThan( startBlock.indexOf('syncEdgeScrollSelectionEndpoint();') ) const dragMoveBlock = sliceBetween( 'function handleDragMove(handle, clientX, clientY)', - ' // Latching document-level touch dispatcher: see' + 'function attachSurfaceEventHandlers(' ) - expect(dragMoveBlock).toContain('edgeScrollClientX = clientX;') - expect(dragMoveBlock).toContain('edgeScrollClientY = clientY;') + expect(dragMoveBlock).toContain('scope.edgeScrollClientX = clientX;') + expect(dragMoveBlock).toContain('scope.edgeScrollClientY = clientY;') expect(dragMoveBlock).toContain('syncSelectionHandleToViewportPoint(handle, clientX, clientY)') }) it('opens links and paths from surface taps before mouse/focus fallback', () => { expect(source).toContain('function buildMouseClickInput(clientX, clientY)') expect(source).toContain('function isClickMouseTrackingMode(mode)') - expect(source).toContain("return mode !== 'none';") - expect(source).toContain('var pixelX = cell.x;') - expect(source).toContain('var pixelY = cell.y;') + expect(source).toContain('return mode !== "none";') + expect(source).toContain('const pixelX = cell.x;') + expect(source).toContain('const pixelY = cell.y;') expect(source).toContain( - 'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return' + 'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) {' ) expect(source).toContain( - 'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return' + 'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) {' ) - expect(source).toContain("if (mouseTrackingMode === 'x10') return pixelPress;") - expect(source).toContain("if (mouseTrackingMode === 'x10') return sgrPress;") - expect(source).toContain("if (mouseTrackingMode === 'x10') return press;") - expect(source).toContain("if (col > 126 || row > 126) return '';") + expect(source).toContain('if (mouseTrackingMode === "x10") {\n return pixelPress;') + expect(source).toContain('if (mouseTrackingMode === "x10") {\n return sgrPress;') + expect(source).toContain('if (mouseTrackingMode === "x10") {\n return press;') + expect(source).toContain('if (col > 126 || row > 126) {\n return "";') const touchEndBlock = sliceBetween( - "document.addEventListener('touchend'", - '}, { capture: true, passive: true });' + 'document.addEventListener(\n "touchend"', + '{ capture: true, passive: true }' ) expect(touchEndBlock).toContain( - 'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)' + 'notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)' ) const tapHandlerBlock = sliceBetween( 'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)', - "document.addEventListener('touchstart'" + 'document.addEventListener(\n "touchstart"' ) expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan( tapHandlerBlock.indexOf('urlAtViewportPoint') @@ -228,14 +234,14 @@ describe('TerminalWebView scroll routing', () => { tapHandlerBlock.indexOf('filePathAtViewportPoint') ) expect(tapHandlerBlock.indexOf('filePathAtViewportPoint')).toBeLessThan( - tapHandlerBlock.indexOf('var clickInput = buildMouseClickInput') + tapHandlerBlock.indexOf('const clickInput = buildMouseClickInput') ) - expect(tapHandlerBlock).toContain("notify({ type: 'open-url', url: tappedUrl });") - expect(tapHandlerBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });") + expect(tapHandlerBlock).toContain('notify({ type: "open-url", url: tappedUrl });') + expect(tapHandlerBlock).toContain('notify({ type: "terminal-input", bytes: clickInput });') expect(tapHandlerBlock).toContain( 'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))' ) - expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });") + expect(tapHandlerBlock).toContain('notify({ type: "terminal-tap" });') }) it('allows x10 mouse gesture reports through the mobile session gate', () => { diff --git a/mobile/src/terminal/terminal-webview-surface-swap-injected.ts b/mobile/src/terminal/terminal-webview-surface-swap-injected.ts deleted file mode 100644 index 0f7b1c9a21d..00000000000 --- a/mobile/src/terminal/terminal-webview-surface-swap-injected.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const TERMINAL_SURFACE_SWAP_JS = String.raw` - // Why: phone-fit startup can issue several init() calls before xterm finishes - // replaying. Track the last painted surface separately from its replacement. - var committedTerm = null; - var committedSurface = surface; - var pendingTerm = null; - var pendingSurface = null; - - function beginTerminalSurfaceSwap() { - // Why: a superseded hidden replacement must not remain between the last - // painted surface and the newest one, or the newest commits below the viewport. - if (pendingSurface) { - try { pendingSurface.remove(); } catch (e) {} - if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {} - pendingSurface = null; - pendingTerm = null; - } - var swap = { - oldTerm: committedTerm, - oldSurface: committedSurface, - nextSurface: document.createElement('div') - }; - disposeTermObservers(); - swap.nextSurface.id = 'terminal-surface'; - swap.nextSurface.style.visibility = 'hidden'; - swap.nextSurface.style.position = 'absolute'; - swap.nextSurface.style.left = '0'; - swap.nextSurface.style.top = '0'; - document.getElementById('terminal-container').appendChild(swap.nextSurface); - surface = swap.nextSurface; - pendingSurface = swap.nextSurface; - attachSurfaceEventHandlers(surface); - swap.oldSurface.removeAttribute('id'); - return swap; - } - - function commitTerminalSurfaceSwap(swap, nextTerm) { - swap.nextSurface.style.visibility = 'visible'; - swap.nextSurface.style.position = ''; - swap.nextSurface.style.left = ''; - swap.nextSurface.style.top = ''; - swap.oldSurface.remove(); - if (swap.oldTerm) swap.oldTerm.dispose(); - committedTerm = nextTerm; - committedSurface = swap.nextSurface; - pendingTerm = null; - pendingSurface = null; - } -` diff --git a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts deleted file mode 100644 index 23036f84dfd..00000000000 --- a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Document-level latching touch dispatcher, injected into XTERM_HTML. Extracted -// from terminal-webview-html.ts to keep that file within its max-lines budget. -// Closes over host-IIFE state/functions: dispatch/tapCandidate/longPress*, -// viewportToCell, enterSelect, cancelSelect, handleDragMove, stopEdgeScroll, -// notify, notifyTerminalSurfaceTap, surface/handle/overlay elements, sel/selMode, -// and the LONG_PRESS_*/TAP_* constants. -export const TERMINAL_TAP_DISPATCH_JS = ` - // ============================================================ - // LATCHING TOUCH DISPATCHER (document-level) - // ============================================================ - var dispatch = { mode: 'idle', touchId: null, touchIds: null, longPressFingerInsideOverlay: false }; - - function touchById(touches, id) { - for (var i = 0; i < touches.length; i++) { - if (touches[i].identifier === id) return touches[i]; - } - return null; - } - - function targetInside(target, el) { - if (!target || !el) return false; - return el.contains(target); - } - - function clearLongPress() { - if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } - longPressOrigin = null; - } - - function armLongPress(touch) { - longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier }; - longPressTimer = setTimeout(function() { - longPressTimer = null; - if (!longPressOrigin) return; - var c = viewportToCell(longPressOrigin.x, longPressOrigin.y); - if (!c) return; - enterSelect(c.col, c.row); - }, LONG_PRESS_MS); - } - - function touchSlopExceeded(t) { - if (!longPressOrigin) return false; - var dx = Math.abs(t.clientX - longPressOrigin.x); - var dy = Math.abs(t.clientY - longPressOrigin.y); - return (dx + dy) > LONG_PRESS_SLOP; - } - - // Why: existing surface handlers stay attached to surface but we wrap - // their entry to no-op when the dispatcher latches into select-drag. - function dispatcherShouldBlockSurface() { - return dispatch.mode === 'select-drag'; - } - - document.addEventListener('touchstart', function(e) { - var t = e.touches[0]; - var target = e.target; - var onHandle = target === handleStart || target === handleEnd; - var inOverlay = targetInside(target, selectionOverlay); - var inSurface = targetInside(target, surface); - // Why: clear any stale tap candidate up front; only a fresh single-finger - // surface touch (below) re-arms it, so handle drags / pinches / dismiss - // taps never resolve as a link tap on touchend. - tapCandidate = null; - - if (e.touches.length === 2) { - // pinch latch - if (selMode === 'select') { - notify({ type: 'mobile-clip-cancel-by-pinch' }); - cancelSelect(); - } - dispatch.mode = 'pinch'; - dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]; - clearLongPress(); - return; - } - - if (onHandle && selMode === 'select') { - // start handle drag - var handleName = (target === handleStart) ? 'start' : 'end'; - sel.activeHandle = handleName; - dispatch.mode = 'select-drag'; - dispatch.touchId = t.identifier; - e.preventDefault(); - return; - } - - if (inOverlay) { - // tap on menu pill — let the buttons' own handlers fire - return; - } - - if (inSurface && selMode === 'select') { - // Why: tap-to-dismiss matches native iOS/Android — touching outside the - // selection clears it. We cancel immediately and latch to 'surface' so - // the same gesture still drives scroll/pan without a second touch. - cancelSelect(); - dispatch.mode = 'surface'; - dispatch.touchId = t.identifier; - return; - } - - if (inSurface) { - dispatch.mode = 'surface'; - dispatch.touchId = t.identifier; - tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }; - armLongPress(t); - } - }, { capture: true, passive: false }); - - document.addEventListener('touchmove', function(e) { - if (dispatch.mode === 'select-drag') { - var t = touchById(e.touches, dispatch.touchId); - if (!t || !sel || !sel.activeHandle) return; - e.preventDefault(); - handleDragMove(sel.activeHandle, t.clientX, t.clientY); - return; - } - if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { - // long-press slop check - if (longPressTimer && e.touches.length === 1) { - if (touchSlopExceeded(e.touches[0])) clearLongPress(); - } - // Why: disqualify the tap only once the finger travels past TAP_SLOP - // (a scroll/pan), independent of the long-press timer — so a tap that - // jitters under TAP_SLOP still opens the link/path under the finger. - if (tapCandidate && e.touches.length === 1) { - var mt = e.touches[0]; - if (mt.identifier === tapCandidate.identifier) { - var dx = Math.abs(mt.clientX - tapCandidate.x); - var dy = Math.abs(mt.clientY - tapCandidate.y); - if (dx + dy > TAP_SLOP) tapCandidate = null; - } - } else if (e.touches.length !== 1) { - tapCandidate = null; - } - // existing surface handler will run from its own listener - } - }, { capture: true, passive: false }); - - document.addEventListener('touchend', function(e) { - if (dispatch.mode === 'select-drag') { - if (sel) sel.activeHandle = null; - stopEdgeScroll(); - dispatch.mode = 'idle'; - dispatch.touchId = null; - return; - } - if (dispatch.mode === 'pinch') { - if (e.touches.length < 2) { - dispatch.mode = (e.touches.length === 1) ? 'surface' : 'idle'; - dispatch.touchIds = null; - if (e.touches.length === 1) dispatch.touchId = e.touches[0].identifier; - } - return; - } - if (dispatch.mode === 'surface') { - // Why: fire the tap from the tap-candidate origin (survives jitter under - // TAP_SLOP) rather than longPressOrigin, which the press-to-select slop - // can null mid-tap — that was dropping URL/file taps that moved a few px. - if ( - e.touches.length === 0 && - tapCandidate && - selMode !== 'select' && - Date.now() - tapCandidate.t <= TAP_MAX_MS - ) { - notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); - } - clearLongPress(); - tapCandidate = null; - if (e.touches.length === 0) { - dispatch.mode = 'idle'; - dispatch.touchId = null; - } - } - }, { capture: true, passive: true }); - - document.addEventListener('touchcancel', function() { - clearLongPress(); - tapCandidate = null; - stopEdgeScroll(); - if (dispatch.mode === 'select-drag') { - if (sel) sel.activeHandle = null; - } - dispatch.mode = 'idle'; - dispatch.touchId = null; - dispatch.touchIds = null; - }, { capture: true, passive: true }); -` diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index d775237ddcc..23964d65969 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -1,7 +1,11 @@ import { readFileSync } from 'node:fs' import { Script } from 'node:vm' import { describe, expect, it } from 'vitest' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' +import { + documentScopePreamble, + generatedDocumentModule +} from './document/generated-document-region.test-support' +import { XTERM_HTML } from './terminal-webview-html' const terminalWebViewSource = readFileSync( new URL('./TerminalWebView.tsx', import.meta.url), @@ -15,24 +19,22 @@ const terminalHtmlDocumentShellSource = readFileSync( new URL('./terminal-webview-html/document-shell.ts', import.meta.url), 'utf8' ) -// Read behavior from the assembled document; the module source only contains -// fragment imports and cannot prove the injected code is present. -const terminalHtmlSource = readTerminalWebViewHtmlSource() -const terminalWebglRecoverySource = readFileSync( - new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url), - 'utf8' -) +// Read behavior from the assembled document: it is what the WebView runs, and the module source +// alone cannot prove the generated script carries the code. +const terminalHtmlSource = XTERM_HTML + +const terminalWebglRecoverySource = await generatedDocumentModule('webgl-recovery') function extractStatusDotNormalizer() { - const declarationStart = terminalHtmlSource.indexOf(' var CLAUDE_STATUS_DOT =') - const declarationEnd = terminalHtmlSource.indexOf(' var PRIVATE_MODE_SCAN_TAIL_LIMIT') + const declarationStart = terminalHtmlSource.indexOf(' scope.CLAUDE_STATUS_DOT =') + const declarationEnd = terminalHtmlSource.indexOf(' scope.PRIVATE_MODE_SCAN_TAIL_LIMIT') const functionStart = terminalHtmlSource.indexOf(' function isStatusDotPresentationSelector') - const functionEnd = terminalHtmlSource.indexOf('\n\n function enqueueWrite', functionStart) + const functionEnd = terminalHtmlSource.indexOf('\n function enqueueWrite', functionStart) expect(declarationStart).toBeGreaterThanOrEqual(0) expect(declarationEnd).toBeGreaterThan(declarationStart) expect(functionStart).toBeGreaterThan(declarationEnd) expect(functionEnd).toBeGreaterThan(functionStart) - return `${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}` + return `${documentScopePreamble()}${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}` } function normalizeStatusDotChunks(chunks: string[]) { @@ -52,8 +54,8 @@ function resolveTerminalFontFamily(navigatorValue: { // Slice only the font block itself (isIOSWebView + terminalFontFamily), anchored // on font-related markers so unrelated edits below it can't break this extraction. const functionStart = terminalHtmlSource.indexOf(' function isIOSWebView()') - const declarationLine = terminalHtmlSource.indexOf(' var terminalFontFamily =', functionStart) - const declarationEnd = terminalHtmlSource.indexOf('\n', declarationLine) + const declarationLine = terminalHtmlSource.indexOf(' scope.terminalFontFamily =', functionStart) + const declarationEnd = terminalHtmlSource.indexOf(';\n', declarationLine) + 1 expect(functionStart).toBeGreaterThanOrEqual(0) expect(declarationLine).toBeGreaterThan(functionStart) expect(declarationEnd).toBeGreaterThan(declarationLine) @@ -61,8 +63,8 @@ function resolveTerminalFontFamily(navigatorValue: { navigator: navigatorValue } new Script(` -${terminalHtmlSource.slice(functionStart, declarationEnd)} -output = terminalFontFamily; +${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, declarationEnd)} +output = scope.terminalFontFamily; `).runInNewContext(context) return context.output ?? '' } @@ -92,16 +94,20 @@ describe('TerminalWebView text zoom', () => { it('forces the Claude status dot to text presentation before xterm writes', () => { expect(terminalHtmlSource).toContain('font-variant-emoji: text') - expect(terminalHtmlSource).toContain('var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa)') - expect(terminalHtmlSource).toContain('TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)') + expect(terminalHtmlSource).toContain('scope.CLAUDE_STATUS_DOT = String.fromCharCode(9210)') expect(terminalHtmlSource).toContain( - 'EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f)' + 'scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)' + ) + expect(terminalHtmlSource).toContain( + 'scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)' ) expect(terminalHtmlSource).toContain('function normalizeStatusDotPresentation(data)') expect(terminalHtmlSource).toContain( - 'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)' + 'data.replace(\n scope.CLAUDE_STATUS_DOT_PATTERN,\n scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR\n )' + ) + expect(terminalHtmlSource).toContain( + 'scope.writeQueue.push(normalizeStatusDotPresentation(data))' ) - expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))') }) it('normalizes Claude status dots idempotently across write chunks', () => { @@ -133,28 +139,28 @@ describe('TerminalWebView text zoom', () => { it('resets pending Claude status dot selector state when the terminal lifecycle resets', () => { const initStart = terminalHtmlSource.indexOf('function init(') const initReplay = terminalHtmlSource.indexOf( - 'var replayData = normalizeInitialData(initialData)' + 'const replayData = normalizeInitialData(initialData)' ) - const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {") - const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart) + const clearStart = terminalHtmlSource.indexOf('} else if (msg.type === "clear") {') + const clearEnd = terminalHtmlSource.indexOf('} else if (msg.type === "measure")', clearStart) expect(initStart).toBeGreaterThanOrEqual(0) expect(initReplay).toBeGreaterThan(initStart) expect(clearStart).toBeGreaterThanOrEqual(0) expect(clearEnd).toBeGreaterThan(clearStart) expect(terminalHtmlSource.slice(initStart, initReplay)).toContain( - 'statusDotPendingSelector = false' + 'scope.statusDotPendingSelector = false' ) expect(terminalHtmlSource.slice(clearStart, clearEnd)).toContain( - 'statusDotPendingSelector = false' + 'scope.statusDotPendingSelector = false' ) }) it('loads Unicode 11 before replaying mobile terminal bytes', () => { expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS') expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') - const open = terminalHtmlSource.indexOf('term.open(surface)') - const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") - const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)") + const open = terminalHtmlSource.indexOf('scope.term.open(scope.surface)') + const unicode = terminalHtmlSource.indexOf('scope.term.unicode.activeVersion = "11"') + const replay = terminalHtmlSource.indexOf('enqueueWrite(scope.ESC + "[0m" + replayData)') expect(open).toBeGreaterThanOrEqual(0) expect(unicode).toBeGreaterThan(open) expect(replay).toBeGreaterThan(unicode) @@ -164,9 +170,9 @@ describe('TerminalWebView text zoom', () => { expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net') expect(terminalWebglRecoverySource).toContain('window.WebglAddon.WebglAddon') expect(terminalHtmlSource).toContain('function isIOSWebView()') - expect(terminalHtmlSource).toContain('fontFamily: terminalFontFamily') - expect(terminalHtmlSource).toContain("fontWeight: '300'") - expect(terminalHtmlSource).toContain("fontWeightBold: '500'") + expect(terminalHtmlSource).toContain('fontFamily: scope.terminalFontFamily') + expect(terminalHtmlSource).toContain('fontWeight: "300"') + expect(terminalHtmlSource).toContain('fontWeightBold: "500"') expect(terminalWebglRecoverySource).toContain('new window.WebglAddon.WebglAddon()') }) diff --git a/mobile/src/terminal/terminal-webview-theme-injected.ts b/mobile/src/terminal/terminal-webview-theme-injected.ts deleted file mode 100644 index 98489b219c7..00000000000 --- a/mobile/src/terminal/terminal-webview-theme-injected.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { colors } from '../theme/mobile-theme' - -// Theme normalization and page-surface painting injected into the WebView IIFE. -// Mirrors the desktop minimumContrastRatio gate (src/renderer/src/lib/terminal-contrast-correction.ts, -// #7934/#10104): a dark composed background gets a mild floor of 3 to rescue near-background body text -// (e.g. Antigravity's #262b30 on #1e242a) without over-brightening vibrant ANSI colors; a light -// background keeps the WCAG-AA 4.5 floor. Gate on the composed background luminance, not app mode, -// because either theme slot can hold either kind of theme. An explicit desktop override published on -// the theme payload (#10754) wins over the luminance gate; older hosts simply omit it. -export const TERMINAL_WEBVIEW_THEME_JS = ` - var DARK_BG_MIN_CONTRAST = 3; - var LIGHT_BG_MIN_CONTRAST = 4.5; - // Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark). - var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 }; - - function parseTerminalBackgroundRgba(value) { - if (typeof value !== 'string') return null; - var v = value.trim().toLowerCase(); - if (!v) return null; - if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 }; - if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 }; - if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }; - var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); - if (hex) { - var h = hex[1]; - var ch; - if (h.length === 3 || h.length === 4) { - ch = h.split('').map(function (p) { return parseInt(p + p, 16); }); - } else { - ch = []; - for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16)); - } - return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 }; - } - var rgb = v.match(/^rgba?\\(([^)]+)\\)$/); - if (!rgb) return null; - var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\\s/]+/); - parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; }); - if (parts.length < 3) return null; - var channel = function (p) { - var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p); - return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null; - }; - var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]); - if (r === null || g === null || b === null) return null; - var a = 1; - if (parts[3] !== undefined) { - var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]); - a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1; - } - return { r: r, g: g, b: b, a: a }; - } - - function terminalRelativeLuminance(rgb) { - var lin = function (c) { - var n = c / 255; - return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); - }; - return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b); - } - - function terminalContrastRatio(a, b) { - var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b); - return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); - } - - // Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override". - function normalizeTerminalContrastOverride(value) { - if (typeof value !== 'number' || !isFinite(value)) return null; - return Math.min(21, Math.max(1, value)); - } - - // Pick the xterm minimumContrastRatio floor from the composed terminal background. - // Unparseable input defaults to the dark floor so agent output never stays invisible. - function resolveTerminalContrastFloor(background) { - var color = parseTerminalBackgroundRgba(background); - if (!color) return DARK_BG_MIN_CONTRAST; - var composited = color.a < 1 - ? { - r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)), - g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)), - b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a)) - } - : color; - var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >= - terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited); - return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST; - } - - function normalizeTerminalTheme(input) { - var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object' - ? input.theme - : null; - if (!source) return defaultTheme; - var next = {}; - var keys = Object.keys(defaultTheme); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - if (typeof source[key] === 'string') next[key] = source[key]; - } - return Object.assign({}, defaultTheme, next); - } - - function applyTerminalTheme(input) { - terminalThemeInput = input; - terminalTheme = normalizeTerminalTheme(input); - var background = terminalTheme.background || '${colors.terminalBg}'; - document.documentElement.style.background = background; - document.body.style.background = background; - // Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754); - // an older host omits the field and the luminance gate stays authoritative. - var publishedFloor = normalizeTerminalContrastOverride( - input && typeof input === 'object' ? input.minimumContrastRatio : undefined - ); - terminalMinimumContrastRatio = - publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor; - if (term) { - term.options.theme = terminalTheme; - term.options.minimumContrastRatio = terminalMinimumContrastRatio; - } - } -` diff --git a/mobile/src/terminal/terminal-webview-theme-injected.test.ts b/mobile/src/terminal/terminal-webview-theme.test.ts similarity index 62% rename from mobile/src/terminal/terminal-webview-theme-injected.test.ts rename to mobile/src/terminal/terminal-webview-theme.test.ts index d0947ef3e92..2b1f5fcbffa 100644 --- a/mobile/src/terminal/terminal-webview-theme-injected.test.ts +++ b/mobile/src/terminal/terminal-webview-theme.test.ts @@ -1,49 +1,69 @@ import { Script } from 'node:vm' import { parse } from 'acorn' import { describe, expect, it } from 'vitest' -import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' +import { + documentDeclaredFunction, + documentScopePreamble, + generatedDocumentModule +} from './document/generated-document-region.test-support' +import type { TerminalDocumentThemeTarget } from './document/terminal-theme' + +const themeSource = await generatedDocumentModule('terminal-theme') const DARK_FLOOR = 3 const LIGHT_FLOOR = 4.5 -// Eval the injected theme JS in a bare context so the declared helpers become -// callable properties on it (mirrors terminal-webview-engine.test.ts). +// Eval the theme block the document carries in a bare context so its declared helpers become +// callable properties on it (mirrors terminal-webview-engine.test.ts). The terminal it drives is +// a scope field in the document, so it is handed in through the scope rather than as a global. function loadThemeInjected(extra: Record = {}): Record { - const context: Record = { - defaultTheme: { background: '#1a1b26', foreground: '#c0caf5' }, - ...extra - } - new Script(TERMINAL_WEBVIEW_THEME_JS).runInNewContext(context) + const { term, ...globals } = extra + const context: Record = { ...globals, hostTerm: term ?? null } + new Script( + `${documentScopePreamble()} +scope.defaultTheme = { background: "#1a1b26", foreground: "#c0caf5" }; +scope.term = hostTerm; +${themeSource}` + ).runInNewContext(context) return context } +function loadContrastFloorResolver(): (bg: unknown) => number { + return documentDeclaredFunction(loadThemeInjected(), 'resolveTerminalContrastFloor') +} + +function loadThemeApplier(term: TerminalDocumentThemeTarget): (input: unknown) => void { + const context = loadThemeInjected({ + term, + document: { + documentElement: { style: { background: '' } }, + body: { style: { background: '' } } + } + }) + return documentDeclaredFunction(context, 'applyTerminalTheme') +} + describe('mobile terminal-webview contrast floor gate', () => { it('parses at the Chrome 74 syntax floor', () => { - expect(() => parse(TERMINAL_WEBVIEW_THEME_JS, { ecmaVersion: 2019 })).not.toThrow() + expect(() => parse(themeSource, { ecmaVersion: 2019 })).not.toThrow() }) it('picks the dark floor for dark composed backgrounds', () => { - const { resolveTerminalContrastFloor } = loadThemeInjected() as { - resolveTerminalContrastFloor: (bg: unknown) => number - } + const resolveTerminalContrastFloor = loadContrastFloorResolver() for (const bg of ['#1a1b26', '#1e242a', '#282828', '#000000', 'black']) { expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR) } }) it('picks the light floor for light composed backgrounds', () => { - const { resolveTerminalContrastFloor } = loadThemeInjected() as { - resolveTerminalContrastFloor: (bg: unknown) => number - } + const resolveTerminalContrastFloor = loadContrastFloorResolver() for (const bg of ['#ffffff', '#fbf1c7', 'white', 'rgb(240 240 240)']) { expect(resolveTerminalContrastFloor(bg)).toBe(LIGHT_FLOOR) } }) it('composites transparency over the dark app surface before deciding', () => { - const { resolveTerminalContrastFloor } = loadThemeInjected() as { - resolveTerminalContrastFloor: (bg: unknown) => number - } + const resolveTerminalContrastFloor = loadContrastFloorResolver() // Fully transparent → app surface (dark) → dark floor. expect(resolveTerminalContrastFloor('transparent')).toBe(DARK_FLOOR) // Faint white over the dark surface stays dark; opaque-enough white flips light. @@ -52,43 +72,28 @@ describe('mobile terminal-webview contrast floor gate', () => { }) it('defaults unparseable backgrounds to the dark floor so output never stays invisible', () => { - const { resolveTerminalContrastFloor } = loadThemeInjected() as { - resolveTerminalContrastFloor: (bg: unknown) => number - } + const resolveTerminalContrastFloor = loadContrastFloorResolver() for (const bg of [undefined, null, '', 'not-a-color', '#12', 42]) { expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR) } }) it('writes the resolved floor onto a live terminal when the theme changes', () => { - const term = { options: { theme: undefined as unknown, minimumContrastRatio: 1 } } - const context = loadThemeInjected({ - term, - document: { - documentElement: { style: { background: '' } }, - body: { style: { background: '' } } - } - }) as Record & { applyTerminalTheme: (input: unknown) => void } + const term: TerminalDocumentThemeTarget = { options: { minimumContrastRatio: 1 } } + const applyTerminalTheme = loadThemeApplier(term) - context.applyTerminalTheme({ theme: { background: '#ffffff' } }) + applyTerminalTheme({ theme: { background: '#ffffff' } }) expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR) - context.applyTerminalTheme({ theme: { background: '#1e242a' } }) + applyTerminalTheme({ theme: { background: '#1e242a' } }) expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR) }) // #10754: the desktop user can lower or disable the floor. Mobile mirrors the desktop gate, so the // published value has to win here or the same session renders differently on the phone. describe('published desktop override', () => { - function applyOn(term: { options: { minimumContrastRatio: number } }, input: unknown): void { - const context = loadThemeInjected({ - term, - document: { - documentElement: { style: { background: '' } }, - body: { style: { background: '' } } - } - }) as Record & { applyTerminalTheme: (input: unknown) => void } - context.applyTerminalTheme(input) + function applyOn(term: TerminalDocumentThemeTarget, input: unknown): void { + loadThemeApplier(term)(input) } it('uses the published floor instead of the luminance gate', () => { diff --git a/mobile/src/terminal/terminal-webview-url-tap.test.ts b/mobile/src/terminal/terminal-webview-url-tap.test.ts index bd7ff4bf06b..edb6523993f 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.test.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.test.ts @@ -1,11 +1,13 @@ import { createContext, Script } from 'node:vm' import { describe, expect, it } from 'vitest' import type { TappedFilePath } from './terminal-path-tap' -import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' +import { + documentDeclaredFunction, + generatedDocumentModule +} from './document/generated-document-region.test-support' import { TERMINAL_HTTP_URL_MAX_LENGTH, TERMINAL_HTTP_URL_REGEX_SOURCE, - URL_TAP_WEBVIEW_JS, findFileUrlAtColumn, findUrlAtColumn, resolveTerminalOscFileTap, @@ -13,6 +15,13 @@ import { } from './terminal-webview-url-tap' import { XTERM_HTML } from './terminal-webview-html' +// The three modules the document carries the URL-tap group as, in its own order. +const urlTapGroupSource = ( + await Promise.all( + ['path-tap', 'url-tap', 'osc-link-tap', 'surface-tap'].map(generatedDocumentModule) + ) +).join('\n') + type FileTapResolverCase = { name: string uri: string @@ -99,19 +108,15 @@ function createInjectedFileTapResolvers(): { resolveTerminalFileUrlTap: InjectedFileTapResolver resolveTerminalOscFileTap: InjectedFileTapResolver } { - const context = createContext({ URL }) + const context: Record = createContext({ URL }) new Script( - `${TERMINAL_PATH_TAP_JS}\n${URL_TAP_WEBVIEW_JS}\n` + + `${urlTapGroupSource}\n` + 'this.__resolveTerminalFileUrlTap = resolveTerminalFileUrlTap;\n' + 'this.__resolveTerminalOscFileTap = resolveTerminalOscFileTap;' ).runInContext(context) - const injected = context as { - __resolveTerminalFileUrlTap: InjectedFileTapResolver - __resolveTerminalOscFileTap: InjectedFileTapResolver - } return { - resolveTerminalFileUrlTap: injected.__resolveTerminalFileUrlTap, - resolveTerminalOscFileTap: injected.__resolveTerminalOscFileTap + resolveTerminalFileUrlTap: documentDeclaredFunction(context, '__resolveTerminalFileUrlTap'), + resolveTerminalOscFileTap: documentDeclaredFunction(context, '__resolveTerminalOscFileTap') } } @@ -204,6 +209,6 @@ describe('findUrlAtColumn', () => { expect(XTERM_HTML).toContain('function isLocalFileUriHostname(') expect(XTERM_HTML).toContain('return parsePathLineCol(value);') expect(XTERM_HTML).toContain('function notifyTerminalSurfaceTap(') - expect(XTERM_HTML).toContain("notify({ type: 'open-url', url: tappedUrl });") + expect(XTERM_HTML).toContain('notify({ type: "open-url", url: tappedUrl });') }) }) diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts index f5d416d5797..c63cf8d48d6 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.ts @@ -43,212 +43,3 @@ function findTerminalUrlAtColumn(lineText: string, col: number, source: string): } return null } - -export const URL_TAP_WEBVIEW_JS = ` - var URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE)}; - var FILE_URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_FILE_URL_REGEX_SOURCE)}; - var URL_TAP_MAX_LENGTH = ${TERMINAL_HTTP_URL_MAX_LENGTH}; - function findUrlAtColumn(lineText, col) { - return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE); - } - function findFileUrlAtColumn(lineText, col) { - return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE); - } - function findTerminalUrlAtColumn(lineText, col, source) { - if (typeof lineText !== 'string' || lineText.length === 0) return null; - var re = new RegExp(source, 'gi'); - var match; - while ((match = re.exec(lineText)) !== null) { - var end = match.index + match[0].length; - if (match[0].length <= URL_TAP_MAX_LENGTH && col >= match.index && col < end) return match[0]; - if (match[0].length === 0) re.lastIndex++; - } - return null; - } - function fileUrlAtViewportPoint(clientX, clientY) { - var cell = viewportToCell(clientX, clientY); - if (!cell) return null; - return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); - } - function urlAtViewportPoint(clientX, clientY) { - var cell = viewportToCell(clientX, clientY); - if (!cell) return null; - // Map the cell column to a string index so wide chars earlier on the line - // don't shift the match column off the tapped URL. - return findUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); - } - - // Why: OSC 8 links can render as labels like "#1234"; the URI lives in - // xterm's internal link service, so every access is guarded and falls through. - function oscLinkService() { - try { - var core = term && term._core; - if (!core) return null; - return core._oscLinkService - || (core._inputHandler && core._inputHandler._oscLinkService) - || null; - } catch (e) { return null; } - } - function oscLinkAtViewportPoint(clientX, clientY) { - try { - var cell = viewportToCell(clientX, clientY); - if (!cell) return null; - var line = term.buffer.active.getLine(cell.row); - if (!line) return null; - var urlId = oscLinkIdAtCell(line, cell.col); - if (!urlId) return initialOscLinkAtCell(cell.row, cell.col); - var svc = oscLinkService(); - if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col); - var data = svc.getLinkData(urlId); - var uri = data && data.uri; - return terminalOscLinkTarget(uri); - } catch (e) { return null; } - } - function initialOscLinkAtCell(row, col) { - for (var i = 0; i < initialOscLinks.length; i++) { - var link = initialOscLinks[i]; - if (!link || typeof link.uri !== 'string') continue; - if (link.row < initialOscLinkRowOffset) continue; - var shiftedRow = link.row - initialOscLinkRowOffset; - if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri); - } - return null; - } - function terminalOscLinkTarget(uri) { - if (typeof uri !== 'string') return null; - if (/^https?:/i.test(uri)) return { kind: 'url', url: uri }; - var fileTap = resolveTerminalOscFileTap(uri); - return fileTap ? { kind: 'file', fileTap: fileTap } : null; - } - function resolveTerminalOscFileTap(uri) { - return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri); - } - function resolveTerminalFileUrlTap(uri) { - var parsed; - try { - parsed = new URL(uri); - } catch (e) { - return null; - } - if (parsed.protocol !== 'file:') return null; - var filePath; - try { - filePath = decodeURIComponent(parsed.pathname || ''); - } catch (e) { - return null; - } - if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) { - filePath = '//' + parsed.hostname + filePath; - } else if (/^\\/[A-Za-z]:\\//.test(filePath)) { - filePath = filePath.slice(1); - } - if (!filePath) return null; - var hashTarget = parseFileUrlLineHash(parsed.hash || ''); - if (hashTarget) { - return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }; - } - if (/%3a/i.test(parsed.pathname || '')) { - return { pathText: filePath, line: null, column: null }; - } - return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null }; - } - function isLocalFileUriHostname(hostname) { - var normalized = String(hostname).toLowerCase(); - return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]'; - } - function parseOscPathLikeTarget(value) { - if (!/^(?:~[\\\\/]|[\\\\/]|\\.{1,2}[\\\\/]|[A-Za-z]:[\\\\/]|[A-Za-z0-9._-]+[\\\\/]|(?=[A-Za-z0-9._-]*\\.[A-Za-z0-9]))/.test(value)) return null; - return parsePathLineCol(value); - } - function parseFileUrlLineHash(hash) { - var match = /^#?L(\\d+)(?:C(\\d+))?$/i.exec(hash); - if (!match) return null; - var line = parseInt(match[1], 10); - var column = match[2] ? parseInt(match[2], 10) : null; - if (line < 1 || (column !== null && column < 1)) return null; - return { line: line, column: column }; - } - function parseFilePathTrailingLineTarget(filePath) { - var match = /^(.*?)(?::(\\d+))(?::(\\d+))?$/.exec(filePath); - if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\\\') return null; - var line = parseInt(match[2], 10); - var column = match[3] ? parseInt(match[3], 10) : null; - if (line < 1 || (column !== null && column < 1)) return null; - return { pathText: match[1], line: line, column: column }; - } - function captureInitialOscLinkTexts() { - if (!Array.isArray(initialOscLinks)) return; - for (var i = 0; i < initialOscLinks.length; i++) { - var link = initialOscLinks[i]; - if (!link || typeof link.text === 'string') continue; - link.text = initialOscLinkTextAtRow(link, link.row); - } - } - function initialOscLinkTextStillMatches(link, row) { - if (typeof link.text !== 'string') return false; - return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text; - } - function initialOscLinkTextAtRow(link, row) { - try { - var lineText = getLineText(row); - var start = cellColToStringIndex(row, link.startCol); - var end = cellColToStringIndex(row, link.endCol); - return lineText.slice(start, end); - } catch (e) { - return ''; - } - } - function oscLinkIdAtCell(line, col) { - try { - var bufCell = line.getCell(col); - return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0; - } catch (e) { return 0; } - } - - function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) { - var tappedOscLink = oscLinkAtViewportPoint(originX, originY); - if (tappedOscLink && tappedOscLink.kind === 'file') { - notify({ - type: 'terminal-file-tap', - pathText: tappedOscLink.fileTap.pathText, - line: tappedOscLink.fileTap.line, - column: tappedOscLink.fileTap.column - }); - return; - } - var tappedFileUrl = fileUrlAtViewportPoint(originX, originY); - var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null; - if (tappedFileUrlPath) { - notify({ - type: 'terminal-file-tap', - pathText: tappedFileUrlPath.pathText, - line: tappedFileUrlPath.line, - column: tappedFileUrlPath.column - }); - return; - } - var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY); - if (tappedUrl) { - notify({ type: 'open-url', url: tappedUrl }); - return; - } - var tappedPath = filePathAtViewportPoint(originX, originY); - if (tappedPath) { - notify({ - type: 'terminal-file-tap', - pathText: tappedPath.pathText, - line: tappedPath.line, - column: tappedPath.column - }); - return; - } - var clickInput = buildMouseClickInput(originX, originY); - if (clickInput) { - notify({ type: 'terminal-input', bytes: clickInput }); - } - // Touch still needs native input focus after the TUI consumes its mouse click. - if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { - notify({ type: 'terminal-tap' }); - } - } -` diff --git a/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts b/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts deleted file mode 100644 index 8e4d27c2348..00000000000 --- a/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts +++ /dev/null @@ -1,62 +0,0 @@ -// WebGL loss and visibility recovery injected into the terminal WebView IIFE. -// It closes over term, terminalGeneration, theme state, and xterm's addon global. -export const TERMINAL_WEBGL_RECOVERY_JS = ` - function refreshTerminalSurface() { - if (!term) return; - try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {} - } - - function cancelWebglContextRecovery() { - if (!webglRecoveryTimer) return; - clearTimeout(webglRecoveryTimer); - webglRecoveryTimer = null; - } - - function attachWebglAddon(allowRecovery) { - if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false; - var addon = null; - try { - addon = new window.WebglAddon.WebglAddon(); - webglAddon = addon; - if (addon.onContextLoss) addon.onContextLoss(function() { - if (webglAddon !== addon) return; - flog('webgl-context-loss', { retry: allowRecovery }); - webglAddon = null; - try { addon.dispose(); } catch (e) {} - refreshTerminalSurface(); - if (!allowRecovery) return; - // Why: one delayed retry handles transient iOS context loss without - // entering a GPU crash loop; a second loss stays on the DOM renderer. - cancelWebglContextRecovery(); - var recoveryTerm = term; - var recoveryGeneration = terminalGeneration; - webglRecoveryTimer = setTimeout(function() { - webglRecoveryTimer = null; - if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return; - attachWebglAddon(false); - }, 100); - }); - term.loadAddon(addon); - if (!allowRecovery) { - try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {} - refreshTerminalSurface(); - } - return true; - } catch (e) { - flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) }); - if (webglAddon === addon) webglAddon = null; - try { if (addon) addon.dispose(); } catch (disposeError) {} - refreshTerminalSurface(); - return false; - } - } - - document.addEventListener('visibilitychange', function() { - if (document.visibilityState !== 'visible') return; - // Why: iOS may restore the xterm model while discarding GPU pixels/theme - // paint state, so visibility must rebuild the atlas and repaint every row. - applyTerminalTheme(terminalThemeInput); - try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {} - refreshTerminalSurface(); - }); -` diff --git a/mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts b/mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts deleted file mode 100644 index 8d2321e7c25..00000000000 --- a/mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Indirect-pointer (external mouse / trackpad) scroll for the terminal surface, -// injected into XTERM_HTML. Extracted from terminal-webview-html.ts to keep that -// file within its max-lines budget. Closes over host-IIFE state/functions: -// term, getCellHeight, getTotalScale, shouldRouteScrollToTerminalInput, -// routeScrollLines, enqueueNormalBufferScrollDelta, resetSmoothScrollOffset, -// and dispatcherShouldBlockSurface. -export const TERMINAL_WHEEL_SCROLL_JS = ` - var wheelAccumDeltaY = 0; - - function wheelEventPixelDeltaY(e) { - var delta = e.deltaY; - if (typeof delta !== 'number' || !isFinite(delta) || delta === 0) return 0; - // DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas - // for external mouse wheels, iOS trackpads report pixels. - if (e.deltaMode === 1) return delta * getCellHeight() * getTotalScale(); - if (e.deltaMode === 2) return delta * window.innerHeight; - return delta; - } - - function attachSurfaceWheelHandler(targetSurface) { - targetSurface.addEventListener('wheel', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - // Why: xterm's own wheel handler scrolls its hidden viewport or emits - // cursor keys through onData, which the mobile query-reply gate drops. - // Claim the event so indirect pointers share the touch scroll router. - e.preventDefault(); - e.stopPropagation(); - - // Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than - // firing cursor keys at the TUI; two-finger pinch still drives text size. - if (e.ctrlKey) return; - - var deltaY = wheelEventPixelDeltaY(e); - if (deltaY === 0) return; - - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - if (!(effectiveCellH > 0)) return; - wheelAccumDeltaY += deltaY; - var lines = Math.trunc(wheelAccumDeltaY / effectiveCellH); - if (lines !== 0) { - wheelAccumDeltaY -= lines * effectiveCellH; - routeScrollLines(lines, e.clientX, e.clientY); - } - return; - } - wheelAccumDeltaY = 0; - enqueueNormalBufferScrollDelta(deltaY); - }, { capture: true, passive: false }); - } -` From 0cc2b2688d8a4bfe2f69634ad7cdbf2bc8601552 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:28:31 +0000 Subject: [PATCH 180/224] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 737762e2b61..43b54d9f382 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 69m + + downloads: 70m @@ -15,7 +15,7 @@ downloads downloads - 69m - 69m + 70m + 70m From fa0010e8d6b2a7ad946fe1f1b005c6a8497c6c17 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:13:27 -0400 Subject: [PATCH 181/224] fix(relay): abort rehomes whose host never arrived, without disabling the switch (#21813) A regional rehome whose host went offline right after accepting the move left its migration row open forever: the target had registered it, the host held nothing on the source, and the completion sweep could never finish it. Eight such rows filled REGIONAL_REHOME_CONCURRENT_LIMIT and every later candidate came back deferred, silently, for 21 hours. The only sweep that touched them fires at 24 hours and also sets enabled = 0 on the durable control, so the first leak to age out would have turned rehoming off, repeatedly. Adds a director sweep that rolls such an attempt back to its source after one migration lease, with abort_reason = 'host_not_arrived', reusing the existing rollback (assignment epoch bump back to the source, lease removal, superseded target reservation release) and leaving the switch untouched. The 24-hour sweep keeps its disable as a last-resort latch. The source cell now names why it deferred, on a new optional response field, and the director stops walking its candidate page on a deferral no later candidate can pass. Each poll that dispatched logs one summary line. Claude-Session: https://claude.ai/session/ced32ebb-7155-4413-adad-1eccd14c2010 --- cloud/apps/relay/src/app.ts | 4 +- .../src/assignment-cleanup-steps.test.ts | 10 +- .../relay/src/assignment-cleanup-steps.ts | 6 +- .../src/assignment-inventory-snapshot.test.ts | 1 + .../src/assignment-inventory-snapshot.ts | 17 ++- cloud/apps/relay/src/assignment-store.ts | 98 +++++++++--- .../src/cell-inventory-lock-census.test.ts | 4 +- cloud/apps/relay/src/database.ts | 4 + cloud/apps/relay/src/host-session-registry.ts | 24 ++- .../src/idle-regional-rehome-store.test.ts | 20 ++- .../relay/src/region-correction-preview.ts | 19 ++- .../relay/src/regional-rehome-abort-reason.ts | 18 +++ .../src/regional-rehome-postgres.test.ts | 4 +- .../relay/src/regional-rehome-store.test.ts | 118 ++++++++++++++- .../relay/src/regional-rehome-worker.test.ts | 140 ++++++++++++++++++ .../apps/relay/src/regional-rehome-worker.ts | 35 ++++- .../src/relay-schema-lock-targets.test.ts | 1 + cloud/apps/relay/src/relay-server.ts | 2 +- .../src/idle-regional-rehome.test.ts | 51 +++++++ .../src/idle-regional-rehome.ts | 50 ++++++- 20 files changed, 569 insertions(+), 57 deletions(-) create mode 100644 cloud/apps/relay/src/regional-rehome-abort-reason.ts create mode 100644 cloud/packages/relay-contract/src/idle-regional-rehome.test.ts diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index 0dcd3a68f9a..b5c76a57f86 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -2,7 +2,7 @@ import { AssignmentRequestSchema, IdleRegionalRehomeRequestSchema, type IdleRegionalRehomeRequest, - type IdleRegionalRehomeOutcome, + type IdleRegionalRehomeResult, type RegionCorrectionResponse, isRelayCellConnectionHardCap, RELAY_ADMISSION_BUDGETS, @@ -79,7 +79,7 @@ export function createRelayApp( idleRehome?: (input: IdleRegionalRehomeRequest & { cohortPercent: number directorSafety: RegionalRehomeSafetySnapshot - }) => Promise<{ outcome: IdleRegionalRehomeOutcome }> + }) => Promise drainHost?: (input: { attemptId: string userId: string diff --git a/cloud/apps/relay/src/assignment-cleanup-steps.test.ts b/cloud/apps/relay/src/assignment-cleanup-steps.test.ts index 02c21f76ada..491dde21962 100644 --- a/cloud/apps/relay/src/assignment-cleanup-steps.test.ts +++ b/cloud/apps/relay/src/assignment-cleanup-steps.test.ts @@ -16,6 +16,7 @@ function stubStore(overrides: Partial = {}) { completeReadyEvacuations: method('completeReadyEvacuations'), completeReadyRegionalRehomes: method('completeReadyRegionalRehomes'), abortExpiredEvacuations: method('abortExpiredEvacuations'), + abortUnarrivedRegionalRehomes: method('abortUnarrivedRegionalRehomes'), abortExpiredRegionalRehomes: method('abortExpiredRegionalRehomes'), reapRegionalRehomeAttempts: method('reapRegionalRehomeAttempts'), releaseExpiredActivityLeases: method('releaseExpiredActivityLeases'), @@ -42,6 +43,7 @@ describe('assignment cleanup steps', () => { 'refreshRegionalRehomeLeases', 'completeReadyEvacuations', 'abortExpiredEvacuations', + 'abortUnarrivedRegionalRehomes', 'abortExpiredRegionalRehomes', 'reapRegionalRehomeAttempts', 'releaseExpiredActivityLeases', @@ -55,13 +57,13 @@ describe('assignment cleanup steps', () => { ) }) - it('covers all ten sweeps exactly once per run', async () => { + it('covers all eleven sweeps exactly once per run', async () => { const { store, calls } = stubStore() await runAssignmentCleanup(store) - expect(calls).toHaveLength(10) - expect(new Set(calls).size).toBe(10) - expect(assignmentCleanupSteps(store)).toHaveLength(10) + expect(calls).toHaveLength(11) + expect(new Set(calls).size).toBe(11) + expect(assignmentCleanupSteps(store)).toHaveLength(11) }) }) diff --git a/cloud/apps/relay/src/assignment-cleanup-steps.ts b/cloud/apps/relay/src/assignment-cleanup-steps.ts index 0b6547d2ae4..b6fee0fdaa5 100644 --- a/cloud/apps/relay/src/assignment-cleanup-steps.ts +++ b/cloud/apps/relay/src/assignment-cleanup-steps.ts @@ -1,9 +1,9 @@ import { runRelayBackgroundOperation } from './relay-background-operation.js' -// The ten periodic assignment sweeps the director runs every 30s. Each step +// The eleven periodic assignment sweeps the director runs every 30s. Each step // re-derives its state from the database and is idempotent, so they carry no // intra-tick ordering dependency — which is what makes per-step isolation -// sound: one failing sweep costs one tick of itself, never the other nine. +// sound: one failing sweep costs one tick of itself, never the other ten. // (A single poisoned rehome row once silenced the whole chained form // fleet-wide.) Sweep failures are logged, never fed into the rehome worker's // dispatch-failure budget: a sweep exception is not a dispatch failure and @@ -13,6 +13,7 @@ export type AssignmentCleanupStore = { completeReadyEvacuations(): Promise completeReadyRegionalRehomes(): Promise abortExpiredEvacuations(): Promise + abortUnarrivedRegionalRehomes(): Promise abortExpiredRegionalRehomes(): Promise reapRegionalRehomeAttempts(): Promise releaseExpiredActivityLeases(): Promise @@ -29,6 +30,7 @@ export function assignmentCleanupSteps( ['complete-ready-evacuations', () => assignments.completeReadyEvacuations()], ['complete-ready-regional-rehomes', () => assignments.completeReadyRegionalRehomes()], ['abort-expired-evacuations', () => assignments.abortExpiredEvacuations()], + ['abort-unarrived-regional-rehomes', () => assignments.abortUnarrivedRegionalRehomes()], ['abort-expired-regional-rehomes', () => assignments.abortExpiredRegionalRehomes()], ['reap-regional-rehome-attempts', () => assignments.reapRegionalRehomeAttempts()], ['release-expired-activity-leases', () => assignments.releaseExpiredActivityLeases()], diff --git a/cloud/apps/relay/src/assignment-inventory-snapshot.test.ts b/cloud/apps/relay/src/assignment-inventory-snapshot.test.ts index afc35f7e601..0e55a22a3df 100644 --- a/cloud/apps/relay/src/assignment-inventory-snapshot.test.ts +++ b/cloud/apps/relay/src/assignment-inventory-snapshot.test.ts @@ -71,6 +71,7 @@ describe('assignment inventory snapshot', () => { targetRegistered: 0, completedLast24Hours: 0, abortedLast24Hours: 0, + hostNotArrivedLast24Hours: 0, oldestActiveAgeMs: null }) diff --git a/cloud/apps/relay/src/assignment-inventory-snapshot.ts b/cloud/apps/relay/src/assignment-inventory-snapshot.ts index 652fbdf2184..9e12610492f 100644 --- a/cloud/apps/relay/src/assignment-inventory-snapshot.ts +++ b/cloud/apps/relay/src/assignment-inventory-snapshot.ts @@ -1,5 +1,6 @@ import { RELAY_DEFAULT_REGION } from '@orca-cloud/relay-contract' import type { RelayDatabase, SqlRow } from './database.js' +import { REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS } from './regional-rehome-abort-reason.js' export type CellInventorySnapshotRow = { cellId: string @@ -22,6 +23,7 @@ export type AssignmentInventorySnapshot = { targetRegistered: number completedLast24Hours: number abortedLast24Hours: number + hostNotArrivedLast24Hours: number oldestActiveAgeMs: number | null } } @@ -79,6 +81,10 @@ export async function readAssignmentInventorySnapshot( AS completed_last_24_hours, COALESCE(SUM(CASE WHEN attempt.aborted_at >= ? THEN 1 ELSE 0 END), 0) AS aborted_last_24_hours, + COALESCE(SUM(CASE WHEN attempt.aborted_at >= ? + AND attempt.abort_reason = 'host_not_arrived' + THEN 1 ELSE 0 END), 0) + AS host_not_arrived_last_24_hours, MIN(CASE WHEN attempt.completed_at IS NULL AND attempt.aborted_at IS NULL THEN attempt.created_at END) AS oldest_active_at FROM relay_region_rehome_attempts attempt @@ -86,7 +92,11 @@ export async function readAssignmentInventorySnapshot( ON migration.user_id = attempt.user_id AND migration.relay_host_id = attempt.relay_host_id AND migration.assignment_epoch = attempt.assignment_epoch`, - [now - 24 * 60 * 60_000, now - 24 * 60 * 60_000] + [ + now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS, + now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS, + now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS + ] ) )[0] const oldestActiveAt = optionalInteger(regionalRehomeRow, 'oldest_active_at') @@ -117,6 +127,10 @@ export async function readAssignmentInventorySnapshot( targetRegistered: asInteger(regionalRehomeRow, 'target_registered'), completedLast24Hours: asInteger(regionalRehomeRow, 'completed_last_24_hours'), abortedLast24Hours: asInteger(regionalRehomeRow, 'aborted_last_24_hours'), + hostNotArrivedLast24Hours: asInteger( + regionalRehomeRow, + 'host_not_arrived_last_24_hours' + ), oldestActiveAgeMs: oldestActiveAt === null ? null : now - oldestActiveAt } } @@ -147,6 +161,7 @@ export function formatAssignmentInventorySnapshot( ` targetRegistered=${snapshot.regionalRehomes.targetRegistered}` + ` completedLast24Hours=${snapshot.regionalRehomes.completedLast24Hours}` + ` abortedLast24Hours=${snapshot.regionalRehomes.abortedLast24Hours}` + + ` hostNotArrivedLast24Hours=${snapshot.regionalRehomes.hostNotArrivedLast24Hours}` + ` oldestActiveAgeMs=${snapshot.regionalRehomes.oldestActiveAgeMs ?? 'none'}` ) return lines diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index b34e25a69a0..fd1193e0d2f 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -30,6 +30,7 @@ import { type RelayRegion, type RegionCorrectionRequest, type RegionCorrectionResponse, + type IdleRegionalRehomeCommit, type IdleRegionalRehomeRequest, } from '@orca-cloud/relay-contract' import { @@ -80,6 +81,10 @@ import { regionalRehomePoolPressure, regionalRehomeSafetyFailure } from './regional-rehome-safety.js' +import { + REGIONAL_REHOME_ARRIVAL_WINDOW_MS, + type RegionalRehomeAbortReason +} from './regional-rehome-abort-reason.js' import { ABANDONED_REGISTERED_MIGRATION, DURABLY_FENCED_MIGRATION_SOURCE, @@ -3445,21 +3450,26 @@ export class RelayAssignmentStore { request: IdleRegionalRehomeRequest, processSafety?: RegionalRehomeSafetySnapshot, cohortPercent = this.regionalRehomeCohortPercent - ): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> { + ): Promise { const prior = await this.reconcileIdleRegionalRehome(request) if (prior !== 'not-committed') return { outcome: prior } - if (!processSafety || !Number.isInteger(cohortPercent) || cohortPercent <= 0 || cohortPercent > 100) { - return { outcome: 'deferred' } + if (!processSafety) return { outcome: 'deferred', reason: 'director-safety-stale' } + if (!Number.isInteger(cohortPercent) || cohortPercent <= 0 || cohortPercent > 100) { + return { outcome: 'deferred', reason: 'cohort-closed' } } let safetyDisable: Record | null = null - const result = await this.database.transaction(async (transaction): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> => { + // Set on every fleet-safety failure, disable or not: the pause is durable + // and global either way, so no later candidate in this poll can get past it. + let safetyPaused = false + const result = await this.database.transaction(async (transaction): Promise => { safetyDisable = null + safetyPaused = false const now = this.now() const control = (await transaction.queryLocked( `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` ))[0] if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) { - return { outcome: 'deferred' } + return { outcome: 'deferred', reason: 'control-closed' } } await transaction.query( `INSERT INTO relay_region_rehome_worker_state @@ -3470,13 +3480,15 @@ export class RelayAssignmentStore { `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` ))[0]! if (Number(worker.paused_until) > now || Number(worker.next_dispatch_at) > now) { - return { outcome: 'deferred' } + return { outcome: 'deferred', reason: 'budget-closed' } } const open = (await transaction.query( `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE completed_at IS NULL AND aborted_at IS NULL` ))[0] - if (Number(open?.count ?? 0) >= REGIONAL_REHOME_CONCURRENT_LIMIT) return { outcome: 'deferred' } + if (Number(open?.count ?? 0) >= REGIONAL_REHOME_CONCURRENT_LIMIT) { + return { outcome: 'deferred', reason: 'concurrency-limit' } + } const attempt = await this.startRegionalRehomeCandidate(transaction, { identity: request, sourceCellId: request.sourceCellId, @@ -3490,9 +3502,14 @@ export class RelayAssignmentStore { skips: [], idleRequest: request, cohortPercent, - onSafetyDisabled: (event) => { safetyDisable = event } + onSafetyDisabled: (event) => { + safetyDisable = event + safetyPaused = true + } }) - if (!attempt) return { outcome: 'deferred' } + if (!attempt) { + return { outcome: 'deferred', reason: safetyPaused ? 'fleet-safety' : 'candidate-ineligible' } + } await this.markRegionalRehomeDispatchClaimed( transaction, request.attemptId, now, Math.ceil(60_000 / Number(control.rate_per_minute)) ) @@ -6354,8 +6371,43 @@ export class RelayAssignmentStore { return integer(completed[0]!, 'changes') + integer(aborted[0]!, 'changes') } + // A move the host never finished: it holds no activity on the source and is + // not present at the target, so the registered migration row can do nothing + // but occupy one of the eight concurrent slots until something clears it. + // Rolling it back leaves the durable assignment on the source, so the host + // lands where it started whenever it next reconnects. + async abortUnarrivedRegionalRehomes(limit = 100): Promise { + return await this.rollBackStalledRegionalRehomes({ + sweep: 'abort-unarrived-regional-rehomes', + minimumAttemptAgeMs: REGIONAL_REHOME_ARRIVAL_WINDOW_MS, + abortReason: 'host_not_arrived', + disableControl: false, + limit + }) + } + + // The last-resort latch, and the only sweep that disables the switch. With + // the arrival sweep above running it should never reach a row; one that + // survives a day past dispatch means the rollback path itself is broken. async abortExpiredRegionalRehomes(limit = 100): Promise { + return await this.rollBackStalledRegionalRehomes({ + sweep: 'abort-expired-regional-rehomes', + minimumAttemptAgeMs: REGIONAL_REHOME_MAX_REFRESH_MS, + abortReason: 'max_refresh_expired', + disableControl: true, + limit + }) + } + + private async rollBackStalledRegionalRehomes(input: { + sweep: string + minimumAttemptAgeMs: number + abortReason: RegionalRehomeAbortReason + disableControl: boolean + limit: number + }): Promise { const now = this.now() + const dispatchedBefore = now - input.minimumAttemptAgeMs const quarantined = this.quarantinedRegionalRehomeAttemptIds(now) const exclusion = quarantined.length ? ` AND attempt_id NOT IN (${quarantined.map(() => '?').join(', ')})` @@ -6366,7 +6418,7 @@ export class RelayAssignmentStore { WHERE completed_at IS NULL AND aborted_at IS NULL AND created_at <= ?${exclusion} ORDER BY created_at, attempt_id LIMIT ?`, - [now - REGIONAL_REHOME_MAX_REFRESH_MS, ...quarantined, limit] + [dispatchedBefore, ...quarantined, input.limit] ) let aborted = 0 let inventoryBusy = 0 @@ -6403,7 +6455,7 @@ export class RelayAssignmentStore { !migration || optionalInteger(attempt, 'completed_at') !== undefined || optionalInteger(attempt, 'aborted_at') !== undefined || - integer(attempt, 'created_at') > now - REGIONAL_REHOME_MAX_REFRESH_MS || + integer(attempt, 'created_at') > dispatchedBefore || optionalInteger(migration, 'completed_at') !== undefined || optionalInteger(migration, 'aborted_at') !== undefined ) { @@ -6472,16 +6524,22 @@ export class RelayAssignmentStore { [now, now, identity.userId, identity.relayHostId, assignmentEpoch] ) await transaction.query( - `UPDATE relay_region_rehome_attempts SET aborted_at = ?, updated_at = ? + `UPDATE relay_region_rehome_attempts + SET aborted_at = ?, abort_reason = ?, updated_at = ? WHERE attempt_id = ?`, - [now, now, text(attempt, 'attempt_id')] - ) - await transaction.query( - `UPDATE relay_region_rehome_control - SET generation = generation + 1, enabled = 0, updated_at = ? - WHERE control_id = 'global' AND enabled = 1`, - [now] + [now, input.abortReason, now, text(attempt, 'attempt_id')] ) + // Only the last-resort latch turns the feature off. A host that closed + // its laptop mid-move says nothing about whether rehoming is safe, and + // one such row a day would otherwise disable the switch every day. + if (input.disableControl) { + await transaction.query( + `UPDATE relay_region_rehome_control + SET generation = generation + 1, enabled = 0, updated_at = ? + WHERE control_id = 'global' AND enabled = 1`, + [now] + ) + } return true }) this.regionalRehomeCandidateQuarantine.delete(attemptId) @@ -6491,7 +6549,7 @@ export class RelayAssignmentStore { } if (changed) aborted++ } - warnSweepCellInventoryBusy('abort-expired-regional-rehomes', inventoryBusy) + warnSweepCellInventoryBusy(input.sweep, inventoryBusy) return aborted } diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts index 6f564844ba6..9e4299f3cce 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts @@ -47,7 +47,9 @@ const CENSUS: CensusEntry[] = [ { method: 'rebalanceDormant', mode: 'request', reach: 'request' }, { method: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'request' }, { method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, - { method: 'abortExpiredRegionalRehomes', mode: 'nowait', reach: 'sweep' }, + // Both regional-rehome abort sweeps share this rollback; only the 24-hour + // one also disables the durable switch. + { method: 'rollBackStalledRegionalRehomes', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 725dde4baec..2b3cebbd89c 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -297,6 +297,7 @@ CREATE TABLE IF NOT EXISTS relay_region_rehome_attempts ( ), completed_at BIGINT, aborted_at BIGINT, + abort_reason TEXT, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, UNIQUE (user_id, relay_host_id, assignment_epoch) @@ -676,6 +677,9 @@ export const POSTGRES_SCHEMA_MIGRATIONS = [ DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}`, `ALTER TABLE relay_control_capabilities ADD COLUMN IF NOT EXISTS idle_regional_rehome BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS source_generation BIGINT NOT NULL DEFAULT 0`, + // Nullable with no default, so the rewrite is catalog-only; every row + // aborted before this column existed reads as an unattributed abort. + `ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS abort_reason TEXT`, // Dropped, not created: see the comment on relay_assignment_activity_leases. Deferrable because // this is the one boot where it has to take ACCESS EXCLUSIVE on a table under continuous write, // and all 28 directors reach it at once; a lock timeout here must not restart the instance, which diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 480b1b6b914..380ccfbc9c6 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -18,6 +18,9 @@ import { RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME, RELAY_PROTOCOL_LIMITS, RELAY_CLOSE_CODE, + type IdleRegionalRehomeCommit, + type IdleRegionalRehomeDeferReason, + type IdleRegionalRehomeResult, type RelayHostCloseReason, type RelayRegion } from '@orca-cloud/relay-contract' @@ -191,7 +194,7 @@ export class HostSessionRegistry { { attemptId: string authorityKey: string - promise: Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> + promise: Promise } >() @@ -205,9 +208,9 @@ export class HostSessionRegistry { sourceCellIncarnation: string targetCellId: string }, - commit: () => Promise<{ outcome: 'committed' | 'deferred' | 'stale' }>, + commit: () => Promise, reconcile: () => Promise<'committed' | 'not-committed' | 'stale'> - ): Promise<{ outcome: 'busy' | 'committed' | 'deferred' | 'stale' }> { + ): Promise { const authorityKey = JSON.stringify([ input.userId, input.sourceAssignmentEpoch, @@ -236,7 +239,7 @@ export class HostSessionRegistry { !session.socket || !this.hostCapabilities.get(session.socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME) ) - return { outcome: 'deferred' } + return { outcome: 'deferred', reason: 'host-unsupported' } if ( (this.idleWork.get(input.relayHostId) ?? 0) !== 0 || session.activeConnIds.size !== 0 || @@ -246,12 +249,18 @@ export class HostSessionRegistry { return { outcome: 'busy' } const revision = session.authorityRevision const promise = Promise.resolve().then(async () => { - let outcome: 'committed' | 'deferred' | 'stale' + let outcome: IdleRegionalRehomeCommit['outcome'] + // The commit's reason survives only while the outcome stays deferred; + // a reconcile that finds a durable outcome answers with that instead. + let reason: IdleRegionalRehomeDeferReason | undefined try { - outcome = (await commit()).outcome + const commitResult = await commit() + outcome = commitResult.outcome + reason = commitResult.reason if (outcome === 'deferred') { const durable = await reconcile() outcome = durable === 'not-committed' ? 'deferred' : durable + if (outcome !== 'deferred') reason = undefined } } catch { let delay = 100 @@ -259,6 +268,7 @@ export class HostSessionRegistry { try { const durable = await reconcile() outcome = durable === 'not-committed' ? 'deferred' : durable + reason = undefined break } catch { await new Promise((resolve) => { @@ -276,7 +286,7 @@ export class HostSessionRegistry { } if (this.idleAttempts.get(input.relayHostId)?.promise === promise) this.idleAttempts.delete(input.relayHostId) - return { outcome } + return reason === undefined ? { outcome } : { outcome, reason } }) this.idleAttempts.set(input.relayHostId, { attemptId: input.attemptId, authorityKey, promise }) return promise diff --git a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts index 489f4ce0b53..b83cd57f92f 100644 --- a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts @@ -171,9 +171,11 @@ describe('constrained idle regional assignment transaction', () => { ) const candidates = await store.selectIdleRegionalRehomeCandidates(safety) expect(candidates).toHaveLength(capacity === 11 ? 1 : 0) - expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ - outcome: capacity === 11 ? 'committed' : 'deferred' - }) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual( + capacity === 11 + ? { outcome: 'committed' } + : { outcome: 'deferred', reason: 'candidate-ineligible' } + ) const [target] = await database.query("SELECT reserved_requests FROM relay_cells WHERE cell_id = 'target'") expect(Number(target!.reserved_requests)).toBe(capacity === 11 ? 11 : 7) expect(await store.resolve(identity)).toMatchObject({ @@ -292,7 +294,7 @@ describe('constrained idle regional assignment transaction', () => { } finally { held.release() } - expect(await commit).toEqual({ outcome: 'deferred' }) + expect(await commit).toEqual({ outcome: 'deferred', reason: 'candidate-ineligible' }) expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) } @@ -368,7 +370,7 @@ describe('constrained idle regional assignment transaction', () => { const { store, safety, request } = await setup() expect( await store.commitIdleRegionalRehome({ ...request, targetCellId: 'missing' }, safety) - ).toEqual({ outcome: 'deferred' }) + ).toEqual({ outcome: 'deferred', reason: 'candidate-ineligible' }) await store.activateControl(identity, { cellId: 'source', assignmentEpoch: 1, @@ -382,9 +384,13 @@ describe('constrained idle regional assignment transaction', () => { it('does not commit without process safety or cohort authorization', async () => { const { store, safety, request, database } = await setup() - expect(await store.commitIdleRegionalRehome(request)).toEqual({ outcome: 'deferred' }) + expect(await store.commitIdleRegionalRehome(request)).toEqual({ + outcome: 'deferred', + reason: 'director-safety-stale' + }) expect(await store.commitIdleRegionalRehome(request, safety, 0)).toEqual({ - outcome: 'deferred' + outcome: 'deferred', + reason: 'cohort-closed' }) expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) }) diff --git a/cloud/apps/relay/src/region-correction-preview.ts b/cloud/apps/relay/src/region-correction-preview.ts index c120dbb2272..6a3ee21ceda 100644 --- a/cloud/apps/relay/src/region-correction-preview.ts +++ b/cloud/apps/relay/src/region-correction-preview.ts @@ -1,5 +1,6 @@ import type { RelayDatabase, SqlRow } from './database.js' import { REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS } from './database.js' +import { REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS } from './regional-rehome-abort-reason.js' import { REGIONAL_REHOME_CONCURRENT_LIMIT, REGION_DECISION_TTL_MS @@ -13,6 +14,11 @@ export type RegionCorrectionPreview = { availableMigrationSlots: number globalSafetyFailure: string | null counts: Record + // Rehomes rolled back to their source in the last day, by the reason the + // sweep recorded. A rising `host_not_arrived` is what a leak looks like + // before it fills the concurrency budget; `unattributed` covers the abort + // paths that settle an attempt without naming one. + abortedLast24Hours: Record } export async function previewRegionalRehomeEligibility(input: { @@ -25,7 +31,7 @@ export async function previewRegionalRehomeEligibility(input: { cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean }): Promise { const { database, now } = input - const [hosts, cells, runtimeRows, capabilityRows, safetyRows, controls, migrations] = + const [hosts, cells, runtimeRows, capabilityRows, safetyRows, controls, migrations, aborts] = await Promise.all([ database.query( `SELECT assignment.cell_id, assignment.assignment_epoch, @@ -63,6 +69,12 @@ export async function previewRegionalRehomeEligibility(input: { database.query(`SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'`), database.query( `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE completed_at IS NULL AND aborted_at IS NULL` + ), + database.query( + `SELECT COALESCE(abort_reason, 'unattributed') AS reason, COUNT(*) AS count + FROM relay_region_rehome_attempts WHERE aborted_at >= ? + GROUP BY COALESCE(abort_reason, 'unattributed')`, + [now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS] ) ]) const byCell = (rows: SqlRow[]) => new Map(rows.map((row) => [String(row.cell_id), row])) @@ -152,6 +164,9 @@ export async function previewRegionalRehomeEligibility(input: { openMigrations, availableMigrationSlots: Math.max(0, REGIONAL_REHOME_CONCURRENT_LIMIT - openMigrations), globalSafetyFailure: input.globalSafetyFailure, - counts + counts, + abortedLast24Hours: Object.fromEntries( + aborts.map((row) => [String(row.reason), Number(row.count)]) + ) } } diff --git a/cloud/apps/relay/src/regional-rehome-abort-reason.ts b/cloud/apps/relay/src/regional-rehome-abort-reason.ts new file mode 100644 index 00000000000..96be5074a72 --- /dev/null +++ b/cloud/apps/relay/src/regional-rehome-abort-reason.ts @@ -0,0 +1,18 @@ +import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' + +// Why an attempt was rolled back to its source. Attempts settled by the +// migration-side abort paths, and every row aborted before the column existed, +// leave it null. +export const REGIONAL_REHOME_ABORT_REASONS = ['host_not_arrived', 'max_refresh_expired'] as const + +export type RegionalRehomeAbortReason = (typeof REGIONAL_REHOME_ABORT_REASONS)[number] + +// One whole migration lease with the host absent from the target and owning +// nothing on the source. Nothing legitimately takes that long: the attach +// deadline is seconds, and a host that is still moving holds a lease at one end +// or the other. The control's `drain_grace_ms` does not fit — idle rehome +// commits with a grace of zero, so these attempts never carry one. +export const REGIONAL_REHOME_ARRIVAL_WINDOW_MS = ASSIGNMENT_LIMITS.migrationLeaseMs + +// The window the inventory line and the preview both report aborts over. +export const REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS = 24 * 60 * 60_000 diff --git a/cloud/apps/relay/src/regional-rehome-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-postgres.test.ts index 07307707124..d56f76b557b 100644 --- a/cloud/apps/relay/src/regional-rehome-postgres.test.ts +++ b/cloud/apps/relay/src/regional-rehome-postgres.test.ts @@ -426,7 +426,7 @@ describePostgres('PostgreSQL regional rehoming', () => { unlock() await held - await expect(claim).resolves.toEqual({ outcome: 'deferred' }) + await expect(claim).resolves.toEqual({ outcome: 'deferred', reason: 'fleet-safety' }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, enabled: false @@ -450,7 +450,7 @@ describePostgres('PostgreSQL regional rehoming', () => { await expect( context.store.commitIdleRegionalRehome(request!, safety(context.now())) - ).resolves.toEqual({ outcome: 'deferred' }) + ).resolves.toEqual({ outcome: 'deferred', reason: 'fleet-safety' }) await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 2, enabled: false diff --git a/cloud/apps/relay/src/regional-rehome-store.test.ts b/cloud/apps/relay/src/regional-rehome-store.test.ts index 2d34661816b..a2affef0f79 100644 --- a/cloud/apps/relay/src/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/regional-rehome-store.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { readAssignmentInventorySnapshot } from './assignment-inventory-snapshot.js' +import { REGIONAL_REHOME_ARRIVAL_WINDOW_MS } from './regional-rehome-abort-reason.js' import { RelayAssignmentStore as BaseRelayAssignmentStore, type RegionalRehomeAttempt, @@ -413,7 +415,8 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ - outcome: 'deferred' + outcome: 'deferred', + reason: 'fleet-safety' }) } finally { warnings.restore() @@ -452,8 +455,10 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { + // Per-cell, so it excludes this target rather than stopping the poll. expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ - outcome: 'deferred' + outcome: 'deferred', + reason: 'candidate-ineligible' }) } finally { warnings.restore() @@ -778,7 +783,8 @@ describe('regional rehome assignment state', () => { ) expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ - outcome: 'deferred' + outcome: 'deferred', + reason: 'fleet-safety' }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, @@ -1085,6 +1091,112 @@ describe('regional rehome assignment state', () => { await context.database.close() }) + it('rolls a host that never reached its target back to the source at the arrival window', async () => { + const context = await setup() + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + expect(await context.store.tryIdleRehome()).not.toBeNull() + await context.store.releaseActivity(identity, sourceControl) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + + context.advance(REGIONAL_REHOME_ARRIVAL_WINDOW_MS - 1) + await freshHeartbeats(context) + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(0) + + context.advance(1) + await freshHeartbeats(context) + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(1) + // Where the host was, so its next reconnect lands on the source it left. + expect(await context.store.resolve(identity)).toMatchObject({ + cellId: source.id, + assignmentEpoch: 3 + }) + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(0) + await context.database.close() + }) + + it('leaves the durable switch alone when it rolls back an unarrived host', async () => { + const context = await setup() + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + expect(await context.store.tryIdleRehome()).not.toBeNull() + await context.store.releaseActivity(identity, sourceControl) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + const before = await context.store.inspectRegionalRehomeControl() + context.advance(REGIONAL_REHOME_ARRIVAL_WINDOW_MS) + await freshHeartbeats(context) + + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(1) + + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ + generation: before.generation, + enabled: true + }) + const [attempt] = await context.database.query( + 'SELECT abort_reason FROM relay_region_rehome_attempts' + ) + expect(attempt!.abort_reason).toBe('host_not_arrived') + // What the rollout tracker reads to see a leak before it fills the budget. + const preview = await context.store.previewRegionalRehomeEligibility() + expect(preview.abortedLast24Hours).toEqual({ host_not_arrived: 1 }) + expect( + (await readAssignmentInventorySnapshot(context.database, context.now())).regionalRehomes + ).toMatchObject({ abortedLast24Hours: 1, hostNotArrivedLast24Hours: 1 }) + await context.database.close() + }) + + it('leaves a host that did reach its target for the completion sweep', async () => { + const context = await setup() + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + expect(await context.store.tryIdleRehome()).not.toBeNull() + // Past the window, but present at the target: the sweep reads live + // ownership, not the attempt's age alone. + context.advance(REGIONAL_REHOME_ARRIVAL_WINDOW_MS) + await freshHeartbeats(context) + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1, + cellIncarnation: targetIncarnation + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(0) + expect(await context.store.completeReadyRegionalRehomes()).toBe(1) + expect(await context.store.resolve(identity)).toMatchObject({ cellId: target.id }) + await context.database.close() + }) + + it('names the 24-hour latch in its own abort reason', async () => { + const context = await setup() + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + expect(await context.store.tryIdleRehome()).not.toBeNull() + await context.store.releaseActivity(identity, sourceControl) + context.advance(24 * 60 * 60_000) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) + + expect(await context.store.abortExpiredRegionalRehomes()).toBe(1) + + const [attempt] = await context.database.query( + 'SELECT abort_reason FROM relay_region_rehome_attempts' + ) + expect(attempt!.abort_reason).toBe('max_refresh_expired') + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ enabled: false }) + await context.database.close() + }) + it('rolls back an inactive registered target only after the 24-hour bound', async () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } diff --git a/cloud/apps/relay/src/regional-rehome-worker.test.ts b/cloud/apps/relay/src/regional-rehome-worker.test.ts index 640ae06b121..ffbc4c88497 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.test.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.test.ts @@ -86,6 +86,76 @@ describe('regional rehome worker', () => { ).toBeNull() }) + it('stops walking the page when the source names a deferral no candidate can pass', async () => { + const fetchImpl = respondWith([{ outcome: 'deferred', reason: 'concurrency-limit' }]) + const summaries = collectSummaries() + try { + await runOnePoll(fetchImpl, 3, summaries) + } finally { + summaries.restore() + } + + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(summaries.entries).toEqual([ + { + event: 'orca_relay_idle_rehome_dispatch_summary', + candidates: 3, + dispatched: 1, + stoppedBy: 'concurrency-limit', + outcomes: { 'deferred:concurrency-limit': 1 } + } + ]) + }) + + it('keeps its whole-page walk when the source sends no reason at all', async () => { + const fetchImpl = respondWith([{ outcome: 'deferred' }]) + const summaries = collectSummaries() + try { + await runOnePoll(fetchImpl, 3, summaries) + } finally { + summaries.restore() + } + + expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(summaries.entries[0]).toMatchObject({ + stoppedBy: null, + outcomes: { deferred: 3 } + }) + }) + + it('walks past a deferral that only concerns the one candidate', async () => { + const fetchImpl = respondWith([ + { outcome: 'deferred', reason: 'host-unsupported' }, + { outcome: 'busy' }, + { outcome: 'committed' } + ]) + const summaries = collectSummaries() + try { + await runOnePoll(fetchImpl, 4, summaries) + } finally { + summaries.restore() + } + + expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(summaries.entries[0]).toMatchObject({ + dispatched: 3, + stoppedBy: 'committed', + outcomes: { 'deferred:host-unsupported': 1, busy: 1, committed: 1 } + }) + }) + + it('counts a source that answers with an error in the same summary', async () => { + const fetchImpl = vi.fn(async () => new Response('nope', { status: 503 })) + const summaries = collectSummaries() + try { + await runOnePoll(fetchImpl, 2, summaries) + } finally { + summaries.restore() + } + + expect(summaries.entries[0]).toMatchObject({ dispatched: 2, outcomes: { failed: 2 } }) + }) + it('treats the reconnect threshold as per-cell and excludes the director', () => { const cells = 2 const limit = cells * REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT @@ -108,6 +178,76 @@ describe('regional rehome worker', () => { }) }) +// Answers each POST with the next scripted body, repeating the last one. +function respondWith(bodies: { outcome: string; reason?: string }[]) { + let index = 0 + return vi.fn(async () => { + const body = bodies[Math.min(index++, bodies.length - 1)]! + return new Response(JSON.stringify({ v: 1, ...body }), { + headers: { 'content-type': 'application/json' } + }) + }) +} + +function collectSummaries() { + const entries: Record[] = [] + let arrived: (() => void) | undefined + // The worker polls once the moment it is constructed, so the poll under test + // is that one; `first` is how a test waits for it rather than for a tick. + const first = new Promise((resolve) => { + arrived = resolve + }) + const original = console.warn + console.warn = (line: unknown, ...rest: unknown[]) => { + try { + const parsed = JSON.parse(line as string) as Record + if (parsed.event === 'orca_relay_idle_rehome_dispatch_summary') { + entries.push(parsed) + arrived?.() + return + } + } catch { + // Not a JSON log line; fall through to the original writer. + } + original(line as string, ...rest) + } + return { entries, first, restore: () => (console.warn = original) } +} + +async function runOnePoll( + fetchImpl: typeof fetch, + candidates: number, + summaries: { first: Promise } +): Promise { + const assignments = { + selectIdleRegionalRehomeCandidates: vi.fn(async () => + Array.from({ length: candidates }, (_, index) => ({ + v: 1 as const, + attemptId: `00000000-0000-4000-8000-00000000000${index}`, + userId: `user-${index}`, + relayHostId: 'abcdefghijklmnop', + sourceCellId: 'us-c1', + sourceCellUrl: 'https://us-c1.relay.example.test', + sourceCellIncarnation: '11111111-1111-4111-8111-111111111111', + sourceAssignmentEpoch: 1, + sourceGeneration: 1, + targetCellId: 'asia-c1' + })) + ) + } as unknown as RelayAssignmentStore + const worker = startRegionalRehomeWorker(config(), assignments, { + fetch: fetchImpl, + identityToken: async () => 'token', + safetySnapshot: () => safety(Date.now()), + intervalMs: 60_000 + })! + try { + await summaries.first + } finally { + worker.stop() + } +} + function config(overrides: Partial = {}): RelayConfig { return { role: 'director', diff --git a/cloud/apps/relay/src/regional-rehome-worker.ts b/cloud/apps/relay/src/regional-rehome-worker.ts index 67d872b888a..5bf426e11b3 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.ts @@ -1,4 +1,7 @@ -import { IdleRegionalRehomeResponseSchema } from '@orca-cloud/relay-contract' +import { + IdleRegionalRehomeResponseSchema, + isGlobalIdleRegionalRehomeDeferral +} from '@orca-cloud/relay-contract' import type { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { googleMetadataIdentityToken } from './google-metadata-identity-token.js' @@ -48,8 +51,13 @@ export function startRegionalRehomeWorker( const candidates = await assignments.selectIdleRegionalRehomeCandidates(safetySnapshot()) if (candidates.length === 0) return const token = await tokenProvider(audience) + const outcomes: Record = {} + const tally = (key: string) => { + outcomes[key] = (outcomes[key] ?? 0) + 1 + } + let stoppedBy: string | null = null for (const candidate of candidates) { - if (stopped) return + if (stopped) break const { sourceCellUrl, ...request } = candidate try { const response = await fetchImpl(new URL('/v1/admin/host-idle-rehome', sourceCellUrl), { @@ -64,6 +72,7 @@ export function startRegionalRehomeWorker( }) if (!response.ok) throw new Error(`regional_rehome_source_${response.status}`) const body = IdleRegionalRehomeResponseSchema.parse(await response.json()) + tally(body.reason ? `${body.outcome}:${body.reason}` : body.outcome) if (body.outcome === 'committed') { console.warn( JSON.stringify({ @@ -72,9 +81,18 @@ export function startRegionalRehomeWorker( targetCellId: candidate.targetCellId }) ) - return + stoppedBy = 'committed' + break + } + // Every remaining candidate would re-read the same durable row and + // answer the same way, so the rest of this page is wasted POSTs. + // A source on an older image sends no reason and keeps the old walk. + if (body.outcome === 'deferred' && isGlobalIdleRegionalRehomeDeferral(body.reason)) { + stoppedBy = body.reason + break } } catch (error) { + tally('failed') // The source may have committed; its durable outcome owns recovery. console.warn( JSON.stringify({ @@ -84,6 +102,17 @@ export function startRegionalRehomeWorker( ) } } + // One line per poll that dispatched: silence used to be the only signal + // that 100+ candidates all came back deferred. + console.warn( + JSON.stringify({ + event: 'orca_relay_idle_rehome_dispatch_summary', + candidates: candidates.length, + dispatched: Object.values(outcomes).reduce((total, count) => total + count, 0), + stoppedBy, + outcomes + }) + ) } catch (error) { console.warn( JSON.stringify({ diff --git a/cloud/apps/relay/src/relay-schema-lock-targets.test.ts b/cloud/apps/relay/src/relay-schema-lock-targets.test.ts index 68a7c6f0216..6b46675f429 100644 --- a/cloud/apps/relay/src/relay-schema-lock-targets.test.ts +++ b/cloud/apps/relay/src/relay-schema-lock-targets.test.ts @@ -126,6 +126,7 @@ const GOLDEN_LOCK_TAKING: SchemaLockTarget[] = [ { kind: 'column', table: 'relay_region_rehome_control', name: 'host_cooldown_ms', skipWhen: 'present' }, { kind: 'column', table: 'relay_control_capabilities', name: 'idle_regional_rehome', skipWhen: 'present' }, { kind: 'column', table: 'relay_region_rehome_attempts', name: 'source_generation', skipWhen: 'present' }, + { kind: 'column', table: 'relay_region_rehome_attempts', name: 'abort_reason', skipWhen: 'present' }, { kind: 'index-by-name', name: 'relay_assignment_activity_expiry', skipWhen: 'absent' }, { kind: 'reloption', diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index 3a0668bbcef..816aa0690bf 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -141,7 +141,7 @@ export function createRelayServer( idleRehome: (input) => { const now = (options.now ?? Date.now)() if (input.directorSafety.observedAt > now || now - input.directorSafety.observedAt > 60_000) { - return Promise.resolve({ outcome: 'deferred' }) + return Promise.resolve({ outcome: 'deferred', reason: 'director-safety-stale' }) } return sessions.idleRehome(input, () => assignments.commitIdleRegionalRehome(input, combineRegionalRehomeSafety( diff --git a/cloud/packages/relay-contract/src/idle-regional-rehome.test.ts b/cloud/packages/relay-contract/src/idle-regional-rehome.test.ts new file mode 100644 index 00000000000..adca4729e79 --- /dev/null +++ b/cloud/packages/relay-contract/src/idle-regional-rehome.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS, + IDLE_REGIONAL_REHOME_DEFER_REASONS, + IdleRegionalRehomeResponseSchema, + isGlobalIdleRegionalRehomeDeferral +} from './idle-regional-rehome.js' + +describe('idle regional rehome response', () => { + it('accepts a source cell that has never heard of the reason field', () => { + expect(IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'deferred' })).toEqual({ + v: 1, + outcome: 'deferred' + }) + }) + + it('carries every reason the source can send', () => { + for (const reason of IDLE_REGIONAL_REHOME_DEFER_REASONS) { + expect( + IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'deferred', reason }) + ).toEqual({ v: 1, outcome: 'deferred', reason }) + } + }) + + it('reads a reason it does not know as absent instead of failing the response', () => { + expect( + IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'deferred', reason: 'from-a-newer-cell' }) + ).toEqual({ v: 1, outcome: 'deferred' }) + }) + + it('drops a field added after this decoder shipped', () => { + expect( + IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'committed', movedAt: 17 }) + ).toEqual({ v: 1, outcome: 'committed' }) + }) + + it('still rejects an outcome it cannot act on', () => { + expect(() => IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'moved' })).toThrow() + }) + + it('classifies only the poll-wide deferrals as global', () => { + for (const reason of IDLE_REGIONAL_REHOME_DEFER_REASONS) { + expect(isGlobalIdleRegionalRehomeDeferral(reason)).toBe( + GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS.some((global) => global === reason) + ) + } + expect(isGlobalIdleRegionalRehomeDeferral(undefined)).toBe(false) + expect(GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS).toContain('concurrency-limit') + expect(isGlobalIdleRegionalRehomeDeferral('candidate-ineligible')).toBe(false) + }) +}) diff --git a/cloud/packages/relay-contract/src/idle-regional-rehome.ts b/cloud/packages/relay-contract/src/idle-regional-rehome.ts index 9f9eff36593..cb148e06b1c 100644 --- a/cloud/packages/relay-contract/src/idle-regional-rehome.ts +++ b/cloud/packages/relay-contract/src/idle-regional-rehome.ts @@ -15,12 +15,58 @@ export const IdleRegionalRehomeRequestSchema = z }) .strict() +// Deferrals no other candidate in the same poll can get past: the source +// re-reads the same durable row for every request, so the next POST takes the +// same branch. The director stops walking its list on one of these. +export const GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS = [ + 'control-closed', + 'budget-closed', + 'concurrency-limit', + 'cohort-closed', + 'fleet-safety' +] as const + +export const IDLE_REGIONAL_REHOME_DEFER_REASONS = [ + ...GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS, + 'candidate-ineligible', + 'host-unsupported', + 'director-safety-stale' +] as const + export const IdleRegionalRehomeResponseSchema = z .object({ v: z.literal(1), - outcome: z.enum(['busy', 'committed', 'deferred', 'stale']) + outcome: z.enum(['busy', 'committed', 'deferred', 'stale']), + // Optional both ways: a source cell on an older image omits it and the + // director keeps its walk-the-whole-list behaviour, and a reason a newer + // cell adds later reads as absent instead of failing the whole response. + reason: z.enum(IDLE_REGIONAL_REHOME_DEFER_REASONS).optional().catch(undefined) }) - .strict() + // Unknown keys are dropped rather than rejected, so the next optional field + // on this response does not have to wait for every director to redeploy. + .strip() export type IdleRegionalRehomeRequest = z.infer export type IdleRegionalRehomeOutcome = z.infer['outcome'] +export type IdleRegionalRehomeDeferReason = (typeof IDLE_REGIONAL_REHOME_DEFER_REASONS)[number] + +// What the source cell answers: `busy` and `stale` come from the host session, +// the rest from the durable commit, and `reason` is set only for a deferral. +export type IdleRegionalRehomeResult = { + outcome: IdleRegionalRehomeOutcome + reason?: IdleRegionalRehomeDeferReason +} + +export type IdleRegionalRehomeCommit = { + outcome: Exclude + reason?: IdleRegionalRehomeDeferReason +} + +export type GlobalIdleRegionalRehomeDeferReason = + (typeof GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS)[number] + +export function isGlobalIdleRegionalRehomeDeferral( + reason: IdleRegionalRehomeDeferReason | undefined +): reason is GlobalIdleRegionalRehomeDeferReason { + return GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS.some((global) => global === reason) +} From 68b11282a50261597d2f33ffc97eb2eca9a8ecfe Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:50:43 -0400 Subject: [PATCH 182/224] fix(relay): let the rehome evidence parser read a line the director grew (#21823) The enable workflow reads the director's `[orca-relay] regional rehome inventory` line out of Cloud Logging and pins the whole line with one regex. Adding `hostNotArrivedLast24Hours` in #21813 made every healthy line stop matching, so "Read fresh aggregate completion and abort evidence" threw "no aggregate regional rehome inventory evidence" and the fail-closed step disabled the durable switch at control generation 26. The parser now requires the six original fields and tolerates further ones in any order. Extra fields stay fenced by value shape rather than by pinning the whole line: a field must be a bare name and a non-negative integer or `none`, so `hostId=someone` is still not a counter and cannot ride along. An absent count reads as null, not zero, because an older director not reporting leaks is not the same as reporting none. `hostNotArrivedLast24Hours` and `oldestActiveAgeMs` now reach the evidence JSON and the operator step summary. Two guards close the chain, each verified to fail on the regression it exists for: a census in the relay package feeds the real formatter's output to the real parser, and a script-side test pins the parser's output to the fields the workflow summary renders. Claude-Session: https://claude.ai/session/ced32ebb-7155-4413-adad-1eccd14c2010 --- ...ud-operate-relay-production-rehome-job.yml | 2 +- ...ional-rehome-inventory-line-census.test.ts | 163 ++++++++++++++++++ .../relay-rehome-aggregate-evidence.mjs | 59 ++++++- .../relay-rehome-aggregate-evidence.test.mjs | 97 +++++++++++ 4 files changed, 311 insertions(+), 10 deletions(-) create mode 100644 cloud/apps/relay/src/regional-rehome-inventory-line-census.test.ts diff --git a/.github/workflows/cloud-operate-relay-production-rehome-job.yml b/.github/workflows/cloud-operate-relay-production-rehome-job.yml index a34552b898f..b16132d9ba5 100644 --- a/.github/workflows/cloud-operate-relay-production-rehome-job.yml +++ b/.github/workflows/cloud-operate-relay-production-rehome-job.yml @@ -320,7 +320,7 @@ jobs: echo '### Regional rehome control' jq -r '"- mode: `\(.mode)`\n- generation: `\(.control.generation)`\n- enabled: `\(.control.enabled)`"' \ "${RUNNER_TEMP}/relay-rehome-control.json" - jq -r '"- active: `\(.active)`\n- awaiting receipt: `\(.awaitingReceipt)`\n- target registered: `\(.targetRegistered)`\n- completed (24h): `\(.completedLast24Hours)`\n- aborted (24h): `\(.abortedLast24Hours)`"' \ + jq -r '"- active: `\(.active)`\n- awaiting receipt: `\(.awaitingReceipt)`\n- target registered: `\(.targetRegistered)`\n- completed (24h): `\(.completedLast24Hours)`\n- aborted (24h): `\(.abortedLast24Hours)`\n- host not arrived (24h): `\(.hostNotArrivedLast24Hours // "not reported")`\n- oldest active age (ms): `\(.oldestActiveAgeMs // "none")`"' \ "${RUNNER_TEMP}/relay-rehome-inventory.json" } >> "${GITHUB_STEP_SUMMARY}" diff --git a/cloud/apps/relay/src/regional-rehome-inventory-line-census.test.ts b/cloud/apps/relay/src/regional-rehome-inventory-line-census.test.ts new file mode 100644 index 00000000000..6e0b00a263c --- /dev/null +++ b/cloud/apps/relay/src/regional-rehome-inventory-line-census.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { + formatAssignmentInventorySnapshot, + type AssignmentInventorySnapshot +} from './assignment-inventory-snapshot.js' + +// The enable workflow reads the director's rehome inventory line out of Cloud +// Logging with a parser that lives in another language, in another package, and +// is never exercised against the formatter that writes the line. When +// `hostNotArrivedLast24Hours` shipped, the parser read a healthy line as no +// evidence at all and the run failed closed, disabling the durable switch. This +// test is the missing edge: the real formatter's output, through the real +// parser, so a future field fails here instead of in an operator's run. + +type InventoryEvidence = { + active: number + awaitingReceipt: number + targetRegistered: number + completedLast24Hours: number + abortedLast24Hours: number + hostNotArrivedLast24Hours: number | null + oldestActiveAgeMs: number | null +} + +// Imported through a computed URL on purpose: the script is plain ESM outside +// this package's compile scope, so a static import would not resolve. +async function loadParser(): Promise<(entries: unknown[], options: unknown) => InventoryEvidence> { + const source = new URL( + '../../../dev/scripts/relay-rehome-aggregate-evidence.mjs', + import.meta.url + ).href + const loaded: unknown = await import(/* @vite-ignore */ source) + if (!(loaded !== null && typeof loaded === 'object' && 'parseRegionalRehomeInventory' in loaded)) { + throw new Error('relay-rehome-aggregate-evidence.mjs no longer exports its parser') + } + const parse = loaded.parseRegionalRehomeInventory + if (typeof parse !== 'function') throw new Error('parseRegionalRehomeInventory is not callable') + return (entries, options) => readEvidence(parse(entries, options)) +} + +function readEvidence(value: unknown): InventoryEvidence { + if (value === null || typeof value !== 'object') throw new Error('parser returned no evidence') + const counts = ['active', 'awaitingReceipt', 'targetRegistered', 'completedLast24Hours', 'abortedLast24Hours'] as const + const evidence: Record = {} + for (const key of [...counts, 'hostNotArrivedLast24Hours', 'oldestActiveAgeMs'] as const) { + if (!(key in value)) throw new Error(`parser dropped ${key}`) + const read: unknown = Reflect.get(value, key) + if (read !== null && typeof read !== 'number') throw new Error(`${key} is not a count`) + evidence[key] = read + } + for (const key of counts) { + if (evidence[key] === null) throw new Error(`${key} must be a number`) + } + return { + active: Number(evidence['active']), + awaitingReceipt: Number(evidence['awaitingReceipt']), + targetRegistered: Number(evidence['targetRegistered']), + completedLast24Hours: Number(evidence['completedLast24Hours']), + abortedLast24Hours: Number(evidence['abortedLast24Hours']), + hostNotArrivedLast24Hours: evidence['hostNotArrivedLast24Hours'] ?? null, + oldestActiveAgeMs: evidence['oldestActiveAgeMs'] ?? null + } +} + +function snapshot( + regionalRehomes: AssignmentInventorySnapshot['regionalRehomes'] +): AssignmentInventorySnapshot { + return { + cells: [], + activityLeases: { total: 0, expired: 0, requestUnits: 0 }, + connectionReservations: { outstanding: 0, lateArrivalDebt: 0 }, + regionalRehomes + } +} + +function inventoryLine(snapshotValue: AssignmentInventorySnapshot): string { + const line = formatAssignmentInventorySnapshot(snapshotValue).find((candidate) => + candidate.startsWith('[orca-relay] regional rehome inventory ') + ) + if (!line) throw new Error('the formatter no longer emits a rehome inventory line') + return line +} + +describe('regional rehome inventory line census', () => { + it('parses what the director actually prints, field for field', async () => { + const parse = await loadParser() + const regionalRehomes = { + active: 3, + awaitingReceipt: 1, + targetRegistered: 2, + completedLast24Hours: 41, + abortedLast24Hours: 12, + hostNotArrivedLast24Hours: 5, + oldestActiveAgeMs: 77_731_209 + } + const now = Date.parse('2026-09-20T12:00:00Z') + + const evidence = parse( + [{ timestamp: '2026-09-20T11:59:00Z', textPayload: inventoryLine(snapshot(regionalRehomes)) }], + { now, maxAgeMs: 5 * 60_000 } + ) + + expect(evidence).toEqual({ ...regionalRehomes }) + }) + + it('parses the line an idle fleet prints, with no oldest active age', async () => { + const parse = await loadParser() + const now = Date.parse('2026-09-20T12:00:00Z') + + const evidence = parse( + [ + { + timestamp: '2026-09-20T11:59:00Z', + textPayload: inventoryLine( + snapshot({ + active: 0, + awaitingReceipt: 0, + targetRegistered: 0, + completedLast24Hours: 0, + abortedLast24Hours: 0, + hostNotArrivedLast24Hours: 0, + oldestActiveAgeMs: null + }) + ) + } + ], + { now, maxAgeMs: 5 * 60_000 } + ) + + expect(evidence.oldestActiveAgeMs).toBeNull() + expect(evidence.hostNotArrivedLast24Hours).toBe(0) + }) + + it('covers every counter the formatter puts on the line', async () => { + const parse = await loadParser() + // A field the parser ignores is a field the operator never sees, so the + // census fails when the formatter gains one and this test is not updated. + const line = inventoryLine( + snapshot({ + active: 1, + awaitingReceipt: 1, + targetRegistered: 1, + completedLast24Hours: 1, + abortedLast24Hours: 1, + hostNotArrivedLast24Hours: 1, + oldestActiveAgeMs: 1 + }) + ) + const printed = line + .slice('[orca-relay] regional rehome inventory '.length) + .split(' ') + .map((field) => field.split('=')[0]) + + const surfaced = Object.keys( + parse([{ timestamp: '2026-09-20T11:59:00Z', textPayload: line }], { + now: Date.parse('2026-09-20T12:00:00Z'), + maxAgeMs: 5 * 60_000 + }) + ) + + expect([...printed].sort()).toEqual([...surfaced].sort()) + }) +}) diff --git a/cloud/dev/scripts/relay-rehome-aggregate-evidence.mjs b/cloud/dev/scripts/relay-rehome-aggregate-evidence.mjs index 82bc2fb522a..a0563ffa94a 100644 --- a/cloud/dev/scripts/relay-rehome-aggregate-evidence.mjs +++ b/cloud/dev/scripts/relay-rehome-aggregate-evidence.mjs @@ -1,6 +1,24 @@ import { pathToFileURL } from 'node:url' -const INVENTORY = /^\[orca-relay\] regional rehome inventory active=(\d+) awaitingReceipt=(\d+) targetRegistered=(\d+) completedLast24Hours=(\d+) abortedLast24Hours=(\d+) oldestActiveAgeMs=(none|\d+)$/ +const INVENTORY_PREFIX = '[orca-relay] regional rehome inventory ' +// A counters line, so every field is a bare name and a non-negative integer or +// `none`. Pinning the whole line instead is what broke the enable workflow when +// `hostNotArrivedLast24Hours` shipped: the director grew a field and the parser +// read a healthy line as no evidence at all. Tolerating extra fields is safe +// only because the value shape stays fenced — `hostId=someone` is still not a +// counter, so an identity-bearing lookalike cannot slip through as an extra. +const FIELD = /^([A-Za-z][A-Za-z0-9]*)=(none|\d{1,15})$/ +// `oldestActiveAgeMs` is the one required field the director can report as +// `none`; a count that reads `none` is a line this parser does not recognise, +// not evidence worth failing the run over. +const REQUIRED_COUNTS = [ + 'active', + 'awaitingReceipt', + 'targetRegistered', + 'completedLast24Hours', + 'abortedLast24Hours' +] +const REQUIRED_FIELDS = [...REQUIRED_COUNTS, 'oldestActiveAgeMs'] function count(value, name) { const parsed = Number(value) @@ -8,20 +26,43 @@ function count(value, name) { return parsed } +// Returns the field map, or null for anything that is not this line. +export function readRegionalRehomeInventoryFields(textPayload) { + if (typeof textPayload !== 'string' || !textPayload.startsWith(INVENTORY_PREFIX)) return null + const fields = new Map() + for (const token of textPayload.slice(INVENTORY_PREFIX.length).split(' ')) { + const field = FIELD.exec(token) + if (!field || fields.has(field[1])) return null + fields.set(field[1], field[2]) + } + if (!REQUIRED_FIELDS.every((name) => fields.has(name))) return null + if (REQUIRED_COUNTS.some((name) => fields.get(name) === 'none')) return null + return fields +} + +// Absent is not zero: a director on an older image emits no such field, and +// reporting 0 would read as "no leaks" rather than "not measured". +function optionalCount(fields, name) { + const value = fields.get(name) + if (value === undefined || value === 'none') return null + return count(value, name) +} + export function parseRegionalRehomeInventory(entries, options = {}) { if (!Array.isArray(entries)) throw new Error('logging response must be an array') const parsed = entries.flatMap((entry) => { - const match = INVENTORY.exec(entry?.textPayload ?? '') + const fields = readRegionalRehomeInventoryFields(entry?.textPayload ?? '') const timestamp = Date.parse(entry?.timestamp ?? '') - if (!match || !Number.isFinite(timestamp)) return [] + if (!fields || !Number.isFinite(timestamp)) return [] return [{ timestamp, - active: count(match[1], 'active'), - awaitingReceipt: count(match[2], 'awaiting receipt'), - targetRegistered: count(match[3], 'target registered'), - completedLast24Hours: count(match[4], 'completed'), - abortedLast24Hours: count(match[5], 'aborted'), - oldestActiveAgeMs: match[6] === 'none' ? null : count(match[6], 'oldest active age') + active: count(fields.get('active'), 'active'), + awaitingReceipt: count(fields.get('awaitingReceipt'), 'awaiting receipt'), + targetRegistered: count(fields.get('targetRegistered'), 'target registered'), + completedLast24Hours: count(fields.get('completedLast24Hours'), 'completed'), + abortedLast24Hours: count(fields.get('abortedLast24Hours'), 'aborted'), + hostNotArrivedLast24Hours: optionalCount(fields, 'hostNotArrivedLast24Hours'), + oldestActiveAgeMs: optionalCount(fields, 'oldestActiveAgeMs') }] }).sort((left, right) => right.timestamp - left.timestamp) if (parsed.length === 0) throw new Error('no aggregate regional rehome inventory evidence') diff --git a/cloud/dev/scripts/relay-rehome-aggregate-evidence.test.mjs b/cloud/dev/scripts/relay-rehome-aggregate-evidence.test.mjs index 2ce2627fb93..e85c4d5606d 100644 --- a/cloud/dev/scripts/relay-rehome-aggregate-evidence.test.mjs +++ b/cloud/dev/scripts/relay-rehome-aggregate-evidence.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { parseRegionalRehomeInventory } from './relay-rehome-aggregate-evidence.mjs' +import { readRelayWorkflow } from './relay-repository.mjs' const now = Date.parse('2026-08-14T12:00:00Z') @@ -22,10 +23,38 @@ test('selects the newest fresh aggregate-only regional rehome inventory', () => targetRegistered: 1, completedLast24Hours: 9, abortedLast24Hours: 0, + hostNotArrivedLast24Hours: null, oldestActiveAgeMs: 30_000 }) }) +test('reads a line the director grew a field on, wherever the field sits', () => { + const result = parseRegionalRehomeInventory([{ + timestamp: '2026-08-14T11:58:00Z', + textPayload: + '[orca-relay] regional rehome inventory hostNotArrivedLast24Hours=4 active=2' + + ' awaitingReceipt=1 targetRegistered=1 completedLast24Hours=9 abortedLast24Hours=7' + + ' oldestActiveAgeMs=30000 someFieldFromALaterRelease=11' + }], { now, maxAgeMs: 5 * 60_000 }) + assert.equal(result.hostNotArrivedLast24Hours, 4) + assert.equal(result.abortedLast24Hours, 7) + assert.equal(result.oldestActiveAgeMs, 30_000) +}) + +test('reports an unmeasured host-not-arrived count as absent, not as zero', () => { + const [withField, withoutField] = ['4', null].map((value) => + parseRegionalRehomeInventory([{ + timestamp: '2026-08-14T11:58:00Z', + textPayload: + '[orca-relay] regional rehome inventory active=0 awaitingReceipt=0 targetRegistered=0' + + ' completedLast24Hours=0 abortedLast24Hours=0 oldestActiveAgeMs=none' + + (value === null ? '' : ` hostNotArrivedLast24Hours=${value}`) + }], { now, maxAgeMs: 5 * 60_000 }) + ) + assert.equal(withField.hostNotArrivedLast24Hours, 4) + assert.equal(withoutField.hostNotArrivedLast24Hours, null) +}) + test('rejects stale, malformed, and identity-bearing lookalikes', () => { assert.throws(() => parseRegionalRehomeInventory([{ timestamp: '2026-08-14T11:00:00Z', @@ -36,3 +65,71 @@ test('rejects stale, malformed, and identity-bearing lookalikes', () => { textPayload: '[orca-relay] regional rehome inventory active=0 hostId=secret' }], { now }), /no aggregate/) }) + +test('keeps out an identity-bearing field riding along on a complete line', () => { + const complete = + '[orca-relay] regional rehome inventory active=0 awaitingReceipt=0 targetRegistered=0' + + ' completedLast24Hours=0 abortedLast24Hours=0 oldestActiveAgeMs=none' + for (const extra of [' hostId=secret', ' userId=someone@example.test', ' note=a b']) { + assert.throws( + () => parseRegionalRehomeInventory( + [{ timestamp: '2026-08-14T11:59:00Z', textPayload: complete + extra }], + { now } + ), + /no aggregate/, + extra + ) + } +}) + +test('refuses a line missing a required field, or repeating one', () => { + const missing = + '[orca-relay] regional rehome inventory active=0 awaitingReceipt=0 targetRegistered=0' + + ' completedLast24Hours=0 oldestActiveAgeMs=none' + assert.throws( + () => parseRegionalRehomeInventory( + [{ timestamp: '2026-08-14T11:59:00Z', textPayload: missing }], + { now } + ), + /no aggregate/ + ) + assert.throws( + () => parseRegionalRehomeInventory( + [{ timestamp: '2026-08-14T11:59:00Z', textPayload: `${missing} abortedLast24Hours=0 abortedLast24Hours=1` }], + { now } + ), + /no aggregate/ + ) + assert.throws( + () => parseRegionalRehomeInventory( + [{ + timestamp: '2026-08-14T11:59:00Z', + textPayload: missing.replace('active=0', 'active=none') + ' abortedLast24Hours=0' + }], + { now } + ), + /no aggregate/ + ) +}) + +// The third edge of the chain the enable workflow depends on. The formatter is +// pinned against this parser in the relay package's inventory-line census; this +// pins the parser against the summary an operator reads, so a field that +// reaches the evidence JSON and stops there fails here. +test('publishes every parsed counter in the operator step summary', () => { + const job = readRelayWorkflow('operate-relay-production-rehome-job.yml') + // The jq program and the file it reads sit on separate continuation lines, so + // match the whole render rather than one line of it. + const summary = /jq -r '([^']*)' \\\n\s*"\$\{RUNNER_TEMP\}\/relay-rehome-inventory\.json"/.exec(job)?.[1] + assert.ok(summary, 'the rehome job no longer renders the inventory evidence') + const evidence = parseRegionalRehomeInventory([{ + timestamp: '2026-08-14T11:58:00Z', + textPayload: + '[orca-relay] regional rehome inventory active=0 awaitingReceipt=0 targetRegistered=0' + + ' completedLast24Hours=0 abortedLast24Hours=0 hostNotArrivedLast24Hours=0 oldestActiveAgeMs=none' + }], { now, maxAgeMs: 5 * 60_000 }) + for (const key of Object.keys(evidence)) { + if (key === 'timestamp') continue + assert.ok(summary.includes(`.${key}`), `${key} is missing from the step summary`) + } +}) From 3cadcabe11126632dc3fa8f293395abde42e23ca Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:01:15 -0700 Subject: [PATCH 183/224] fix(release): keep GitHub releases draft until all assets exist (#21835) electron-builder --publish always was creating a public GitHub release as soon as the first platform uploaded, so /releases/latest could serve a missing Windows exe. Keep the main-repo publisher on draft, pin draft creation to the tag commit, re-draft immediately if anything flips public, and refuse mac publish after the parent cut is cancelled. --- .github/workflows/release-cut.yml | 38 ++--- .github/workflows/release-mac-build.yml | 38 ++--- config/electron-builder.config.cjs | 6 +- .../assert-github-release-is-draft.mjs | 116 +++++++++++++++ .../assert-github-release-is-draft.test.mjs | 139 ++++++++++++++++++ config/scripts/create-draft-release.mjs | 48 ++++-- config/scripts/create-draft-release.test.mjs | 34 ++++- ...ectron-builder-mac-channel-config.test.mjs | 2 +- .../verify-dev-channel-packaging.test.mjs | 2 +- 9 files changed, 360 insertions(+), 63 deletions(-) create mode 100644 config/scripts/assert-github-release-is-draft.mjs create mode 100644 config/scripts/assert-github-release-is-draft.test.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index cd940482917..7280a027b0e 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -107,6 +107,10 @@ jobs: with: ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }} fetch-depth: 0 + # Why: version math recovers unpublished tags; checkout's default + # fetch-tags:false hides them, so a patch cut recreates vX.Y.Z and + # `git push` overwrites the existing tag. + fetch-tags: true - name: Setup Node.js uses: actions/setup-node@v6 @@ -2193,36 +2197,18 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Verify release remains draft after artifact upload - # Why: the build matrix must never be the actor that exposes a partial - # release. If an uploader or GitHub transition flips draft early, fail - # this platform leg and leave the diagnostic monitor artifact behind. - shell: bash + # Why: electron-builder `--publish always` can create a public release + # as soon as this platform uploads. Re-draft immediately, then fail, so + # /releases/latest never keeps serving a missing Windows exe. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.cut.outputs.tag }} - run: | - set -euo pipefail - releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")" - # Why: release upload must validate the draft before it is publicly visible. - draft="$(jq -e -r --arg tag "$TAG" ' - map(select(.tag_name == $tag)) - | if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end - ' <<<"$releases_json")" || { - echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing." - exit 1 - } - if [[ "$draft" != "true" ]]; then - echo "::error::Release $TAG was published during the ${{ matrix.platform }} artifact upload." - exit 1 - fi + run: node config/scripts/assert-github-release-is-draft.mjs "$TAG" - # Why post-publish for Linux: electron-builder packs and uploads in a - # single `--publish always` invocation, so there is no cheap insertion - # point between pack and upload without splitting those steps. Running - # verify last still blocks the bad release: the binary is uploaded to the - # draft, but a failed matrix job blocks `publish-release` from flipping - # the release from draft → published, so users never see it. A human then - # deletes the draft and re-cuts. + # Why post-pack for Linux: electron-builder packs and uploads in one + # `--publish always` invocation. The previous step re-drafts if that + # upload flipped the GitHub release public; this telemetry check still + # blocks `publish-release` from undrafting a bad binary. # # Why this guards against: a misconfigured CI run where # `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 45193dfe1ae..002622b7154 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -142,6 +142,21 @@ jobs: # Kill only its child and require both PTY and watch recovery before packaging. node config/scripts/relay-watcher-fault-harness.mjs + - name: Abort if the parent release-cut run was cancelled + # Why: this workflow is dispatched separately, so cancelling release-cut + # does not stop mac `--publish always`. A cancelled parent left v1.4.206 + # public with only a partial mac upload. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PARENT_RUN: ${{ inputs.release_run_id }} + run: | + set -euo pipefail + conclusion="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$PARENT_RUN" --jq '.conclusion // empty')" + if [[ "$conclusion" == "cancelled" || "$conclusion" == "failure" || "$conclusion" == "timed_out" ]]; then + echo "::error::Parent release-cut run $PARENT_RUN is $conclusion; refusing to publish mac artifacts." + exit 1 + fi + - name: Publish release artifacts (macOS) uses: nick-fields/retry@v4 with: @@ -158,28 +173,13 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Verify release remains draft after artifact upload - # Why: the macOS build must never be the actor that exposes a partial - # release. If an uploader or GitHub transition flips draft early, fail - # this job so release-cut never publishes the release. - shell: bash + # Why: re-draft immediately if electron-builder flipped the GitHub + # release public, then fail. Checking without restoring leaves + # /releases/latest serving a missing Windows exe. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ inputs.tag }} - run: | - set -euo pipefail - releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")" - # Why: release upload must validate the draft before it is publicly visible. - draft="$(jq -e -r --arg tag "$TAG" ' - map(select(.tag_name == $tag)) - | if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end - ' <<<"$releases_json")" || { - echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing." - exit 1 - } - if [[ "$draft" != "true" ]]; then - echo "::error::Release $TAG was published during the mac artifact upload." - exit 1 - fi + run: node config/scripts/assert-github-release-is-draft.mjs "$TAG" # Why post-publish for macOS: electron-builder packs and uploads in a # single `--publish always` invocation, so there is no cheap insertion diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 72efa691dbf..60091ed1f93 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -665,7 +665,11 @@ module.exports = { provider: 'github', owner: 'stablyai', repo: devChannelRepo ?? 'orca', - releaseType: devChannelRepo ? 'prerelease' : 'release' + // Why draft on the main repo: `--publish always` otherwise creates a + // public GitHub release as soon as the first platform uploads, and + // /releases/latest serves a missing Windows exe. release-cut undrafts + // only after every required asset exists. + releaseType: devChannelRepo ? 'prerelease' : 'draft' } } diff --git a/config/scripts/assert-github-release-is-draft.mjs b/config/scripts/assert-github-release-is-draft.mjs new file mode 100644 index 00000000000..c60722452bb --- /dev/null +++ b/config/scripts/assert-github-release-is-draft.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +import { pathToFileURL } from 'node:url' + +const API_VERSION = '2022-11-28' + +function githubHeaders(token) { + return { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': API_VERSION + } +} + +async function githubJson(fetchImpl, url, token, options = {}) { + const res = await fetchImpl(url, { + ...options, + headers: { + ...githubHeaders(token), + ...options.headers + } + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`) + } + return res.json() +} + +export function matchingDesktopReleases(releases, tag) { + const version = tag.startsWith('v') ? tag.slice(1) : tag + return (releases ?? []).filter((release) => { + const tagName = release?.tag_name + const name = release?.name + return tagName === tag || tagName === version || name === tag || name === version + }) +} + +export async function restorePublishedDesktopReleasesToDraft({ + repo, + tag, + token, + fetchImpl = fetch, + log = console.log +}) { + if (!repo) { + throw new Error('repo is required') + } + if (!tag) { + throw new Error('tag is required') + } + if (!token) { + throw new Error('token is required') + } + + const releases = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases?per_page=100`, + token + ) + if (!Array.isArray(releases)) { + throw new Error(`GitHub releases response for ${repo} was not an array`) + } + + const matches = matchingDesktopReleases(releases, tag) + if (matches.length === 0) { + throw new Error(`No GitHub release named ${tag} was found after artifact upload`) + } + + const restored = [] + for (const release of matches) { + if (release?.draft === true) { + continue + } + if (!Number.isInteger(release.id)) { + throw new Error(`Release ${tag} is missing a GitHub release id`) + } + const patched = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${release.id}`, + token, + { + method: 'PATCH', + body: JSON.stringify({ draft: true, make_latest: 'false' }) + } + ) + log(`Restored GitHub release ${release.id} (${release.tag_name}) to draft.`) + restored.push(patched) + } + return restored +} + +async function main() { + const tag = process.argv[2] + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca' + const restored = await restorePublishedDesktopReleasesToDraft({ + repo, + tag, + token, + log: (message) => console.error(message) + }) + if (restored.length > 0) { + console.error( + `::error::Release ${tag} was published during artifact upload. Restored ${restored.length} release(s) to draft so /releases/latest does not serve partial assets.` + ) + process.exit(1) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error.message) + process.exit(1) + }) +} diff --git a/config/scripts/assert-github-release-is-draft.test.mjs b/config/scripts/assert-github-release-is-draft.test.mjs new file mode 100644 index 00000000000..b549d25b59d --- /dev/null +++ b/config/scripts/assert-github-release-is-draft.test.mjs @@ -0,0 +1,139 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { + matchingDesktopReleases, + restorePublishedDesktopReleasesToDraft +} from './assert-github-release-is-draft.mjs' + +const require = createRequire(import.meta.url) +const repoRoot = join(import.meta.dirname, '../..') + +function jsonResponse(body, init = {}) { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? 'OK', + json: vi.fn(async () => body), + text: vi.fn(async () => JSON.stringify(body)) + } +} + +describe('matchingDesktopReleases', () => { + it('matches tagged, untagged-name, and version-name releases', () => { + const releases = [ + { id: 1, tag_name: 'v1.4.206', name: 'v1.4.206', draft: true }, + { id: 2, tag_name: 'untagged-abc', name: '1.4.206', draft: false }, + { id: 3, tag_name: 'v1.4.205', name: 'v1.4.205', draft: false } + ] + + expect(matchingDesktopReleases(releases, 'v1.4.206').map((release) => release.id)).toEqual([ + 1, 2 + ]) + }) +}) + +describe('restorePublishedDesktopReleasesToDraft', () => { + it('leaves drafts alone', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([{ id: 1, tag_name: 'v1.4.206', name: 'v1.4.206', draft: true }]) + ) + + await expect( + restorePublishedDesktopReleasesToDraft({ + repo: 'stablyai/orca', + tag: 'v1.4.206', + token: 'token', + fetchImpl, + log: vi.fn() + }) + ).resolves.toEqual([]) + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + + it('re-drafts a published match immediately', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([{ id: 9, tag_name: 'v1.4.206', name: '1.4.206', draft: false }]) + ) + .mockResolvedValueOnce(jsonResponse({ id: 9, tag_name: 'v1.4.206', draft: true })) + + const log = vi.fn() + await expect( + restorePublishedDesktopReleasesToDraft({ + repo: 'stablyai/orca', + tag: 'v1.4.206', + token: 'token', + fetchImpl, + log + }) + ).resolves.toEqual([{ id: 9, tag_name: 'v1.4.206', draft: true }]) + + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, + 'https://api.github.com/repos/stablyai/orca/releases/9', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ draft: true, make_latest: 'false' }) + }) + ) + expect(log).toHaveBeenCalledWith('Restored GitHub release 9 (v1.4.206) to draft.') + }) + + it('fails closed when no matching release exists', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse([])) + + await expect( + restorePublishedDesktopReleasesToDraft({ + repo: 'stablyai/orca', + tag: 'v1.4.206', + token: 'token', + fetchImpl + }) + ).rejects.toThrow('No GitHub release named v1.4.206 was found after artifact upload') + }) +}) + +describe('release draft workflow contract', () => { + it('keeps GitHub releases draft until publish-release undrafts complete assets', () => { + const releaseWorkflow = parse( + readFileSync(join(repoRoot, '.github/workflows/release-cut.yml'), 'utf8') + ) + const macWorkflow = parse( + readFileSync(join(repoRoot, '.github/workflows/release-mac-build.yml'), 'utf8') + ) + const electronBuilderConfig = require('../electron-builder.config.cjs') + const cutCheckout = releaseWorkflow.jobs.cut.steps.find((step) => step.name === 'Checkout ref') + const linuxDraftStep = releaseWorkflow.jobs.build.steps.find( + (step) => step.name === 'Verify release remains draft after artifact upload' + ) + const publishRelease = releaseWorkflow.jobs['publish-release'].steps.find( + (step) => step.name === 'Publish release' + ) + const macSteps = macWorkflow.jobs['build-mac'].steps + const abortParentStep = macSteps.find( + (step) => step.name === 'Abort if the parent release-cut run was cancelled' + ) + const macPublishStep = macSteps.find( + (step) => step.name === 'Publish release artifacts (macOS)' + ) + const macDraftStep = macSteps.find( + (step) => step.name === 'Verify release remains draft after artifact upload' + ) + + expect(electronBuilderConfig.publish.releaseType).toBe('draft') + expect(cutCheckout.with['fetch-tags']).toBe(true) + expect(linuxDraftStep.run).toContain('assert-github-release-is-draft.mjs') + expect(publishRelease.run).toContain('gh release edit') + expect(publishRelease.run).toContain('--draft=false') + expect(macSteps.indexOf(abortParentStep)).toBeLessThan(macSteps.indexOf(macPublishStep)) + expect(abortParentStep.env.PARENT_RUN).toBe('${{ inputs.release_run_id }}') + expect(abortParentStep.run).toContain('refusing to publish mac artifacts') + expect(macDraftStep.run).toContain('assert-github-release-is-draft.mjs') + }) +}) diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs index 1732e9a1e8a..a25bced199b 100644 --- a/config/scripts/create-draft-release.mjs +++ b/config/scripts/create-draft-release.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { execFileSync } from 'node:child_process' import { pathToFileURL } from 'node:url' const API_VERSION = '2022-11-28' @@ -115,6 +116,7 @@ export async function createDraftRelease({ repo, tag, token, + targetCommitish, fetchImpl = fetch, log = console.log }) { @@ -127,6 +129,9 @@ export async function createDraftRelease({ if (!token) { throw new Error('token is required') } + if (!targetCommitish) { + throw new Error('targetCommitish is required') + } const releases = await fetchRepoReleases(repo, token, fetchImpl) const existingRelease = releases.find((release) => release?.tag_name === tag) @@ -221,19 +226,33 @@ export async function createDraftRelease({ return } } else { - // Why: GitHub's generated release notes can exceed the release body API - // limit, so create with a bounded body. Omit target_commitish because the - // release-cut tag already exists and GitHub rejects the tag name there. - await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, { - method: 'POST', - body: JSON.stringify({ - tag_name: tag, - name, - body, - draft: true, - prerelease - }) - }) + // Why target_commitish is the tag commit, not omitted: GitHub defaults it + // to the repo default branch. A release-cut tag is a detached bump commit, + // so that default creates an untagged draft. electron-builder then misses + // it by tag name and `--publish always` opens a public release with the + // first platform's assets, which /releases/latest serves without the exe. + const createdRelease = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases`, + token, + { + method: 'POST', + body: JSON.stringify({ + tag_name: tag, + target_commitish: targetCommitish, + name, + body, + draft: true, + prerelease, + make_latest: 'false' + }) + } + ) + if (createdRelease?.draft !== true || createdRelease?.tag_name !== tag) { + throw new Error( + `GitHub created ${createdRelease?.draft ? 'draft' : 'published'} release ${createdRelease?.tag_name ?? ''} instead of draft ${tag}` + ) + } } if (generatedBody.length !== body.length) { @@ -251,7 +270,8 @@ async function main() { const tag = process.argv[2] const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca' - await createDraftRelease({ repo, tag, token }) + const targetCommitish = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() + await createDraftRelease({ repo, tag, token, targetCommitish }) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/config/scripts/create-draft-release.test.mjs b/config/scripts/create-draft-release.test.mjs index 911ac00be63..8dc36369b32 100644 --- a/config/scripts/create-draft-release.test.mjs +++ b/config/scripts/create-draft-release.test.mjs @@ -140,6 +140,7 @@ describe('createDraftRelease', () => { repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', + targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) @@ -173,9 +174,11 @@ describe('createDraftRelease', () => { const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body) expect(createBody).toMatchObject({ tag_name: 'v1.4.36', + target_commitish: 'abc123', name: 'v1.4.36', draft: true, - prerelease: false + prerelease: false, + make_latest: 'false' }) expect(createBody.body).toHaveLength(120_000) expect(createBody.body).toContain('Release notes were truncated') @@ -192,6 +195,7 @@ describe('createDraftRelease', () => { repo: 'stablyai/orca', tag: 'v1.4.36-rc.1', token: 'token', + targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) @@ -214,6 +218,7 @@ describe('createDraftRelease', () => { repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', + targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) @@ -243,6 +248,7 @@ describe('createDraftRelease', () => { repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', + targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) @@ -272,6 +278,7 @@ describe('createDraftRelease', () => { repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', + targetCommitish: 'abc123', fetchImpl, log }) @@ -304,6 +311,7 @@ describe('createDraftRelease', () => { repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', + targetCommitish: 'abc123', fetchImpl, log }) @@ -319,6 +327,7 @@ describe('createDraftRelease', () => { repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', + targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) @@ -337,6 +346,7 @@ describe('createDraftRelease', () => { repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', + targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) @@ -359,6 +369,7 @@ describe('createDraftRelease', () => { repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', + targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) @@ -376,4 +387,25 @@ describe('createDraftRelease', () => { const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body) expect(generateNotesBody.previous_tag_name).toBe('v1.4.35') }) + + it('refuses an untagged GitHub draft so electron-builder cannot publish latest', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce( + jsonResponse({ tag_name: 'untagged-abc', name: 'v1.4.36', draft: true }) + ) + + await expect( + createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + targetCommitish: 'abc123', + fetchImpl, + log: vi.fn() + }) + ).rejects.toThrow('GitHub created draft release untagged-abc instead of draft v1.4.36') + }) }) diff --git a/config/scripts/electron-builder-mac-channel-config.test.mjs b/config/scripts/electron-builder-mac-channel-config.test.mjs index dbd5a1170a1..05572f3497a 100644 --- a/config/scripts/electron-builder-mac-channel-config.test.mjs +++ b/config/scripts/electron-builder-mac-channel-config.test.mjs @@ -80,7 +80,7 @@ describe('electron-builder mac channel config', () => { }) expect(electronBuilderConfig.publish).toMatchObject({ repo: 'orca', - releaseType: 'release' + releaseType: 'draft' }) }) diff --git a/config/scripts/verify-dev-channel-packaging.test.mjs b/config/scripts/verify-dev-channel-packaging.test.mjs index 8e5a00f48e1..dfde5d461d2 100644 --- a/config/scripts/verify-dev-channel-packaging.test.mjs +++ b/config/scripts/verify-dev-channel-packaging.test.mjs @@ -40,7 +40,7 @@ describe('electron-builder dev-channel identity', () => { expect(config.win.signtoolOptions.publisherName).toBe('SignPath Foundation') expect(config.win.verifyUpdateCodeSignature).toBeUndefined() expect(config.publish.repo).toBe('orca') - expect(config.publish.releaseType).toBe('release') + expect(config.publish.releaseType).toBe('draft') }) // The whole point of the change: an unsigned build that advertised a From 5b8b01b206afa398ecfad9166d0e8f83ce12b637 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:23:30 -0700 Subject: [PATCH 184/224] fix(terminal): arm native-paste suppression on middle-click in mouse-tracking TUIs (#21834) * fix(terminal): arm native-paste suppression on middle-click in mouse-tracking TUIs (#21762) Orca's own middle-click paste path bailed out entirely whenever the pane was in mouse tracking mode (Claude Code, Codex, ...), skipping preventDefault() and never arming the #8993 native-paste suppression window. Chromium's native Linux middle-click paste then landed unsuppressed alongside the TUI's own PRIMARY paste from the forwarded mouse report, pasting the selection twice. Split pane lookup from the tracking-mode gate: any terminal pane target now arms suppression and blocks the native paste, while only the paste-to-PTY (and the propagation stop that would swallow the click before xterm can report it) stays gated on mouseTrackingMode === 'none'. * fix(terminal): address review nits on the #21762 middle-click fix - Fix a mis-attributing comment: the suppression window (not preventDefault, which only helps on mousedown while Chromium's native paste fires on mouseup) is what swallows the duplicate native paste. - Drop the now-unused getPrimarySelectionMiddleClickPane. - Assert stopPropagation is/isn't called per tracking mode in the repro test. --------- Co-authored-by: bench --- ...de-middle-click-double-paste.repro.test.ts | 151 ++++++++++++++++++ .../use-terminal-pane-mobile-actions.ts | 54 ++++--- 2 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts diff --git a/src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts b/src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts new file mode 100644 index 00000000000..19dab3e0eda --- /dev/null +++ b/src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom +// +// Issue #21762: in a terminal pane running a TUI that enables mouse tracking +// (Claude Code, Codex, ...), a middle-click pastes the PRIMARY selection twice. +// Orca's own paste path bails out in tracking mode (correctly — the TUI owns +// the click), but bailing out also skips arming the native-paste suppression +// window from #8993, so Chromium's native "paste PRIMARY into focused editable" +// reaches xterm's helper textarea unsuppressed while the TUI performs its own +// primary paste from the forwarded mouse report — two copies from one click. +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { TerminalPaneContextController } from './use-terminal-pane-context-actions' + +const { armPrimarySelectionNativePasteSuppressionMock, isPrimarySelectionEnabledMock } = vi.hoisted( + () => ({ + armPrimarySelectionNativePasteSuppressionMock: vi.fn(), + isPrimarySelectionEnabledMock: vi.fn(() => true) + }) +) + +vi.mock('@/lib/primary-selection', () => ({ + armPrimarySelectionNativePasteSuppression: armPrimarySelectionNativePasteSuppressionMock, + isPrimarySelectionEnabled: isPrimarySelectionEnabledMock, + readPrimarySelectionText: vi.fn().mockResolvedValue('') +})) + +vi.mock('@/lib/pane-manager/mobile-fit-overrides', () => ({ getMobileFitOverridePtyIds: () => [] })) +vi.mock('@/lib/pane-manager/mobile-driver-state', () => ({ getAllDrivers: () => new Map() })) +vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ + refitAndRefreshAllTerminalPanes: vi.fn() +})) +vi.mock('./terminal-fit-restore', () => ({ + restoreTerminalFitToDesktop: vi.fn(), + restoreTerminalFitsToDesktop: vi.fn() +})) +vi.mock('./terminal-pane-split-with-inherited-cwd', () => ({ + splitTerminalPaneWithInheritedCwd: vi.fn() +})) +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('./terminal-paste-coordinator', () => ({ + planTerminalPasteWithYield: vi.fn(), + executeTerminalPastePlan: vi.fn() +})) +vi.mock('./terminal-paste-runtime', () => ({ resolveTerminalPasteRuntime: vi.fn() })) +vi.mock('./terminal-paste-ssh-platform', () => ({ getTerminalPasteSshRemotePlatform: vi.fn() })) +vi.mock('./terminal-bracketed-paste', () => ({ pasteTerminalText: vi.fn() })) +vi.mock('./terminal-pty-paste-writer', () => ({ writeTerminalPastePtyInput: vi.fn() })) +vi.mock('./terminal-paste-errors', () => ({ formatTerminalPasteExecutionError: vi.fn() })) +vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: vi.fn() })) + +import { useTerminalPaneMobileActions } from './use-terminal-pane-mobile-actions' + +function buildTrackedPane(mouseTrackingMode: 'none' | 'sgr'): ManagedPane { + const container = document.createElement('div') + document.body.appendChild(container) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test-only stub; the hook only reads id/leafId/container/terminal.modes/terminal.focus off ManagedPane. + return { + id: 1, + leafId: 'leaf-1', + container, + terminal: { + modes: { mouseTrackingMode, bracketedPasteMode: false }, + focus: vi.fn() + } + } as unknown as ManagedPane +} + +function buildController(pane: ManagedPane): TerminalPaneContextController { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test-only stub; the hook only reads getPanes/getActivePane off PaneManager. + const manager = { + getPanes: () => [pane], + getActivePane: () => pane + } as unknown as PaneManager + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test-only stub covering only the controller fields useTerminalPaneMobileActions destructures. + return { + cwd: '/repo', + managerRef: { current: manager }, + paneCwdRef: { current: new Map() }, + paneTransportsRef: { current: new Map() }, + refreshMobileOverlays: vi.fn(), + setTerminalError: vi.fn(), + settingsRef: { current: undefined }, + tabId: 'tab-1', + worktreeId: 'wt-1' + } as unknown as TerminalPaneContextController +} + +function fireMiddleMouseDown( + handler: (event: React.MouseEvent) => void, + target: EventTarget +): { defaultPrevented: boolean; propagationStopped: boolean } { + let defaultPrevented = false + let propagationStopped = false + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test-only stub; the handler only calls button/target/preventDefault/stopPropagation off the event. + handler({ + button: 1, + target, + preventDefault: () => { + defaultPrevented = true + }, + stopPropagation: () => { + propagationStopped = true + } + } as unknown as React.MouseEvent) + return { defaultPrevented, propagationStopped } +} + +describe('issue 21762: middle-click native-paste suppression in mouse-tracking TUIs', () => { + beforeEach(() => { + armPrimarySelectionNativePasteSuppressionMock.mockClear() + isPrimarySelectionEnabledMock.mockReturnValue(true) + }) + + afterEach(() => { + document.body.replaceChildren() + }) + + it('arms native-paste suppression without stopping propagation when the pane is in mouse-tracking mode', () => { + const pane = buildTrackedPane('sgr') + const { result } = renderHook(() => useTerminalPaneMobileActions(buildController(pane))) + + const outcome = fireMiddleMouseDown( + result.current.handlePrimarySelectionMiddleMouseDown, + pane.container + ) + + // The #8993 suppression window must be armed so the native follow-up paste + // doesn't reach xterm's helper textarea and duplicate the TUI's own paste. + expect(armPrimarySelectionNativePasteSuppressionMock).toHaveBeenCalled() + // Propagation must NOT be stopped here — xterm's own mousedown listener + // (a descendant of this capture handler) still needs to see the event so + // the tracking TUI receives the click as a mouse report. + expect(outcome.propagationStopped).toBe(false) + expect(pane.terminal.focus).not.toHaveBeenCalled() + }) + + it('stops propagation and pastes directly to the PTY when the pane is not in mouse-tracking mode', () => { + const pane = buildTrackedPane('none') + const { result } = renderHook(() => useTerminalPaneMobileActions(buildController(pane))) + + const outcome = fireMiddleMouseDown( + result.current.handlePrimarySelectionMiddleMouseDown, + pane.container + ) + + expect(armPrimarySelectionNativePasteSuppressionMock).toHaveBeenCalled() + expect(outcome.propagationStopped).toBe(true) + expect(pane.terminal.focus).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts index be7f996c393..ba47eaa7d5c 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts @@ -90,7 +90,11 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont }, [] ) - const getPrimarySelectionMiddleClickPane = useCallback( + // Why: any terminal pane target must arm native-paste suppression, even one + // in mouse-tracking mode where the TUI (not Orca) owns the click and performs + // its own PRIMARY paste from the forwarded mouse report — otherwise + // Chromium's unsuppressed native paste lands on top of it (#21762). + const findTerminalPaneForMiddleClick = useCallback( (target: EventTarget | null) => { if (!terminalShouldHandleMiddleClick(target)) { return null @@ -99,14 +103,12 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont if (!manager) { return null } - const clickedPane = + return ( manager.getPanes().find((pane) => pane.container.contains(target as Node)) ?? manager.getActivePane() ?? - manager.getPanes()[0] - if (!clickedPane || clickedPane.terminal.modes.mouseTrackingMode !== 'none') { - return null - } - return clickedPane + manager.getPanes()[0] ?? + null + ) }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. [terminalShouldHandleMiddleClick] @@ -116,13 +118,23 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont if (event.button !== 1 || !isPrimarySelectionEnabled()) { return } - const clickedPane = getPrimarySelectionMiddleClickPane(event.target) - if (!clickedPane) { + const targetPane = findTerminalPaneForMiddleClick(event.target) + if (!targetPane) { return } + // Why: arm the shared suppression window unconditionally — it, not + // preventDefault, is what swallows Chromium's native follow-up paste + // (fired on mouseup, not mousedown; see usePrimarySelectionPaste.ts). + // Only the paste-to-PTY below is gated on tracking mode, since a + // tracking TUI still needs the click forwarded as a mouse report and + // must not have propagation stopped. event.preventDefault() - event.stopPropagation() armPrimarySelectionNativePasteSuppression() + if (targetPane.terminal.modes.mouseTrackingMode !== 'none') { + return + } + const clickedPane = targetPane + event.stopPropagation() clickedPane.terminal.focus() void readPrimarySelectionText().then(async (text) => { if (!text) { @@ -185,21 +197,24 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont }) }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - [getPrimarySelectionMiddleClickPane, tabId, worktreeId] + [findTerminalPaneForMiddleClick, tabId, worktreeId] ) const handlePrimarySelectionAuxClick = useCallback( (event: React.MouseEvent): void => { - if ( - event.button === 1 && - isPrimarySelectionEnabled() && - getPrimarySelectionMiddleClickPane(event.target) - ) { - event.preventDefault() + if (event.button !== 1 || !isPrimarySelectionEnabled()) { + return + } + const targetPane = findTerminalPaneForMiddleClick(event.target) + if (!targetPane) { + return + } + event.preventDefault() + armPrimarySelectionNativePasteSuppression() + if (targetPane.terminal.modes.mouseTrackingMode === 'none') { event.stopPropagation() - armPrimarySelectionNativePasteSuppression() } }, - [getPrimarySelectionMiddleClickPane] + [findTerminalPaneForMiddleClick] ) const activatePaneTitleInteraction = useCallback((paneId: number): void => { managerRef.current?.setActivePane(paneId, { focus: false }) @@ -241,7 +256,6 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont restorePaneTerminalFit, restoreAllTerminalFits, terminalShouldHandleMiddleClick, - getPrimarySelectionMiddleClickPane, handlePrimarySelectionMiddleMouseDown, handlePrimarySelectionAuxClick, activatePaneTitleInteraction, From 72d61c459f78a991a9b2934ed705bed6b6986f08 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:28:03 -0700 Subject: [PATCH 185/224] fix(e2e): wait for terminal remount after golden worktree switch (#21837) Mac release goldens failed after switching back to the original worktree: sidebar aria-current landed while the store still pointed at the child tab, so waitForActiveTerminalManager timed out. Wait for activeWorktreeId, force the terminal tab visible, and restore this spec from the workflow ref so older cut SHAs pick up the harness. --- .github/workflows/release-cut.yml | 3 ++- .../release-e2e-dispatch-contract.test.mjs | 1 + tests/e2e/golden-terminal-file-link.spec.ts | 10 ++++++++-- tests/e2e/golden-worktree-create-switch.spec.ts | 16 +++++++++++++++- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 7280a027b0e..cf7df2374a5 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -850,7 +850,8 @@ jobs: git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" git checkout "$WORKFLOW_SHA" -- \ tests/e2e/golden-source-control-open-diff.spec.ts \ - tests/e2e/golden-terminal-file-link.spec.ts + tests/e2e/golden-terminal-file-link.spec.ts \ + tests/e2e/golden-worktree-create-switch.spec.ts - name: Install native build tools if: runner.os == 'Linux' diff --git a/config/scripts/release-e2e-dispatch-contract.test.mjs b/config/scripts/release-e2e-dispatch-contract.test.mjs index 5fd4503d4e3..4590e8bc136 100644 --- a/config/scripts/release-e2e-dispatch-contract.test.mjs +++ b/config/scripts/release-e2e-dispatch-contract.test.mjs @@ -19,6 +19,7 @@ describe('release E2E dispatch contract', () => { expect(restoreStep.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"') expect(restoreStep.run).toContain('golden-source-control-open-diff.spec.ts') expect(restoreStep.run).toContain('golden-terminal-file-link.spec.ts') + expect(restoreStep.run).toContain('golden-worktree-create-switch.spec.ts') }) it('dispatches tag-scoped E2E only after publication', () => { diff --git a/tests/e2e/golden-terminal-file-link.spec.ts b/tests/e2e/golden-terminal-file-link.spec.ts index a5d940c4cb5..f05f448e30d 100644 --- a/tests/e2e/golden-terminal-file-link.spec.ts +++ b/tests/e2e/golden-terminal-file-link.spec.ts @@ -246,11 +246,17 @@ test('reuses a terminal file link already open in a sibling workspace @golden', await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) - await orcaPage.evaluate(() => { + await orcaPage.evaluate((sourceWorktreeId) => { const state = window.__store?.getState() state?.setSidebarOpen(false) state?.setRightSidebarOpen(false) - }) + // Why: closing the left sidebar can drop activeWorktreeId on mac CI, + // which remounts Landing and leaves terminal.cols at 0. + if (state && state.activeWorktreeId !== sourceWorktreeId) { + state.setActiveWorktree(sourceWorktreeId) + } + }, sourceWorktreeId) + await ensureTerminalVisible(orcaPage) await expect .poll( () => diff --git a/tests/e2e/golden-worktree-create-switch.spec.ts b/tests/e2e/golden-worktree-create-switch.spec.ts index e327cfe7f77..dd15ca3115c 100644 --- a/tests/e2e/golden-worktree-create-switch.spec.ts +++ b/tests/e2e/golden-worktree-create-switch.spec.ts @@ -1,7 +1,12 @@ import { openSidebarWorkspaceComposer } from './helpers/sidebar-project-dialog' import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { getActiveWorktreeId, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + ensureTerminalVisible, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' import { createTerminalTabFromMenu } from './helpers/terminal-tab-menu' import { execInTerminal, @@ -62,6 +67,15 @@ test('creates a worktree, keeps its terminal isolated, and switches back @golden await expect( orcaPage.locator(`[role="option"][data-worktree-id="${originalWorktreeId}"]`) ).toHaveAttribute('aria-current', 'page', { timeout: 20_000 }) + // Why: sidebar aria-current can land before the store/terminal remount. + // Mac release goldens then wait 30s on a child tab whose PaneManager is gone. + await expect + .poll(() => getActiveWorktreeId(orcaPage), { + timeout: 20_000, + message: 'store did not activate the original worktree after sidebar click' + }) + .toBe(originalWorktreeId) + await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) expect(await waitForActivePanePtyId(orcaPage, 30_000)).toBe(parentPtyId) } finally { From 4085e1cf603fd503a1761f7a03a2cd6e49ee5403 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 20 Sep 2026 14:41:50 -0700 Subject: [PATCH 186/224] fix(memory): release stale session registries (#21734) * fix(memory): bound session and lifecycle registries * fix(memory): bound transient filesystem registries * fix(memory): cap path and locale caches * fix(memory): bound runtime recovery registries * fix(memory): bound host mirror gap verdicts * fix(memory): bound shell startup env cache * fix(memory): bound gitlab host context cache * fix(memory): release removed ssh generations * fix(memory): expire cloud refresh replay guards * fix(memory): release retired plugin generations * fix(memory): bound plugin log key retention * fix(memory): bound automation authority generations * fix(memory): bound native chat enrichment cache * fix(memory): bound web session tracking generations * fix(memory): bound codex credential absence paths * fix(memory): bound WSL canonical path cache * fix(memory): bound sparse checkout cache * fix(memory): bound shared directory cache * fix(memory): bound advertised URL scan snapshots * fix(memory): bound automation manager cache * fix(memory): bound web session reorder intents * fix(memory): bound web session focus intents * fix(memory): bound web session handoffs * fix(memory): bound automation dispatch tokens * fix(memory): bound host mirror waiters * fix(memory): bound retained session activity * fix(memory): bound retained session activity * fix(memory): bound web session close intents * fix(memory): bound cloud session cache * fix(memory): bound WSL home cache * fix(memory): bound SSH capability cache * fix(memory): bound trust grant cooldowns * fix(memory): bound WSL auth drain state * fix(memory): bound Linear workspace credential cache * fix(memory): bound local Git capability cache * fix(memory): bound WSL Git environment cache * fix(memory): bound WSL Git environment cache * fix(memory): bound WSL preflight cache * fix(memory): keep hot cache entries warm * fix(memory): preserve generation fences across eviction * fix(memory): close remaining eviction fences * fix(memory): align evicted upstream generations * fix(memory): trim successful capability probes * fix(auth): retain expired refresh replay evidence --------- Co-authored-by: m4air --- config/tsconfig.tc.web.json | 1 + .../agent-hooks/wsl-hook-default-distro.ts | 13 +++ .../wsl-hook-relay-manager.test.ts | 4 +- .../agent-hooks/wsl-hook-relay-manager.ts | 48 +++++------ src/main/agent-hooks/wsl-hook-relay-resume.ts | 17 ++++ src/main/automations/dispatch-tokens.test.ts | 16 ++++ src/main/automations/dispatch-tokens.ts | 16 ++++ .../external-automation-manager-cache.test.ts | 13 ++- .../external-automation-manager-cache.ts | 12 ++- .../codex-credential-absence-grace.test.ts | 12 +++ .../codex-credential-absence-grace.ts | 13 +++ .../legacy-wsl-runtime-auth-drain.test.ts | 16 ++++ .../legacy-wsl-runtime-auth-drain.ts | 35 +++++++- .../codex-app-server-capability-cache.test.ts | 11 +++ .../codex-app-server-capability-cache.ts | 3 +- src/main/codex/codex-hook-trust-grant.test.ts | 20 ++++- src/main/codex/codex-hook-trust-grant.ts | 12 +++ .../codex/codex-wsl-hook-install-plan.test.ts | 26 +++++- src/main/codex/codex-wsl-hook-install-plan.ts | 24 +++++- .../terminal-history-permission-repair.ts | 12 ++- src/main/git/git-capability-state.test.ts | 11 +++ src/main/git/git-capability-state.ts | 11 ++- src/main/git/runner-wsl-direct-read.test.ts | 9 ++ .../effective-upstream-status-cache.ts | 28 +++++-- .../effective-upstream-status-probe.ts | 4 +- .../status-upstream-negative-cache.test.ts | 74 +++++++++++++++++ .../git/worktree-shared-directories.test.ts | 11 +++ src/main/git/worktree-shared-directories.ts | 16 ++++ .../worktree-sparse-checkout-cache.test.ts | 11 +++ .../git/worktree-sparse-checkout-cache.ts | 18 +++- src/main/git/wsl-git-read-environment.ts | 33 ++++++++ .../gitlab/gitlab-known-host-probe.test.ts | 10 +++ src/main/gitlab/gitlab-known-host-probe.ts | 23 ++++++ ...e-environment-transport-generation.test.ts | 29 +++++++ ...untime-environment-transport-generation.ts | 23 ++++-- .../linear/linear-workspace-registry.test.ts | 20 +++++ src/main/linear/linear-workspace-registry.ts | 31 ++++++- src/main/local-worktree-scan-generation.ts | 13 +++ ...ctured-agent-session-activity-retention.ts | 19 +++++ ...ructured-agent-session-subscribers.test.ts | 21 +++++ .../structured-agent-session-subscribers.ts | 14 ++-- ...wsl-transcript-fs-route-quarantine.test.ts | 16 +++- .../wsl-transcript-fs-route-quarantine.ts | 17 ++++ .../profile-cloud-dev-org-members.ts | 8 ++ ...profile-cloud-refresh-replay-guard.test.ts | 32 ++++++++ .../profile-cloud-refresh-replay-guard.ts | 53 +++++++++++- .../profile-cloud-session-store.test.ts | 11 +++ .../profile-cloud-session-store.ts | 29 +++++-- src/main/persistence-repo-lifecycle.test.ts | 13 +++ .../repo-lifecycle-operations.ts | 39 ++------- .../repo-lifecycle-ui-residue.ts | 22 +++++ .../loading-store/ssh-profile-operations.ts | 5 ++ src/main/plugins/plugin-log-buffer.ts | 12 +++ .../plugin-worker-generation-retention.ts | 21 +++++ src/main/plugins/plugin-worker-lifecycle.ts | 13 +++ .../plugins/plugin-worker-manager.test.ts | 11 +++ src/main/plugins/plugin-worker-manager.ts | 48 +++++++---- .../plugin-worker-output-retention.test.ts | 13 ++- src/main/ports/advertised-url-watcher.test.ts | 15 +++- src/main/ports/advertised-url-watcher.ts | 10 +++ src/main/preflight-wsl-cache.ts | 27 ++++++ src/main/preflight/agent-detection.ts | 14 ++++ src/main/providers/ssh-git-dispatch.test.ts | 35 ++++++++ src/main/providers/ssh-git-dispatch.ts | 33 +++++++- src/main/pty/shell-startup-env.test.ts | 15 +++- src/main/pty/shell-startup-env.ts | 19 ++++- src/main/repo-git-username-enrichment.test.ts | 16 ++++ src/main/repo-git-username-enrichment.ts | 11 ++- .../orca-runtime-notify-ssh-state-changed.ts | 14 +++- ...ktree-records-with-controller-inventory.ts | 14 +--- src/main/runtime/prune-oldest-map-entry.ts | 9 ++ src/main/sidecar-snapshot-file.test.ts | 18 ++++ src/main/sidecar-snapshot-file.ts | 24 ++++-- .../ssh/ssh-connection-generation.test.ts | 12 +++ src/main/ssh/ssh-connection-generation.ts | 10 +++ ...tem-ssh-windows-write-capabilities.test.ts | 11 +++ .../system-ssh-windows-write-capabilities.ts | 20 ++++- src/main/workspace-cleanup-scan-snapshot.ts | 13 +++ src/main/wsl-home-cache.ts | 33 ++++++++ src/main/wsl.test.ts | 23 ++++++ src/main/wsl.ts | 44 ++++------ ...automation-host-catalog-generation.test.ts | 24 ++++-- .../automation-host-catalog-generation.ts | 32 ++++++-- .../host-guest/browser-page-viewport.test.ts | 9 ++ .../host-guest/browser-page-viewport.ts | 12 +++ .../github-project/roadmap-tick-format.ts | 8 ++ ...ive-chat-session-option-enrichment.test.ts | 18 ++++ .../native-chat-session-option-enrichment.ts | 26 +++++- .../star-nag/StarNagToastHost.test.tsx | 10 +++ .../components/star-nag/StarNagToastHost.tsx | 5 ++ ...st-mirror-handle-gap-verdict-union.test.ts | 12 +++ .../src/lib/host-mirror-handle-gap-wait.ts | 28 +++++-- src/renderer/src/lib/monaco-setup.ts | 1 - src/renderer/src/lib/repo-slug-index.ts | 23 +++++- ...ost-session-mirror-hydration-drain.test.ts | 12 +++ .../runtime/host-session-mirror-hydration.ts | 16 +++- .../src/runtime/web-agent-session-handoff.ts | 12 ++- .../src/runtime/web-session-close-intent.ts | 8 ++ .../src/runtime/web-session-focus-intent.ts | 13 ++- .../runtime/web-session-intent-owner.test.ts | 82 +++++++++++++++++++ .../src/runtime/web-session-reorder-intent.ts | 19 +++++ ...eb-session-tabs-sync-agent-handoff.test.ts | 31 ++++++- .../tracking-lifecycle.test.ts | 21 +++++ .../tracking-lifecycle.ts | 32 ++++++-- .../repos/runtime-repo-catalog-actions.ts | 16 +++- .../store/repos/safe-auto-fork-sync.test.ts | 13 +++ .../src/store/repos/safe-auto-fork-sync.ts | 20 ++++- .../store/slices/runtime-environment-ssh.ts | 43 +++++++--- src/renderer/src/store/slices/ssh.ts | 18 +++- src/shared/capability-probe-cache.ts | 27 +++++- src/shared/commit-message-agent-spec.test.ts | 1 - .../commit-message-agent-specs-primary.ts | 6 +- 112 files changed, 1920 insertions(+), 234 deletions(-) create mode 100644 src/main/agent-hooks/wsl-hook-default-distro.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-resume.ts create mode 100644 src/main/automations/dispatch-tokens.test.ts create mode 100644 src/main/ipc/runtime-environment-transport-generation.test.ts create mode 100644 src/main/linear/linear-workspace-registry.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-activity-retention.ts create mode 100644 src/main/orca-profiles/profile-cloud-refresh-replay-guard.test.ts create mode 100644 src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts create mode 100644 src/main/plugins/plugin-worker-generation-retention.ts create mode 100644 src/main/plugins/plugin-worker-lifecycle.ts create mode 100644 src/main/preflight-wsl-cache.ts create mode 100644 src/main/runtime/prune-oldest-map-entry.ts create mode 100644 src/main/sidecar-snapshot-file.test.ts create mode 100644 src/main/wsl-home-cache.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.test.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 2caf2149f73..70b3439d7c9 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -31,6 +31,7 @@ "../src/main/wsl-distro-list-output.ts", "../src/main/wsl-distro-retry.ts", "../src/main/wsl-running-distro-cache.ts", + "../src/main/wsl-home-cache.ts", "../src/main/wsl.ts", "../src/main/wsl-interop-spawn-directory.ts", "../src/main/persistence/applying-settings/ui-state-read.ts", diff --git a/src/main/agent-hooks/wsl-hook-default-distro.ts b/src/main/agent-hooks/wsl-hook-default-distro.ts new file mode 100644 index 00000000000..c6dea41c98d --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-default-distro.ts @@ -0,0 +1,13 @@ +export async function resolveWslHookDefaultDistro( + currentDistro: string | null, + listDistros: () => Promise +): Promise { + if (currentDistro) { + return currentDistro + } + try { + return (await listDistros())[0] ?? null + } catch { + return null + } +} diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 9b27e4c2207..d2f528cc546 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -201,8 +201,8 @@ describe('WslHookRelayManager', () => { // A guest bundle predating the plugin overlay omits this handler (-32601). if (registerInstallPlugins) { harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({ - installed: { opencode: true, opencode2: true, pi: false, omp: false }, - overlayDirs: { opencode: opencodeOverlayDir, opencode2: opencode2OverlayDir } + installed: { opencode: true, opencode2: true, pi: false, omp: false }, + overlayDirs: { opencode: opencodeOverlayDir, opencode2: opencode2OverlayDir } })) } return harness.transport diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 696800b6ca5..14941489aa4 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -30,6 +30,8 @@ import { recordManagedWslCodexHome, wslRuntimeHomePathsEqual } from '../codex/managed-wsl-codex-home-registry' +import { resolveWslHookDefaultDistro } from './wsl-hook-default-distro' +import { resumeStoppedWslHookRelays } from './wsl-hook-relay-resume' type DistroState = { /** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */ @@ -40,7 +42,8 @@ type DistroState = { guestHome?: string codexHomePath?: string guestEndpointFilePath?: string - opencodeOverlayDir?: string; opencode2OverlayDir?: string + opencodeOverlayDir?: string + opencode2OverlayDir?: string failures: number cooldownUntil: number connectedAt?: number @@ -101,7 +104,15 @@ export class WslHookRelayManager { return this.stateFor(distro)?.guestEndpointFilePath ?? null } - getOpenCodeOverlayDir(distro: string | null, agent: 'opencode' | 'opencode2' = 'opencode'): string | null { const state = this.stateFor(distro); return agent === 'opencode2' ? (state?.opencode2OverlayDir ?? null) : (state?.opencodeOverlayDir ?? null) } + getOpenCodeOverlayDir( + distro: string | null, + agent: 'opencode' | 'opencode2' = 'opencode' + ): string | null { + const state = this.stateFor(distro) + return agent === 'opencode2' + ? (state?.opencode2OverlayDir ?? null) + : (state?.opencodeOverlayDir ?? null) + } /** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the * manager reusable, so re-enabling hooks can start relays again without an app restart. */ @@ -121,18 +132,11 @@ export class WslHookRelayManager { /** Restarts what a hooks-off teardown stopped. Skips distros the user has since shut * down: `wsl -d` BOOTS a stopped distro, and nothing in it is waiting on status. */ resumeStoppedRelays(): void { - const distros = [...this.stoppedByHooksOff] - this.stoppedByHooksOff.clear() - for (const [distro, codexHomePath] of distros) { - void this.deps - .isDistroRunning(distro) - .then((running) => { - if (running) { - this.ensureForDistro(distro, codexHomePath) - } - }) - .catch(() => undefined) - } + resumeStoppedWslHookRelays( + this.stoppedByHooksOff, + this.deps.isDistroRunning, + (distro, codexHomePath) => this.ensureForDistro(distro, codexHomePath) + ) } private async ensureInternal( @@ -190,7 +194,8 @@ export class WslHookRelayManager { failures: existing?.failures ?? 0, // Why: instance-keyed and on the distro's persistent fs, so it outlives a relay // crash — dropping it would blank status on panes spawned mid-relaunch. - opencodeOverlayDir: existing?.opencodeOverlayDir, opencode2OverlayDir: existing?.opencode2OverlayDir, + opencodeOverlayDir: existing?.opencodeOverlayDir, + opencode2OverlayDir: existing?.opencode2OverlayDir, codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath, cooldownUntil: 0 } @@ -328,15 +333,10 @@ export class WslHookRelayManager { } private async resolveDefaultDistro(): Promise { - if (this.defaultDistro) { - return this.defaultDistro - } - try { - const distros = await this.deps.listDistros() - this.defaultDistro = distros[0] ?? null - } catch { - this.defaultDistro = null - } + this.defaultDistro = await resolveWslHookDefaultDistro( + this.defaultDistro, + this.deps.listDistros + ) return this.defaultDistro } } diff --git a/src/main/agent-hooks/wsl-hook-relay-resume.ts b/src/main/agent-hooks/wsl-hook-relay-resume.ts new file mode 100644 index 00000000000..278d3e7033d --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-resume.ts @@ -0,0 +1,17 @@ +export function resumeStoppedWslHookRelays( + stoppedByHooksOff: Map, + isDistroRunning: (distro: string) => Promise, + ensureForDistro: (distro: string, codexHomePath?: string) => void +): void { + const distros = [...stoppedByHooksOff] + stoppedByHooksOff.clear() + for (const [distro, codexHomePath] of distros) { + void isDistroRunning(distro) + .then((running) => { + if (running) { + ensureForDistro(distro, codexHomePath) + } + }) + .catch(() => undefined) + } +} diff --git a/src/main/automations/dispatch-tokens.test.ts b/src/main/automations/dispatch-tokens.test.ts new file mode 100644 index 00000000000..4898f75fe4d --- /dev/null +++ b/src/main/automations/dispatch-tokens.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { + createAutomationDispatchToken, + getAutomationDispatchTokenCountForTests, + MAX_AUTOMATION_DISPATCH_TOKENS +} from './dispatch-tokens' + +describe('automation dispatch tokens', () => { + it('bounds distinct token churn', () => { + for (let index = 0; index < MAX_AUTOMATION_DISPATCH_TOKENS + 4; index += 1) { + createAutomationDispatchToken(`automation-${index}`, `run-${index}`) + } + + expect(getAutomationDispatchTokenCountForTests()).toBe(MAX_AUTOMATION_DISPATCH_TOKENS) + }) +}) diff --git a/src/main/automations/dispatch-tokens.ts b/src/main/automations/dispatch-tokens.ts index b8856a16220..ce623d65d7d 100644 --- a/src/main/automations/dispatch-tokens.ts +++ b/src/main/automations/dispatch-tokens.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' const DISPATCH_TOKEN_TTL_MS = 30 * 60_000 +export const MAX_AUTOMATION_DISPATCH_TOKENS = 1024 type DispatchTokenRecord = { automationId: string @@ -20,6 +21,16 @@ function pruneExpiredDispatchTokens(now = Date.now()): void { } } +function trimDispatchTokens(): void { + while (dispatchTokens.size > MAX_AUTOMATION_DISPATCH_TOKENS) { + const oldestEvictable = [...dispatchTokens].find(([, record]) => !record.inFlight) + if (!oldestEvictable) { + return + } + dispatchTokens.delete(oldestEvictable[0]) + } +} + export function createAutomationDispatchToken(automationId: string, runId: string): string { pruneExpiredDispatchTokens() const token = randomUUID() @@ -29,9 +40,14 @@ export function createAutomationDispatchToken(automationId: string, runId: strin expiresAt: Date.now() + DISPATCH_TOKEN_TTL_MS, inFlight: false }) + trimDispatchTokens() return token } +export function getAutomationDispatchTokenCountForTests(): number { + return dispatchTokens.size +} + export function beginAutomationDispatchTokenUse(args: { automationId: string runId: string diff --git a/src/main/automations/external-automation-manager-cache.test.ts b/src/main/automations/external-automation-manager-cache.test.ts index 8b73b306209..9c7b7d97be5 100644 --- a/src/main/automations/external-automation-manager-cache.test.ts +++ b/src/main/automations/external-automation-manager-cache.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { ExternalAutomationManagerCache, - describeExternalManagerFailure + describeExternalManagerFailure, + MAX_EXTERNAL_AUTOMATION_MANAGER_CACHE_ENTRIES } from './external-automation-manager-cache' import { ExternalAutomationProbeCancelledError } from './external-automation-probe-scheduler' import type { ExternalAutomationManager } from '../../shared/automations-types' @@ -24,6 +25,16 @@ const selfOwner = 'owner:desktop:self' const sshOwner = 'owner:desktop:ssh:target-a:3' describe('ExternalAutomationManagerCache', () => { + it('bounds distinct scope churn', () => { + const cache = new ExternalAutomationManagerCache() + + for (let index = 0; index < MAX_EXTERNAL_AUTOMATION_MANAGER_CACHE_ENTRIES + 4; index += 1) { + cache.write({ ownerKey: `owner-${index}`, provider: 'hermes' }, null) + } + + expect(cache.size).toBe(MAX_EXTERNAL_AUTOMATION_MANAGER_CACHE_ENTRIES) + }) + it('keys entries per owner and per provider', () => { const cache = new ExternalAutomationManagerCache() diff --git a/src/main/automations/external-automation-manager-cache.ts b/src/main/automations/external-automation-manager-cache.ts index 701da22a2f1..3e931144682 100644 --- a/src/main/automations/external-automation-manager-cache.ts +++ b/src/main/automations/external-automation-manager-cache.ts @@ -23,6 +23,7 @@ export type ExternalAutomationManagerCacheEntry = { const DEFAULT_CACHE_TTL_MS = 30_000 const MAX_CACHED_ERROR_LENGTH = 300 +export const MAX_EXTERNAL_AUTOMATION_MANAGER_CACHE_ENTRIES = 512 /** * Bounded, provider-agnostic failure text. Provider payloads can carry prompts, @@ -133,7 +134,16 @@ export class ExternalAutomationManagerCache { entry: ExternalAutomationManagerCacheEntry ): ExternalAutomationManagerCacheEntry { this.pruneExpired() - this.entries.set(externalAutomationManagerCacheKey(key), entry) + const cacheKey = externalAutomationManagerCacheKey(key) + this.entries.delete(cacheKey) + this.entries.set(cacheKey, entry) + while (this.entries.size > MAX_EXTERNAL_AUTOMATION_MANAGER_CACHE_ENTRIES) { + const oldest = this.entries.keys().next() + if (oldest.done || oldest.value === cacheKey) { + break + } + this.entries.delete(oldest.value) + } return entry } diff --git a/src/main/codex-accounts/codex-credential-absence-grace.test.ts b/src/main/codex-accounts/codex-credential-absence-grace.test.ts index 37e360a3a6e..4b5dbd12acc 100644 --- a/src/main/codex-accounts/codex-credential-absence-grace.test.ts +++ b/src/main/codex-accounts/codex-credential-absence-grace.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { CODEX_CREDENTIAL_ABSENCE_GRACE_MS, + CODEX_CREDENTIAL_ABSENCE_MAX_TRACKED_PATHS, CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' @@ -25,6 +26,17 @@ describe('CodexCredentialAbsenceGrace', () => { rmSync(dir, { recursive: true, force: true }) }) + it('bounds unresolved credential paths', () => { + const grace = new CodexCredentialAbsenceGrace() + for (let index = 0; index < CODEX_CREDENTIAL_ABSENCE_MAX_TRACKED_PATHS + 4; index += 1) { + expect(grace.assess(join(dir, `account-${index}.json`), 1_000)).toMatchObject({ + durable: false + }) + } + + expect(grace.trackedPathCountForTests()).toBe(CODEX_CREDENTIAL_ABSENCE_MAX_TRACKED_PATHS) + }) + it('treats a torn mid-write read as transient until it outlives the grace window', () => { const grace = new CodexCredentialAbsenceGrace() writeFileSync(authPath, '{"tokens":{"acc', 'utf-8') diff --git a/src/main/codex-accounts/codex-credential-absence-grace.ts b/src/main/codex-accounts/codex-credential-absence-grace.ts index e6572297ac3..8015f3b2546 100644 --- a/src/main/codex-accounts/codex-credential-absence-grace.ts +++ b/src/main/codex-accounts/codex-credential-absence-grace.ts @@ -7,6 +7,7 @@ import { } from './managed-codex-auth-readiness' export const CODEX_CREDENTIAL_ABSENCE_GRACE_MS = 5_000 +export const CODEX_CREDENTIAL_ABSENCE_MAX_TRACKED_PATHS = 512 export type CodexCredentialAbsenceVerdict = { state: StoredCodexCredentialState @@ -39,8 +40,20 @@ export class CodexCredentialAbsenceGrace { const firstAbsenceAt = this.firstAbsenceAtByPath.get(key) if (firstAbsenceAt === undefined) { this.firstAbsenceAtByPath.set(key, now) + while (this.firstAbsenceAtByPath.size > CODEX_CREDENTIAL_ABSENCE_MAX_TRACKED_PATHS) { + const oldest = this.firstAbsenceAtByPath.keys().next() + if (oldest.done) { + break + } + this.firstAbsenceAtByPath.delete(oldest.value) + } return { state, durable: false } } return { state, durable: now - firstAbsenceAt >= this.graceMs } } + + /** @internal - exposed for leak-regression tests. */ + trackedPathCountForTests(): number { + return this.firstAbsenceAtByPath.size + } } diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts index 79564148287..a70b2707cc5 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts @@ -304,4 +304,20 @@ describe('legacy WSL runtime auth drain', () => { }) ) }) + + it('bounds completed distro state during distro churn', async () => { + runWslProcessMock.mockResolvedValue(result(20)) + for (let index = 0; index < 132; index += 1) { + await startLegacyWslRuntimeAuthDrain({ + distro: `Distro-${index}`, + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => null + }) + } + expect(_internals.drainDistroStateCountsForTests()).toEqual({ + completed: 128, + pendingRoutes: 0 + }) + }) }) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts index 32beb908a53..f282e879a77 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -39,6 +39,31 @@ type LegacyWslRuntimeAuthDrainOptions = { const drainQueueByDistro = new Map>() const completedDistroKeys = new Set() const pendingSessionBridgeRouteByDistro = new Map() +const MAX_DRAIN_DISTRO_ENTRIES = 128 + +function rememberCompletedDistro(key: string): void { + completedDistroKeys.delete(key) + completedDistroKeys.add(key) + while (completedDistroKeys.size > MAX_DRAIN_DISTRO_ENTRIES) { + const oldest = completedDistroKeys.values().next().value + if (oldest === undefined) { + break + } + completedDistroKeys.delete(oldest) + } +} + +function rememberPendingRoute(key: string, route: string): void { + pendingSessionBridgeRouteByDistro.delete(key) + pendingSessionBridgeRouteByDistro.set(key, route) + while (pendingSessionBridgeRouteByDistro.size > MAX_DRAIN_DISTRO_ENTRIES) { + const oldest = pendingSessionBridgeRouteByDistro.keys().next().value + if (oldest === undefined) { + break + } + pendingSessionBridgeRouteByDistro.delete(oldest) + } +} export function startLegacyWslRuntimeAuthDrain( options: LegacyWslRuntimeAuthDrainOptions, @@ -57,7 +82,7 @@ export function startLegacyWslRuntimeAuthDrain( } const next = drainLegacyWslRuntimeAuth(options).then((status) => { if (status === 'complete') { - completedDistroKeys.add(key) + rememberCompletedDistro(key) } }) drainQueueByDistro.set(key, next) @@ -147,7 +172,7 @@ export async function drainLegacyWslRuntimeAuth( return recoverAfterFailedApply(options.distro, paths) } if (!deleteSource) { - pendingSessionBridgeRouteByDistro.set(distroKey, sessionBridgeRoute) + rememberPendingRoute(distroKey, sessionBridgeRoute) } return deleteSource ? 'complete' : 'pending' } @@ -265,5 +290,9 @@ export const _internals = { drainQueueByDistro.clear() completedDistroKeys.clear() pendingSessionBridgeRouteByDistro.clear() - } + }, + drainDistroStateCountsForTests: (): { completed: number; pendingRoutes: number } => ({ + completed: completedDistroKeys.size, + pendingRoutes: pendingSessionBridgeRouteByDistro.size + }) } diff --git a/src/main/codex/codex-app-server-capability-cache.test.ts b/src/main/codex/codex-app-server-capability-cache.test.ts index 676c3f8191b..7f06ed8a10c 100644 --- a/src/main/codex/codex-app-server-capability-cache.test.ts +++ b/src/main/codex/codex-app-server-capability-cache.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + CODEX_APP_SERVER_CAPABILITY_MAX_ENTRIES, CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS, CodexAppServerCapabilityCache, getCodexAppServerHostKey @@ -74,6 +75,16 @@ describe('CodexAppServerCapabilityCache', () => { expect(nativePreferred).toHaveBeenCalledTimes(1) }) + it('bounds host capability state during WSL distro churn', () => { + const cache = new CodexAppServerCapabilityCache() + cache.rememberUnsupported('native', 1_000) + for (let index = 0; index < CODEX_APP_SERVER_CAPABILITY_MAX_ENTRIES + 4; index += 1) { + cache.rememberUnsupported(`wsl:distro-${index}`, 1_000) + } + + expect(cache.shouldTry('native', 1_001)).toBe(true) + }) + it('drops known support when a later call reports the capability unsupported', async () => { const cache = new CodexAppServerCapabilityCache() await expect( diff --git a/src/main/codex/codex-app-server-capability-cache.ts b/src/main/codex/codex-app-server-capability-cache.ts index ae6c175f043..8cfbc1e2dd3 100644 --- a/src/main/codex/codex-app-server-capability-cache.ts +++ b/src/main/codex/codex-app-server-capability-cache.ts @@ -4,6 +4,7 @@ import { CapabilityProbeCache } from '../../shared/capability-probe-cache' // in-place codex upgrade during a long Orca session self-heals after the // interval, mirroring GitCapabilityCache's rationale. export const CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000 +export const CODEX_APP_SERVER_CAPABILITY_MAX_ENTRIES = 256 /** Execution host that runs the codex binary. WSL distros are isolated from * the native host and from each other — each can carry a different codex. */ @@ -23,7 +24,7 @@ export function getCodexAppServerHostKey( */ export class CodexAppServerCapabilityCache extends CapabilityProbeCache { constructor() { - super(CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS) + super(CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS, CODEX_APP_SERVER_CAPABILITY_MAX_ENTRIES) } } diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts index 5292be120c1..44ee3f0f7cc 100644 --- a/src/main/codex/codex-hook-trust-grant.test.ts +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -70,13 +70,16 @@ function managedEntry(eventLabel: CodexTrustEntry['eventLabel']): CodexTrustEntr } } -function buildPlan(entries: CodexTrustEntry[]): CodexManagedTrustGrantPlan { +function buildPlan( + entries: CodexTrustEntry[], + host: CodexManagedTrustGrantPlan['host'] = { kind: 'native' } +): CodexManagedTrustGrantPlan { return { runtimeHomePath: runtimeHomeDir, tomlPath: join(runtimeHomeDir, 'config.toml'), managedCommand: MANAGED_COMMAND, managedEntries: entries, - host: { kind: 'native' }, + host, telemetryLane: 'real-home' } } @@ -270,6 +273,19 @@ describe('grantManagedCodexHookTrust', () => { expect(runner).toHaveBeenCalledTimes(2) }) + it('bounds transient cooldowns when host identities churn', async () => { + _internals.setGrantSessionRunner(() => { + throw new Error('spawn ETIMEDOUT') + }) + const entry = managedEntry('session_start') + for (let index = 0; index < 260; index += 1) { + await grantManagedCodexHookTrust( + buildPlan([entry], { kind: 'wsl', distro: `Distro-${index}`, linuxRuntimeHome: '/home/u' }) + ) + } + expect(_internals.transientCooldownCountForTests()).toBe(256) + }) + it('falls back on verify-failed without marking unsupported', async () => { const entries = [managedEntry('session_start')] const runner = vi.fn(async () => ({ diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts index f886dbb6344..d4b2f0fa359 100644 --- a/src/main/codex/codex-hook-trust-grant.ts +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -40,6 +40,7 @@ import { isCodexStateDbBackfillPending } from './codex-state-db' // Why: a transiently hung app-server must not block launch prep on every pane. // The legacy lane remains available while a short, host-scoped cooldown runs. export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000 +const MAX_TRANSIENT_TRUST_COOLDOWNS = 256 /** * Ops escape hatch (not a setting): forces the unchanged fallback lane for the @@ -109,7 +110,15 @@ function fallback( } function startTransientCooldown(hostKey: CodexAppServerHostKey): void { + transientRetryAfterByHost.delete(hostKey) transientRetryAfterByHost.set(hostKey, Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS) + while (transientRetryAfterByHost.size > MAX_TRANSIENT_TRUST_COOLDOWNS) { + const oldest = transientRetryAfterByHost.keys().next().value + if (oldest === undefined) { + break + } + transientRetryAfterByHost.delete(oldest) + } } type GrantAttempt = { @@ -305,5 +314,8 @@ export const _internals = { diagnostics.verifyFailed = 0 diagnostics.lastFallbackReason = null transientRetryAfterByHost.clear() + }, + transientCooldownCountForTests(): number { + return transientRetryAfterByHost.size } } diff --git a/src/main/codex/codex-wsl-hook-install-plan.test.ts b/src/main/codex/codex-wsl-hook-install-plan.test.ts index c4726d46c7a..ab2a8b99b51 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.test.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.test.ts @@ -6,7 +6,11 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { _internals, createCodexWslRuntimeHookInstallPlan } from './codex-wsl-hook-install-plan' +import { + MAX_WSL_CANONICAL_PATH_CACHE_ENTRIES, + _internals, + createCodexWslRuntimeHookInstallPlan +} from './codex-wsl-hook-install-plan' const originalPlatform = process.platform @@ -24,6 +28,26 @@ afterEach(() => { }) describe('canonicalizeWslLinuxPath', () => { + it('bounds successful canonical path entries', () => { + setPlatform('win32') + execFileMock.mockImplementation( + ( + _file: string, + _args: string[], + _options: unknown, + callback: (error: null, stdout: string) => void + ) => callback(null, '/home/canonical\n') + ) + + for (let index = 0; index < MAX_WSL_CANONICAL_PATH_CACHE_ENTRIES + 4; index += 1) { + _internals.canonicalizeWslLinuxPath('Ubuntu', `/home/path-${index}`) + } + + expect(_internals.getWslCanonicalPathCacheSizeForTests()).toBe( + MAX_WSL_CANONICAL_PATH_CACHE_ENTRIES + ) + }) + it('joins guest paths without producing a double slash at the filesystem root', () => { const plan = createCodexWslRuntimeHookInstallPlan( 'C:\\runtime', diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index fbce302a73d..9f5a3088f73 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -48,6 +48,7 @@ function toDefaultWslLinuxPath(windowsPath: string): string { const WSL_CANONICALIZE_TIMEOUT_MS = 5000 const WSL_PATH_MISSING_OUTPUT = '__ORCA_WSL_PATH_MISSING__' +export const MAX_WSL_CANONICAL_PATH_CACHE_ENTRIES = 512 // Why: `readlink -f` over wsl.exe stalls up to the timeout on a cold or wedged // distro. Running it synchronously on the Electron main process froze the UI on @@ -55,6 +56,18 @@ const WSL_PATH_MISSING_OUTPUT = '__ORCA_WSL_PATH_MISSING__' const canonicalWslPathCache = new Map() const inFlightWslCanonicalizations = new Map>() +function rememberCanonicalWslPath(key: string, value: string): void { + canonicalWslPathCache.delete(key) + canonicalWslPathCache.set(key, value) + while (canonicalWslPathCache.size > MAX_WSL_CANONICAL_PATH_CACHE_ENTRIES) { + const oldest = canonicalWslPathCache.keys().next() + if (oldest.done) { + break + } + canonicalWslPathCache.delete(oldest.value) + } +} + function wslCanonicalizeCacheKey(distro: string, linuxPath: string): string { return `${distro}\x00${linuxPath}` } @@ -116,7 +129,7 @@ function scheduleWslLinuxPathCanonicalization( ? { status: 'missing' } : { status: 'unavailable' } if (settlement.status === 'resolved') { - canonicalWslPathCache.set(key, canonicalPath) + rememberCanonicalWslPath(key, canonicalPath) } else if (settlement.status === 'missing') { // Why: a successful directory probe is stronger than a transport error; // clear the identity so stale trust can be revoked and later rediscovered. @@ -147,7 +160,11 @@ function canonicalizeWslLinuxPath( if (process.platform !== 'win32') { return linuxPath } - const cached = canonicalWslPathCache.get(wslCanonicalizeCacheKey(distro, linuxPath)) + const cacheKey = wslCanonicalizeCacheKey(distro, linuxPath) + const cached = canonicalWslPathCache.get(cacheKey) + if (cached !== undefined) { + rememberCanonicalWslPath(cacheKey, cached) + } // Why: every launch revalidates asynchronously. Returning the cache keeps // launch prep synchronous while settlement repairs or revokes trust in-place. scheduleWslLinuxPathCanonicalization(distro, linuxPath, windowsPath, onSettled) @@ -200,5 +217,8 @@ export const _internals = { resetWslCanonicalPathCache(): void { canonicalWslPathCache.clear() inFlightWslCanonicalizations.clear() + }, + getWslCanonicalPathCacheSizeForTests(): number { + return canonicalWslPathCache.size } } diff --git a/src/main/daemon/terminal-history-permission-repair.ts b/src/main/daemon/terminal-history-permission-repair.ts index d2449efebfb..7cb8414026e 100644 --- a/src/main/daemon/terminal-history-permission-repair.ts +++ b/src/main/daemon/terminal-history-permission-repair.ts @@ -23,10 +23,11 @@ const MAX_REPAIR_DEPTH = 3 // Same 10s the sibling history GC waits before walking this very tree, and for the same reason: // stay off startup-critical I/O (see scheduleHistoryGc in src/main/terminal-history-gc.ts). const REPAIR_START_DELAY_MS = 10_000 +const MAX_SCHEDULED_BASE_PATHS = 512 // Per-process, keyed by base path: getDaemonHistoryDir() is the accessor every history producer -// goes through, and a single startup calls it more than once. Never cleared, so a sweep that throws -// cannot wedge a retry loop — the on-disk marker is what carries the decision across launches. +// goes through, and a single startup calls it more than once. It is bounded so +// unusual base-path churn cannot retain every historical path. const scheduledBasePaths = new Set() async function chmodQuietly(path: string, mode: number): Promise { @@ -108,6 +109,13 @@ export function scheduleTerminalHistoryPermissionRepair(basePath: string): Promi return null } scheduledBasePaths.add(key) + while (scheduledBasePaths.size > MAX_SCHEDULED_BASE_PATHS) { + const oldest = scheduledBasePaths.values().next() + if (oldest.done) { + break + } + scheduledBasePaths.delete(oldest.value) + } const { promise, resolve: settle } = Promise.withResolvers() const timer = setTimeout(() => { repairTerminalHistoryPermissions(key).then(settle, () => settle(false)) diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index 845c2f47bf8..caaf4e4b4f9 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -35,6 +35,17 @@ describe('Git capability execution-host state', () => { ) }) + it('bounds local capability entries during WSL distro churn', () => { + const first = getLocalGitCapabilityCache({ wslDistro: 'first-distro' }) + first.rememberUnsupported('worktree-list-z') + for (let index = 0; index < 132; index += 1) { + getLocalGitCapabilityCache({ wslDistro: `distro-${index}` }) + } + expect( + getLocalGitCapabilityCache({ wslDistro: 'first-distro' }).shouldTry('worktree-list-z') + ).toBe(true) + }) + it('shares one SSH provider lifetime without leaking into a replacement provider', () => { const provider = createProviderIdentity() const replacementProvider = createProviderIdentity() diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index d998c21ee75..11ca33790ce 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -13,6 +13,7 @@ type LocalGitCapabilityTarget = { } const localCapabilitiesByExecutionHost = new Map() +const MAX_LOCAL_GIT_CAPABILITY_HOSTS = 128 // Why: reconnecting creates a new provider, while concurrent IPC/runtime users // of one SSH connection must share the same remote Git capability results. let sshCapabilitiesByProvider = new WeakMap() @@ -30,7 +31,15 @@ export function getLocalGitCapabilityCache( let cache = localCapabilitiesByExecutionHost.get(executionHost) if (!cache) { cache = new GitCapabilityCache() - localCapabilitiesByExecutionHost.set(executionHost, cache) + } + localCapabilitiesByExecutionHost.delete(executionHost) + localCapabilitiesByExecutionHost.set(executionHost, cache) + while (localCapabilitiesByExecutionHost.size > MAX_LOCAL_GIT_CAPABILITY_HOSTS) { + const oldest = localCapabilitiesByExecutionHost.keys().next().value + if (oldest === undefined) { + break + } + localCapabilitiesByExecutionHost.delete(oldest) } return cache } diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index e7ea423f78e..f57f18f2df0 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -28,6 +28,7 @@ import { import { disableWslGitReadEnvironment, getWslGitReadEnvironment, + peekWslGitReadEnvironment, resetWslGitReadEnvironmentForTests, seedWslGitReadEnvironmentForTests, WSL_GIT_READ_ENVIRONMENT_WAIT_MS @@ -167,6 +168,14 @@ describe('WSL direct Git reads', () => { } }) + it('bounds settled environment entries during distro churn', () => { + for (let index = 0; index < 132; index += 1) { + seedWslGitReadEnvironmentForTests(`distro-${index}`, LOGIN_ENVIRONMENT) + } + expect(peekWslGitReadEnvironment('distro-0')).toBeUndefined() + expect(peekWslGitReadEnvironment('distro-131')).toEqual(LOGIN_ENVIRONMENT) + }) + it('runs an opted-in read directly with translated cwd and arguments', async () => { await withPlatform('win32', async () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) diff --git a/src/main/git/source-control/effective-upstream-status-cache.ts b/src/main/git/source-control/effective-upstream-status-cache.ts index 574b6413876..cf153b71b70 100644 --- a/src/main/git/source-control/effective-upstream-status-cache.ts +++ b/src/main/git/source-control/effective-upstream-status-cache.ts @@ -16,6 +16,8 @@ export const effectiveUpstreamStatusInFlight = new Map>() export const effectiveUpstreamStatusWriteGeneration = new Map() +let writeGenerationSequence = 0 +let evictedWriteGeneration = 0 // Why: tests reuse this hook, so every memoization layer resets together despite the upstream-only name. export function clearEffectiveUpstreamStatusCacheForTests(): void { @@ -23,6 +25,8 @@ export function clearEffectiveUpstreamStatusCacheForTests(): void { effectiveUpstreamStatusInFlight.clear() retiredEffectiveUpstreamStatusInFlight.clear() effectiveUpstreamStatusWriteGeneration.clear() + writeGenerationSequence = 0 + evictedWriteGeneration = 0 invalidateGitReadCaches() } @@ -34,6 +38,10 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number { return effectiveUpstreamStatusWriteGeneration.size } +export function getEffectiveUpstreamStatusWriteGeneration(cacheKey: string): number { + return effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? evictedWriteGeneration +} + export function getEffectiveUpstreamStatusCacheKey( worktreePath: string, branchName: string, @@ -59,10 +67,7 @@ export function clearEffectiveUpstreamNegativeStatusCache(identity: { effectiveUpstreamStatusCache.delete(cacheKey) effectiveUpstreamStatusInFlight.delete(cacheKey) resolvedUpstreamNameCache.delete(cacheKey) - effectiveUpstreamStatusWriteGeneration.set( - cacheKey, - (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 - ) + effectiveUpstreamStatusWriteGeneration.set(cacheKey, ++writeGenerationSequence) } function retireEffectiveUpstreamStatusProbe(cacheKey: string): void { @@ -98,6 +103,10 @@ export function trimEffectiveUpstreamStatusGeneration(): void { if (hasPendingEffectiveUpstreamStatusProbe(cacheKey)) { continue } + evictedWriteGeneration = Math.max( + evictedWriteGeneration, + effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 + ) effectiveUpstreamStatusWriteGeneration.delete(cacheKey) } } @@ -127,11 +136,14 @@ export function rememberEffectiveUpstreamStatus( // Why: hasConfiguredPushTarget gates a write action; re-probe each poll rather than cache a stale positive. if (status.hasUpstream || status.hasConfiguredPushTarget) { effectiveUpstreamStatusCache.delete(cacheKey) - effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1) + effectiveUpstreamStatusWriteGeneration.set(cacheKey, ++writeGenerationSequence) trimEffectiveUpstreamStatusGeneration() return } - if ((effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { + if ( + (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? evictedWriteGeneration) !== + writeGeneration + ) { return } if (!probedSameNameOriginRef) { @@ -148,6 +160,10 @@ export function rememberEffectiveUpstreamStatus( break } effectiveUpstreamStatusCache.delete(oldest.value) + evictedWriteGeneration = Math.max( + evictedWriteGeneration, + effectiveUpstreamStatusWriteGeneration.get(oldest.value) ?? 0 + ) effectiveUpstreamStatusWriteGeneration.delete(oldest.value) } trimEffectiveUpstreamStatusGeneration() diff --git a/src/main/git/source-control/effective-upstream-status-probe.ts b/src/main/git/source-control/effective-upstream-status-probe.ts index 1fd15cda486..c9483dcf681 100644 --- a/src/main/git/source-control/effective-upstream-status-probe.ts +++ b/src/main/git/source-control/effective-upstream-status-probe.ts @@ -11,7 +11,7 @@ import { gitExecFileAsync } from '../runner' import { MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES, effectiveUpstreamStatusInFlight, - effectiveUpstreamStatusWriteGeneration, + getEffectiveUpstreamStatusWriteGeneration, readCachedEffectiveUpstreamStatus, rememberEffectiveUpstreamStatus, trimEffectiveUpstreamStatusGeneration @@ -46,7 +46,7 @@ export async function readOrProbeEffectiveUpstreamStatus( } // Why: overlapping refreshes at startup — coalesce the upstream probe so a stable missing ref fails once. - const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 + const writeGeneration = getEffectiveUpstreamStatusWriteGeneration(cacheKey) const probe = probeOrRevalidateEffectiveUpstreamStatus( cacheKey, worktreePath, diff --git a/src/main/git/status-upstream-negative-cache.test.ts b/src/main/git/status-upstream-negative-cache.test.ts index 263842810a2..5618ff1910f 100644 --- a/src/main/git/status-upstream-negative-cache.test.ts +++ b/src/main/git/status-upstream-negative-cache.test.ts @@ -45,6 +45,11 @@ import { getEffectiveUpstreamStatusGenerationCountForTests, getStatus } from './status' +import { + getEffectiveUpstreamStatusWriteGeneration, + readCachedEffectiveUpstreamStatus, + rememberEffectiveUpstreamStatus +} from './source-control/effective-upstream-status-cache' describe('local upstream negative cache', () => { beforeEach(() => { @@ -285,6 +290,56 @@ describe('local upstream negative cache', () => { expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) }) + it('does not let an evicted strict probe republish a stale negative', async () => { + let deferredOriginReject: ((error: Error) => void) | null = null + const branchQueue = ['feature', ...Array.from({ length: 512 }, (_, index) => `other-${index}`)] + let currentBranch = 'feature' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = branchQueue.shift() ?? currentBranch + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (isConfigListSnapshotCommand(args)) { + return emptyGitConfigSnapshot() + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + if (currentBranch === 'feature') { + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const strict = getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + clearEffectiveUpstreamNegativeStatusCache({ worktreePath: '/repo', branchName: 'feature' }) + for (let index = 0; index < 512; index += 1) { + await getStatus(`/repo-${index}`, { bypassEffectiveUpstreamNegativeCache: true }) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await strict + + expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(0) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) + }) + it('bounds effective-upstream negative entries', async () => { let branchIndex = 0 let currentBranch = 'feature-0' @@ -355,4 +410,23 @@ describe('local upstream negative cache', () => { expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(0) expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) }) + + it('continues caching new negatives after generation eviction', () => { + const now = Date.now() + for (let index = 0; index < 513; index += 1) { + rememberEffectiveUpstreamStatus( + `positive-${index}`, + { hasUpstream: true, ahead: 0, behind: 1 }, + now, + true, + 0 + ) + } + const cacheKey = 'new-negative' + const writeGeneration = getEffectiveUpstreamStatusWriteGeneration(cacheKey) + const status = { hasUpstream: false, ahead: 0, behind: 0 } + rememberEffectiveUpstreamStatus(cacheKey, status, now, true, writeGeneration) + + expect(readCachedEffectiveUpstreamStatus(cacheKey, now)).toEqual(status) + }) }) diff --git a/src/main/git/worktree-shared-directories.test.ts b/src/main/git/worktree-shared-directories.test.ts index 7bd33987fba..21c6926c7b3 100644 --- a/src/main/git/worktree-shared-directories.test.ts +++ b/src/main/git/worktree-shared-directories.test.ts @@ -6,6 +6,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { clearConfiguredWorktreeSharedDirectoriesCacheForTests, getConfiguredWorktreeSharedDirectories, + getConfiguredWorktreeSharedDirectoriesCacheSizeForTests, + MAX_CONFIGURED_SHARED_DIRECTORIES_CACHE_ENTRIES, getWorktreeSharedLinkPaths, resolveWorktreeSharedDirectories } from './worktree-shared-directories' @@ -175,6 +177,15 @@ describe('resolveWorktreeSharedDirectories', () => { }) describe('getConfiguredWorktreeSharedDirectories', () => { + it('bounds cache growth when repository paths churn', () => { + for (let index = 0; index < MAX_CONFIGURED_SHARED_DIRECTORIES_CACHE_ENTRIES + 4; index += 1) { + getConfiguredWorktreeSharedDirectories(`/repo-${index}`) + } + + expect(getConfiguredWorktreeSharedDirectoriesCacheSizeForTests()).toBe( + MAX_CONFIGURED_SHARED_DIRECTORIES_CACHE_ENTRIES + ) + }) let repo: string beforeEach(() => { diff --git a/src/main/git/worktree-shared-directories.ts b/src/main/git/worktree-shared-directories.ts index 1dfe990a0e6..5d633d89c88 100644 --- a/src/main/git/worktree-shared-directories.ts +++ b/src/main/git/worktree-shared-directories.ts @@ -10,6 +10,7 @@ import { mapWithConcurrency } from '../../shared/map-with-concurrency' // duplicates disk; `orca.yaml` names the ones every worktree should share instead. const CONFIGURED_SHARED_DIRECTORIES_CACHE_TTL_MS = 30_000 +export const MAX_CONFIGURED_SHARED_DIRECTORIES_CACHE_ENTRIES = 512 // Why: resolving a worktree may list many generated directories; overlap // independent local probes without flooding the filesystem threadpool. const SHARED_DIRECTORY_STAT_CONCURRENCY = 8 @@ -33,6 +34,8 @@ export function getConfiguredWorktreeSharedDirectories(repoPath: string): readon const cached = configuredSharedDirectoriesByRepoPath.get(repoPath) const now = Date.now() if (cached && cached.expiresAt > now) { + configuredSharedDirectoriesByRepoPath.delete(repoPath) + configuredSharedDirectoriesByRepoPath.set(repoPath, cached) return cached.directories } const configured = loadHooks(repoPath)?.worktree?.sharedDirectories ?? [] @@ -40,6 +43,15 @@ export function getConfiguredWorktreeSharedDirectories(repoPath: string): readon directories: configured, expiresAt: now + CONFIGURED_SHARED_DIRECTORIES_CACHE_TTL_MS }) + while ( + configuredSharedDirectoriesByRepoPath.size > MAX_CONFIGURED_SHARED_DIRECTORIES_CACHE_ENTRIES + ) { + const oldest = configuredSharedDirectoriesByRepoPath.keys().next() + if (oldest.done || oldest.value === repoPath) { + break + } + configuredSharedDirectoriesByRepoPath.delete(oldest.value) + } return configured } @@ -48,6 +60,10 @@ export function clearConfiguredWorktreeSharedDirectoriesCacheForTests(): void { configuredSharedDirectoriesByRepoPath.clear() } +export function getConfiguredWorktreeSharedDirectoriesCacheSizeForTests(): number { + return configuredSharedDirectoriesByRepoPath.size +} + /** Every path Orca may have symlinked into a worktree: the per-user Worktree * Shared Paths setting plus the repo's `orca.yaml` shared directories. * diff --git a/src/main/git/worktree-sparse-checkout-cache.test.ts b/src/main/git/worktree-sparse-checkout-cache.test.ts index 7963c0ecee4..21c343aa5aa 100644 --- a/src/main/git/worktree-sparse-checkout-cache.test.ts +++ b/src/main/git/worktree-sparse-checkout-cache.test.ts @@ -16,6 +16,7 @@ import { clearSparseCheckoutStateCacheForRepo, detectSparseCheckoutCached, invalidateSparseCheckoutState, + MAX_SPARSE_CHECKOUT_CACHE_ENTRIES, onSparseCheckoutStateChanged } from './worktree-sparse-checkout-cache' @@ -34,6 +35,16 @@ beforeEach(() => { }) describe('detectSparseCheckoutCached', () => { + it('bounds cache growth when worktree paths churn', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + + for (let index = 0; index < MAX_SPARSE_CHECKOUT_CACHE_ENTRIES + 4; index += 1) { + await detectSparseCheckoutCached('/repo', `/repo/worktree-${index}`) + } + + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(MAX_SPARSE_CHECKOUT_CACHE_ENTRIES) + }) + it('caches a detection result across repeated calls for the same repo+path', async () => { detectSparseCheckoutMock.mockResolvedValue(true) diff --git a/src/main/git/worktree-sparse-checkout-cache.ts b/src/main/git/worktree-sparse-checkout-cache.ts index 3c356dad7c8..56f8dedc8e8 100644 --- a/src/main/git/worktree-sparse-checkout-cache.ts +++ b/src/main/git/worktree-sparse-checkout-cache.ts @@ -24,6 +24,7 @@ import { detectSparseCheckout } from './worktree-sparse-state' // on the rare edge that actually flipped, rather than partial state that could quietly diverge. // - App cold start: the map starts empty, so the first read is always a fresh detect. const SPARSE_CHECKOUT_CACHE_RECONCILE_INTERVAL_MS = 5 * 60_000 +export const MAX_SPARSE_CHECKOUT_CACHE_ENTRIES = 512 // Part of the cache key, not just a probe argument. A distro-less read of a WSL-hosted repo // resolves the gitdir pointer against a fabricated Win32 path and reports "not sparse"; several @@ -49,6 +50,18 @@ export type SparseCheckoutChangeListener = ( const sparseCheckoutStateCache = new Map() let changeListener: SparseCheckoutChangeListener | undefined +function retainSparseCheckoutCacheEntry(key: string, entry: SparseCheckoutCacheEntry): void { + sparseCheckoutStateCache.delete(key) + sparseCheckoutStateCache.set(key, entry) + while (sparseCheckoutStateCache.size > MAX_SPARSE_CHECKOUT_CACHE_ENTRIES) { + const oldest = sparseCheckoutStateCache.keys().next() + if (oldest.done || oldest.value === key) { + break + } + sparseCheckoutStateCache.delete(oldest.value) + } +} + // Distro last so the repo- and worktree-scoped prefix deletes below still match every variant. function cacheKey( repoPath: string, @@ -87,10 +100,11 @@ export async function detectSparseCheckoutCached( const cached = sparseCheckoutStateCache.get(key) if (!cached) { const isSparse = await detectSparseCheckout(worktreePath, options) - sparseCheckoutStateCache.set(key, { isSparse, cachedAt: Date.now() }) + retainSparseCheckoutCacheEntry(key, { isSparse, cachedAt: Date.now() }) return isSparse } if (Date.now() - cached.cachedAt < SPARSE_CHECKOUT_CACHE_RECONCILE_INTERVAL_MS) { + retainSparseCheckoutCacheEntry(key, cached) return cached.isSparse } // Stale-while-revalidate: serve the still-cached value now and correct it in the background, @@ -115,7 +129,7 @@ async function revalidateInBackground( // A `has()`/presence check can't tell "still mine" from "someone else's fresh value" sharing // the key; comparing the map's current entry object to the one we started from can. if (sparseCheckoutStateCache.get(key) === startingEntry) { - sparseCheckoutStateCache.set(key, { isSparse, cachedAt: Date.now() }) + retainSparseCheckoutCacheEntry(key, { isSparse, cachedAt: Date.now() }) } if (isSparse !== startingEntry.isSparse) { changeListener?.(repoPath, worktreePath, isSparse) diff --git a/src/main/git/wsl-git-read-environment.ts b/src/main/git/wsl-git-read-environment.ts index e75efee30d4..21193404c30 100644 --- a/src/main/git/wsl-git-read-environment.ts +++ b/src/main/git/wsl-git-read-environment.ts @@ -15,12 +15,39 @@ const PROBE_TIMEOUT_MS = 10_000 export const WSL_GIT_READ_ENVIRONMENT_WAIT_MS = 1_500 const PROBE_MAX_BUFFER = 64 * 1024 const TRANSIENT_PROBE_RETRY_MS = 30_000 +const MAX_WSL_GIT_READ_ENVIRONMENT_DISTROS = 128 const environmentByDistro = new Map>() // Why the null entries matter: a settled "no direct route" answer is what lets a read skip the // bounded probe wait entirely instead of racing an already-decided promise on every call. const settledEnvironmentByDistro = new Map() const transientRetryAfterByDistro = new Map() +function touchDistroState(distro: string): void { + const settled = settledEnvironmentByDistro.get(distro) + if (settledEnvironmentByDistro.has(distro)) { + settledEnvironmentByDistro.delete(distro) + settledEnvironmentByDistro.set(distro, settled ?? null) + } + while (settledEnvironmentByDistro.size > MAX_WSL_GIT_READ_ENVIRONMENT_DISTROS) { + const oldest = settledEnvironmentByDistro.keys().next().value + if (oldest === undefined) { + break + } + settledEnvironmentByDistro.delete(oldest) + environmentByDistro.delete(oldest) + transientRetryAfterByDistro.delete(oldest) + } + while (environmentByDistro.size > MAX_WSL_GIT_READ_ENVIRONMENT_DISTROS) { + const oldest = environmentByDistro.keys().next().value + if (oldest === undefined) { + break + } + environmentByDistro.delete(oldest) + settledEnvironmentByDistro.delete(oldest) + transientRetryAfterByDistro.delete(oldest) + } +} + type ProbeOutcome = | { kind: 'resolved'; environment: WslGitReadEnvironment } | { kind: 'rejected' } @@ -95,17 +122,21 @@ export function getWslGitReadEnvironment(distro: string): Promise ({ glabExecFileAsyncMock: vi. vi.mock('../git/runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { + _getKnownHostsCacheSize, _resetKnownHostsCache, getGlabKnownHosts, + KNOWN_HOSTS_CACHE_MAX_ENTRIES, rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe' @@ -196,6 +198,14 @@ describe('getGlabKnownHosts', () => { expect(glabExecFileAsyncMock).not.toHaveBeenCalled() }) + it('bounds execution-context cache keys across SSH reconnect churn', () => { + for (let index = 0; index < KNOWN_HOSTS_CACHE_MAX_ENTRIES + 20; index += 1) { + rememberGlabKnownHost(`host-${index}.example`, `connection-${index}`) + } + + expect(_getKnownHostsCacheSize()).toBe(KNOWN_HOSTS_CACHE_MAX_ENTRIES) + }) + it('recognizes a self-hosted host on a non-default port', async () => { glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.example.com:8080 as user\n', diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index d328cd38cac..6f65e189d6f 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -12,6 +12,7 @@ export type LocalGitExecOptions = { const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 const UNAUTHENTICATED_HOSTS_MAX_ENTRIES = 128 +export const KNOWN_HOSTS_CACHE_MAX_ENTRIES = 128 const knownHostsCacheByExecutionContext = new Map< string, { key: string; hosts: readonly string[] } @@ -55,6 +56,21 @@ export function _resetGlabUnauthenticatedHosts(): void { unauthenticatedHostExpiries.clear() } +/** @internal - exposed for cache-bound tests only. */ +export function _getKnownHostsCacheSize(): number { + return knownHostsCacheByExecutionContext.size +} + +function trimKnownHostsCache(): void { + while (knownHostsCacheByExecutionContext.size > KNOWN_HOSTS_CACHE_MAX_ENTRIES) { + const oldest = knownHostsCacheByExecutionContext.keys().next() + if (oldest.done) { + break + } + knownHostsCacheByExecutionContext.delete(oldest.value) + } +} + function unauthenticatedHostKey( host: string, connectionId?: string | null, @@ -138,6 +154,7 @@ export function rememberGlabKnownHosts( return } knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: [...cached, ...additions] }) + trimKnownHostsCache() } export async function getGlabKnownHosts( @@ -147,6 +164,11 @@ export async function getGlabKnownHosts( const { key, cacheKey } = knownHostsCacheContext(connectionId, localGitOptions) const cached = knownHostsCacheByExecutionContext.get(cacheKey)?.hosts if (cached) { + const entry = knownHostsCacheByExecutionContext.get(cacheKey) + if (entry) { + knownHostsCacheByExecutionContext.delete(cacheKey) + knownHostsCacheByExecutionContext.set(cacheKey, entry) + } return cached } // Why: only join a probe still young enough to answer, so a wedged one cannot @@ -183,6 +205,7 @@ async function probeGlabKnownHosts( const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...remembered, ...hosts])) if (ownsKey() && knownHostsExecutionKey(connectionId, localGitOptions) === key) { knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: merged }) + trimKnownHostsCache() } return merged } catch { diff --git a/src/main/ipc/runtime-environment-transport-generation.test.ts b/src/main/ipc/runtime-environment-transport-generation.test.ts new file mode 100644 index 00000000000..eb283f94f62 --- /dev/null +++ b/src/main/ipc/runtime-environment-transport-generation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { + _getRuntimeEnvironmentTransportGenerationCacheSize, + advanceRuntimeEnvironmentTransportGeneration, + getRuntimeEnvironmentTransportGeneration +} from './runtime-environment-transport-generation' + +describe('runtime environment transport generations', () => { + it('bounds retired environment generations', () => { + for (let index = 0; index < 600; index += 1) { + advanceRuntimeEnvironmentTransportGeneration(`retired-environment-${index}`) + } + + expect(_getRuntimeEnvironmentTransportGenerationCacheSize()).toBeLessThanOrEqual(512) + }) + + it('does not reopen a fence after an environment key is evicted', () => { + advanceRuntimeEnvironmentTransportGeneration('reused-environment') + const beforeEviction = getRuntimeEnvironmentTransportGeneration('reused-environment') + for (let index = 0; index < 1_100; index += 1) { + advanceRuntimeEnvironmentTransportGeneration(`churn-${index}`) + } + advanceRuntimeEnvironmentTransportGeneration('reused-environment') + + expect(getRuntimeEnvironmentTransportGeneration('reused-environment')).toBeGreaterThan( + beforeEviction + ) + }) +}) diff --git a/src/main/ipc/runtime-environment-transport-generation.ts b/src/main/ipc/runtime-environment-transport-generation.ts index e39c86a52df..fd210c5e7cd 100644 --- a/src/main/ipc/runtime-environment-transport-generation.ts +++ b/src/main/ipc/runtime-environment-transport-generation.ts @@ -1,12 +1,25 @@ const generationByEnvironment = new Map() +const MAX_TRACKED_ENVIRONMENTS = 512 +let generationSequence = 0 +let evictedGeneration = 0 export function getRuntimeEnvironmentTransportGeneration(environmentId: string): number { - return generationByEnvironment.get(environmentId) ?? 0 + return generationByEnvironment.get(environmentId) ?? evictedGeneration } export function advanceRuntimeEnvironmentTransportGeneration(environmentId: string): void { - generationByEnvironment.set( - environmentId, - getRuntimeEnvironmentTransportGeneration(environmentId) + 1 - ) + const generation = ++generationSequence + generationByEnvironment.set(environmentId, generation) + while (generationByEnvironment.size > MAX_TRACKED_ENVIRONMENTS) { + const oldest = generationByEnvironment.keys().next() + if (oldest.done) { + break + } + evictedGeneration = Math.max(evictedGeneration, generationByEnvironment.get(oldest.value) ?? 0) + generationByEnvironment.delete(oldest.value) + } +} + +export function _getRuntimeEnvironmentTransportGenerationCacheSize(): number { + return generationByEnvironment.size } diff --git a/src/main/linear/linear-workspace-registry.test.ts b/src/main/linear/linear-workspace-registry.test.ts new file mode 100644 index 00000000000..8cc3d3b4b35 --- /dev/null +++ b/src/main/linear/linear-workspace-registry.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + cacheToken, + getCachedToken, + recordCredentialError, + resetCredentialCaches +} from './linear-workspace-registry' + +describe('Linear workspace credential caches', () => { + afterEach(() => resetCredentialCaches()) + + it('bounds token and credential-error entries during workspace churn', () => { + for (let index = 0; index < 132; index += 1) { + cacheToken(`workspace-${index}`, `token-${index}`) + recordCredentialError(`workspace-${index}`, `error-${index}`) + } + expect(getCachedToken('workspace-0')).toBeUndefined() + expect(getCachedToken('workspace-131')).toBe('token-131') + }) +}) diff --git a/src/main/linear/linear-workspace-registry.ts b/src/main/linear/linear-workspace-registry.ts index 0a658a153ae..5636988b316 100644 --- a/src/main/linear/linear-workspace-registry.ts +++ b/src/main/linear/linear-workspace-registry.ts @@ -19,6 +19,7 @@ import { credentialFileHasContent } from '../integration-credential-file' import type { LinearWorkspace } from '../../shared/linear/workspace-types' let cachedTokens = new Map() +const MAX_LINEAR_WORKSPACE_CREDENTIAL_ENTRIES = 128 // Why: decrypt failures are recorded per workspace so getStatus can explain // failing reads without re-touching the keychain on every status poll. const credentialErrors = new Map() @@ -26,11 +27,24 @@ let cachedWorkspaceFile: LinearWorkspaceFile | null = null let workspaceFileLoadedFromDisk = false export function getCachedToken(workspaceId: string): string | undefined { - return cachedTokens.get(workspaceId) + const token = cachedTokens.get(workspaceId) + if (token !== undefined) { + cachedTokens.delete(workspaceId) + cachedTokens.set(workspaceId, token) + } + return token } export function cacheToken(workspaceId: string, token: string): void { + cachedTokens.delete(workspaceId) cachedTokens.set(workspaceId, token) + while (cachedTokens.size > MAX_LINEAR_WORKSPACE_CREDENTIAL_ENTRIES) { + const oldest = cachedTokens.keys().next().value + if (oldest === undefined) { + break + } + cachedTokens.delete(oldest) + } } export function forgetCachedToken(workspaceId: string): void { @@ -44,7 +58,15 @@ export function resetCredentialCaches(): void { } export function recordCredentialError(workspaceId: string, message: string): void { + credentialErrors.delete(workspaceId) credentialErrors.set(workspaceId, message) + while (credentialErrors.size > MAX_LINEAR_WORKSPACE_CREDENTIAL_ENTRIES) { + const oldest = credentialErrors.keys().next().value + if (oldest === undefined) { + break + } + credentialErrors.delete(oldest) + } } export function clearCredentialError(workspaceId: string): void { @@ -52,7 +74,12 @@ export function clearCredentialError(workspaceId: string): void { } export function getCredentialError(workspaceId: string): string | undefined { - return credentialErrors.get(workspaceId) + const error = credentialErrors.get(workspaceId) + if (error !== undefined) { + credentialErrors.delete(workspaceId) + credentialErrors.set(workspaceId, error) + } + return error } export function resetWorkspaceFileCacheToEmpty(): void { diff --git a/src/main/local-worktree-scan-generation.ts b/src/main/local-worktree-scan-generation.ts index a2c86afcc33..aae2c0378b1 100644 --- a/src/main/local-worktree-scan-generation.ts +++ b/src/main/local-worktree-scan-generation.ts @@ -17,6 +17,15 @@ export function bumpLocalWorktreeScanGeneration(repoId: string): void { mutationRevision += 1 } +export function forgetLocalWorktreeScanGeneration(repoId: string): void { + generationByRepoId.delete(repoId) +} + +export function retireLocalWorktreeScanGeneration(repoId: string): void { + bumpLocalWorktreeScanGeneration(repoId) + forgetLocalWorktreeScanGeneration(repoId) +} + /** * Advances on every event above that can change what a worktree scan would find — repo add, * removal, update, and scan-cache invalidation — and on nothing else. A cache that must not answer @@ -41,3 +50,7 @@ export function resetLocalWorktreeScanGenerationsForTests(): void { mutationRevision += 1 generationByRepoId.clear() } + +export function _getLocalWorktreeScanGenerationCacheSize(): number { + return generationByRepoId.size +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-activity-retention.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-activity-retention.ts new file mode 100644 index 00000000000..bd526301e5a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-activity-retention.ts @@ -0,0 +1,19 @@ +import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' + +export const MAX_RETAINED_SESSION_ACTIVITIES = 512 + +export function rememberSessionActivity( + activities: Map, + sessionId: string, + activity: AgentSessionTurnActivity +): void { + activities.delete(sessionId) + activities.set(sessionId, activity) + while (activities.size > MAX_RETAINED_SESSION_ACTIVITIES) { + const oldest = activities.keys().next() + if (oldest.done || oldest.value === sessionId) { + break + } + activities.delete(oldest.value) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 3827c5c1078..e982dcb5d10 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -18,6 +18,7 @@ import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { MAX_RETAINED_SESSION_ACTIVITIES } from './structured-agent-session-activity-retention' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' const SESSION = 'subscriber-session' @@ -35,6 +36,26 @@ afterEach(async () => { }) describe('AgentSessionSubscribers', () => { + it('bounds retained turn activity across session churn', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'activity-churn-journal') + }) + const subscribers = new AgentSessionSubscribers() + + for (let index = 0; index < MAX_RETAINED_SESSION_ACTIVITIES + 4; index += 1) { + subscribers.publish(`session-${index}`, journal, { turnId: `turn-${index}`, text: 'working' }) + } + + expect(subscribers.retainedActivityCountForTests).toBe(MAX_RETAINED_SESSION_ACTIVITIES) + }) + it('publishes the current fence when a resumed cursor is already caught up', async () => { const journal = await journals.open({ identity: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index 24415df1d3c..ce5fb03c246 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -22,6 +22,7 @@ import { createAgentSessionCatchUpReader, readAgentSessionHydrationPage } from './agent-session-history-page' +import { rememberSessionActivity } from './structured-agent-session-activity-retention' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void export type AgentSessionSubscribeInput = { @@ -42,10 +43,8 @@ type Subscriber = { export type AgentSessionSubscribersHooks = { readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined - /** Fires after any publication that can change journal content, whether or not anyone - * is subscribed to the transcript: session lists project status from this same edge. */ + /** Fires after publications that can change journal content. */ onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void - /** Host wall clock, stamped once per published frame as `hostNow`. */ now?: () => number } @@ -55,8 +54,10 @@ export class AgentSessionSubscribers { constructor(private readonly hooks: AgentSessionSubscribersHooks = {}) {} - /** Opens the stream with a bounded tail page or, when the client's cursor - * still resolves, with the rows it missed. Returns the disposer. */ + get retainedActivityCountForTests(): number { + return this.activityBySession.size + } + open(input: { id: string sessionId: string @@ -113,7 +114,6 @@ export class AgentSessionSubscribers { } } - /** Fan out whatever each subscriber has not yet seen. */ publish( sessionId: string, journal: AgentSessionJournal, @@ -121,7 +121,7 @@ export class AgentSessionSubscribers { ): void { if (activity !== undefined) { if (activity) { - this.activityBySession.set(sessionId, activity) + rememberSessionActivity(this.activityBySession, sessionId, activity) } else { this.activityBySession.delete(sessionId) } diff --git a/src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts b/src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts index 2a8a7668b57..65d6e4faf67 100644 --- a/src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts +++ b/src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts @@ -6,7 +6,12 @@ import { WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS, WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from './wsl-transcript-fs-gate' -import { WSL_TRANSCRIPT_FS_ROUTE_STRIKE_DECAY_MS } from './wsl-transcript-fs-route-quarantine' +import { + _getBlockedRouteCountForTests, + quarantineRoute, + resetRouteQuarantinesForTests, + WSL_TRANSCRIPT_FS_ROUTE_STRIKE_DECAY_MS +} from './wsl-transcript-fs-route-quarantine' function deferred(): { promise: Promise @@ -149,3 +154,12 @@ describe('WSL transcript fs route quarantine strike accounting', () => { } }) }) + +it('bounds retired route entries', () => { + resetRouteQuarantinesForTests() + for (let index = 0; index < 600; index += 1) { + quarantineRoute(`route-${index}`, 1_000, 0) + } + + expect(_getBlockedRouteCountForTests()).toBeLessThanOrEqual(512) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-route-quarantine.ts b/src/main/native-chat/wsl-transcript-fs-route-quarantine.ts index affce12f0c5..ecade289727 100644 --- a/src/main/native-chat/wsl-transcript-fs-route-quarantine.ts +++ b/src/main/native-chat/wsl-transcript-fs-route-quarantine.ts @@ -10,6 +10,7 @@ export const WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS = 5_000 // Strikes older than this stop escalating: a distro that wakes slowly once a // day must restart from the base window, not resume yesterday's back-off. export const WSL_TRANSCRIPT_FS_ROUTE_STRIKE_DECAY_MS = 5 * 60_000 +const MAX_BLOCKED_ROUTES = 512 type RouteQuarantine = { until: number; strikes: number; setAt: number } // Monotonic clock: wall time would misjudge the window across sleep/NTP steps. @@ -46,6 +47,18 @@ export function quarantineRoute(route: string, deadlineMs: number, taskStartedAt strikes, setAt: sameIncident ? seed.setAt : now }) + for (const [key, entry] of blockedRoutes) { + if (now - entry.until > WSL_TRANSCRIPT_FS_ROUTE_STRIKE_DECAY_MS) { + blockedRoutes.delete(key) + } + } + while (blockedRoutes.size > MAX_BLOCKED_ROUTES) { + const oldest = blockedRoutes.keys().next() + if (oldest.done) { + break + } + blockedRoutes.delete(oldest.value) + } } /** A real filesystem answer proves the mount is alive: forget the strikes. */ @@ -56,3 +69,7 @@ export function liftRouteQuarantine(route: string): void { export function resetRouteQuarantinesForTests(): void { blockedRoutes.clear() } + +export function _getBlockedRouteCountForTests(): number { + return blockedRoutes.size +} diff --git a/src/main/orca-profiles/profile-cloud-dev-org-members.ts b/src/main/orca-profiles/profile-cloud-dev-org-members.ts index 4f61e03df6a..8c977997dfd 100644 --- a/src/main/orca-profiles/profile-cloud-dev-org-members.ts +++ b/src/main/orca-profiles/profile-cloud-dev-org-members.ts @@ -19,6 +19,7 @@ type DevOrgRoster = { } const devRostersByOrg = new Map() +const MAX_DEV_ORG_ROSTERS = 64 function cleanEnvString(value: string | undefined, fallback: string): string { const trimmed = value?.trim() @@ -58,6 +59,13 @@ function getDevRoster(orgId: string): DevOrgRoster { } const seeded = seedDevRoster() devRostersByOrg.set(orgId, seeded) + while (devRostersByOrg.size > MAX_DEV_ORG_ROSTERS) { + const oldest = devRostersByOrg.keys().next() + if (oldest.done) { + break + } + devRostersByOrg.delete(oldest.value) + } return seeded } diff --git a/src/main/orca-profiles/profile-cloud-refresh-replay-guard.test.ts b/src/main/orca-profiles/profile-cloud-refresh-replay-guard.test.ts new file mode 100644 index 00000000000..ebf38f48926 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-refresh-replay-guard.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + AMBIGUOUS_REFRESH_REPLAY_DELAY_MS, + blocksAmbiguousRefreshReplay, + forgetAmbiguousRefreshAttempt, + recordAmbiguousRefreshAttempt +} from './profile-cloud-refresh-replay-guard' + +describe('ambiguous cloud refresh replay guard', () => { + beforeEach(() => { + forgetAmbiguousRefreshAttempt('profile') + }) + + it('releases expired attempts before the next replay check', () => { + recordAmbiguousRefreshAttempt('profile', 'token', 1_000) + + expect(blocksAmbiguousRefreshReplay('profile', 'token', 1_000)).toBe(true) + expect( + blocksAmbiguousRefreshReplay('profile', 'token', 1_000 + AMBIGUOUS_REFRESH_REPLAY_DELAY_MS) + ).toBe(false) + expect(blocksAmbiguousRefreshReplay('profile', 'token', 1_000)).toBe(false) + }) + + it('keeps the newest bounded working set under key churn', () => { + for (let index = 0; index < 600; index += 1) { + recordAmbiguousRefreshAttempt(`profile-${index}`, 'token', 1_000 + index) + } + + expect(blocksAmbiguousRefreshReplay('profile-0', 'token', 1_000)).toBe(false) + expect(blocksAmbiguousRefreshReplay('profile-599', 'token', 1_599)).toBe(true) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-refresh-replay-guard.ts b/src/main/orca-profiles/profile-cloud-refresh-replay-guard.ts index d5c22f6b527..d8989e87cce 100644 --- a/src/main/orca-profiles/profile-cloud-refresh-replay-guard.ts +++ b/src/main/orca-profiles/profile-cloud-refresh-replay-guard.ts @@ -7,6 +7,7 @@ // A replay this soon after the ambiguous attempt is a retry loop, not a person // asking again; holding it back keeps one lost reply from becoming a storm. export const AMBIGUOUS_REFRESH_REPLAY_DELAY_MS = 30_000 +const AMBIGUOUS_REFRESH_ATTEMPTS_MAX_ENTRIES = 512 type AmbiguousRefreshAttempt = { refreshToken: string @@ -14,6 +15,33 @@ type AmbiguousRefreshAttempt = { } const ambiguousRefreshAttempts = new Map() +const expiredAmbiguousRefreshAttempts = new Map() + +function pruneAmbiguousRefreshAttempts(now: number, preserveKey?: string): void { + for (const [key, attempt] of ambiguousRefreshAttempts) { + if (key === preserveKey) { + continue + } + if (now - attempt.attemptedAt >= AMBIGUOUS_REFRESH_REPLAY_DELAY_MS) { + ambiguousRefreshAttempts.delete(key) + expiredAmbiguousRefreshAttempts.set(key, attempt) + } + } + while (expiredAmbiguousRefreshAttempts.size > AMBIGUOUS_REFRESH_ATTEMPTS_MAX_ENTRIES) { + const oldest = expiredAmbiguousRefreshAttempts.keys().next() + if (oldest.done) { + return + } + expiredAmbiguousRefreshAttempts.delete(oldest.value) + } + while (ambiguousRefreshAttempts.size > AMBIGUOUS_REFRESH_ATTEMPTS_MAX_ENTRIES) { + const oldest = ambiguousRefreshAttempts.keys().next() + if (oldest.done) { + return + } + ambiguousRefreshAttempts.delete(oldest.value) + } +} export class AmbiguousRefreshReplayBlockedError extends Error { constructor() { @@ -27,17 +55,25 @@ export function recordAmbiguousRefreshAttempt( refreshToken: string, now = Date.now() ): void { + pruneAmbiguousRefreshAttempts(now) + ambiguousRefreshAttempts.delete(key) + expiredAmbiguousRefreshAttempts.delete(key) ambiguousRefreshAttempts.set(key, { refreshToken, attemptedAt: now }) + pruneAmbiguousRefreshAttempts(now) } // Call once the token's fate is known: it rotated, or the session it belonged to // is gone. Leaving the record would mislabel a later, unrelated 401. export function forgetAmbiguousRefreshAttempt(key: string): void { ambiguousRefreshAttempts.delete(key) + expiredAmbiguousRefreshAttempts.delete(key) } export function wasRefreshTokenAmbiguouslyAttempted(key: string, refreshToken: string): boolean { - return ambiguousRefreshAttempts.get(key)?.refreshToken === refreshToken + return ( + ambiguousRefreshAttempts.get(key)?.refreshToken === refreshToken || + expiredAmbiguousRefreshAttempts.get(key)?.refreshToken === refreshToken + ) } export function blocksAmbiguousRefreshReplay( @@ -47,8 +83,23 @@ export function blocksAmbiguousRefreshReplay( ): boolean { const attempt = ambiguousRefreshAttempts.get(key) if (!attempt || attempt.refreshToken !== refreshToken) { + pruneAmbiguousRefreshAttempts(now) return false } + if (now < attempt.attemptedAt) { + // A clock rollback must not resurrect an already-expired replay window. + ambiguousRefreshAttempts.delete(key) + return false + } + if (now - attempt.attemptedAt >= AMBIGUOUS_REFRESH_REPLAY_DELAY_MS) { + // Keep the evidence separate from the temporary block so a later 401 can + // still be classified as a possible replay after the window expires. + ambiguousRefreshAttempts.delete(key) + expiredAmbiguousRefreshAttempts.set(key, attempt) + pruneAmbiguousRefreshAttempts(now) + return false + } + pruneAmbiguousRefreshAttempts(now, key) // Why bounded rather than permanent: the token is only *possibly* spent. A // permanent block would sign out every desktop whose refresh merely timed out. return now - attempt.attemptedAt < AMBIGUOUS_REFRESH_REPLAY_DELAY_MS diff --git a/src/main/orca-profiles/profile-cloud-session-store.test.ts b/src/main/orca-profiles/profile-cloud-session-store.test.ts index ca32f0529f1..2a7153c901d 100644 --- a/src/main/orca-profiles/profile-cloud-session-store.test.ts +++ b/src/main/orca-profiles/profile-cloud-session-store.test.ts @@ -136,6 +136,17 @@ describe('Orca cloud session store', () => { } }) + it('bounds memory-session cache churn', async () => { + const store = await loadSessionStore() + const session = makeSession() + + for (let index = 0; index < store.MAX_MEMORY_CLOUD_SESSIONS + 4; index += 1) { + store.saveOrcaCloudSession(`profile-${index}`, userDataPath, session) + } + + expect(store.getOrcaCloudMemorySessionCountForTests()).toBe(store.MAX_MEMORY_CLOUD_SESSIONS) + }) + it('writes explicit dev plaintext only when the dev escape hatch is enabled', async () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) vi.stubEnv('ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION', '1') diff --git a/src/main/orca-profiles/profile-cloud-session-store.ts b/src/main/orca-profiles/profile-cloud-session-store.ts index d1334d96b6b..4741a471544 100644 --- a/src/main/orca-profiles/profile-cloud-session-store.ts +++ b/src/main/orca-profiles/profile-cloud-session-store.ts @@ -55,6 +55,19 @@ type CachedOrcaCloudSession = { } const memorySessions = new Map() +export const MAX_MEMORY_CLOUD_SESSIONS = 64 + +function rememberMemorySession(key: string, session: CachedOrcaCloudSession): void { + memorySessions.delete(key) + memorySessions.set(key, session) + while (memorySessions.size > MAX_MEMORY_CLOUD_SESSIONS) { + const oldest = memorySessions.keys().next() + if (oldest.done || oldest.value === key) { + break + } + memorySessions.delete(oldest.value) + } +} function sessionCacheKey(profileId: string, userDataPath: string): string { return `${userDataPath}\0${profileId}` @@ -119,7 +132,7 @@ export function saveOrcaCloudSession( ciphertext: safeStorage.encryptString(JSON.stringify(session)).toString('base64') } writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), encrypted) - memorySessions.set(cacheKey, { session, persistence: 'encrypted' }) + rememberMemorySession(cacheKey, { session, persistence: 'encrypted' }) return 'encrypted' } @@ -131,13 +144,13 @@ export function saveOrcaCloudSession( session } writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), plaintext) - memorySessions.set(cacheKey, { session, persistence: 'dev-plaintext' }) + rememberMemorySession(cacheKey, { session, persistence: 'dev-plaintext' }) return 'dev-plaintext' } // Why: Orca account refresh tokens must not silently fall back to plaintext // in production. Memory-only keeps cloud features usable until restart. - memorySessions.set(cacheKey, { session, persistence: 'memory-only' }) + rememberMemorySession(cacheKey, { session, persistence: 'memory-only' }) return 'memory-only' } @@ -177,6 +190,8 @@ export function readOrcaCloudSession( const cacheKey = sessionCacheKey(profileId, userDataPath) const memorySession = memorySessions.get(cacheKey) if (memorySession) { + memorySessions.delete(cacheKey) + memorySessions.set(cacheKey, memorySession) return { status: 'found', session: memorySession.session, @@ -209,14 +224,14 @@ export function readOrcaCloudSession( if (!isOrcaCloudSession(session)) { return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' } } - memorySessions.set(cacheKey, { session, persistence: 'encrypted' }) + rememberMemorySession(cacheKey, { session, persistence: 'encrypted' }) return { status: 'found', session, persistence: 'encrypted' } } if (parsed.format === 'dev-plaintext-v1' && allowsPlaintextOrcaCloudSession()) { if (!isOrcaCloudSession(parsed.session)) { return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' } } - memorySessions.set(cacheKey, { session: parsed.session, persistence: 'dev-plaintext' }) + rememberMemorySession(cacheKey, { session: parsed.session, persistence: 'dev-plaintext' }) return { status: 'found', session: parsed.session, persistence: 'dev-plaintext' } } return { status: 'decrypt-failed', persistence: 'none', error: 'Unsafe session format.' } @@ -240,3 +255,7 @@ export function clearOrcaCloudSession(profileId: string, userDataPath: string): memorySessions.delete(sessionCacheKey(profileId, userDataPath)) rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true }) } + +export function getOrcaCloudMemorySessionCountForTests(): number { + return memorySessions.size +} diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 66f3fc2c32e..e9cc169bb2d 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -15,6 +15,7 @@ import { makeWorktreeLineage } from './persistence-test-harness' import { + _getLocalWorktreeScanGenerationCacheSize, getLocalWorktreeScanGeneration, isLocalWorktreeScanGenerationCurrent } from './local-worktree-scan-generation' @@ -100,6 +101,18 @@ describe('Store', () => { expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeReAdd)).toBe(false) }) + it('forgets scan generations when repos are removed', async () => { + const store = await createStore() + const initialCacheSize = _getLocalWorktreeScanGenerationCacheSize() + for (let index = 0; index < 200; index += 1) { + const repoId = `scan-churn-${index}` + store.addRepo(makeRepo({ id: repoId })) + store.removeProject(repoId) + } + + expect(_getLocalWorktreeScanGenerationCacheSize()).toBe(initialCacheSize) + }) + it('setResolvedRepoGitUsername persists the enriched username for hydration', async () => { const store = await createStore() store.addRepo(makeRepo()) diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index d96f7c31481..1dde3076116 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -18,14 +18,15 @@ import { retireLocalWorktreeMetadataPruneStateForRepo } from '../../local-worktr import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' -import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' -import type { PersistedState } from '../../../shared/persisted-state-types' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + bumpLocalWorktreeScanGeneration, + retireLocalWorktreeScanGeneration +} from '../../local-worktree-scan-generation' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' import { scheduleSave } from './write-scheduling' - +import { pruneDeregisteredRepoUiResidue } from './repo-lifecycle-ui-residue' type RepoLifecycleOperationsRuntime = Pick< StoreRuntimeState, | 'gitUsernameCache' @@ -69,10 +70,9 @@ export class RepoLifecycleOperations { repoLifecycleOperationsContext ].runtime.state.repos.filter((r) => r.id !== id) if (repoRemoved) { - bumpLocalWorktreeScanGeneration(id) + retireLocalWorktreeScanGeneration(id) } syncProjectHostSetupCompatibilityState(this) - // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] pruneWorktreeStateForRepo(this, id, null) @@ -102,13 +102,12 @@ export class RepoLifecycleOperations { const idStillPresent = this[repoLifecycleOperationsContext].runtime.state.repos.some( (r) => r.id === id ) - // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. if (!idStillPresent) { + retireLocalWorktreeScanGeneration(id) delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] } syncProjectHostSetupCompatibilityState(this) - // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). pruneWorktreeStateForRepo(this, id, idStillPresent ? hostId : null) if (!idStillPresent) { this[repoLifecycleOperationsContext].runtime.state.workspaceSession = @@ -136,7 +135,6 @@ export class RepoLifecycleOperations { /** * Drop every persisted row owned by a repo id that is no longer registered. - * * Runs at load to reach leftover local rows after deregistration. Rows owned by a `runtime:*` * host are exempt: this runs before pairing, so their absence from the local catalog cannot * establish deletion. Only an explicit `removeProjectForHost` retires them. @@ -148,6 +146,7 @@ export class RepoLifecycleOperations { return [] } for (const repoId of orphanRepoIds) { + retireLocalWorktreeScanGeneration(repoId) pruneWorktreeStateForRepo(this, repoId, null) state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId) state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( @@ -223,8 +222,6 @@ export function pruneWorktreeStateForRepo( hostId, (matchesWorktreeId) => pruneMobileClientTabSelections(owner, matchesWorktreeId) ) - // Why: this drops metadata, lineage, leases and session owners in bulk, which can unpin rows in - // other repos, and a full removal retires this repo's own gate state (#17775). retireLocalWorktreeMetadataPruneStateForRepo(id, hostId) } @@ -247,26 +244,6 @@ export function pruneMobileClientTabSelections( } } -function pruneDeregisteredRepoUiResidue( - ui: PersistedState['ui'], - orphanRepoIds: ReadonlySet -): void { - const isOrphanWorktree = (worktreeId: string): boolean => - orphanRepoIds.has(getRepoIdFromWorktreeId(worktreeId)) - if (ui.lastActiveRepoId && orphanRepoIds.has(ui.lastActiveRepoId)) { - ui.lastActiveRepoId = null - } - if (ui.lastActiveWorktreeId && isOrphanWorktree(ui.lastActiveWorktreeId)) { - ui.lastActiveWorktreeId = null - } - ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? [] - for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) { - if (isOrphanWorktree(worktreeId)) { - delete ui.showDotfilesByWorktree?.[worktreeId] - } - } -} - export function getRepoUpdateOperations( owner: RepoLifecycleOperations ): RepoUpdatePersistenceOperations { diff --git a/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts b/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts new file mode 100644 index 00000000000..d1c2ed76d7b --- /dev/null +++ b/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts @@ -0,0 +1,22 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +export function pruneDeregisteredRepoUiResidue( + ui: PersistedState['ui'], + orphanRepoIds: ReadonlySet +): void { + const isOrphanWorktree = (worktreeId: string): boolean => + orphanRepoIds.has(getRepoIdFromWorktreeId(worktreeId)) + if (ui.lastActiveRepoId && orphanRepoIds.has(ui.lastActiveRepoId)) { + ui.lastActiveRepoId = null + } + if (ui.lastActiveWorktreeId && isOrphanWorktree(ui.lastActiveWorktreeId)) { + ui.lastActiveWorktreeId = null + } + ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? [] + for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) { + if (isOrphanWorktree(worktreeId)) { + delete ui.showDotfilesByWorktree?.[worktreeId] + } + } +} diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index 5fed1a3d021..d21ab5f04da 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -30,6 +30,7 @@ import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { RepoLifecycleOperations } from './repo-lifecycle-operations' import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operations' import { scheduleSave } from './write-scheduling' +import { forgetSshConnectionGeneration } from '../../ssh/ssh-connection-generation' type SshProfileOperationsRuntime = Pick @@ -70,7 +71,11 @@ export class SshProfileOperations { } removeSshTarget(id: string): void { + const existed = this.getSshTarget(id) !== undefined removeSshTargetOperation(getSshTargetStateOperations(this), id) + if (existed) { + forgetSshConnectionGeneration(id) + } } allocateSshTargetGeneration(): number { diff --git a/src/main/plugins/plugin-log-buffer.ts b/src/main/plugins/plugin-log-buffer.ts index 032e7df20e1..c7210d28997 100644 --- a/src/main/plugins/plugin-log-buffer.ts +++ b/src/main/plugins/plugin-log-buffer.ts @@ -1,6 +1,7 @@ export type PluginLogLine = { ts: number; level: 'info' | 'warn' | 'error'; line: string } const LOG_RING_LIMIT = 200 +export const PLUGIN_LOG_KEY_LIMIT = 256 export class PluginLogBuffer { private readonly logs = new Map() @@ -9,6 +10,10 @@ export class PluginLogBuffer { return this.logs.get(pluginKey)?.lines ?? [] } + get size(): number { + return this.logs.size + } + capture(pluginKey: string): (level: PluginLogLine['level'], line: string) => void { const token = this.ensure(pluginKey).token return (level, line) => { @@ -27,6 +32,13 @@ export class PluginLogBuffer { if (!entry) { entry = { token: {}, lines: [] } this.logs.set(pluginKey, entry) + while (this.logs.size > PLUGIN_LOG_KEY_LIMIT) { + const oldest = this.logs.keys().next() + if (oldest.done) { + break + } + this.logs.delete(oldest.value) + } } return entry } diff --git a/src/main/plugins/plugin-worker-generation-retention.ts b/src/main/plugins/plugin-worker-generation-retention.ts new file mode 100644 index 00000000000..0e1f4c6d55a --- /dev/null +++ b/src/main/plugins/plugin-worker-generation-retention.ts @@ -0,0 +1,21 @@ +export function forgetPluginWorkerGenerationIfIdle( + pluginKey: string, + activations: ReadonlyMap, + workers: ReadonlyMap, + knownSpecs: ReadonlyMap, + generations: Map +): void { + if (activations.has(pluginKey) || workers.has(pluginKey) || knownSpecs.has(pluginKey)) { + return + } + generations.delete(pluginKey) +} + +export function nextPluginWorkerGeneration( + pluginKey: string, + generations: Map +): number { + const generation = (generations.get(pluginKey) ?? 0) + 1 + generations.set(pluginKey, generation) + return generation +} diff --git a/src/main/plugins/plugin-worker-lifecycle.ts b/src/main/plugins/plugin-worker-lifecycle.ts new file mode 100644 index 00000000000..41222117004 --- /dev/null +++ b/src/main/plugins/plugin-worker-lifecycle.ts @@ -0,0 +1,13 @@ +export function pluginWorkerErrorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function finishPluginWorkerActivation( + activations: Map, + pluginKey: string, + record: T +): void { + if (activations.get(pluginKey) === record) { + activations.delete(pluginKey) + } +} diff --git a/src/main/plugins/plugin-worker-manager.test.ts b/src/main/plugins/plugin-worker-manager.test.ts index 1126042e566..81cec01354d 100644 --- a/src/main/plugins/plugin-worker-manager.test.ts +++ b/src/main/plugins/plugin-worker-manager.test.ts @@ -66,6 +66,17 @@ afterEach(() => { }) describe('PluginWorkerManager capacity', () => { + it('releases generations for removed plugin keys', async () => { + const subject = manager(vi.fn(async () => worker())) + + for (let index = 0; index < 600; index += 1) { + await subject.deactivate(`removed-${index}`) + } + + expect(subject.generationCountForTests()).toBe(0) + await subject.disposeAll() + }) + it('atomically counts in-flight starts against maxActive', async () => { const starts: { key: string; resolve: (handle: TestWorker) => void }[] = [] const factory = vi.fn( diff --git a/src/main/plugins/plugin-worker-manager.ts b/src/main/plugins/plugin-worker-manager.ts index 928358ff929..e92909091f8 100644 --- a/src/main/plugins/plugin-worker-manager.ts +++ b/src/main/plugins/plugin-worker-manager.ts @@ -18,6 +18,11 @@ import { } from './plugin-worker-startup' import { runPluginWorkerRestartLoop } from './plugin-worker-restart-loop' import { pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec' +import { + forgetPluginWorkerGenerationIfIdle, + nextPluginWorkerGeneration +} from './plugin-worker-generation-retention' +import { finishPluginWorkerActivation, pluginWorkerErrorText } from './plugin-worker-lifecycle' export type { PluginWorkerFactory, PluginWorkerSpawnSpec } from './plugin-worker-startup' @@ -72,6 +77,11 @@ export class PluginWorkerManager { return new Map(this.knownSpecs) } + /** @internal - exposed for lifecycle retention tests. */ + generationCountForTests(): number { + return this.generations.size + } + async ensureActive( spec: PluginWorkerSpawnSpec, assertApproved: () => void = () => undefined @@ -118,18 +128,12 @@ export class PluginWorkerManager { const record: ActivationRecord = { spec, generation, controller, task } this.activations.set(spec.pluginKey, record) void task.then( - () => this.finishActivation(spec.pluginKey, record), - () => this.finishActivation(spec.pluginKey, record) + () => finishPluginWorkerActivation(this.activations, spec.pluginKey, record), + () => finishPluginWorkerActivation(this.activations, spec.pluginKey, record) ) return task } - private finishActivation(pluginKey: string, record: ActivationRecord): void { - if (this.activations.get(pluginKey) === record) { - this.activations.delete(pluginKey) - } - } - private async activate( spec: PluginWorkerSpawnSpec, generation: number, @@ -168,7 +172,7 @@ export class PluginWorkerManager { recordFailure: (error) => this.recordFailure(spec.pluginKey, 'worker failed to start', error), erroredError: (error) => new Error( - `plugin ${spec.pluginKey} is errored after repeated failures: ${this.errorText(error)}` + `plugin ${spec.pluginKey} is errored after repeated failures: ${pluginWorkerErrorText(error)}` ) }) } @@ -201,7 +205,7 @@ export class PluginWorkerManager { if (decision.restart) { this.options.log(pluginKey)( 'warn', - `${context}${error ? `: ${this.errorText(error)}` : ''}; restart ${decision.attempt} in ${decision.delayMs}ms` + `${context}${error ? `: ${pluginWorkerErrorText(error)}` : ''}; restart ${decision.attempt} in ${decision.delayMs}ms` ) } else if (decision.state === 'errored') { this.options.log(pluginKey)('error', `${context}; marked errored after repeated failures`) @@ -238,6 +242,7 @@ export class PluginWorkerManager { record?.handle.dispose().catch(() => undefined) ]) record?.lease.release() + this.forgetGenerationIfIdle(pluginKey) } reapIdle(now = Date.now()): void { @@ -260,7 +265,10 @@ export class PluginWorkerManager { .catch(() => undefined) .finally(() => record.lease.release()) this.stoppingWorkers.add(stopping) - void stopping.then(() => this.stoppingWorkers.delete(stopping)) + void stopping.then(() => { + this.stoppingWorkers.delete(stopping) + this.forgetGenerationIfIdle(pluginKey) + }) } } @@ -291,9 +299,17 @@ export class PluginWorkerManager { } private nextGeneration(pluginKey: string): number { - const generation = (this.generations.get(pluginKey) ?? 0) + 1 - this.generations.set(pluginKey, generation) - return generation + return nextPluginWorkerGeneration(pluginKey, this.generations) + } + + private forgetGenerationIfIdle(pluginKey: string): void { + forgetPluginWorkerGenerationIfIdle( + pluginKey, + this.activations, + this.workers, + this.knownSpecs, + this.generations + ) } private isCancelled(pluginKey: string, generation: number, signal?: AbortSignal): boolean { @@ -307,8 +323,4 @@ export class PluginWorkerManager { throw new Error('plugin worker activation was cancelled') } } - - private errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error) - } } diff --git a/src/main/plugins/plugin-worker-output-retention.test.ts b/src/main/plugins/plugin-worker-output-retention.test.ts index 35784541f74..dab4f76afdb 100644 --- a/src/main/plugins/plugin-worker-output-retention.test.ts +++ b/src/main/plugins/plugin-worker-output-retention.test.ts @@ -1,7 +1,7 @@ import { once } from 'node:events' import { PassThrough } from 'node:stream' import { describe, expect, it } from 'vitest' -import { PluginLogBuffer } from './plugin-log-buffer' +import { PLUGIN_LOG_KEY_LIMIT, PluginLogBuffer } from './plugin-log-buffer' import { pipePluginWorkerOutput } from './plugin-worker-output-buffer' async function heapAfterGc(): Promise { @@ -35,6 +35,17 @@ function writeLine(stream: PassThrough, index: number, truncated: boolean): void } describe('plugin worker retained output', () => { + it('bounds retained plugin keys while keeping the newest history', () => { + const logs = new PluginLogBuffer() + for (let index = 0; index < PLUGIN_LOG_KEY_LIMIT + 4; index += 1) { + logs.append(`plugin-${index}`, 'info', `line-${index}`) + } + + expect(logs.size).toBe(PLUGIN_LOG_KEY_LIMIT) + expect(logs.get('plugin-0')).toEqual([]) + expect(logs.get(`plugin-${PLUGIN_LOG_KEY_LIMIT + 3}`)).toHaveLength(1) + }) + it('keeps unfinished output after consuming a large chunk without retaining the parent', async () => { const lines: string[] = [] const before = await heapAfterGc() diff --git a/src/main/ports/advertised-url-watcher.test.ts b/src/main/ports/advertised-url-watcher.test.ts index 5fe46ef181a..16ad402f421 100644 --- a/src/main/ports/advertised-url-watcher.test.ts +++ b/src/main/ports/advertised-url-watcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { AdvertisedUrlWatcher } from './advertised-url-watcher' +import { AdvertisedUrlWatcher, MAX_ADVERTISED_URL_SCAN_SNAPSHOTS } from './advertised-url-watcher' import { classifyHost, extractUrlCandidates, stripTerminalControls } from './advertised-url-parsing' const WORKTREE = 'repo::/repo' @@ -311,6 +311,19 @@ describe('AdvertisedUrlWatcher.ingest', () => { expect(events).toEqual([{ worktreeId: WORKTREE, port: 3001 }]) }) + it('bounds scan snapshot growth when worktree ids churn', () => { + const watcher = new AdvertisedUrlWatcher() + + for (let index = 0; index < MAX_ADVERTISED_URL_SCAN_SNAPSHOTS + 4; index += 1) { + watcher.reconcileScan([`repo::/worktree-${index}`], []) + } + + const internals = watcher as unknown as { + scanSnapshots: Map> + } + expect(internals.scanSnapshots.size).toBe(MAX_ADVERTISED_URL_SCAN_SNAPSHOTS) + }) + it('different worktrees on the same port are tracked independently', () => { const watcher = bindFresh() watcher.bindPty('pty-2', 'repo::/other') diff --git a/src/main/ports/advertised-url-watcher.ts b/src/main/ports/advertised-url-watcher.ts index c26c3e2b9ce..685399057dc 100644 --- a/src/main/ports/advertised-url-watcher.ts +++ b/src/main/ports/advertised-url-watcher.ts @@ -21,6 +21,8 @@ import { shouldEvictAdvertisedUrlAfterScan } from './advertised-url-reconciliation' import { ownRetainedString } from '../../shared/own-retained-string' + +export const MAX_ADVERTISED_URL_SCAN_SNAPSHOTS = 512 export type HostKind = 'custom' | 'loopback' | 'private-ip' | 'public-ip' export type AdvertisedUrl = { @@ -268,7 +270,15 @@ export class AdvertisedUrlWatcher { } for (const worktreeId of worktreeSet) { + this.scanSnapshots.delete(worktreeId) this.scanSnapshots.set(worktreeId, new Map(observedByPort)) + while (this.scanSnapshots.size > MAX_ADVERTISED_URL_SCAN_SNAPSHOTS) { + const oldest = this.scanSnapshots.keys().next() + if (oldest.done || oldest.value === worktreeId) { + break + } + this.scanSnapshots.delete(oldest.value) + } } for (const event of removedEvents) { this.emitChange(event) diff --git a/src/main/preflight-wsl-cache.ts b/src/main/preflight-wsl-cache.ts new file mode 100644 index 00000000000..bf378b4f841 --- /dev/null +++ b/src/main/preflight-wsl-cache.ts @@ -0,0 +1,27 @@ +export function prunePreflightWslCache( + cached: Map, + latestRuns: Map, + now: number, + maxEntries: number +): void { + for (const [key, entry] of cached) { + if (entry.expiresAt <= now) { + cached.delete(key) + } + } + while (cached.size > maxEntries) { + const oldest = cached.keys().next().value + if (oldest === undefined) { + break + } + cached.delete(oldest) + latestRuns.delete(oldest) + } + while (latestRuns.size > maxEntries) { + const oldest = latestRuns.keys().next().value + if (oldest === undefined) { + break + } + latestRuns.delete(oldest) + } +} diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index 7e1c87b3763..5abac7cd342 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -45,6 +45,7 @@ import { resolveDetectedTuiAgentIds } from '../ipc/tui-agent-detection-commands' import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' +import { prunePreflightWslCache } from '../preflight-wsl-cache' export type PreflightStatus = { git: { installed: boolean } @@ -88,6 +89,7 @@ let cached: PreflightStatus | null = null // would report "git not installed" until relaunch; expiring lets it self-heal // while still collapsing the burst of calls that made this expensive. const WSL_PREFLIGHT_CACHE_TTL_MS = 30_000 +const MAX_WSL_PREFLIGHT_DISTRO_ENTRIES = 128 const cachedByWslDistro = new Map() // Collapses concurrent callers (several panes mounting at once) onto one probe // set instead of one full set each before the first result lands. @@ -292,6 +294,12 @@ export async function runPreflightCheck( ): Promise { const wslTarget = getPreflightWslTarget(context) const cacheKey = preflightCacheKey(wslTarget) + prunePreflightWslCache( + cachedByWslDistro, + latestPreflightRun, + Date.now(), + MAX_WSL_PREFLIGHT_DISTRO_ENTRIES + ) if (!force) { if (wslTarget) { @@ -326,6 +334,12 @@ export async function runPreflightCheck( result, expiresAt: Date.now() + WSL_PREFLIGHT_CACHE_TTL_MS }) + prunePreflightWslCache( + cachedByWslDistro, + latestPreflightRun, + Date.now(), + MAX_WSL_PREFLIGHT_DISTRO_ENTRIES + ) } else { cached = result } diff --git a/src/main/providers/ssh-git-dispatch.test.ts b/src/main/providers/ssh-git-dispatch.test.ts index a975f3db475..a4f0709a43d 100644 --- a/src/main/providers/ssh-git-dispatch.test.ts +++ b/src/main/providers/ssh-git-dispatch.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { + _getSshGitProviderGenerationCacheSize, + getSshGitProvider, getSshGitProviderGeneration, registerSshGitProvider, unregisterSshGitProvider @@ -25,4 +27,37 @@ describe('SSH Git provider registry', () => { expect(unregistered).toBe(registered + 1) expect(reRegistered).toBe(unregistered + 1) }) + + it('bounds retired connection generations', () => { + registerSshGitProvider(connectionId, {} as never) + const provider = getSshGitProvider(connectionId) + if (!provider) { + throw new Error('test provider was not registered') + } + for (let index = 0; index < 600; index += 1) { + const id = `retired-${index}` + registerSshGitProvider(id, provider) + unregisterSshGitProvider(id) + } + + expect(_getSshGitProviderGenerationCacheSize()).toBeLessThanOrEqual(512) + }) + + it('does not reuse a generation after the target leaves both bounded maps', () => { + registerSshGitProvider(connectionId, {} as never) + const provider = getSshGitProvider(connectionId) + if (!provider) { + throw new Error('test provider was not registered') + } + const beforeChurn = getSshGitProviderGeneration(connectionId) + for (let index = 0; index < 1_100; index += 1) { + const id = `churn-${index}` + registerSshGitProvider(id, provider) + unregisterSshGitProvider(id) + } + unregisterSshGitProvider(connectionId) + registerSshGitProvider(connectionId, {} as never) + + expect(getSshGitProviderGeneration(connectionId)).toBeGreaterThan(beforeChurn) + }) }) diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts index c0acc757ada..8ec7c33bc12 100644 --- a/src/main/providers/ssh-git-dispatch.ts +++ b/src/main/providers/ssh-git-dispatch.ts @@ -2,23 +2,50 @@ import type { SshGitProvider } from './ssh-git-provider' const sshProviders = new Map() const sshProviderGenerations = new Map() +const SSH_PROVIDER_GENERATION_MAX_ENTRIES = 512 +let providerGenerationSequence = 0 +let evictedProviderGeneration = 0 export const SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE = 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' export function registerSshGitProvider(connectionId: string, provider: SshGitProvider): void { sshProviders.set(connectionId, provider) - sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) + sshProviderGenerations.set(connectionId, ++providerGenerationSequence) + pruneProviderGenerations() } export function unregisterSshGitProvider(connectionId: string): void { if (sshProviders.delete(connectionId)) { - sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) + sshProviderGenerations.set(connectionId, ++providerGenerationSequence) + pruneProviderGenerations() + } +} + +// Connection ids are externally supplied and can churn across repeated SSH +// sessions. Keep recent generations for cache invalidation without retaining +// every retired target for the lifetime of the main process. +function pruneProviderGenerations(): void { + while (sshProviderGenerations.size > SSH_PROVIDER_GENERATION_MAX_ENTRIES) { + const oldest = sshProviderGenerations.keys().next() + if (oldest.done) { + return + } + evictedProviderGeneration = Math.max( + evictedProviderGeneration, + sshProviderGenerations.get(oldest.value) ?? 0 + ) + sshProviderGenerations.delete(oldest.value) } } export function getSshGitProviderGeneration(connectionId: string): number { - return sshProviderGenerations.get(connectionId) ?? 0 + return sshProviderGenerations.get(connectionId) ?? evictedProviderGeneration +} + +/** @internal - exposed for cache-bound regression tests. */ +export function _getSshGitProviderGenerationCacheSize(): number { + return sshProviderGenerations.size } export function getSshGitProvider(connectionId: string): SshGitProvider | undefined { diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts index eaf8cffeba4..ffaab8ede99 100644 --- a/src/main/pty/shell-startup-env.test.ts +++ b/src/main/pty/shell-startup-env.test.ts @@ -16,7 +16,8 @@ import { __resetShellStartupEnvCache, isShellStartupEnvProbeSupported, readSessionShellStartupEnvVar, - readShellStartupEnvVar + readShellStartupEnvVar, + SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES } from './shell-startup-env' describe('readShellStartupEnvVar', () => { @@ -309,6 +310,18 @@ describe('readShellStartupEnvVar', () => { expect(readFileSyncMock.mock.calls.length).toBe(callsAfterFirst) }) + it('bounds cache keys from long-lived home and host churn', () => { + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/cached\n' }) + for (let index = 0; index < SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES + 40; index += 1) { + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', `/home/user-${index}`)).toBe('/cached') + } + + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/user-0')).toBe('/cached') + expect(readFileSyncMock.mock.calls.length).toBeGreaterThan( + SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES + 40 + ) + }) + it('rejects names with regex metacharacters', () => { mockStartupFiles({ '.zshrc': 'export FOO=/x\n' }) expect(readShellStartupEnvVar('FOO.*', '/home/alice')).toBeUndefined() diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts index ad6392160a9..ef15e383774 100644 --- a/src/main/pty/shell-startup-env.ts +++ b/src/main/pty/shell-startup-env.ts @@ -193,6 +193,7 @@ function expandHome(value: string, home: string): string { .replace(/\$HOME(?![A-Za-z0-9_])/g, home) } +export const SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES = 256 const cache = new Map() /** @@ -216,9 +217,8 @@ const cache = new Map() * seen when the assignment is also written in a config file. * - Windows is unsupported (PowerShell profile parsing is out of scope). * - * Results are memoized per (name, home, shell, configHome) for the process - * lifetime — shell startup files do not change mid-session in any practical - * scenario, and PTY spawn is on the hot path. + * Results are memoized per (name, home, shell, configHome); a bounded recent + * window keeps SSH/WSL home churn from retaining every historical key. */ export function readShellStartupEnvVar( name: string, @@ -236,8 +236,12 @@ export function readShellStartupEnvVar( } const cacheKey = `${name}\0${home}\0${shell ?? ''}\0${configHome ?? ''}` + const cached = cache.get(cacheKey) if (cache.has(cacheKey)) { - return cache.get(cacheKey) + // Keep frequently used homes warm when historical homes churn. + cache.delete(cacheKey) + cache.set(cacheKey, cached) + return cached } let lastMatch: string | undefined @@ -256,6 +260,13 @@ export function readShellStartupEnvVar( } cache.set(cacheKey, lastMatch) + while (cache.size > SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES) { + const oldest = cache.keys().next() + if (oldest.done) { + break + } + cache.delete(oldest.value) + } return lastMatch } diff --git a/src/main/repo-git-username-enrichment.test.ts b/src/main/repo-git-username-enrichment.test.ts index 7d4b1894f7f..b4911848d8c 100644 --- a/src/main/repo-git-username-enrichment.test.ts +++ b/src/main/repo-git-username-enrichment.test.ts @@ -94,6 +94,22 @@ describe('enrichRepoGitUsernames', () => { expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(1) }) + it('releases attempted locations after a repo is removed', async () => { + const repos = [makeRepo()] + const store = makeStore(repos) + + enrichRepoGitUsernames(store) + await flushRepoGitUsernameEnrichmentForTests() + repos.length = 0 + enrichRepoGitUsernames(store) + await flushRepoGitUsernameEnrichmentForTests() + repos.push(makeRepo({ id: 'replacement' })) + enrichRepoGitUsernames(store) + await flushRepoGitUsernameEnrichmentForTests() + + expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(2) + }) + it('probes a local and a runtime repo that share a path separately', async () => { const store = makeStore([ makeRepo(), diff --git a/src/main/repo-git-username-enrichment.ts b/src/main/repo-git-username-enrichment.ts index fcbb68340f2..aa9bb7dd415 100644 --- a/src/main/repo-git-username-enrichment.ts +++ b/src/main/repo-git-username-enrichment.ts @@ -32,7 +32,16 @@ async function enrichRepoGitUsernamesInBackground( store: RepoUsernameStore, options: EnrichmentOptions ): Promise { - const candidates = store.getRepos().filter( + const repos = store.getRepos() + const liveLocations = new Set( + repos.filter((repo) => repo.kind !== 'folder' && !repo.connectionId).map(getRepoLocationKey) + ) + for (const location of attemptedLocations) { + if (!liveLocations.has(location)) { + attemptedLocations.delete(location) + } + } + const candidates = repos.filter( (repo) => repo.kind !== 'folder' && // Why: SSH repo paths are remote; local git cannot inspect them. The diff --git a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts index 12c4c275928..4a60ab458a4 100644 --- a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts +++ b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts @@ -13,6 +13,8 @@ import type { BrowserBackend } from '../browser/browser-backend' import type { EmulatorBridge } from '../emulator/emulator-bridge' export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStatus { + private static readonly MAX_SSH_RELAY_RECOVERY_GENERATIONS = 512 + private sshRelayRecoveryGenerationSequence = 0 // Why: SSH state changes originate in main's ssh handlers, not in runtime // methods, so they need a public entry point onto the client-event stream. notifySshStateChanged(targetId: string, state: SshConnectionState): void { @@ -63,8 +65,18 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat } protected bumpSshRelayRecoveryGeneration(targetId: string): number { - const generation = (this.sshRelayRecoveryGenerationByTargetId.get(targetId) ?? 0) + 1 + const generation = ++this.sshRelayRecoveryGenerationSequence this.sshRelayRecoveryGenerationByTargetId.set(targetId, generation) + while ( + this.sshRelayRecoveryGenerationByTargetId.size > + OrcaRuntimeWithNotifySshStateChanged.MAX_SSH_RELAY_RECOVERY_GENERATIONS + ) { + const oldest = this.sshRelayRecoveryGenerationByTargetId.keys().next() + if (oldest.done) { + break + } + this.sshRelayRecoveryGenerationByTargetId.delete(oldest.value) + } return generation } diff --git a/src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts b/src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts index 930907da120..ed14a6b37e4 100644 --- a/src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts +++ b/src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts @@ -13,6 +13,7 @@ import { PTY_CONTROLLER_LIST_TIMEOUT_MS } from './orca-runtime-postlude' import type { ExecutionHostId } from '../../shared/execution-host' +import { pruneOldestMapEntry } from './prune-oldest-map-entry' import { withTimeoutResult } from './runtime-async-boundaries' import { getPtyExecutionHost } from '../../shared/terminal-execution-host' import { @@ -55,23 +56,18 @@ export class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory ext } const inventoryGeneration = this.ptyControllerInventorySequence + 1 this.ptyControllerInventorySequence = inventoryGeneration - const providerKey = - typeof connectionId === 'string' - ? toSshExecutionHostId(connectionId) - : LOCAL_EXECUTION_HOST_ID + const providerKey = connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID const livenessObservationAtStart = this.ptyLivenessObservationSequence if (connectionId === undefined) { this.ptyControllerAggregateInventoryGeneration = inventoryGeneration } else { this.ptyControllerInventoryGenerationByProvider.set(providerKey, inventoryGeneration) + pruneOldestMapEntry(this.ptyControllerInventoryGenerationByProvider, 512) } const listBudgetMs = deadline === undefined ? PTY_CONTROLLER_LIST_TIMEOUT_MS : Math.max(1, Math.min(PTY_CONTROLLER_LIST_TIMEOUT_MS, deadline - Date.now())) - // Why: give each provider a deadline strictly inside our own, so a relay that - // never answers still leaves the aggregate time to return the providers that did - // — expiring at the same instant would discard the whole inventory instead. const providerListOpts = { deadlineMs: Date.now() + Math.max(1, listBudgetMs - PTY_CONTROLLER_LIST_PROVIDER_MARGIN_MS), ...(inventoryOptions?.includeForegroundProcessEvidence === undefined @@ -90,7 +86,6 @@ export class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory ext }) const sessionsResult = await withTimeoutResult(processInventory, listBudgetMs) if (!sessionsResult.ok) { - // Why: a transient controller failure is not evidence that retained PTYs exited. return null } const isCurrentInventory = @@ -103,9 +98,6 @@ export class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory ext inventoryGeneration && this.ptyControllerAggregateInventoryGeneration <= inventoryGeneration if (!isCurrentInventory) { - // A fleet census that began after this targeted poll must not turn a - // user-driven open into an empty result. Re-query the owning provider; - // the second generation is then fenced against both operations. if (targetWorktreeId !== null && !retryStale) { return this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, diff --git a/src/main/runtime/prune-oldest-map-entry.ts b/src/main/runtime/prune-oldest-map-entry.ts new file mode 100644 index 00000000000..a0240dbcbb8 --- /dev/null +++ b/src/main/runtime/prune-oldest-map-entry.ts @@ -0,0 +1,9 @@ +export function pruneOldestMapEntry(map: Map, maxEntries: number): void { + if (map.size <= maxEntries) { + return + } + const oldest = map.keys().next() + if (!oldest.done) { + map.delete(oldest.value) + } +} diff --git a/src/main/sidecar-snapshot-file.test.ts b/src/main/sidecar-snapshot-file.test.ts new file mode 100644 index 00000000000..7df50763602 --- /dev/null +++ b/src/main/sidecar-snapshot-file.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { + _getSidecarSnapshotPendingFileCountForTests, + withSidecarSnapshotQueue +} from './sidecar-snapshot-file' + +describe('sidecar snapshot queues', () => { + it('releases completed per-file queue entries', async () => { + await Promise.all( + Array.from({ length: 600 }, (_, index) => + withSidecarSnapshotQueue(`snapshot-${index}`, async () => undefined) + ) + ) + + await Promise.resolve() + expect(_getSidecarSnapshotPendingFileCountForTests()).toBe(0) + }) +}) diff --git a/src/main/sidecar-snapshot-file.ts b/src/main/sidecar-snapshot-file.ts index f7c0f23c0e4..4ee5c2dfa5c 100644 --- a/src/main/sidecar-snapshot-file.ts +++ b/src/main/sidecar-snapshot-file.ts @@ -21,13 +21,16 @@ export function sidecarSnapshotFile(snapshotDirectory: string, fileName: string) export function withSidecarSnapshotQueue(file: string, task: () => Promise): Promise { const previous = queues.get(file) ?? Promise.resolve() const run = previous.then(task, task) - queues.set( - file, - run.then( - () => undefined, - () => undefined - ) + const queued = run.then( + () => undefined, + () => undefined ) + queues.set(file, queued) + void queued.then(() => { + if (queues.get(file) === queued) { + queues.delete(file) + } + }) return run } @@ -45,7 +48,16 @@ export async function writeSidecarSnapshot(file: string, payload: unknown): Prom if (!cleanup) { cleanup = removeStaleDurableWriteTempFiles(file, { minimumAgeMs: STALE_TEMP_AGE_MS }) staleTempCleanups.set(file, cleanup) + void cleanup.then(() => { + if (staleTempCleanups.get(file) === cleanup) { + staleTempCleanups.delete(file) + } + }) } await cleanup await writeFileDurable(durableWriteTempPath(file), file, JSON.stringify(payload)) } + +export function _getSidecarSnapshotPendingFileCountForTests(): number { + return queues.size + staleTempCleanups.size +} diff --git a/src/main/ssh/ssh-connection-generation.test.ts b/src/main/ssh/ssh-connection-generation.test.ts index a6c995bcab2..6f288a71022 100644 --- a/src/main/ssh/ssh-connection-generation.test.ts +++ b/src/main/ssh/ssh-connection-generation.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { advanceSshConnectionGeneration, assertSshMutationExpectation, + forgetSshConnectionGeneration, getSshConnectionGeneration, + getSshConnectionGenerationEntryCountForTests, resetSshConnectionGenerations, setSshConnectionGeneration } from './ssh-connection-generation' @@ -34,6 +36,16 @@ describe('SSH connection generation session scope', () => { expect(getSshConnectionGeneration('ssh-a')).toBe(getSshConnectionGeneration('ssh-b')) }) + it('forgets generations when a target is permanently removed', () => { + resetSshConnectionGenerations(7) + advanceSshConnectionGeneration('removed-target') + + forgetSshConnectionGeneration('removed-target') + + expect(getSshConnectionGenerationEntryCountForTests()).toBe(0) + expect(getSshConnectionGeneration('removed-target')).toBe(7 * SESSION_COUNTER_STRIDE) + }) + it('rejects an SSH execution-host expectation when direct IPC resolves locally', () => { expect(() => assertSshMutationExpectation(undefined, undefined, undefined, 'ssh:ssh-a') diff --git a/src/main/ssh/ssh-connection-generation.ts b/src/main/ssh/ssh-connection-generation.ts index 96f1ae04c83..0bab85440c5 100644 --- a/src/main/ssh/ssh-connection-generation.ts +++ b/src/main/ssh/ssh-connection-generation.ts @@ -14,6 +14,16 @@ let sessionInitialized = false const connectionGenerationByTarget = new Map() const usedSessionScopes = new Set() +/** Permanent target removal makes its reconnect fence unreachable; release its key. */ +export function forgetSshConnectionGeneration(targetId: string): void { + connectionGenerationByTarget.delete(targetId) +} + +/** @internal - cache-bound test view. */ +export function getSshConnectionGenerationEntryCountForTests(): number { + return connectionGenerationByTarget.size +} + function assertGenerationInCurrentSession(generation: number): void { if ( !Number.isSafeInteger(generation) || diff --git a/src/main/ssh/system-ssh-windows-write-capabilities.test.ts b/src/main/ssh/system-ssh-windows-write-capabilities.test.ts index ad723d592b0..7926341a141 100644 --- a/src/main/ssh/system-ssh-windows-write-capabilities.test.ts +++ b/src/main/ssh/system-ssh-windows-write-capabilities.test.ts @@ -76,4 +76,15 @@ describe('getWindowsRemoteWriteCapabilities', () => { expect(capabilities.shouldTry('sftp-subsystem')).toBe(true) expect(capabilities.shouldTry('pwsh')).toBe(false) }) + + it('bounds host capability entries while retaining recent hosts', () => { + const first = asTarget({ id: 'first', host: 'win-first.example', username: 'dev' }) + getWindowsRemoteWriteCapabilities(first).rememberUnsupported('sftp-subsystem') + for (let index = 0; index < 260; index += 1) { + getWindowsRemoteWriteCapabilities( + asTarget({ id: String(index), host: `win-${index}.example`, username: 'dev' }) + ) + } + expect(getWindowsRemoteWriteCapabilities(first).shouldTry('sftp-subsystem')).toBe(true) + }) }) diff --git a/src/main/ssh/system-ssh-windows-write-capabilities.ts b/src/main/ssh/system-ssh-windows-write-capabilities.ts index dcd03f19807..902818a4158 100644 --- a/src/main/ssh/system-ssh-windows-write-capabilities.ts +++ b/src/main/ssh/system-ssh-windows-write-capabilities.ts @@ -12,12 +12,29 @@ export type WindowsRemoteWriteCapability = 'sftp-subsystem' | 'pwsh' // Why re-probe at all: an admin can enable the subsystem, or install PowerShell 7, without the // user restarting Orca. Long enough that a hardened host costs one failed probe per half hour. export const WINDOWS_WRITE_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000 +const MAX_WINDOWS_WRITE_CAPABILITY_HOSTS = 256 const capabilitiesByExecutionHost = new Map< string, CapabilityProbeCache >() +function rememberCapabilityCache( + key: string, + cache: CapabilityProbeCache +): CapabilityProbeCache { + capabilitiesByExecutionHost.delete(key) + capabilitiesByExecutionHost.set(key, cache) + while (capabilitiesByExecutionHost.size > MAX_WINDOWS_WRITE_CAPABILITY_HOSTS) { + const oldest = capabilitiesByExecutionHost.keys().next().value + if (oldest === undefined) { + break + } + capabilitiesByExecutionHost.delete(oldest) + } + return cache +} + /** * Keyed by the endpoint that executes, not by target id: two Orca targets pointing at one host * describe the same sshd, and a target re-created under a new id has not changed what that host @@ -42,9 +59,8 @@ export function getWindowsRemoteWriteCapabilities( cache = new CapabilityProbeCache( WINDOWS_WRITE_CAPABILITY_RETRY_INTERVAL_MS ) - capabilitiesByExecutionHost.set(key, cache) } - return cache + return rememberCapabilityCache(key, cache) } export function clearWindowsRemoteWriteCapabilitiesForTests(): void { diff --git a/src/main/workspace-cleanup-scan-snapshot.ts b/src/main/workspace-cleanup-scan-snapshot.ts index b87020e1ffe..7d0aa98df56 100644 --- a/src/main/workspace-cleanup-scan-snapshot.ts +++ b/src/main/workspace-cleanup-scan-snapshot.ts @@ -196,6 +196,17 @@ function clearSupersededPrunes( // comparison — on a large fleet that read is a multi-hundred-KB synchronous // JSON.parse per scan. const lastPersistedScannedAtByFile = new Map() +const MAX_PERSISTED_SCAN_FILES = 512 + +function prunePersistedScanTimes(): void { + while (lastPersistedScannedAtByFile.size > MAX_PERSISTED_SCAN_FILES) { + const oldest = lastPersistedScannedAtByFile.keys().next() + if (oldest.done) { + return + } + lastPersistedScannedAtByFile.delete(oldest.value) + } +} export async function persistWorkspaceCleanupScanResult( snapshotDirectory: string, @@ -218,10 +229,12 @@ export async function persistWorkspaceCleanupScanResult( } if (knownScannedAt !== undefined && knownScannedAt > filteredResult.scannedAt) { lastPersistedScannedAtByFile.set(file, knownScannedAt) + prunePersistedScanTimes() return } await writeSnapshot(file, filteredResult) lastPersistedScannedAtByFile.set(file, filteredResult.scannedAt) + prunePersistedScanTimes() clearSupersededPrunes(file, result, true) return } diff --git a/src/main/wsl-home-cache.ts b/src/main/wsl-home-cache.ts new file mode 100644 index 00000000000..368bf163102 --- /dev/null +++ b/src/main/wsl-home-cache.ts @@ -0,0 +1,33 @@ +const MAX_WSL_HOME_CACHE_ENTRIES = 64 +const wslHomeCache = new Map() + +export function getCachedWslHome(distro: string): string | undefined { + const home = wslHomeCache.get(distro) + if (home === undefined) { + return undefined + } + wslHomeCache.delete(distro) + wslHomeCache.set(distro, home) + return home +} + +export function rememberWslHome(distro: string, home: string): string { + wslHomeCache.delete(distro) + wslHomeCache.set(distro, home) + while (wslHomeCache.size > MAX_WSL_HOME_CACHE_ENTRIES) { + const oldest = wslHomeCache.keys().next().value + if (oldest === undefined) { + break + } + wslHomeCache.delete(oldest) + } + return home +} + +export function hasCachedWslHome(distro: string): boolean { + return wslHomeCache.has(distro) +} + +export function clearWslHomeCache(): void { + wslHomeCache.clear() +} diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 55327c465e5..e81aac84714 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -20,6 +20,7 @@ import { _setWslCachesForTests, getCachedWslAvailability, getCachedWslDistros, + getWslHome, hasCachedWslAvailability, hasCachedWslDistros, isWslAvailable, @@ -311,6 +312,28 @@ describe('WSL distro discovery cache', () => { }) }) +describe('WSL home cache', () => { + afterEach(() => { + execFileMock.mockReset() + execFileSyncMock.mockReset() + _resetWslCachesForTests() + }) + + it('bounds cached homes while retaining the most recently used distros', () => { + execFileSyncMock.mockImplementation((_command, args) => `/home/${args[1]}\n`) + + withPlatform('win32', () => { + for (let index = 0; index < 68; index += 1) { + expect(getWslHome(`Distro-${index}`)).toContain(`Distro-${index}`) + } + expect(getWslHome('Distro-4')).toContain('Distro-4') + expect(execFileSyncMock).toHaveBeenCalledTimes(68) + expect(getWslHome('Distro-0')).toContain('Distro-0') + expect(execFileSyncMock).toHaveBeenCalledTimes(69) + }) + }) +}) + describe('WSL availability cache', () => { afterEach(() => { execFileMock.mockReset() diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 579031de934..be69dde401a 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -16,7 +16,8 @@ import { getWslDirectoryProbeArgs, parseWslDirectoryProbeOutput } from './wsl-directory-probe-command' - +import { clearWslHomeCache, getCachedWslHome, rememberWslHome } from './wsl-home-cache' +export { hasCachedWslHome } from './wsl-home-cache' // Why re-exported rather than defined here: the relay bundle needs the path // conversion without this module's distro-probing subprocess graph. export { toLinuxPath, toWindowsWslPath } from '../shared/wsl-paths' @@ -26,21 +27,11 @@ export { isWslAvailable, isWslAvailableAsync } from './wsl-availability' - export type WslPathInfo = { distro: string linuxPath: string } - -/** - * Detect if a Windows path is a WSL UNC path and extract the distro name - * and equivalent Linux path. - * - * Why: Windows exposes WSL filesystems as UNC paths under \\wsl.localhost\\... - * (modern) or \\wsl$\\... (legacy). When a repo lives on a WSL filesystem, - * native Windows git.exe is either absent or painfully slow — all process spawning - * must be routed through `wsl.exe -d ` with Linux-native paths instead. - */ +/** Detect and parse a WSL UNC path on Windows. */ export function parseWslPath(windowsPath: string): WslPathInfo | null { if (process.platform !== 'win32') { return null @@ -103,9 +94,6 @@ export function wslUncDirectoryExistsAsync(uncPath: string): Promise() const wslHomeProbeCache = new Map>() let wslDistroCache: string[] | null = null let wslDistroListInFlight: Promise | null = null @@ -118,6 +106,7 @@ let wslDistroListRetryAfterMs = 0 let wslDistroListEmptyStreak = 0 let wslDistroProbeSequence = 0 let wslDistroCacheSequence = 0 + function armWslDistroListRetry(): void { const now = Date.now() // Concurrent completions belong to the retry window already armed by the first result. @@ -279,8 +268,9 @@ export function getDefaultWslDistro(): string | null { * WSL user's $HOME to compute that path. */ export function getWslHome(distro: string): string | null { - if (wslHomeCache.has(distro)) { - return wslHomeCache.get(distro)! + const cachedHome = getCachedWslHome(distro) + if (cachedHome !== undefined) { + return cachedHome } try { @@ -296,22 +286,17 @@ export function getWslHome(distro: string): string | null { } const uncPath = toWindowsWslPath(home, distro) - wslHomeCache.set(distro, uncPath) - return uncPath + return rememberWslHome(distro, uncPath) } catch { return null } } -/** Pure cache lookup — never probes. Lets callers that memoize a derived value avoid caching one - * built from the unresolved fallback, since only the success path is cached above. */ -export function hasCachedWslHome(distro: string): boolean { - return wslHomeCache.has(distro) -} - +/** Pure cache lookup — never probes. */ export async function getWslHomeAsync(distro: string): Promise { - if (wslHomeCache.has(distro)) { - return wslHomeCache.get(distro)! + const cachedHome = getCachedWslHome(distro) + if (cachedHome !== undefined) { + return cachedHome } const inflight = wslHomeProbeCache.get(distro) if (inflight) { @@ -325,8 +310,7 @@ export async function getWslHomeAsync(distro: string): Promise { return null } const uncPath = toWindowsWslPath(home, distro) - wslHomeCache.set(distro, uncPath) - return uncPath + return rememberWslHome(distro, uncPath) }) .catch(() => null) .finally(() => { @@ -358,7 +342,7 @@ function resetWslDistroListState(): void { } export function _resetWslCachesForTests(): void { - wslHomeCache.clear() + clearWslHomeCache() wslHomeProbeCache.clear() resetWslDistroListState() _resetRunningWslDistroCacheForTests() diff --git a/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts b/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts index f085311fda2..7a30e6c0ff4 100644 --- a/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts +++ b/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts @@ -63,8 +63,20 @@ describe('automation catalog generation', () => { it('advances every authority on the first sync', () => { registry.sync(buildAutomationHostCatalog(input())) expect(registry.get(DESKTOP)).toBe(1) - expect(registry.get(ENV_A)).toBe(1) - expect(registry.get(ENV_B)).toBe(1) + expect(registry.get(ENV_A)).toBe(2) + expect(registry.get(ENV_B)).toBe(3) + }) + + it('bounds authority generations without reopening an evicted fence', () => { + const authorities = Array.from({ length: 1_100 }, (_, index) => runtime(`env-${index}`)) + registry.sync(buildAutomationHostCatalog(input({ runtimes: authorities }))) + + const afterEviction = registry.get({ kind: 'runtime', environmentId: 'env-0' }) + expect(afterEviction).toBeGreaterThan(1) + expect(registry.get({ kind: 'runtime', environmentId: 'env-1099' })).toBe(1_101) + + registry.sync(buildAutomationHostCatalog(input({ runtimes: [runtime('env-0')] }))) + expect(registry.get({ kind: 'runtime', environmentId: 'env-0' })).toBeGreaterThan(afterEviction) }) it('advances only the authority whose target bucket hydrated', () => { @@ -96,7 +108,7 @@ describe('automation catalog generation', () => { ) ) expect(advanced.advancedAuthorityKeys).toEqual(['authority:runtime:env-a']) - expect(registry.get(ENV_A)).toBe(before.a + 1) + expect(registry.get(ENV_A)).toBeGreaterThan(before.a) expect(registry.get(ENV_B)).toBe(before.b) expect(registry.get(DESKTOP)).toBe(before.desktop) }) @@ -152,8 +164,8 @@ describe('automation catalog generation', () => { input({ runtimes: [runtime('env-a', { pairingRevision: 2 }), runtime('env-b')] }) ) ) - expect(registry.get(ENV_A)).toBe(before + 1) - expect(registry.get(ENV_B)).toBe(1) + expect(registry.get(ENV_A)).toBeGreaterThan(before) + expect(registry.get(ENV_B)).toBe(3) expect(advanced.reincarnatedStableKeys).toEqual(['host:runtime:env-a:self']) }) @@ -204,7 +216,7 @@ describe('automation catalog generation', () => { registry.sync(buildAutomationHostCatalog(input())) registry.sync(buildAutomationHostCatalog(input({ runtimes: [runtime('env-a')] }))) const afterRemoval = registry.get(ENV_B) - expect(afterRemoval).toBe(2) + expect(afterRemoval).toBe(4) registry.sync(buildAutomationHostCatalog(input({ runtimes: [runtime('env-a')] }))) expect(registry.get(ENV_B)).toBe(afterRemoval) }) diff --git a/src/renderer/src/components/automations/automation-host-catalog-generation.ts b/src/renderer/src/components/automations/automation-host-catalog-generation.ts index 8db2c85c16c..5a145a7c6cf 100644 --- a/src/renderer/src/components/automations/automation-host-catalog-generation.ts +++ b/src/renderer/src/components/automations/automation-host-catalog-generation.ts @@ -38,6 +38,8 @@ export type AutomationCatalogGenerationRegistry = { reset: () => void } +const AUTOMATION_AUTHORITY_GENERATION_MAX_ENTRIES = 512 + // Health is deliberately absent: only membership and incarnation belong here. export function automationHostCatalogEntryFingerprint(entry: AutomationHostCatalogEntry): string { return `${entry.stableKey}|${entry.catalogState}|${entry.owner ? ownerKey(entry.owner) : '-'}` @@ -62,6 +64,21 @@ export function createAutomationCatalogGenerationRegistry(): AutomationCatalogGe const generationByAuthorityKey = new Map() const fingerprintByAuthorityKey = new Map() const ownerKeyByStableKey = new Map() + let generationSequence = 0 + let evictedGeneration = 0 + + const trimAuthorityGenerations = (): void => { + while (generationByAuthorityKey.size > AUTOMATION_AUTHORITY_GENERATION_MAX_ENTRIES) { + const oldest = generationByAuthorityKey.keys().next() + if (oldest.done) { + break + } + const key = oldest.value + evictedGeneration = Math.max(evictedGeneration, generationByAuthorityKey.get(key) ?? 0) + generationByAuthorityKey.delete(key) + fingerprintByAuthorityKey.delete(key) + } + } /** Compared only where an owner exists: a disconnect can strip owner refs, and that is not a new incarnation. */ const reincarnations = (catalog: AutomationHostCatalog): string[] => { @@ -90,14 +107,17 @@ export function createAutomationCatalogGenerationRegistry(): AutomationCatalogGe const advance = (authorityKey: string, fingerprint: string): void => { fingerprintByAuthorityKey.set(authorityKey, fingerprint) - generationByAuthorityKey.set( - authorityKey, - (generationByAuthorityKey.get(authorityKey) ?? 0) + 1 - ) + const next = ++generationSequence + generationByAuthorityKey.set(authorityKey, next) + trimAuthorityGenerations() } return { - get: (authority) => generationByAuthorityKey.get(automationAuthorityCatalogKey(authority)) ?? 0, + get: (authority) => { + const key = automationAuthorityCatalogKey(authority) + const generation = generationByAuthorityKey.get(key) + return generation ?? evictedGeneration + }, sync: (catalog) => { const reincarnatedStableKeys = reincarnations(catalog) const next = fingerprintByAuthority(catalog) @@ -121,6 +141,8 @@ export function createAutomationCatalogGenerationRegistry(): AutomationCatalogGe generationByAuthorityKey.clear() fingerprintByAuthorityKey.clear() ownerKeyByStableKey.clear() + generationSequence = 0 + evictedGeneration = 0 } } } diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts index 716f7d971c3..c265f02cff6 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { applyBrowserPageViewportLayout, BROWSER_PAGE_PRESET_VIEWPORT_CLASS_NAME, + _getRememberedBrowserPageInsetCountForTests, ensureBrowserPageViewport, getBrowserPageViewportScrollState, getBrowserOverlaySlotViewport, @@ -243,6 +244,14 @@ describe('ensureBrowserPageViewport', () => { }) describe('syncBrowserPageChromeInset', () => { + it('bounds remembered insets across page churn', () => { + for (let index = 0; index < 600; index += 1) { + syncBrowserPageChromeInset(`retired-page-${index}`, 40) + } + + expect(_getRememberedBrowserPageInsetCountForTests()).toBeLessThanOrEqual(512) + }) + it('reserves space above the webview container for the React chrome header', () => { mountSlotViewport('workspace-1') ensureBrowserPageViewport('page-1', 'workspace-1') diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts index 38a37415527..bf42e112569 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts @@ -28,6 +28,7 @@ const browserPageViewports = new Map() // the inset keeps geometry a property of attaching a guest, not of the first mount. const browserPageChromeInsetHeights = new Map() const browserPageViewportPresetSizes = new Map() +const MAX_REMEMBERED_BROWSER_PAGE_INSETS = 512 const slotRootListeners = new Map void>>() @@ -264,6 +265,13 @@ export function applyBrowserPageViewportLayout( export function syncBrowserPageChromeInset(browserPageId: string, heightPx: number): void { const insetHeight = Math.max(0, heightPx) browserPageChromeInsetHeights.set(browserPageId, insetHeight) + while (browserPageChromeInsetHeights.size > MAX_REMEMBERED_BROWSER_PAGE_INSETS) { + const oldest = browserPageChromeInsetHeights.keys().next() + if (oldest.done) { + break + } + browserPageChromeInsetHeights.delete(oldest.value) + } const viewport = browserPageViewports.get(browserPageId) if (!viewport) { return @@ -281,3 +289,7 @@ export function parkBrowserPageViewport(browserPageId: string): void { viewport.shell.style.opacity = '0' } } + +export function _getRememberedBrowserPageInsetCountForTests(): number { + return browserPageChromeInsetHeights.size +} diff --git a/src/renderer/src/components/github-project/roadmap-tick-format.ts b/src/renderer/src/components/github-project/roadmap-tick-format.ts index 2157939ab75..c7c11811a80 100644 --- a/src/renderer/src/components/github-project/roadmap-tick-format.ts +++ b/src/renderer/src/components/github-project/roadmap-tick-format.ts @@ -13,6 +13,7 @@ export type RoadmapTickLabel = { label: string; sublabel: string | null } // and per bar on every render — cache per locale; the options never vary. const monthFormatters = new Map() const dayFormatters = new Map() +const MAX_FORMATTER_LOCALES = 32 function cachedFormatter( cache: Map, @@ -23,6 +24,13 @@ function cachedFormatter( if (!formatter) { formatter = new Intl.DateTimeFormat(locale, options) cache.set(locale, formatter) + while (cache.size > MAX_FORMATTER_LOCALES) { + const oldest = cache.keys().next() + if (oldest.done) { + break + } + cache.delete(oldest.value) + } } return formatter } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index b5650cc16bc..6e574132764 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -7,6 +7,8 @@ import { import { clearNativeChatModelEnrichmentForTests, ensureNativeChatModelEnrichment, + getNativeChatModelEnrichmentEntryCountForTests, + NATIVE_CHAT_MODEL_ENRICHMENT_MAX_ENTRIES, readNativeChatEnrichedModels, resolveNativeChatLaunchSessionOptions, subscribeNativeChatEnrichedModels @@ -27,6 +29,22 @@ describe('native chat session option enrichment', () => { mocks.discoverRuntimeCommitMessageModels.mockReset() }) + it('bounds settled host enrichment entries', async () => { + for (let index = 0; index < NATIVE_CHAT_MODEL_ENRICHMENT_MAX_ENTRIES + 4; index += 1) { + ensureNativeChatModelEnrichment({ + agent: 'cursor', + hostKey: `ssh:${index}`, + discover: async () => [] + }) + } + await Promise.resolve() + await Promise.resolve() + + expect(getNativeChatModelEnrichmentEntryCountForTests()).toBe( + NATIVE_CHAT_MODEL_ENRICHMENT_MAX_ENTRIES + ) + }) + it('keeps reads synchronous while one host-scoped probe is in flight', async () => { let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined const discover = vi.fn( diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts index 1bb49699050..5254854dada 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -19,6 +19,21 @@ type CatalogEnrichmentEntry = { } const enrichmentByAgentHost = new Map() +export const NATIVE_CHAT_MODEL_ENRICHMENT_MAX_ENTRIES = 256 + +function retainEnrichmentEntry(key: string, entry: CatalogEnrichmentEntry): void { + enrichmentByAgentHost.delete(key) + enrichmentByAgentHost.set(key, entry) + while (enrichmentByAgentHost.size > NATIVE_CHAT_MODEL_ENRICHMENT_MAX_ENTRIES) { + const evictable = [...enrichmentByAgentHost].find( + ([, candidate]) => candidate.listeners.size === 0 && candidate.state !== 'pending' + ) + if (!evictable) { + return + } + enrichmentByAgentHost.delete(evictable[0]) + } +} function enrichmentKey(agent: AgentType, hostKey: string): string { return JSON.stringify([agent, hostKey]) @@ -45,7 +60,7 @@ export function subscribeNativeChatEnrichedModels( listeners: new Set<(models: CatalogModel[]) => void>() } entry.listeners.add(listener) - enrichmentByAgentHost.set(key, entry) + retainEnrichmentEntry(key, entry) return () => entry.listeners.delete(listener) } @@ -90,7 +105,7 @@ export function ensureNativeChatModelEnrichment(args: { listeners: new Set() } entry.state = 'pending' - enrichmentByAgentHost.set(key, entry) + retainEnrichmentEntry(key, entry) // Why: model discovery must never delay rendering or launching; the seed is // immediately usable while this once-per-host probe runs in the background. @@ -98,6 +113,7 @@ export function ensureNativeChatModelEnrichment(args: { .discover() .then((discovered) => { entry.state = 'settled' + retainEnrichmentEntry(key, entry) if (!discovered || discovered.length === 0) { return } @@ -113,9 +129,15 @@ export function ensureNativeChatModelEnrichment(args: { }) .catch(() => { entry.state = 'settled' + retainEnrichmentEntry(key, entry) }) } export function clearNativeChatModelEnrichmentForTests(): void { enrichmentByAgentHost.clear() } + +/** @internal - exposed for leak-regression tests only. */ +export function getNativeChatModelEnrichmentEntryCountForTests(): number { + return enrichmentByAgentHost.size +} diff --git a/src/renderer/src/components/star-nag/StarNagToastHost.test.tsx b/src/renderer/src/components/star-nag/StarNagToastHost.test.tsx index def7fdba63a..0734c019d67 100644 --- a/src/renderer/src/components/star-nag/StarNagToastHost.test.tsx +++ b/src/renderer/src/components/star-nag/StarNagToastHost.test.tsx @@ -292,4 +292,14 @@ describe('StarNagToastHost', () => { expect(toastDismissMock).toHaveBeenCalledWith('toast-1') expect(starNag.dismiss).not.toHaveBeenCalled() }) + + it('dismisses an infinite toast when the host unmounts', () => { + ;({ root, container } = renderHost()) + + act(() => showCallback?.({ mode: 'gh', surface: 'toast' })) + act(() => root?.unmount()) + + expect(toastDismissMock).toHaveBeenCalledWith('toast-1') + expect(starNag.dismiss).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/star-nag/StarNagToastHost.tsx b/src/renderer/src/components/star-nag/StarNagToastHost.tsx index 6f4d8b006c4..d61a35482b2 100644 --- a/src/renderer/src/components/star-nag/StarNagToastHost.tsx +++ b/src/renderer/src/components/star-nag/StarNagToastHost.tsx @@ -231,6 +231,11 @@ export function StarNagToastHost(): null { return () => { unsubscribeShow() unsubscribeHide() + // The toast has an infinite lifetime; dismiss it when this host unmounts + // so its React tree and captured callbacks cannot outlive the surface. + dismissActiveToast() + activeToastIdRef.current = null + activeToastResolvedRef.current = null } }, []) diff --git a/src/renderer/src/lib/host-mirror-handle-gap-verdict-union.test.ts b/src/renderer/src/lib/host-mirror-handle-gap-verdict-union.test.ts index 61fae8cbe3d..d1cb35be2fa 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-verdict-union.test.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-verdict-union.test.ts @@ -244,4 +244,16 @@ describe('handle-gap verdict map, all rules on one tree', () => { expect(countHostMirrorHandleGapVerdictsForTests()).toBe(0) expect(vi.getTimerCount()).toBe(0) }) + + it('bounds permanently orphaned verdicts across distinct environments', () => { + for (let round = 0; round < 600; round += 1) { + const environmentId = `env-orphan-${round}` + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1) + const tabId = `orphan-${round}` + setLiveTabs([tabId], { [tabId]: `remote:${environmentId}@@term_${round}` }) + parkAndExpire(environmentId, tabId) + } + + expect(countHostMirrorHandleGapVerdictsForTests()).toBeLessThanOrEqual(512) + }) }) diff --git a/src/renderer/src/lib/host-mirror-handle-gap-wait.ts b/src/renderer/src/lib/host-mirror-handle-gap-wait.ts index e2d3a58367d..06e269b4605 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-wait.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-wait.ts @@ -77,14 +77,15 @@ const waitersByPane = new Map() * about its predecessor. Pinned as class D in host-mirror-handle-gap-verdict-union.test.ts; do not * delete that case. * - * KNOWN LEAK, deliberately not drained: a verdict whose row the host retracts for good on an - * environment that stays paired and never records again. The generation has not moved, teardown - * never fires, the retracted row can never publish a handle, and the tab-death rule only runs from - * inside a later recording. That entry outlives the session, and because - * `stopStoreSubscriptionIfIdle` counts verdicts, so does the store subscription — a no-op rescan on - * every write to the two `HandleGapStoreState` slices above. It cannot answer: the STORED binding is - * non-empty, so the `''` early return below does not catch it; what does is the compare against a - * fresh `paneBindingFor`, which reads '' for a row that is gone. So it costs work, not correctness. + * BOUNDED RETENTION BACKSTOP: a verdict whose row the host retracts for good on an environment + * that stays paired and never records again. The generation has not moved, teardown never fires, + * the retracted row can never publish a handle, and the tab-death rule only runs from inside a + * later recording. While retained, `stopStoreSubscriptionIfIdle` keeps the subscription alive and + * causes a no-op rescan on writes to the two `HandleGapStoreState` slices above. The 512-entry cap + * eventually evicts it under cross-pane churn; the entry still costs work while retained, not + * correctness. It cannot answer: the STORED binding is non-empty, so the `''` early return below + * does not catch it; what does is the compare against a fresh `paneBindingFor`, which reads '' for + * a row that is gone. * The obvious drain — drop a verdict whose binding no longer matches — is NOT safe: it would break * the genuine reattach, where * the binding goes away and comes back and the verdict must still answer @@ -106,6 +107,7 @@ type ExpiredHandleGapVerdict = { /** Sorted environment-minted PTY ids the tab's leaves held AT PARK TIME; '' when none. */ paneBinding: string } +const MAX_EXPIRED_HANDLE_GAP_VERDICTS = 512 const expiredGenerationByPane = new Map() let unsubscribeStore: (() => void) | null = null @@ -199,6 +201,16 @@ function recordExpiredWait(environmentId: string, key: string): void { // gate stops recording anything at all rather than admitting ''. It pins a different property // (reconnect-void, host-mirror-handle-gap-resume.test.ts). Both are load-bearing, for different // reasons — do not collapse them as redundant. + // Eviction is conservative: a missing verdict makes the pane wait once more, never resume early. + if (!expiredGenerationByPane.has(key)) { + while (expiredGenerationByPane.size >= MAX_EXPIRED_HANDLE_GAP_VERDICTS) { + const oldest = expiredGenerationByPane.keys().next() + if (oldest.done) { + break + } + expiredGenerationByPane.delete(oldest.value) + } + } expiredGenerationByPane.set(key, { generation, paneBinding: waitersByPane.get(key)?.paneBinding ?? '' diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index 97c5b64963c..994410e6c82 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -93,7 +93,6 @@ installMonacoContextMenuPaste(monaco) // Configure Monaco to use the locally bundled editor instead of CDN loader.config({ monaco }) - const unregisterEditorModelRegistry = editorModelRegistry.register(monaco) if (import.meta.hot) { import.meta.hot.dispose(unregisterEditorModelRegistry) diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 51460ceb551..1cdfbc17a70 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -35,6 +35,7 @@ import { githubRepoIdentityKey } from '../../../shared/github/repository-identit export { lookupReposBySlugFromCache } from './repo-slug-cache' const slugResolutionInFlight = new Map>() +const MAX_SLUG_RESOLUTION_GENERATIONS = 1024 // Why: an invalidation (repo removed, remote changed) can land while a // resolution is in-flight — before it ever wrote to `slugByRepoId`. Deleting @@ -43,10 +44,23 @@ const slugResolutionInFlight = new Map>() // generation on every invalidation and commit a result only if the generation // it started with is still current. const slugResolutionGeneration = new Map() +let slugResolutionGenerationSequence = 0 +let evictedSlugResolutionGeneration = 0 function invalidateSlugResolution(cacheKey: string): void { slugResolutionInFlight.delete(cacheKey) - slugResolutionGeneration.set(cacheKey, (slugResolutionGeneration.get(cacheKey) ?? 0) + 1) + slugResolutionGeneration.set(cacheKey, ++slugResolutionGenerationSequence) + while (slugResolutionGeneration.size > MAX_SLUG_RESOLUTION_GENERATIONS) { + const oldest = slugResolutionGeneration.keys().next() + if (oldest.done) { + return + } + evictedSlugResolutionGeneration = Math.max( + evictedSlugResolutionGeneration, + slugResolutionGeneration.get(oldest.value) ?? 0 + ) + slugResolutionGeneration.delete(oldest.value) + } } // Why: clear after remove/remote-change so the next index build re-resolves. @@ -84,12 +98,15 @@ async function resolveRepoSlug( if (inFlight) { return inFlight } - const generation = slugResolutionGeneration.get(cacheKey) ?? 0 + const generation = slugResolutionGeneration.get(cacheKey) ?? evictedSlugResolutionGeneration const resolution = (async () => { // Why: only write the resolved value if this key wasn't invalidated // mid-flight; otherwise a stale slug would repopulate the cache. const commit = (value: string | null): string | null => { - if ((slugResolutionGeneration.get(cacheKey) ?? 0) === generation) { + if ( + slugResolutionInFlight.get(cacheKey) === resolution && + (slugResolutionGeneration.get(cacheKey) ?? evictedSlugResolutionGeneration) === generation + ) { rememberRepoSlug(cacheKey, value) } return value diff --git a/src/renderer/src/runtime/host-session-mirror-hydration-drain.test.ts b/src/renderer/src/runtime/host-session-mirror-hydration-drain.test.ts index 0b96e293c19..19738ffcb09 100644 --- a/src/renderer/src/runtime/host-session-mirror-hydration-drain.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-hydration-drain.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' import { + getParkedHostSessionMirrorWaiterCountForTests, markHostSessionMirrorHydrated, + MAX_PARKED_HOST_SESSION_MIRROR_WAITERS, parkUntilHostSessionMirrorHydrates, resetHostSessionMirrorHydrationForTests } from './host-session-mirror-hydration' @@ -28,4 +30,14 @@ describe('host session mirror hydration drain', () => { expect(() => markHostSessionMirrorHydrated(ENVIRONMENT_ID)).not.toThrow() expect(secondReplay).toHaveBeenCalledTimes(1) }) + + it('bounds parked waiter growth when environments churn', () => { + for (let index = 0; index < MAX_PARKED_HOST_SESSION_MIRROR_WAITERS + 4; index += 1) { + parkUntilHostSessionMirrorHydrates(`env-${index}`, 'repo::worktree', () => {}) + } + + expect(getParkedHostSessionMirrorWaiterCountForTests()).toBe( + MAX_PARKED_HOST_SESSION_MIRROR_WAITERS + ) + }) }) diff --git a/src/renderer/src/runtime/host-session-mirror-hydration.ts b/src/renderer/src/runtime/host-session-mirror-hydration.ts index aaeb8685d73..93e23fc53d9 100644 --- a/src/renderer/src/runtime/host-session-mirror-hydration.ts +++ b/src/renderer/src/runtime/host-session-mirror-hydration.ts @@ -16,6 +16,7 @@ type ParkedMirrorWaiter = { environmentId: string; worktreeId: string; run: () = const hydratedGenerationByEnvironment = new Map() const hydratedGenerationByWorktree = new Map() const parkedWaitersByWorktree = new Map() +export const MAX_PARKED_HOST_SESSION_MIRROR_WAITERS = 512 function worktreeKey(environmentId: string, worktreeId: string): string { return `${environmentId}\0${worktreeId}` @@ -113,11 +114,24 @@ export function parkUntilHostSessionMirrorHydrates( worktreeId: string, run: () => void ): void { - parkedWaitersByWorktree.set(worktreeKey(environmentId, worktreeId), { + const key = worktreeKey(environmentId, worktreeId) + parkedWaitersByWorktree.delete(key) + parkedWaitersByWorktree.set(key, { environmentId, worktreeId, run }) + while (parkedWaitersByWorktree.size > MAX_PARKED_HOST_SESSION_MIRROR_WAITERS) { + const oldest = parkedWaitersByWorktree.keys().next() + if (oldest.done || oldest.value === key) { + break + } + parkedWaitersByWorktree.delete(oldest.value) + } +} + +export function getParkedHostSessionMirrorWaiterCountForTests(): number { + return parkedWaitersByWorktree.size } export function resetHostSessionMirrorHydrationForTests(): void { diff --git a/src/renderer/src/runtime/web-agent-session-handoff.ts b/src/renderer/src/runtime/web-agent-session-handoff.ts index 04f2d2c242c..a0e7a356323 100644 --- a/src/renderer/src/runtime/web-agent-session-handoff.ts +++ b/src/renderer/src/runtime/web-agent-session-handoff.ts @@ -18,6 +18,7 @@ type WebAgentSessionHandoffState = { } const handoffByProvisionalTab = new Map() +export const MAX_WEB_AGENT_SESSION_HANDOFFS = 512 function handoffKey(args: WebAgentSessionHandoffKey): string { return `${args.environmentId}\0${args.worktreeId}\0${args.provisionalTabId}` @@ -33,11 +34,20 @@ export function recordWebAgentSessionHandoff(args: WebAgentSessionHandoff): void ) { return } - handoffByProvisionalTab.set(handoffKey(args), { + const key = handoffKey(args) + handoffByProvisionalTab.delete(key) + handoffByProvisionalTab.set(key, { hostTabId: args.hostTabId, hostTerminalHandle: args.hostTerminalHandle, postCreateSnapshotConfirmed: false }) + while (handoffByProvisionalTab.size > MAX_WEB_AGENT_SESSION_HANDOFFS) { + const oldest = handoffByProvisionalTab.keys().next() + if (oldest.done || oldest.value === key) { + break + } + handoffByProvisionalTab.delete(oldest.value) + } } export function resolveWebAgentSessionHandoff(args: WebAgentSessionHandoffKey): string | null { diff --git a/src/renderer/src/runtime/web-session-close-intent.ts b/src/renderer/src/runtime/web-session-close-intent.ts index 5a80fdf87f3..6d1a7f14b28 100644 --- a/src/renderer/src/runtime/web-session-close-intent.ts +++ b/src/renderer/src/runtime/web-session-close-intent.ts @@ -12,6 +12,7 @@ import { WEB_SESSION_TAB_RPC_TIMEOUT_MS } from './web-session-tab-rpc-timeout' const CLOSE_INTENT_ANSWER_GRACE_MS = 5_000 export const WEB_SESSION_CLOSE_INTENT_TTL_MS = WEB_SESSION_TAB_RPC_TIMEOUT_MS + CLOSE_INTENT_ANSWER_GRACE_MS +export const MAX_WEB_SESSION_CLOSE_INTENT_PARTITIONS = 512 type CloseIntent = { recordedAt: number; durable: boolean } @@ -35,6 +36,13 @@ export function recordWebSessionCloseIntent( let byTab = pendingCloseByOwnerAndWorktree.get(partitionKey) if (!byTab) { byTab = new Map() + while (pendingCloseByOwnerAndWorktree.size >= MAX_WEB_SESSION_CLOSE_INTENT_PARTITIONS) { + const oldest = pendingCloseByOwnerAndWorktree.keys().next() + if (oldest.done || oldest.value === partitionKey) { + break + } + pendingCloseByOwnerAndWorktree.delete(oldest.value) + } pendingCloseByOwnerAndWorktree.set(partitionKey, byTab) } byTab.set(trimmed, { recordedAt: now, durable: byTab.get(trimmed)?.durable === true }) diff --git a/src/renderer/src/runtime/web-session-focus-intent.ts b/src/renderer/src/runtime/web-session-focus-intent.ts index e1a45917c79..9f8728e65c2 100644 --- a/src/renderer/src/runtime/web-session-focus-intent.ts +++ b/src/renderer/src/runtime/web-session-focus-intent.ts @@ -19,6 +19,8 @@ export type WebSessionFocusIntent = { expectedCurrentLocalTabId?: string | null } +export const MAX_WEB_SESSION_FOCUS_INTENTS = 512 + const pendingFocusByOwnerAndWorktree = new Map() type WebSessionVisibleTabState = Pick< @@ -143,11 +145,20 @@ export function recordWebSessionFocusIntent( return } const trimmedLeafId = leafId?.trim() - pendingFocusByOwnerAndWorktree.set(focusIntentPartitionKey(owner, worktreeId), { + const key = focusIntentPartitionKey(owner, worktreeId) + pendingFocusByOwnerAndWorktree.delete(key) + pendingFocusByOwnerAndWorktree.set(key, { hostTabId: trimmed, ...(trimmedLeafId ? { leafId: trimmedLeafId } : {}), ...(expectedCurrentLocalTabId !== undefined ? { expectedCurrentLocalTabId } : {}) }) + while (pendingFocusByOwnerAndWorktree.size > MAX_WEB_SESSION_FOCUS_INTENTS) { + const oldest = pendingFocusByOwnerAndWorktree.keys().next() + if (oldest.done || oldest.value === key) { + break + } + pendingFocusByOwnerAndWorktree.delete(oldest.value) + } } export function peekWebSessionFocusIntent( diff --git a/src/renderer/src/runtime/web-session-intent-owner.test.ts b/src/renderer/src/runtime/web-session-intent-owner.test.ts index 137b89ed991..9e6962b839d 100644 --- a/src/renderer/src/runtime/web-session-intent-owner.test.ts +++ b/src/renderer/src/runtime/web-session-intent-owner.test.ts @@ -1,16 +1,19 @@ import { afterEach, describe, expect, it } from 'vitest' import { + MAX_WEB_SESSION_CLOSE_INTENT_PARTITIONS, isWebSessionCloseIntentPending, recordWebSessionCloseIntent, resetWebSessionCloseIntentForTests } from './web-session-close-intent' import { + MAX_WEB_SESSION_FOCUS_INTENTS, peekWebSessionFocusIntent, clearWebSessionFocusIntentIfMatches, recordWebSessionFocusIntent, resetWebSessionFocusIntentForTests } from './web-session-focus-intent' import { + MAX_REORDER_INTENT_PARTITIONS, recordWebSessionReorderIntent, resetWebSessionReorderIntentForTests, resolveWebSessionReorderedOrder @@ -28,6 +31,37 @@ afterEach(() => { }) describe('web session intent ownership', () => { + it('bounds unresolved reorder intent churn', () => { + for (let index = 0; index < MAX_REORDER_INTENT_PARTITIONS + 4; index += 1) { + recordWebSessionReorderIntent( + { environmentId: `env-${index}`, pairingRevision: 1 }, + WORKTREE_ID, + 'group-1', + ['tab-b', 'tab-a'], + 1_000 + ) + } + + expect( + resolveWebSessionReorderedOrder( + { environmentId: 'env-0', pairingRevision: 1 }, + WORKTREE_ID, + 'group-1', + ['tab-a', 'tab-b'], + 1_000 + ) + ).toEqual(['tab-a', 'tab-b']) + expect( + resolveWebSessionReorderedOrder( + { environmentId: `env-${MAX_REORDER_INTENT_PARTITIONS + 3}`, pairingRevision: 1 }, + WORKTREE_ID, + 'group-1', + ['tab-a', 'tab-b'], + 1_000 + ) + ).toEqual(['tab-b', 'tab-a']) + }) + it('isolates close intents across runtimes and same-id re-pairs', () => { recordWebSessionCloseIntent(OWNER_A, WORKTREE_ID, 'host-tab', 1_000) @@ -38,6 +72,34 @@ describe('web session intent ownership', () => { expect(isWebSessionCloseIntentPending(OWNER_B, WORKTREE_ID, 'host-tab', 1_000)).toBe(false) }) + it('bounds close-intent partition churn', () => { + for (let index = 0; index < MAX_WEB_SESSION_CLOSE_INTENT_PARTITIONS + 4; index += 1) { + recordWebSessionCloseIntent( + { environmentId: `env-${index}`, pairingRevision: 1 }, + WORKTREE_ID, + `host-tab-${index}`, + 1_000 + ) + } + + expect( + isWebSessionCloseIntentPending( + { environmentId: 'env-0', pairingRevision: 1 }, + WORKTREE_ID, + 'host-tab-0', + 1_000 + ) + ).toBe(false) + expect( + isWebSessionCloseIntentPending( + { environmentId: `env-${MAX_WEB_SESSION_CLOSE_INTENT_PARTITIONS + 3}`, pairingRevision: 1 }, + WORKTREE_ID, + `host-tab-${MAX_WEB_SESSION_CLOSE_INTENT_PARTITIONS + 3}`, + 1_000 + ) + ).toBe(true) + }) + it('isolates focus intents across runtimes and same-id re-pairs', () => { recordWebSessionFocusIntent(OWNER_A, WORKTREE_ID, 'host-tab') @@ -48,6 +110,26 @@ describe('web session intent ownership', () => { expect(peekWebSessionFocusIntent(OWNER_B, WORKTREE_ID)).toBeNull() }) + it('bounds unresolved focus intent churn', () => { + for (let index = 0; index < MAX_WEB_SESSION_FOCUS_INTENTS + 4; index += 1) { + recordWebSessionFocusIntent( + { environmentId: `env-${index}`, pairingRevision: 1 }, + WORKTREE_ID, + `host-tab-${index}` + ) + } + + expect( + peekWebSessionFocusIntent({ environmentId: 'env-0', pairingRevision: 1 }, WORKTREE_ID) + ).toBeNull() + expect( + peekWebSessionFocusIntent( + { environmentId: `env-${MAX_WEB_SESSION_FOCUS_INTENTS + 3}`, pairingRevision: 1 }, + WORKTREE_ID + ) + ).toEqual({ hostTabId: `host-tab-${MAX_WEB_SESSION_FOCUS_INTENTS + 3}` }) + }) + it('does not let an older failed create clear a newer focus intent', () => { recordWebSessionFocusIntent(OWNER_A, WORKTREE_ID, 'agent-session:newer') diff --git a/src/renderer/src/runtime/web-session-reorder-intent.ts b/src/renderer/src/runtime/web-session-reorder-intent.ts index 272ef635fa5..fe7d5d4b952 100644 --- a/src/renderer/src/runtime/web-session-reorder-intent.ts +++ b/src/renderer/src/runtime/web-session-reorder-intent.ts @@ -13,6 +13,8 @@ // rejected RPC) from pinning a stale order forever. const REORDER_INTENT_TTL_MS = 10_000 +export const MAX_REORDER_INTENT_PARTITIONS = 512 +export const MAX_REORDER_INTENTS_PER_PARTITION = 256 type ReorderIntent = { order: string[]; recordedAt: number } @@ -53,7 +55,24 @@ export function recordWebSessionReorderIntent( byGroup = new Map() pendingReorderByOwnerAndWorktree.set(partitionKey, byGroup) } + byGroup.delete(groupId) byGroup.set(groupId, { order: [...order], recordedAt: now }) + while (byGroup.size > MAX_REORDER_INTENTS_PER_PARTITION) { + const oldest = byGroup.keys().next() + if (oldest.done || oldest.value === groupId) { + break + } + byGroup.delete(oldest.value) + } + pendingReorderByOwnerAndWorktree.delete(partitionKey) + pendingReorderByOwnerAndWorktree.set(partitionKey, byGroup) + while (pendingReorderByOwnerAndWorktree.size > MAX_REORDER_INTENT_PARTITIONS) { + const oldest = pendingReorderByOwnerAndWorktree.keys().next() + if (oldest.done || oldest.value === partitionKey) { + break + } + pendingReorderByOwnerAndWorktree.delete(oldest.value) + } } /** diff --git a/src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts index 8f769d9a068..054aa4bc60e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts @@ -3,7 +3,9 @@ import type { Tab } from '../../../shared/tab-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import { confirmWebAgentSessionHandoffAfterCreate, - recordWebAgentSessionHandoff + MAX_WEB_AGENT_SESSION_HANDOFFS, + recordWebAgentSessionHandoff, + resolveWebAgentSessionHandoff } from './web-agent-session-handoff' import { applyWebSessionTabsSnapshot, @@ -30,6 +32,33 @@ vi.mock('../store', () => ({ describe('applyWebSessionTabsSnapshot', () => { beforeEach(resetWebSessionTabsSyncTestState) + it('bounds unresolved handoff churn', () => { + for (let index = 0; index < MAX_WEB_AGENT_SESSION_HANDOFFS + 4; index += 1) { + recordWebAgentSessionHandoff({ + environmentId: ENV, + worktreeId: WT, + provisionalTabId: `provisional-${index}`, + hostTabId: `host-${index}`, + hostTerminalHandle: `terminal-${index}` + }) + } + + expect( + resolveWebAgentSessionHandoff({ + environmentId: ENV, + worktreeId: WT, + provisionalTabId: 'provisional-0' + }) + ).toBeNull() + expect( + resolveWebAgentSessionHandoff({ + environmentId: ENV, + worktreeId: WT, + provisionalTabId: `provisional-${MAX_WEB_AGENT_SESSION_HANDOFFS + 3}` + }) + ).toBe(`host-${MAX_WEB_AGENT_SESSION_HANDOFFS + 3}`) + }) + it('keeps a provisional Claude tab when the host Claude surface is unrelated', () => { const staleLocalAgentTab: TerminalTab = { id: 'local-agent-tab', diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.test.ts new file mode 100644 index 00000000000..a00c084b92e --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.test.ts @@ -0,0 +1,21 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + clearWebSessionTabsTrackingForEnvironment, + getWebSessionTabsTrackingGeneration, + resetWebSessionTabsSnapshotFreshnessForTests +} from './tracking-lifecycle' + +describe('web session tabs tracking generations', () => { + beforeEach(() => resetWebSessionTabsSnapshotFreshnessForTests()) + + it('bounds retired environments without reopening an evicted fence', () => { + for (let index = 0; index < 1_100; index += 1) { + clearWebSessionTabsTrackingForEnvironment(`environment-${index}`) + } + + expect(getWebSessionTabsTrackingGeneration('environment-0')).toBeGreaterThan(1) + expect(getWebSessionTabsTrackingGeneration('environment-1099')).toBe(1_100) + clearWebSessionTabsTrackingForEnvironment('environment-0') + expect(getWebSessionTabsTrackingGeneration('environment-0')).toBeGreaterThan(1) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts index 0c32f953986..68d9afe6386 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts @@ -49,6 +49,27 @@ import { removeWebSessionTabsEnvironment } from './tracking' +const MAX_SESSION_TABS_TRACKING_GENERATIONS = 512 +let sessionTabsTrackingGenerationSequence = 0 +let evictedSessionTabsTrackingGeneration = 0 + +function advanceSessionTabsTrackingGeneration(environmentId: string): void { + const next = ++sessionTabsTrackingGenerationSequence + sessionTabsTrackingGenerationByEnvironment.set(environmentId, next) + while (sessionTabsTrackingGenerationByEnvironment.size > MAX_SESSION_TABS_TRACKING_GENERATIONS) { + const oldest = sessionTabsTrackingGenerationByEnvironment.keys().next() + if (oldest.done) { + break + } + const oldestEnvironmentId = oldest.value + evictedSessionTabsTrackingGeneration = Math.max( + evictedSessionTabsTrackingGeneration, + sessionTabsTrackingGenerationByEnvironment.get(oldestEnvironmentId) ?? 0 + ) + sessionTabsTrackingGenerationByEnvironment.delete(oldestEnvironmentId) + } +} + export function getLastKnownHostTerminalTabCount( environmentId: string, worktreeId: string @@ -97,6 +118,9 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { hostSessionTabIdByLocalKey.clear() hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() hostWorkingClientBoundaryByPaneKey.clear() + sessionTabsTrackingGenerationByEnvironment.clear() + sessionTabsTrackingGenerationSequence = 0 + evictedSessionTabsTrackingGeneration = 0 resetWebSessionBrowserPlacementsForTests() } @@ -157,10 +181,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) return } const keyPrefix = `${trimmedEnvironmentId}:` - sessionTabsTrackingGenerationByEnvironment.set( - trimmedEnvironmentId, - (sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1 - ) + advanceSessionTabsTrackingGeneration(trimmedEnvironmentId) for (const key of latestSessionTabsSnapshotByWorktree.keys()) { if (key.startsWith(keyPrefix)) { latestSessionTabsSnapshotByWorktree.delete(key) @@ -223,5 +244,6 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) } export function getWebSessionTabsTrackingGeneration(environmentId: string): number { - return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0 + const key = environmentId.trim() + return sessionTabsTrackingGenerationByEnvironment.get(key) ?? evictedSessionTabsTrackingGeneration } diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index 80548aff381..3c64a0c9a3a 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -27,6 +27,18 @@ import { mergeFetchedProjectCompatibilityForHost } from '../projects/project-com import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' export const runtimeRepoFetchGenerationByEnvironment = new Map() +const MAX_RUNTIME_REPO_FETCH_GENERATIONS = 512 +let runtimeRepoFetchGenerationSequence = 0 + +function pruneRuntimeRepoFetchGenerations(): void { + while (runtimeRepoFetchGenerationByEnvironment.size > MAX_RUNTIME_REPO_FETCH_GENERATIONS) { + const oldest = runtimeRepoFetchGenerationByEnvironment.keys().next() + if (oldest.done) { + return + } + runtimeRepoFetchGenerationByEnvironment.delete(oldest.value) + } +} export function createRuntimeRepoCatalogActions( set: Parameters>[0], @@ -34,9 +46,9 @@ export function createRuntimeRepoCatalogActions( ): Pick { return { fetchRuntimeEnvironmentRepos: async (environmentId) => { - const requestGeneration = - (runtimeRepoFetchGenerationByEnvironment.get(environmentId) ?? 0) + 1 + const requestGeneration = ++runtimeRepoFetchGenerationSequence runtimeRepoFetchGenerationByEnvironment.set(environmentId, requestGeneration) + pruneRuntimeRepoFetchGenerations() const connectionGeneration = getEnvironmentSshStateGeneration(environmentId) const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) let catalogGeneration = 0 diff --git a/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts b/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts index ec30e09944f..5bf4732cc33 100644 --- a/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts +++ b/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts @@ -95,4 +95,17 @@ describe('scheduleSafeAutoForkSync', () => { }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + + it('bounds completed attempt history under repo churn', async () => { + const repos = Array.from({ length: 600 }, (_, index) => ({ + ...RUNTIME_REPO, + id: `repo-${index}`, + path: `/srv/repo-${index}` + })) + + scheduleSafeAutoForkSync(() => stateWith(RUNTIME_REPO), repos) + await flushScheduledSyncs() + + expect(safeAutoForkSyncAttempts.size).toBeLessThanOrEqual(512) + }) }) diff --git a/src/renderer/src/store/repos/safe-auto-fork-sync.ts b/src/renderer/src/store/repos/safe-auto-fork-sync.ts index 3db51383ee5..2a992a89c92 100644 --- a/src/renderer/src/store/repos/safe-auto-fork-sync.ts +++ b/src/renderer/src/store/repos/safe-auto-fork-sync.ts @@ -11,19 +11,36 @@ export const safeAutoForkSyncAttempts = new Map< string, { attemptedAt: number; promise?: Promise } >() +const MAX_SAFE_AUTO_FORK_SYNC_ATTEMPTS = 512 + +function pruneSafeAutoForkSyncAttempts(now: number): void { + for (const [key, attempt] of safeAutoForkSyncAttempts) { + if (!attempt.promise && now - attempt.attemptedAt >= SAFE_AUTO_FORK_SYNC_COOLDOWN_MS) { + safeAutoForkSyncAttempts.delete(key) + } + } + while (safeAutoForkSyncAttempts.size > MAX_SAFE_AUTO_FORK_SYNC_ATTEMPTS) { + const oldest = safeAutoForkSyncAttempts.keys().next() + if (oldest.done) { + return + } + safeAutoForkSyncAttempts.delete(oldest.value) + } +} export function getSafeAutoForkSyncKey(repo: Repo): string { return `${getRepoExecutionHostId(repo)}:${repo.id}:${repo.path}` } export function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Repo[]): void { + const now = Date.now() + pruneSafeAutoForkSyncAttempts(now) for (const repo of repos) { if (repo.kind === 'folder' || repo.forkSyncMode !== 'safe-auto' || !repo.upstream) { continue } const key = getSafeAutoForkSyncKey(repo) const existingAttempt = safeAutoForkSyncAttempts.get(key) - const now = Date.now() if ( existingAttempt?.promise || (existingAttempt && now - existingAttempt.attemptedAt < SAFE_AUTO_FORK_SYNC_COOLDOWN_MS) @@ -52,4 +69,5 @@ export function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Re }) safeAutoForkSyncAttempts.set(key, { attemptedAt: now, promise }) } + pruneSafeAutoForkSyncAttempts(now) } diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts index 61c0ce65638..3efbf0e7805 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts @@ -93,6 +93,12 @@ function targetGenerationsEqual(current: Map, next: Map() const targetConnectionGenerationByEnvironment = new Map() +const MAX_SSH_STATE_GENERATIONS = 512 +const MAX_SSH_TARGET_GENERATIONS = 4096 +let targetConnectionGenerationSequence = 0 +let evictedTargetConnectionGeneration = 0 +let stateGenerationSequence = 0 +let evictedStateGeneration = 0 function targetGenerationKey(environmentId: string, targetId: string): string { return `${environmentId}\0${targetId}` @@ -103,7 +109,8 @@ export function getEnvironmentSshTargetConnectionGeneration( targetId: string ): number { return ( - targetConnectionGenerationByEnvironment.get(targetGenerationKey(environmentId, targetId)) ?? 0 + targetConnectionGenerationByEnvironment.get(targetGenerationKey(environmentId, targetId)) ?? + evictedTargetConnectionGeneration ) } @@ -112,21 +119,37 @@ function advanceEnvironmentSshTargetConnectionGeneration( targetId: string ): void { const key = targetGenerationKey(environmentId, targetId) - targetConnectionGenerationByEnvironment.set( - key, - getEnvironmentSshTargetConnectionGeneration(environmentId, targetId) + 1 - ) + targetConnectionGenerationByEnvironment.set(key, ++targetConnectionGenerationSequence) + while (targetConnectionGenerationByEnvironment.size > MAX_SSH_TARGET_GENERATIONS) { + const oldest = targetConnectionGenerationByEnvironment.keys().next() + if (oldest.done) { + break + } + evictedTargetConnectionGeneration = Math.max( + evictedTargetConnectionGeneration, + targetConnectionGenerationByEnvironment.get(oldest.value) ?? 0 + ) + targetConnectionGenerationByEnvironment.delete(oldest.value) + } } export function getEnvironmentSshStateGeneration(environmentId: string): number { - return stateGenerationByEnvironment.get(environmentId) ?? 0 + return stateGenerationByEnvironment.get(environmentId) ?? evictedStateGeneration } function advanceEnvironmentSshStateGeneration(environmentId: string): void { - stateGenerationByEnvironment.set( - environmentId, - getEnvironmentSshStateGeneration(environmentId) + 1 - ) + stateGenerationByEnvironment.set(environmentId, ++stateGenerationSequence) + while (stateGenerationByEnvironment.size > MAX_SSH_STATE_GENERATIONS) { + const oldest = stateGenerationByEnvironment.keys().next() + if (oldest.done) { + break + } + evictedStateGeneration = Math.max( + evictedStateGeneration, + stateGenerationByEnvironment.get(oldest.value) ?? 0 + ) + stateGenerationByEnvironment.delete(oldest.value) + } } function generationIsCurrent(environmentId: string, generation: number | undefined): boolean { diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 7a7acec0467..36142d0c1c6 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -86,13 +86,27 @@ export type SshSlice = { } const targetConnectionGeneration = new Map() +const MAX_LOCAL_SSH_TARGET_GENERATIONS = 4096 +let targetConnectionGenerationSequence = 0 +let evictedTargetConnectionGeneration = 0 export function getLocalSshTargetConnectionGeneration(targetId: string): number { - return targetConnectionGeneration.get(targetId) ?? 0 + return targetConnectionGeneration.get(targetId) ?? evictedTargetConnectionGeneration } function advanceLocalSshTargetConnectionGeneration(targetId: string): void { - targetConnectionGeneration.set(targetId, getLocalSshTargetConnectionGeneration(targetId) + 1) + targetConnectionGeneration.set(targetId, ++targetConnectionGenerationSequence) + while (targetConnectionGeneration.size > MAX_LOCAL_SSH_TARGET_GENERATIONS) { + const oldest = targetConnectionGeneration.keys().next() + if (oldest.done) { + break + } + evictedTargetConnectionGeneration = Math.max( + evictedTargetConnectionGeneration, + targetConnectionGeneration.get(oldest.value) ?? 0 + ) + targetConnectionGeneration.delete(oldest.value) + } } export const createSshSlice: StateCreator = (set) => ({ diff --git a/src/shared/capability-probe-cache.ts b/src/shared/capability-probe-cache.ts index 0a571dcf4da..79c45fc79b2 100644 --- a/src/shared/capability-probe-cache.ts +++ b/src/shared/capability-probe-cache.ts @@ -14,7 +14,10 @@ export class CapabilityProbeCache { private readonly probesByCapability = new Map>() private readonly supportedCapabilities = new Set() - constructor(private readonly retryIntervalMs: number) {} + constructor( + private readonly retryIntervalMs: number, + private readonly maxEntries = Number.POSITIVE_INFINITY + ) {} shouldTry(capability: TCapability, nowMs = Date.now()): boolean { const retryAfterMs = this.retryAfterByCapability.get(capability) @@ -34,7 +37,9 @@ export class CapabilityProbeCache { rememberSupported(capability: TCapability): void { this.retryAfterByCapability.delete(capability) + this.supportedCapabilities.delete(capability) this.supportedCapabilities.add(capability) + this.trimSettledEntries() } rememberUnsupported(capability: TCapability, nowMs = Date.now()): void { @@ -42,6 +47,7 @@ export class CapabilityProbeCache { // failure on every poll/search wastes subprocesses and trace space. this.supportedCapabilities.delete(capability) this.retryAfterByCapability.set(capability, nowMs + this.retryIntervalMs) + this.trimSettledEntries() } async runWithFallback( @@ -97,6 +103,23 @@ export class CapabilityProbeCache { this.supportedCapabilities.clear() } + private trimSettledEntries(): void { + while (this.supportedCapabilities.size > this.maxEntries) { + const oldest = this.supportedCapabilities.values().next() + if (oldest.done) { + break + } + this.supportedCapabilities.delete(oldest.value) + } + while (this.retryAfterByCapability.size > this.maxEntries) { + const oldest = this.retryAfterByCapability.keys().next() + if (oldest.done) { + break + } + this.retryAfterByCapability.delete(oldest.value) + } + } + private async runPreferredOrFallback( capability: TCapability, runPreferred: () => Promise, @@ -111,7 +134,7 @@ export class CapabilityProbeCache { // overwrite that stronger signal. const outcome = this.retryAfterByCapability.has(capability) ? 'unsupported' : 'supported' if (outcome === 'supported') { - this.supportedCapabilities.add(capability) + this.rememberSupported(capability) } settleProbe?.(outcome) return result diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index 197a053422a..a9073cb0057 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -658,7 +658,6 @@ describe('buildArgs (Antigravity)', () => { }) }) - describe('Pi Source Control AI model selection', () => { it('leaves provider selection to Pi for the config default', () => { const args = getCommitMessageAgentSpec('pi')!.buildArgs({ diff --git a/src/shared/commit-message-agent-specs-primary.ts b/src/shared/commit-message-agent-specs-primary.ts index 0889ebdcef6..729228824bf 100644 --- a/src/shared/commit-message-agent-specs-primary.ts +++ b/src/shared/commit-message-agent-specs-primary.ts @@ -207,7 +207,11 @@ export function buildPrimaryCommitMessageAgentSpecs({ modelDiscovery: { binary: 'opencode2', args: ['models'], parse: parseLineModels }, models: [ { id: 'opencode/deepseek-v4-flash-free', label: 'OpenCode DeepSeek V4 Flash Free' }, - { id: 'opencode/gpt-5.4-mini', label: 'OpenCode GPT 5.4 Mini', ...withOpenAiThinking('gpt-5.4-mini') } + { + id: 'opencode/gpt-5.4-mini', + label: 'OpenCode GPT 5.4 Mini', + ...withOpenAiThinking('gpt-5.4-mini') + } ], defaultModelId: 'opencode/deepseek-v4-flash-free' }, From eb6068a4342ad9acf62cb034bcb1a5a42a2f176f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:46:25 -0400 Subject: [PATCH 187/224] fix(relay): stop a terminated checked-out PostgreSQL client from killing the cell (#21840) pg-pool removes its own `error` listener when it hands a client out (pg-pool@3.14.0 index.js:344) and only reattaches it in `_release` (index.js:385). Between acquire and release the client therefore has no `error` listener, so when Cloud SQL terminates that session mid-statement the emit becomes an unhandled 'error' event and the process exits. `absorbPostgresIdleClientErrors` cannot see it: pg-pool routes to `pool.on('error')` only from the idle listener. Attach a per-checkout `error` listener in the one seam every relay checkout passes through, log a single warn line, and release the client with the error so pg-pool destroys it instead of pooling a dead connection. The listener is removed on release so it cannot accumulate. The in-flight query still rejects, so existing failure reporting and the transaction retry ladder are unchanged. Claude-Session: https://claude.ai/session/ced32ebb-7155-4413-adad-1eccd14c2010 --- .../cell-inventory-lock-contention.test.ts | 10 ++- .../src/database-postgres-timeout.test.ts | 10 ++- cloud/apps/relay/src/database.ts | 2 +- .../postgres-checked-out-client-error.test.ts | 80 +++++++++++++++++++ .../relay/src/postgres-pool-pressure.test.ts | 4 +- .../apps/relay/src/postgres-pool-pressure.ts | 39 ++++++++- .../relay/src/postgres-query-failure.test.ts | 12 ++- 7 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 cloud/apps/relay/src/postgres-checked-out-client-error.test.ts diff --git a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts index dcc63e8c02d..4c89315ec3d 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts @@ -8,6 +8,14 @@ const fakes = vi.hoisted(() => ({ return { rows: [], rowCount: 0 } }), release: vi.fn(), + // A real pooled client is an EventEmitter, and the acquire path attaches an + // `error` listener to it before handing it to the caller. + client: () => ({ + query: fakes.query, + release: fakes.release, + on: vi.fn(), + removeListener: vi.fn() + }), end: vi.fn(async () => undefined) })) @@ -19,7 +27,7 @@ vi.mock('pg', () => ({ waitingCount = 0 end = fakes.end on = vi.fn() - connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release })) + connect = vi.fn(async () => fakes.client()) } } })) diff --git a/cloud/apps/relay/src/database-postgres-timeout.test.ts b/cloud/apps/relay/src/database-postgres-timeout.test.ts index adc27cc451c..2922ea3a4d6 100644 --- a/cloud/apps/relay/src/database-postgres-timeout.test.ts +++ b/cloud/apps/relay/src/database-postgres-timeout.test.ts @@ -8,6 +8,14 @@ const fakes = vi.hoisted(() => ({ lifecycle: [] as string[], query: vi.fn(async (_sql: string) => ({ rows: [], rowCount: 0 })), release: vi.fn(), + // A real pooled client is an EventEmitter, and the acquire path attaches an + // `error` listener to it before handing it to the caller. + client: () => ({ + query: fakes.query, + release: fakes.release, + on: vi.fn(), + removeListener: vi.fn() + }), end: vi.fn(async () => undefined) })) @@ -18,7 +26,7 @@ vi.mock('pg', () => ({ idleCount = 1 waitingCount = 0 on = vi.fn() - connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release })) + connect = vi.fn(async () => fakes.client()) private readonly label: string constructor(config: Record) { diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 2b3cebbd89c..005716c0657 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -992,7 +992,7 @@ async function waitForPostgresRetry(random: () => number = Math.random): Promise await new Promise((resolve) => setTimeout(resolve, delayMs)) } -class PostgresDatabase implements RelayDatabase { +export class PostgresDatabase implements RelayDatabase { readonly dialect = 'postgres' as const private readonly pressure: PostgresPoolPressure private readonly holds = new CellInventoryHoldSamples() diff --git a/cloud/apps/relay/src/postgres-checked-out-client-error.test.ts b/cloud/apps/relay/src/postgres-checked-out-client-error.test.ts new file mode 100644 index 00000000000..871b013cf96 --- /dev/null +++ b/cloud/apps/relay/src/postgres-checked-out-client-error.test.ts @@ -0,0 +1,80 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { PostgresDatabase } from './database.js' + +// Stands in for a pg client between acquire and release. pg-pool assigns +// `release` per checkout, which is the property the guard wraps. +class FakePoolClient extends EventEmitter { + readonly released: Array = [] + readonly statements: string[] = [] + + constructor(private readonly respond: (sql: string) => { rows: unknown[]; rowCount: number }) { + super() + } + + query = vi.fn((sql: string) => { + this.statements.push(sql) + return Promise.resolve(this.respond(sql)) + }) + + release = (error?: Error | boolean): void => { + this.released.push(error) + } +} + +function poolOf(client: FakePoolClient) { + return { totalCount: 1, idleCount: 0, waitingCount: 0, connect: async () => client } +} + +describe('checked-out PostgreSQL client failure handling', () => { + it('crashes the process when nothing listens, which is the bug being fixed', () => { + // Node's own contract: this is what killed cell c28 on 2026-09-20 20:18Z. + const unguarded = new EventEmitter() + expect(() => unguarded.emit('error', new Error('Connection terminated unexpectedly'))).toThrow( + 'Connection terminated unexpectedly' + ) + }) + + it('absorbs the error, rejects the transaction, and releases the client as failed', async () => { + const terminated = Object.assign(new Error('Connection terminated unexpectedly'), { + code: '57P01' + }) + let listenersWhileCheckedOut = 0 + const client: FakePoolClient = new FakePoolClient((sql) => { + if (sql !== 'SELECT 1') return { rows: [], rowCount: 0 } + listenersWhileCheckedOut = client.listenerCount('error') + // Cloud SQL terminating the session: the client emits `error` and the + // in-flight statement rejects with the same failure. + expect(() => client.emit('error', terminated)).not.toThrow() + throw terminated + }) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const database = new PostgresDatabase(poolOf(client) as never) + + await expect( + database.transaction(async (transaction) => await transaction.query('SELECT 1')) + ).rejects.toBe(terminated) + + expect(listenersWhileCheckedOut).toBe(1) + expect(client.listenerCount('error')).toBe(0) + expect(client.released).toEqual([terminated]) + expect(client.statements).toEqual(['BEGIN', 'SELECT 1', 'ROLLBACK']) + expect(warning).toHaveBeenCalledWith( + '[orca-relay] checked-out PostgreSQL client failed: 57P01 Connection terminated unexpectedly' + ) + + warning.mockRestore() + }) + + it('releases a healthy client back to the pool with no error', async () => { + const client = new FakePoolClient(() => ({ rows: [{ one: 1 }], rowCount: 1 })) + const database = new PostgresDatabase(poolOf(client) as never) + + await expect( + database.transaction(async (transaction) => await transaction.query('SELECT 1')) + ).resolves.toEqual([{ one: 1 }]) + + expect(client.released).toEqual([undefined]) + expect(client.listenerCount('error')).toBe(0) + }) +}) diff --git a/cloud/apps/relay/src/postgres-pool-pressure.test.ts b/cloud/apps/relay/src/postgres-pool-pressure.test.ts index 2e020bf43fb..4a59dbfef97 100644 --- a/cloud/apps/relay/src/postgres-pool-pressure.test.ts +++ b/cloud/apps/relay/src/postgres-pool-pressure.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { PostgresPoolPressure } from './postgres-pool-pressure.js' @@ -32,7 +33,8 @@ describe('PostgreSQL pool pressure', () => { now = 2_250 pool.waitingCount-- - resolveConnection({ query: vi.fn(), release: vi.fn() }) + // An EventEmitter because the acquire path now attaches an `error` listener. + resolveConnection(Object.assign(new EventEmitter(), { query: vi.fn(), release: vi.fn() })) await pending expect(pressure.consumeCounts()).toMatchObject({ databasePoolWaiting: 0, diff --git a/cloud/apps/relay/src/postgres-pool-pressure.ts b/cloud/apps/relay/src/postgres-pool-pressure.ts index ab0f188067f..afeab79f5a1 100644 --- a/cloud/apps/relay/src/postgres-pool-pressure.ts +++ b/cloud/apps/relay/src/postgres-pool-pressure.ts @@ -30,6 +30,10 @@ function errorMessage(error: unknown): string { return String((error as { message?: unknown } | null)?.message) } +function errorCode(error: unknown): string { + return String((error as { code?: unknown } | null)?.code) +} + function isPostgresPoolAcquireFailure(error: unknown): boolean { return typeof error === 'object' && error !== null && poolAcquireFailures.has(error) } @@ -131,12 +135,45 @@ export class PostgresPoolPressure { } async function markedAcquire(connection: Promise): Promise { + let client: pg.PoolClient try { - return await connection + client = await connection } catch (error) { if (typeof error === 'object' && error !== null) poolAcquireFailures.add(error) throw error } + return guardCheckedOutClient(client) +} + +// pg-pool strips its own `error` listener when it hands a client out +// (pg-pool@3.14.0 index.js:344) and only reattaches it in `_release` +// (index.js:385), so a checked-out client has no `error` listener at all. A +// backend that terminates that session mid-statement therefore emits `error` +// with nothing listening, which is an unhandled 'error' event and kills the +// process. `pool.on('error')` cannot cover this: pg-pool routes there only from +// the idle listener. Every relay checkout awaits this function, so it is the +// one seam that sees them all. +function guardCheckedOutClient(client: pg.PoolClient): pg.PoolClient { + let failure: Error | undefined + const onError = (error: Error) => { + failure ??= error + // Printable unlike the idle path: a checked-out client is past the + // handshake, so its error carries no connection string. + console.warn( + `[orca-relay] checked-out PostgreSQL client failed: ${errorCode(error)} ${errorMessage(error)}` + ) + } + client.on('error', onError) + + // pg-pool assigns a fresh `release` on every acquire, so this never stacks. + const release = client.release.bind(client) + client.release = (releaseError?: Error | boolean) => { + client.removeListener('error', onError) + // Passing the error makes pg-pool destroy the client instead of returning a + // dead connection to the pool for the next caller to trip over. + release(releaseError ?? failure) + } + return client } export function emptyPostgresPoolPressureCounts(): PostgresPoolPressureCounts { diff --git a/cloud/apps/relay/src/postgres-query-failure.test.ts b/cloud/apps/relay/src/postgres-query-failure.test.ts index c0dca423bab..25a3d7f7900 100644 --- a/cloud/apps/relay/src/postgres-query-failure.test.ts +++ b/cloud/apps/relay/src/postgres-query-failure.test.ts @@ -3,7 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const fakes = vi.hoisted(() => ({ connectError: undefined as unknown, query: vi.fn(async (_sql: string, _params?: unknown[]) => ({ rows: [], rowCount: 0 })), - release: vi.fn() + release: vi.fn(), + // A real pooled client is an EventEmitter, and the acquire path attaches an + // `error` listener to it before handing it to the caller. + client: () => ({ + query: fakes.query, + release: fakes.release, + on: vi.fn(), + removeListener: vi.fn() + }) })) vi.mock('pg', () => ({ @@ -15,7 +23,7 @@ vi.mock('pg', () => ({ on = vi.fn() async connect() { if (fakes.connectError) throw fakes.connectError - return { query: fakes.query, release: fakes.release } + return fakes.client() } async end() {} } From 1b9d218df508e50b8ffac2a969edc7854c4907b8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:58:35 -0700 Subject: [PATCH 188/224] fix(release): force draft publishes on tag checkouts (#21842) Build jobs check out the release tag, so electron-builder still used releaseType:release from older SHAs and published v1.4.206 as latest with only Linux assets. Override publish.releaseType=draft on the CLI (workflow YAML comes from main) and restore the draft helpers from the workflow ref. --- .github/workflows/release-cut.yml | 26 +++++++++++++++++-- .github/workflows/release-mac-build.yml | 10 ++++++- .../assert-github-release-is-draft.test.mjs | 22 ++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index cf7df2374a5..966396ac453 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -809,6 +809,16 @@ jobs: with: ref: refs/tags/${{ needs.cut.outputs.tag }} + - name: Restore draft-release scripts from the workflow ref + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- \ + config/scripts/create-draft-release.mjs \ + config/scripts/assert-github-release-is-draft.mjs + - name: Create draft release with bounded generated notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1181,14 +1191,14 @@ jobs: ~\AppData\Local\electron-builder\Cache - os: ubuntu-latest platform: linux-x64 - release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always -c.publish.releaseType=draft unpacked_dir: dist/linux-unpacked eb_cache_path: | ~/.cache/electron ~/.cache/electron-builder - os: ubuntu-24.04-arm platform: linux-arm64 - release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always -c.publish.releaseType=draft unpacked_dir: dist/linux-arm64-unpacked eb_cache_path: | ~/.cache/electron @@ -1226,6 +1236,18 @@ jobs: # actions directory from the commit this workflow file itself came from. # Not Windows-only: every platform now consumes install-mobile-dependencies, so # any of them can be the one whose cut ref predates the action. + - name: Restore draft-publish scripts from the workflow ref + # Why: this job checks out the release tag, so a cut from an older SHA + # still has electron-builder releaseType:release and no re-draft helper. + # The workflow YAML is from main; restore the scripts it invokes. + shell: bash + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs + - name: Restore composite actions from the workflow ref shell: bash env: diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 002622b7154..b8e0079984b 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -37,6 +37,14 @@ jobs: with: ref: refs/tags/${{ inputs.tag }} + - name: Restore draft-publish scripts from the workflow ref + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs + - name: Setup pnpm uses: pnpm/setup@v2 with: @@ -163,7 +171,7 @@ jobs: timeout_minutes: 45 max_attempts: 3 retry_wait_seconds: 30 - command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always -c.publish.releaseType=draft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTS }} diff --git a/config/scripts/assert-github-release-is-draft.test.mjs b/config/scripts/assert-github-release-is-draft.test.mjs index b549d25b59d..8b730e9fb37 100644 --- a/config/scripts/assert-github-release-is-draft.test.mjs +++ b/config/scripts/assert-github-release-is-draft.test.mjs @@ -135,5 +135,27 @@ describe('release draft workflow contract', () => { expect(abortParentStep.env.PARENT_RUN).toBe('${{ inputs.release_run_id }}') expect(abortParentStep.run).toContain('refusing to publish mac artifacts') expect(macDraftStep.run).toContain('assert-github-release-is-draft.mjs') + expect(macPublishStep.with.command).toContain('-c.publish.releaseType=draft') + + const linuxCommands = releaseWorkflow.jobs.build.strategy.matrix.include + .filter((entry) => String(entry.platform).startsWith('linux')) + .map((entry) => entry.release_command) + expect(linuxCommands.length).toBe(2) + for (const command of linuxCommands) { + expect(command).toContain('-c.publish.releaseType=draft') + } + + const createRestore = releaseWorkflow.jobs['create-release'].steps.find( + (step) => step.name === 'Restore draft-release scripts from the workflow ref' + ) + const buildRestore = releaseWorkflow.jobs.build.steps.find( + (step) => step.name === 'Restore draft-publish scripts from the workflow ref' + ) + const macRestore = macSteps.find( + (step) => step.name === 'Restore draft-publish scripts from the workflow ref' + ) + expect(createRestore.run).toContain('create-draft-release.mjs') + expect(buildRestore.run).toContain('assert-github-release-is-draft.mjs') + expect(macRestore.run).toContain('assert-github-release-is-draft.mjs') }) }) From ec8217313066999623ace6a6755e80f99785c63b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:49:38 -0400 Subject: [PATCH 189/224] feat(mobile): mount the terminal document in the page over its own modules (OTA phase C, C7.5) (#21809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `.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 3006d8dfdf is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the query-reply gate a module the page can import The second of the twelve groups, and the one that corrects the scope table's membership rule. `terminalDataRepliesEnabled` is written from four places, so the whole-script census counted it among the 57 variables that cannot stay free across modules. All four writes are in this group. Once the script is modules, a variable written only inside the module that declares it is that module's own state, not the document's, and it stays a `let` there. So the scope object holds what crosses a module boundary, and the 57 is an upper bound rather than the answer; the qualifier count the flip commit pins will be lower than the 641 measured over the single scope, and by how much is a function of where the boundaries fall. Two references do cross here and are qualified: the write-queue generation this group compares against, and the observer-disposal list it pushes onto. Counts pinned: two qualified references, one `var` to `let`, two one-statement `if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make reflow a module, and give the generator its own tests The third group, and the defect it found: esbuild wraps a long import list across lines, and the generator was skipping only the first of them, which left the remaining names loose in the emitted script. The document did not parse, and the equivalence check said so by name rather than throwing — which is what that refusal path was added for. Both lists, import and export, are now skipped to their closer instead of by their first line. The generator's own tests cover what the per-group comparisons cannot say on their own: an export is unmarked and indented into the document scope, a one-line import is dropped, a wrapped import is dropped whole, the trailing export block esbuild prints is dropped rather than left as a bare block statement, and types are erased without touching the program. Reflow's counts: eleven qualified references — the terminal ten times and the settled row count once — six locals that became `const`, and the two early returns braced. The row count is written from three groups, so unlike the query-reply flag it is the document's state rather than one module's. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the keyboard-avoidance metrics a module The fourth group, and the first that needed a non-null assertion. `lineHasVisibleContent` reads the terminal's column count with no guard of its own; the guard is in `computeContentBottomRow`, which is its only caller. Adding a guard would change the program, and optional chaining would change what happens when there is no terminal — the document throws there today. TypeScript erases a non-null assertion, so the emitted script is unchanged and the invariant is written down where the reader needs it. Reflow now imports the metrics call from this module rather than declaring it an external, which is the shape every group takes as its neighbours arrive. Counts: fourteen qualified references, nine locals rebound, ten one-statement bodies braced, and the two `catch (e) {}` clauses — the row scan and the alternate-screen probe — unbound. The scope table's rule is stated more precisely with it: a variable is this module's own only when the group both declares and assigns it. While the rest of the document is still strings, one the main slice declares stays shared even if every use is in one group, because emitting a second declaration beside the one the slice still carries would not be the same program. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make WebGL loss recovery a module The fifth group, and the first carrying a top-level statement rather than only declarations: the visibility listener it registers. In the document that runs when the IIFE reaches it; as a module it runs on import, which is the same single registration. The context-loss listener disposes the addon it is registered on, so it cannot run before that addon exists, but the assignment is to a `let` a closure captures and TypeScript will not carry the narrowing across it. A non-null assertion, erased by the compiler, keeps the emitted script identical and puts the invariant where the reader is. Counts: twenty-three qualified references across the terminal, the addon, its retry timer and the theme the host last sent; three locals rebound; twelve one-statement bodies braced; five of the six catch clauses unbound, the sixth keeping its binding because the attach failure reads the error into its diagnostic. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make indirect-pointer scroll a module, and count a fifth class The sixth group found a rule the four classes do not cover, so I measured the whole script rather than meeting them one at a time: linting all 2,757 lines as a module trips `curly` 279 times and `no-unused-vars` 38, both already counted, and then five further rules at 23 sites — `prefer-number-properties` 17, `prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1 and `no-unused-expressions` 1. Seventeen of those 23 are one rewrite: a global numeric function moved onto `Number`. It has the same token shape as the qualifier, so it is counted as its own class rather than folded into anything, and only the four numeric globals are admitted — anything else appearing under `Number` is refused, which a case pins. Every site is already behind a `typeof … === 'number'` check or is parsing a string, so the two forms are the same test. The remaining six sites are each a different shape and too few to be worth matching; they will surface as refusals in whichever group carries them, and I will report each rather than widen this. The scroll accumulator is the first declaration to move onto the scope: it is declared in this group but a touch scroll in another slice resets it, so the `var` becomes an assignment to the shared field and the class that exists for exactly that counts one. Counts: five qualified references, one declaration moved, four locals rebound, eight bodies braced, one `Number` rewrite. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal surface-swap group into a module The seventh named group. `surface` and the uncommitted terminal are read by other slices, so both move onto the scope; the two committed handles and the pending surface are declared and assigned only here and stay module locals. Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2, unbound catches 2, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): substitute build-time constants into the emitted document The document's script text is not all hand-written: parts of it are template literals interpolating real values, starting with the theme background. A module cannot interpolate and still be the same program, so the generator now derives an esbuild `define` from `document-constants.ts` and substitutes after the import lines are dropped, when the names are free again. The page imports the very same bindings, so there is one source either way. The fixture script's TypeScript loader moves beside it rather than being written twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal theme group into a module The eighth named group, and the first parameterised one: its background fallback comes from the mobile theme through `document-constants.ts`. Two sites carry a line-scoped lint disable rather than the rewrite the rule asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every normalisation class the equivalence instrument counts, so taking them would change the program the native document carries, which is the one thing this branch holds fixed. The reason is on the disable line. Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13, unbound catches 0, number properties 9. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal path-tap group into a module The ninth named group, and a pure query: it reads no shared state, so it has no qualifier sites at all. Two things this group forced. The generator now drops lint directive lines before the transform, because a directive inside an expression makes esbuild parenthesise that expression to keep the comment where it was, and those parentheses are tokens the document does not have. And the two regexes keep their `no-useless-escape` escapes behind a line-scoped disable, for the same reason the theme group keeps `Math.pow`. One name the document declares twice in one function stays `var`. Two block-scoped declarations would be two bindings where the document has one, and esbuild renames the inner one to say so. Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20, unbound catches 0, number properties 2. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal tap-dispatch group into a module The tenth named group, and the heaviest reader of shared state: the selection, its elements, its thresholds and both press origins are all declared by the overlay slice, which is still document text, so all of them move onto the scope with their declarations left where they are. Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11, unbound catches 0, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal mouse-click-drag group into a module The eleventh named group. The escape byte and both SGR mouse modes join the scope from the runtime slice; the gesture itself is declared here and never read outside, so it stays a module local. Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27, unbound catches 1, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal url-tap group into three modules The twelfth and last named group, and the second parameterised one: both candidate patterns and the length bound come through `document-constants.ts`. Three modules rather than one. At 303 lines it was over the file cap, and the document's own order interleaves the OSC 8 lookup with the file-URL parsing, so the split follows that order and the group's text is the three emissions joined. The test does the joining. Note for a later lane: `terminal-webview-url-tap.ts` and `terminal-file-url-tap.ts` already hold TypeScript twins of some of this, written for the React Native side and not identical to what the document carries. Collapsing the two is a behaviour change and does not belong in a branch whose whole claim is that the document did not move. Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25, unbound catches 6, number properties 4. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the mouse-mode DECSET scan slice into a module The first of the thirteen inline slices. Both control-sequence introducers, the straddling scan tail and all three mode fields are declared by the runtime-state slice, which is still document text, so they move onto the scope with their declarations left where they are. Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9, unbound catches 0, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal message-bridge slice into a module The script and the document end in the same slice, so the slice splits in two at the point where the IIFE closes: the script half becomes a module, the document half stays text. The byte pin proves the join is unchanged. The second catch keeps its binding: it names the error and reports it. Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0, unbound catches 1, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the document close its own slice file The previous commit put two exports in one slice file, which the slice-count guard reads as a mismatch: it derives the slice list from the composer's imports and cross-checks it against the composed entries, one per file. Five suites failed to load. Splitting the file rather than the constant is the better shape anyway. The file was called `message-bridge-and-document-close` because it carried two concerns; now each has its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal term-observers slice into modules This slice interpolates the already-extracted keyboard-avoidance group between its own two halves, so its text is three emissions joined in that order and the test does the joining. A sixth normalisation class, measured here rather than assumed: the printer writes `{ name: name }` back as shorthand, and qualifying the value makes the property name unavoidable again, so one baseline token faces four. It is counted on its own like the others, with its own acceptance case in the instrument's test, and every existing group's pin now carries a zero for it. Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12, unbound catches 6, number properties 0, shorthand properties 4. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the selection-state-and-eviction slice into a module The slice that declares most of the shared selection state: every threshold, every overlay element and the selection itself, twenty-two scope declarations in one place. The eviction counter is declared and assigned only here, so it stays a module local. Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the smooth-scroll and cell-geometry slice Two modules, not one: the slice carries the normal-buffer smooth scroll and then the cell-to-pixel geometry, and the split follows that order so the group's text is the two emissions joined. Four names stop being externals and become real imports. Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal write-queue slice into a module The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`, which belong to other concerns but sit here because emitted-document order pins them here; four names stop being externals as a result. The observer disposal keeps its guard-as-expression form behind a line-scoped disable: the rewrite the rule asks for is outside every counted class. Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10, unbound catches 1, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal fit-scale slice into a module The slice opens with the already-extracted theme group, so its text is two emissions joined. Four more names stop being externals. Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20, unbound catches 0, number properties 9, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal init-and-write slice into a module The slice opens with the already-extracted webgl-recovery group, so its text is two emissions joined. init() resets almost every field the document shares, which makes this the densest qualifier site in the script. The caret options were interpolated from the theme module, so they join `document-constants.ts` as four exports: a substitution is keyed by name, not by property path. One local the document declares and never reads keeps a line-scoped `no-unused-vars` disable. Removing it would be a different program, which is the one thing this branch does not do. Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18, unbound catches 7, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the runtime-state and text-scaling slice The document's declaration block, where almost everything it shares is declared, with the query-reply and surface-swap groups interpolated inside it. Three modules: the two declarations that come before the groups, the text scaling, and the viewport transform with the scroll indicator. Seven more names stop being externals. Two things this slice forced. The scope-declaration rule now counts each declarator of one `var`, because `var panX = 0, panY = 0` becomes two assignments onto the scope. It has its own acceptance case in the instrument's test. The two halves are compared against their own text rather than as one joined program. The declaration the slice opens with is shadowed by a parameter inside one of the interpolated groups, and printing the baseline as one program renames that parameter; qualifying the outer name removes the shadow, so the rename has nothing to correspond to. Splitting the slice on the group constants compares like with like, and those groups have their own tests. Build-time constants are now substituted textually rather than through an esbuild `define`: a `define` whose value is an object or an array is injected as a helper binding instead of being inlined. Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38, rebindings 25, braced bodies 13, unbound catches 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the two test files the last commit left unformatted Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the mouse-report and scroll-routing slice Two modules around the already-extracted mouse-report-cell group: the viewport cell lookup that precedes it, and the mouse input encoding and scroll routing that follow. Eight more names stop being externals, which leaves ten. Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42, unbound catches 3, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the host-message-router slice into modules Two modules after the already-extracted reflow group: the postMessage bridge with the engine error reporting that rides on it, and the router itself. `notify`, `handleMsg` and `reportEngineError` stop being externals, which leaves seven. The catch binding handed to the error reporter keeps a cast: a catch variable is `unknown` under strict mode, and the reporter reads only `message` before falling back to `String()`. The reason is on the line. Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12, unbound catches 2, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the selection-overlay slice into modules Two modules after the already-extracted path-tap and url-tap groups: the selection range with the xterm mirror, and the overlay positioning with the edge scroll. Six more names stop being externals, which leaves one. Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63, unbound catches 9, number properties 6, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the surface-touch-gestures slice into modules The last of the thirteen slices. Two modules after the three already-extracted groups: the selection menu's buttons, and the touch gestures with the pinch and the momentum scroll. `attachSurfaceEventHandlers` was the last external, so `document-externals.ts` is gone: every name the document uses now resolves to a module. The instrument reads both sides strict. A loose script has to defend Annex B's block-scoped function declarations, and the printer does that by hoisting a `var` and renaming the function, so one side carried a rename the other could not. Neither name escapes its block, so the two readings agree on behaviour and only the strict one can be compared. It has its own acceptance case. Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57, unbound catches 2, number properties 2, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the document's opening declarations into a module The document shell carried the IIFE opener and the eight declarations inside it, so it splits the way the message-bridge slice did: the shell keeps the HTML and the opener, a new slice file holds the declarations, and the byte pin proves the join is unchanged. With this every line of the document's script has a module behind it. Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the whole document script against the modules Every line of the script now has a module behind it, so the whole thing can be compared at once. This is the review of the move, as one number per class: qualifier 609 references + 73 declarations = 682 sites var rebindings 373, the document's 446 declarators less those 73 curly braces 279, the number measured before any of this started unbound catches 36 of 38; two name their error and report it Number properties 17, also measured up front shorthand properties 4, two SGR flags written twice each unshadowed names 7 A seventh class was needed and is counted like the others: a binding that shadowed a document variable stops being a shadow once that variable moves onto the scope, so the printer stops disambiguating it. It has its own acceptance case. The module order lives in one file that both this test and the generator read, so neither can drift from the other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): keep only the lint directives that do something Seventeen of the disables were inert: `typescript/no-non-null-assertion` is not enabled here, and a directive naming two rules on one line is not parsed at all, so the one rule that did apply was being ignored too. The changed-code quality gate reports an inert directive as a finding. The two that matter are back, one rule per line: the guard-as-expression in the observer disposal, and the local the document declares and never reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): generate the terminal document from its modules The WebView document is no longer a hand-written IIFE pasted into a template string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts` and the 36 modules under `src/terminal/document/` in document order, strips their imports, exports and line-scoped lint directives, substitutes the `document-constants.ts` exports textually, reprints each with esbuild and wraps the result in one IIFE. `terminal-webview-html.ts` composes the shell, that generated script and the close fragment. The artifact is gitignored and built by postinstall, like the two engine artifacts. The emitted document is token-equivalent to the old one under eight counted normalisation classes, each pinned as an exact number in `document/terminal-document-flip.test.ts` against the pre-flip text: qualifiedReferences 609 scopeFieldDeclarations 73 rebindings 373 bracedBodies 279 unboundCatches 36 numberProperties 17 shorthandProperties 4 unshadowedNames 7 Any other difference fails with the token index and both sides. The second case pins that the new document adds the scope object and nothing else. Ruling 17: the behavioural tests now grep the generated document through `XTERM_HTML`, never a module source, so every assertion still speaks about what the WebView runs. Every assertion stays and the `expect` count per file is unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22, keyboard-avoidance 18, query-reply 14. One control per file was run by deleting the module line the updated pattern guards; all seven red, and the tree restores green. Pattern changes, old -> new. terminal-webview-scroll-routing.test.ts var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y; smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY; var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); -> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH); 'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break }, { capture: true, passive: false }); -> { capture: true, passive: false } function momentumStep() -> let momentumStep = function() pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY; if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) { normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame( pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0; cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId); var writeQueueHead = 0; -> scope.writeQueueHead = 0; writeQueueHead++; -> scope.writeQueueHead++; writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead); surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale(" var FRICTION = 0.972; -> const FRICTION = 0.972; var MIN_VEL = 0.012; -> const MIN_VEL = 0.012; edgeScrollDir = dir; -> scope.edgeScrollDir = dir; term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir); // Latching document-level touch dispatcher -> function attachSurfaceEventHandlers( edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX; edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY; return mode !== 'none'; -> return mode !== "none"; var pixelX = cell.x; -> const pixelX = cell.x; var pixelY = cell.y; -> const pixelY = cell.y; ...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) { ...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) { if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress; if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress; if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press; if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return ""; document.addEventListener('touchend' -> document.addEventListener( "touchend" }, { capture: true, passive: true }); -> { capture: true, passive: true } notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...) document.addEventListener('touchstart' -> document.addEventListener( "touchstart" var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl }); notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput }); terminal-webview-text-zoom.test.ts var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT = var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT \n\n function enqueueWrite -> \n function enqueueWrite var terminalFontFamily = -> scope.terminalFontFamily = output = terminalFontFamily; -> output = scope.terminalFontFamily; String.fromCharCode(0x23fa) -> String.fromCharCode(9210) TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038) EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039) data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR ) writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data)) var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData) } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2) term.open(surface) -> scope.term.open(scope.surface) term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11" enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData) fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily fontWeight: '300' -> fontWeight: "300" fontWeightBold: '500' -> fontWeightBold: "500" terminal-webview-engine.test.ts var webglAddon = null; .. var webglRecoveryTimer = null; -> the refreshTerminalSurface()..init( block, with the scope preamble window.addEventListener('resize' -> window.addEventListener("resize" 'terminal init failed' -> "terminal init failed" 'terminal message failed' -> "terminal message failed" var everReady = false; -> scope.everReady = false; everReady = true; -> scope.everReady = true; fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady /fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/ if (msg.type === 'ping') -> if (msg.type === "ping") notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id }) terminal-webview-reflow.test.ts } else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2) var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20; if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) { flog('measure-skip-small-width' -> flog("measure-skip-small-width" notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... }) var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle" window.addEventListener('message' -> window.addEventListener("message" terminal-keyboard-avoidance-webview.test.ts \n // reflow() -> \n function reflow( } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") \n var panX -> \n scope.panX TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document terminal-webview-query-reply.test.ts attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2) term.attachCustomKeyEventHandler(function() { return false; }) -> term.attachCustomKeyEventHandler(function() { \n return false; \n }); term.textarea.readOnly = true -> term.textarea.readOnly = true; } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") terminal-webview-url-tap.test.ts notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl }); terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the generated document's digest, 730472 -> 723480 bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): delete the slice constants and injected fragments The document is generated from its modules now, so the strings it used to be pasted together from are dead. Deleted: the fourteen slice constants under `terminal-webview-html/` (host-message-router, message-bridge, mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants, runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction, smooth-scroll-and-cell-geometry, surface-touch-gestures, term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write, write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`, `document-close.ts` and `theme.ts` stay: the shell and close are still the document's HTML, and `theme.ts` is where `document-constants.ts` reads the palette from. Ruling 17, second commit. Tests that asserted the extraction mechanism itself went with it: they compared one module's emission against the slice text it was extracted from, and the flip test now pins the whole document against the whole pre-flip script with the same eight classes. Deleted, all under `document/`: fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge, mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing, mouse-report-cell, path-tap, query-reply, reflow, runtime-constants, runtime-state, selection-overlay, selection-state-and-eviction, smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures, tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery, wheel-scroll. `document/url-tap.test.ts` stays: it pins against `URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and still has a consumer. Tests that asserted behaviour through a deleted string now read the generated document. `document/generated-document-region.test-support.ts` is the one way in: `documentScopePreamble()` returns the scope object the document opens with, and `generatedDocumentModule(name)` re-emits a module and refuses unless the document carries that text verbatim, so an evaluated block is the WebView's own bytes. The two local copies of the preamble in the engine and text-zoom tests were folded into it. Moved, with every assertion kept and the `expect` count per file unchanged: terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34 terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14 terminal-webview-query-reply.test.ts 14 terminal-path-tap.test.ts 25 terminal-webview-url-tap.test.ts 33 terminal-keyboard-avoidance-webview.test.ts 18 terminal-webview-reflow.test.ts 22 terminal-webview-text-zoom.test.ts 59 terminal-webview-engine.test.ts 49 Pattern changes, old -> new. terminal-webview-reflow.test.ts if (!term || isAlternateBufferActive()) return; -> if (!scope.term || isAlternateBufferActive()) { term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows); var wasAtBottom = buffer.viewportY >= buffer.baseY; -> const wasAtBottom = buffer.viewportY >= buffer.baseY; term.scrollToBottom(); -> scope.term.scrollToBottom(); if (nextCols === term.cols && nextRows === term.rows) return; -> if (nextCols === scope.term.cols && nextRows === scope.term.rows) { The other eight files kept their patterns; only the text they read changed, from a deleted constant to the document block. The harnesses that evaluate a block now build the document's scope object instead of declaring the vars it replaced, and hand the terminal in as `scope.term`. Controls, one per file: the module line an updated pattern guards was removed, the document rebuilt, and the test run. All red, and the tree restores green. query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed path-tap const parsed = parsePathLineCol(...) -> path-tap test, red keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed terminal-theme scope.term.options.minimumContrastRatio = ... -> theme test, 4 failed write-queue scope.writeQueue[scope.writeQueueHead] = undefined -> write-queue test, 4 failed `document-scope.ts` docstrings named the slice each field belonged to; they name the owning module now. Three module comments pointed at deleted injected files and point at the modules instead. Neither changes the document: esbuild drops comments, and the byte pin is unmoved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the right number of counted classes The flip test's title still said seven; the table it asserts has eight. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the shape applyTerminalTheme writes through The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test. `applyTerminalTheme` touches exactly two slots on the terminal it is handed, so `terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and the test's parameter and both fixtures use it. The theme is optional on the way in because `applyTerminalTheme` is what writes it. No cast. The type is erased by the generator's transform, so the document is unchanged and the flip test's class table and the byte pin both still hold. Control: restoring the `object` parameter reproduces the finding at terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type it exits 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): retire the flip pin, leaving the byte golden as the document's fence `terminal-document-flip.test.ts` compared the emitted modules against `terminal-document-pre-flip-script.txt`, the hand-written script as it stood before C7.1, and held exactly while no module changed. That is the proof of the flip, not a standing fence: the first lane that must change a module has to retire it or restate its counted classes for a reason that has nothing to do with the move. C7.5 is that lane — the document's host seams become scope fields so the page can set them — so both go here, while the test is still green. The flip proof lives at 51ae7b1b03 ("test(mobile): name the right number of counted classes"), which is where anyone reviewing the move should read it. From here the standing pin is the whole-document byte golden, `terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by the payload-hash digest beside it. Regenerating it is a review event: the emitted diff is listed old to new in the commit message and in the PR body, and a golden that moves without a listed diff is a blocking finding. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the terminal document's host seams a field on its scope Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a terminal `notify` through it would post raw terminal JSON into the bridge's channel, and there is no engine IIFE hanging `Terminal` and the two addons off `window` because the page imports xterm. Four reads had to become seams: host-notify.ts notify() -> scope.postToHost viewport-transform flog() -> scope.postToHost terminal-init.ts new Terminal(...) -> scope.createTerminal terminal-init.ts window.Unicode11Addon-> scope.createUnicode11Addon webgl-recovery.ts window.WebglAddon -> scope.createWebglAddon Each default is the window read the site already did, still performed at call time and not captured when the scope is built, so inside the WebView the program is the one it was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object, because the scope's defaults are those functions and the factory runs as the script is parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's file, which the four fields pushed over the 300-line cap; document-scope re-exports them, so no importer moves. The page's side of the seam lands in C7.5's later commits. Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside the `try` it was built in, so a constructor that throws is still swallowed; and no WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`, which is the DOM-renderer fallback rather than a failure. Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document 723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else: + (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } } + (new) function createEngineTerminal(options) { return new Terminal(options); } + (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; } + (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; } - " pendingTerm: null" + " pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon - flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }" + flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });" - " if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {" + " if (!scope.term) {" - " addon = new window.WebglAddon.WebglAddon();" + " addon = scope.createWebglAddon();" then " if (!addon) {" / " return false;" / " }" - " scope.term = new Terminal({" + " scope.term = scope.createTerminal({" - " if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / " try {" / " scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / " } catch {" + " try {" / " const unicodeAddon = scope.createUnicode11Addon();" / " if (unicodeAddon) {" / " scope.term.loadAddon(unicodeAddon);" / " } catch {" - notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }" + " scope.postToHost(msg);" Nothing else in the document moved: the emitted indentation, statement order and every other literal are byte for byte what they were. Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions and aimed them where the text now is: `window.WebglAddon.WebglAddon` and `new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on the recovery module, and the recovery module is asserted to call `scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope before the globals exist to show the defaults read the window when they post, shows each addon factory answering null when the engine has none, and drives a host message in and a notify out with all four fields set, asserting the bridge is never touched. Red before this commit at 6 of 7 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): write the xterm stylesheet as its own generated artifact The page mounts xterm itself, so it needs the engine's stylesheet and must never resolve the engine string: 612 KiB of minified IIFE built to be injected as text into a WebView document, unusable under the shell's `script-src 'self'` with no nested frame to load one into, and the largest single module the session route's closure would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of the CSS pulled the string in behind it. `build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts` beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside its neighbour and written by the same postinstall step, so a fresh tree gets both or neither. `document-shell.ts` takes the CSS from the new module and the engine string from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold both constants read them from their new homes. The document did not move: `terminal-document-golden.txt` is byte for byte what the last commit left, 105,968 bytes, and the payload digest is unchanged. The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks every module under `src/terminal/document/` as an entry point — the document is one script whose modules reach each other by side effect, so no single one of them roots a graph holding the rest — and asserts the engine string is in none of their closures, with two modules named as the precondition that the walk resolved anything at all. The native document's own closure is asserted to still hold both generated modules, so the first case cannot pass by the CSS having gone missing. And the third case plants a document module that imports the engine string in a scratch tree and shows the walk reports it, which is what makes the absence above a measurement. `mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes the entry points and an optional working directory; the route closure's own two entry points and its extensionless-specifier reason are unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the dead URL-tap constant and two stale reflow guards Round 1 fixes, all three folded here. 1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with `document/url-tap.test.ts` deleted alongside it. The document is generated from its modules now, so that constant was a second copy of the URL-tap group with no consumer but its own tests. terminal-webview-url-tap.test.ts's resolver harness reads the document's own text instead, the path-tap, url-tap, osc-link-tap and surface-tap modules in document order through `generatedDocumentModule`, which refuses unless the document carries each verbatim. Its 33 expects all stay. One mechanism-only assertion went with the file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts` pin of the three emissions against the constant, which the flip test's whole-document pin already covers. The file's other exports stay. The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts concatenated terminal-webview-url-tap.ts into its `source`, and its `notify({ type: 'terminal-tap' });` assertion was matching the constant's single-quoted text, not the document. The read is dropped, since nothing else in that file needed it, and the assertion is the document's form: notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" }); Its 95 expects stay. Leaving the read in place would let a document assertion pass against a module source, which is the hazard this lane exists to remove. 2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer exists, so it could not fail: expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}') -> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1) Same intent against the generated document: the reflow module's emitted text is in the document exactly once. The case is renamed to say so and the comment above it describes the generator, not the deleted template. 3. Same file, the routine assertion still passed as a substring of the qualified call; qualified as line 30 already was: term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows); Its 22 expects stay. Controls, each verified to have changed the file first, all red, tree green after restore: osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed module order 'reflow' listed twice -> reflow test, expected 2 to be 1 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): mount the terminal document in the page instead of a WebView `react-native-webview` has no web build that renders anything: measured, it paints the line "React Native WebView does not support this platform" where the terminal was. So the page mounts the document itself — xterm imported from `@xterm/xterm` with the unicode11 and webgl addons, and the document's own modules imported in the order the generator emits them — behind the identical `TerminalWebViewProps` and `TerminalWebViewHandle`. Written as one implementation, not two. `use-terminal-webview-controller.ts` is everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the readiness handshake, the pending queue, the write coalescer, the notify dispatch and the whole imperative handle. Its two arguments are the difference between the hosts — a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to re-prove the document with a ping. The native component posts across the bridge and answers yes on iOS; the web component calls `handleMsg` and answers no, because its document is the page's own modules and there is no second content process to lose. A second copy of that file is the fork the series exists to avoid, since the handle is the contract every consumer holds. `terminal-webview-ready-promises.ts` carries the two promises the handle hands out, `awaitReady` and `measureFitDimensions`, which the controller's length made a module. `document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out of the document shell; the shell composes them and the golden is byte for byte unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the caret options and nothing else, so the page resolves no document string and no engine string. `terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the stylesheet and the markup, sets the four scope seams, and reaches the modules by one dynamic import — they read their elements as they are parsed, so a static import would hoist above the planting and leave every one of them holding null. `page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19 because on the page those `message` frames belong to the shell; its one non-bridge duty, the window-resize refit, is re-armed by the mount. `page-document-module-order.test.ts` holds that list against the generator's own, so a sorted import list or a module added on one side cannot pass. Two page-side degradations, both bounded and both stated. The document assigns `window.onerror` as it is parsed, so while a terminal is mounted page errors reach its reporter; the mount restores the previous handler on dispose. And a browser that refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery` already has for a context loss, with a `[fit]webgl-unavailable` notify saying so rather than a silent halving of the drain rate. `terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it scans `src/session` and the terminal directory for an import of the component file by name, of `terminal-webview-html`, of either generated engine module or of anything under `document/`, finds none outside the component and its mount, and shows on planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs` gains the component's own closure: `TerminalWebView.web.tsx` and `terminal-webview-html.web.ts` are in it, the engine string, the native HTML module and `message-bridge` are not. Four source greps follow the code into its new home, every assertion kept: `terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the controller, and reads the two lifecycle clears once in `resetReadiness` plus both WebView callers in the component; `terminal-webview-reflow` and `terminal-webview-scroll-routing` read the handle in the controller and the two timers in the promises module (`measureResolveRef.current === finish` -> `measureResolve === finish`, `void p.finally` -> `void pending.finally`). One behaviour was nearly lost and is pinned by an existing case: the native foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render, so the transport asks a predicate rather than carrying a boolean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): render the page's terminal in a browser under the shell's policy Everything below the contract is new on the page: xterm is an import rather than a 612 KiB string in a WebView document, the document's modules run in the page's own realm, and the elements they read by id are planted by the component. No module test settles whether that opens at all under `script-src 'self'` with neither `unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the buffer intact. Three cases in the C6 render harness, against the bundle built by the real builder and served under the policy parsed out of the shell's own Kotlin constant. The stream is built for the grid rather than committed: an SGR colour change per cell, an erase-to-end and an absolute cursor position per row, run out past the host's own 48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the document's own path — select all, then the Copy button the overlay carries — so the oracle is the component's `onSelectionCopy` prop and not a private reach into xterm: 6,133 characters, both edge markers present, and no escape byte or SGR text left in them, which is what says the parser consumed the stream instead of printing it. The second case takes a fit through the handle, which on the page is a command in and a notify back with no bridge between, and carries design §8's cheap half of the IME question. It first pins something that changes where that probe can even point: xterm's own textarea is inert by the document's design — `query-reply.ts` makes it read-only, untabbable and `inputmode=none` so touch and hardware keys go to the screen's input — so text entering a terminal on the page arrives at a `TextInput`, and that is what is typed into. Chrome reports `insertText` with `isComposing` false for each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft keyboard is the device step and this does not claim to answer it. CSP violations are counted with a `securitypolicyviolation` listener installed before anything else runs, which is stricter than the console-error filter the other render checks use — and the first thing it found was not the terminal's. The page entry carries Zod, whose `new Function` probe is swallowed by its own catch, so `script-src: eval` is refused once on any page route with no page error and no console line. The first case is the control that names it, on a route that mounts a marker and no terminal; the two terminal cases subtract it and report zero of their own. Zero page errors and zero console errors besides. No route serves this screen until C7.7, so the component is bundled through a scratch route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx` exactly as a real route would. That step retires when the session route is registered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): retire the last module concatenator and guard the order list Round 2 fixes, all five folded here. 1. Deleted terminal-webview-html-source.test-support.ts. `readTerminalWebViewHtmlSource()` had no consumers left once the behavioural tests moved to the generated document, and it was the last thing that built a document-shaped string by concatenating module sources — its filter admitted `.test-support.ts` files too, so it could have grown one. Confirmed by grep that the only occurrence of either name in the repository was its own declaration. 2. New document-module-order.test.ts asserts both directions: the non-test, non-test-support `.ts` files under `document/` are exactly `{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`, and no name is listed twice. `document-constants` is the one exception because it is never emitted: its exports are substituted into the modules that import them as literals, so the document carries its values without carrying the module. A module added here and forgotten there would be dead code that reads as live; a name left after its file goes makes the generator throw at build time rather than at review time. 3. terminal-document-flip.test.ts's docstring now carries the retirement policy from ruling 18: the test is the proof of the flip and holds only while no module changes, the first lane that must change one retires it together with `terminal-document-pre-flip-script.txt`, and the standing pin from then on is `terminal-document-identity.test.ts`, whose fixture regeneration is a review event. Comment only. 4. terminal-document-equivalence.test-support.ts said 57 reassigned variables and "Four classes and no others". It now says 73 declaration sites and eight classes, with each class's measured figure named. Two doc comments sat above the wrong declaration and were moved onto what they describe: the `NUMBER_GLOBALS` one down to that constant, and the printing one down to `significantTokens`, with `STRICT_DIRECTIVE` given its own line. 5. build-terminal-document-script.mjs substituted constants with `replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a constant's value are read as replacement patterns. The substitution is now `substituteDocumentConstants`, exported so it can be tested directly, and replaces with a function. Controls, each verified to have changed its input first, all red, tree green after restore: plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module" drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll" revert to the string replacer -> 4 failed, "a $& b" became "a marker b" The `$n` case is deliberately absent from that table: the pattern has no capture group, so `$1` is already literal under either form and a case for it could not tell them apart. The document did not move. The byte golden, the digest and the flip test's class table are all unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): measure what the page terminal costs the session route's closure The session route is not served on the page until C7.7, but the closure the bundler would walk is the same one and the terminal is the largest thing in it. Measured against this branch's base, `ota-c7-1-terminal-document` at 51ae7b1b03: modules 4316 -> 4363 (+47) local modules 927 -> 971 (+44) minified bytes 3,930,787 -> 3,883,532 (-47,255) The route gets smaller. It sheds six modules — the native component, the 612 KiB engine string, the 105 KiB generated document script, the HTML module and the shell and close around it — all string literals of a program the page cannot run, and gains fifty: the component, its mount, the stylesheet and markup modules, the two the controller split made, and the document's own thirty-nine, with xterm and the two addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts` is not among them: it declares types and esbuild emits nothing for it. The census pins the trade in both directions, because "the engine string is absent" passes just as well on a closure that resolved nothing: the six shed modules are asserted gone, the eight gained ones and the three xterm packages asserted present, and the document asserted whole except `message-bridge`, which ruling 19 keeps off the page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved styles — since the terminal's modules joining this closure is exactly the change that could add a tenth unread. The page-closure families were run before and after on the full corpus, never a filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879 include the four page-closure pins, which assert the verdict of every golden C1, C2, C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed and 773 skipped. No family moved, which is what an inert lane should show: this branch changes no RPC, no opcode, no grant and nothing the recorder reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): clear the changed-code gate findings this lane introduced Eleven findings from `check-changed-code-quality.mjs` against the base, all in code this lane added, none of them a behaviour change. Two type assertions lost their directive to the formatter. The xterm `Terminal` cast sits on the second line of a wrapped arrow body, so a directive above the assignment aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon cast had no directive at all. Both keep the same `SAFETY:` rationale on one line, which is the only shape oxlint reads. Two more assertions in `host-seams.test.ts` are gone rather than annotated. The terminal double's `element` is a getter over a local the double's own `open` writes, and `withSeams` reads each field it is about to overwrite through `getOwnPropertyDescriptor` instead of indexing the scope with a cast. Then three `eslint-disable no-console` directives that disabled nothing, an `oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the reason it carried stays as a comment, since the dependency list is still deliberate — and one duplicated `node:fs/promises` import. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(config): name the closure helper what main already named it A trial merge against `origin/main` conflicts on this function: main grew the same generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with `mobileWebAppRouteClosure` delegating to it and three callers in the page-closure families census. This branch is based on `ota-c7-1-terminal-document` and so cannot merge main, but it can stop being a second spelling of the same thing. Taken over wholesale: main's name, its parameter, its extension stripping and its comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main already has. The only addition is an options bag carrying `absWorkingDir`, which the engine-closure census needs to plant a module in a tree of its own and show the walk would report it; the real measurements never pass it. What was a whole-function conflict is now that one hunk. The census case that measured the native document had named `terminal-webview-html.ts` with its extension, which main's stripping does not allow. It names `terminal-webview-html/document-shell` instead — the module that actually reads both generated ones — which is the better probe anyway and needs no extension to resolve, since it has no `.web` sibling. `web-overrides.json` also conflicts and is left alone: both sides append entries to one list and the resolution is mechanical. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(config): put the two closure helpers in main's order The previous commit took main's name and signature but left the route closure below the module closure, where this branch had written it. Git merged both orderings and produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint reports as a duplicated export — a red the trial merge found and neither side's own lint could. Same order as main now: the route closure and its docstring first, the module closure under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the merged tree lints clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the flip comparator refuse what it was accepting Round 2 items 6 and 7, both in the equivalence instrument. 6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving the two were the same binding, so an unrelated rename ending in a digit would have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`, an explicit list of pre-flip name, generated name and declaring module. The whole script has one entry: `term2` -> `term` in `query-reply`, which is the `term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven sites in all. That is stated in the docstring rather than encoded as a second pin, since the flip test already pins the total. 7. Brace absorption treated every unexpected `{` as a linter-added body and absorbed any later `}` while one was outstanding, so a bare block anywhere would have been swallowed. `isBraceableHeadBody` now requires the open to be the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its `(` and reading the keyword before it — and `matchingCloseIndex` records the index the close must appear at, so the absorbed `}` is that body's own. That check had to move ahead of the equality check. Wherever a braced body ends a block, the baseline's next token is a `}` as well, so pairing them would consume the wrong one and leave the counts right for the wrong reason. Both refusals are tested over snippets: function f() { return value2; } vs return value; -> token 6: expected name value2, generated name value let value = 1; use(value); vs { let value = 1; } use(value); -> token 0: expected name let, generated { and the braceable heads are tested one by one, `if`, `for`, `while`, `if`/`else` and `do`, so the new rule is shown to accept every shape the `curly` rule produces and not only the one the document happens to exercise. Controls: restoring the shape rule fails the first refusal case and nothing else; restoring the accept-any-brace rule fails the second and nothing else. The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7. Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The tightened rules put the file over the 300-line cap, and a `max-lines` disable is forbidden, so the token reader moved to its own module: that side answers what a script says, and says nothing about which differences between two of them are allowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(config): take main's docstrings for the two closure helpers The order matched but the prose did not, so the trial merge still conflicted on the whole block. Both docstrings are now main's own text, with one sentence trimmed: main names `MobileBrowserPane` as the first component with a pin of its own, which is C6's fact and not one this branch can assert. What remains between this branch and main in this file is the `absWorkingDir` parameter, which is what the engine-closure census plants a module with. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page terminal's notify sink in an effect, not during render React Doctor's one error on this branch, and a real one: `receiveRef.current = receive` ran during render. React may replay or discard render work, so a mutation made there can leak from UI that never commits — and this ref is read from a callback the mounted document keeps, which outlives the render that installed it. Moved into its own effect, declared above the mount effect so the first read already sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0. Found late because the first run of that gate was read through `| tail`, which reports the pipeline's last command rather than the gate's own exit code. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): teach C7.1's order guard the three modules this lane added The guard C7.1 landed says the document directory and the order list name the same modules. On this branch three files are in that directory and not in that list, so it was red on the merge — which is the guard working, and the fix is to name each of them with its reason rather than to loosen the scan. document-host-seams emitted, but ahead of the scope rather than inside the order list, because the scope's defaults are its four functions and the factory runs as the script is parsed document-terminal-shape types only; esbuild emits nothing and an empty emission would add a blank line to the document page-document-modules the page's entry, not the WebView's, holding the same order for a host that has no generator to splice them Named one by one, not filtered by a pattern, so a fourth cannot join them by looking similar. A third case asserts the seams module is neither in the order list nor the scope module, which is the ordering the first two cannot see. Red before this commit: C7.1's version of the file on this tree reports `document-host-seams` and the other two as directory modules the list does not name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): re-measure the session closure against the merged C7.1 base Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`, a module that enters this closure only once the page's component reaches it, so the saving shows on the after side and cannot show on the base. Both readings are recorded with the commit each was taken against, because a number with one base named and another used is the kind of thing a reviewer cannot check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): retire the flip comparator with the pin it was built for The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts` went in C7.1's own round-1 fold at 8da7680c9b, and `terminal-document-flip.test.ts` went in this lane's first commit under ruling 18, because the flip pin holds only while no module changes and C7.5 is the lane that changes them. What was left was a tool, its token reader and a test of the tool, answering to nothing. So `terminal-document-equivalence.test-support.ts`, the `terminal-document-tokens.test-support.ts` C7.1 split out of it, and `terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on the comparator — bounding an absorbed body to one statement, and refusing a bare block as `use();` against `{ use(); }` — since there is no comparator left to tighten. The standing pin on the document is the whole-document byte golden, which is a stronger claim than token equivalence ever was: it admits no normalisation at all. `document-module-order.test.ts` gains the case its exception list was asserting in prose. `document-terminal-shape` is not in the order list because esbuild erases a module of type declarations to the empty string, and emitting it would put a blank line in the document rather than a program; that emission is now measured and pinned as `''`. If the module ever declares a value the case goes red and the module belongs in the order list with its own line in the golden diff. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): make the document's error reporter the sixth host seam Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which inside the WebView is taking nothing from anyone — that document owns its page — and on the page is a guest displacing whatever the host installed. Restoring it on dispose was a patch over the takeover, not an answer to it: while a terminal was mounted, every page error still went to the terminal's reporter. So `scope.installErrorReporter` joins the five, with today's assignment as its default. `host-notify` hands it the same handler it always installed, and the WebView's document is the program it was. The page supplies its own: an `error` listener that adapts the event to the reporter's arguments, added on mount and removed on dispose, and `window.onerror` is never written. This one seam is *called* as the modules are parsed rather than later, so the mount now reaches `document-scope` on its own first and sets every field before a single document module runs — which is also the safer order for the other five. Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines out, seven in, and nowhere else: + (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; } - " createWebglAddon: createEngineWebglAddon" + " createWebglAddon: createEngineWebglAddon," and " installErrorReporter: installWindowErrorReporter" - " window.onerror = function(msg, source, line, column, err) {" + " scope.installErrorReporter(function(msg, source, line, column, err) {" - " };" + " });" `terminal-webview-payload-hash.test.ts` takes the new length and digest. Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror` and a host that installs its reporter elsewhere leaving it null. The render check adds a browser case: `window.onerror` is null before the mount, null after it, and null after the component unmounts — with a real uncaught error thrown in between and asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that had simply stopped reporting, and a second error after dispose asserted to reach nothing. Red with the mount's override removed: `expected undefined to be null`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): empty the session closure's react-native-webview list C7.6's census on main names the terminal as the last consumer and says whose work it is: "The terminal is the third and is C7.5's, which drops the engine string and mounts xterm in the document". This is that lane, so the list it left is now empty and the session closure reaches `react-native-webview` from nothing at all. Emptying a list weakens the case that reads it, because an empty result is also what a scan that read no file reports, so two things change with it. The main case gains its preconditions: the walk read a closure of more than 500 local modules, and it read the three web siblings whose native halves are exactly the modules that would have imported the package. And the control stops walking the list — with the list empty that compared nothing against nothing — and walks the three native files instead, which do import it, alongside the three web siblings, which do not. `TerminalWebView.web.tsx` joins the answered list, so the case that the builder resolves a web sibling rather than its native file now covers all three. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): pin the onerror seam against a handler the page actually owns The case read `null` before the mount, while mounted and after dispose. That is true but weak: a terminal that assigned `null` over a real handler would pass it, which is exactly the takeover ruling 19 forbids. So the page now installs a handler of its own in an init script, before the bundle loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`, compared inside the page because a function does not survive `evaluate` — at all three points. Between them an uncaught error is thrown and both reporters are asserted to see it: the page keeps the handler it installed, and the terminal's own listener still works, so the readings cannot pass on a terminal that had simply stopped reporting. After dispose a second error reaches the page's handler and not the terminal's, which is what taking the listener off has to mean. The `null` reading stays as its own case, because the other half matters too: on a page that installed nothing the terminal must not leave a handler behind for the next consumer to find. Both go red with the mount's `installErrorReporter` override removed — `expected false to be true` and `expected undefined to be null`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start the terminal document per mount (ruling 20) Round 1's blocking finding: ES module bodies run once per page, so the page's second mount re-imported nothing and inherited the first mount's elements, listeners and error reporter. Measured after a remount: zero .xterm nodes in the live DOM, no selection overlay, nothing reaching onEngineError, and onWebReady still firing. Ruling 20: no emitted module does work as it is parsed. Every top-level effect moved into an exported per-module start function — 86 statements across 14 modules, plus three parse-time captures whose declarations became typed lets. The generator emits one call sequence in module order at the foot of the document, so the native script still runs them once at parse; the page runs the same sequence per mount and dispose undoes the three that outlive the host element (tap-dispatch, webgl-recovery, host-notify). installErrorReporter now hands back its own undo, so it stays five seams at six document sites rather than growing a sixth. M2: a failed document chunk was an unhandled rejection with no engine error. It now goes down the document's own reporting path, so the overlay names the cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at the wire in the render check. L3: the seam count now reads five fields / six sites / three files everywhere. L4: three unrelated web-overrides entries keep main's escaping. Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256 2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the parse-time census tree without assertions The changed-code gate refuses type assertions. The walker reached node fields through `as Record`; it now reads them with Object.entries, which is checked and says the same thing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the document's state onto the scope (ruling 21) Round 2's blocking finding, and ruling 20's second half: moving parse-time effects out of the module bodies left the state behind. Nine module-level bindings survived a mount, so the second terminal inherited a spent non-fatal error budget (reporting nothing however it failed), the first terminal as its committed surface (disposing it twice), and the first mount's momentum loop. Every mutable binding now lives on the scope, and the scope carries one reset the start sequence calls first: native once at parse, the page once per mount. Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1, host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1, tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the objects tap-dispatch and surface-touch-gestures used to own outright. Because the reset is now the one initialiser, the start functions keep only what it cannot do: element reads, listener installs and the reporter install. Four start functions emptied and went; terminal-handle held nothing else and is deleted from the order list. The scope type splits into state and host seams, because a reset must restore the first and never the second. Every stop function cancels what its module scheduled. Timers go back through the handles the scope already held; frames go through the scope's own scheduleDocumentFrame, so dispose can take back the ones no module tracks by id. terminalGeneration and fitRetryToken carry forward across a reset, because a stale callback tests itself against them and a reset to zero would make the old number match again. L2: the seams-before-scope case asserts the order in the emitted document, not just non-membership. L3: the style docstring says what is true — one scope per page, so mount refuses a second live document and gives the page back when a mount fails. Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256 6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse frames from a stopped document The frame case went red under full-suite load: tearing the terminal down runs the engine's own disposal, which calls back into these modules, and a frame asked for on the way out was owed by nobody because the cancel had already run. A stopped document now asks for no frames at all, so the ordering inside dispose stops mattering. The render case is also rewritten around the work that survives a loaded machine. It gives the terminal a scrollback and sends one wheel, which reveals the scroll indicator and arms the 550 ms timer to hide it again, and the boundary between the two mounts is drawn when the first terminal leaves the page rather than when the component is told to go — React unmounts on its own schedule, and a callback that runs while the first terminal is still up is not a leak. The precondition counts what the document scheduled under the first mount, so an empty leak list cannot mean the wheel reached nothing. Verified both ways at this head: red with stopViewportTransform and cancelDocumentFrames removed, green with them, and green in the whole config/scripts suite. Payload 726081 -> 726195, sha256 67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): style only what the page mount owns CodeRabbit on document-style.ts:16. The mount appended the document's whole stylesheet to the page head, so its `*`, `html` and `body` rules restyled every screen the shell can show and went on doing it after unmount. Ruling 19's shape: the native document owns its page and keeps the sheet as it is; the page mount may style only what it owns. The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted document does not move for the split - verified byte-identical before the seam below. The page injects the element half only, with every selector held under the host's own class, and xterm's sheet goes through the same rewrite. The rewrite refuses an at-rule rather than passing its inner selectors through unscoped. A second leak of the same kind was in the same measurement: applyTerminalTheme wrote the terminal background straight onto `html` and `body`. That is a sixth seam - six fields at seven document sites now. Its default does exactly the two writes it did; the page paints the host element instead. Emitted lines, old to new: `paintWindowDocumentBackground` added beside the other defaults (3 lines); `paintDocumentBackground: paintWindowDocumentBackground` added to the seam factory (1 line); in applyTerminalTheme, the two `document...style.background` writes become one `scope.paintDocumentBackground(background)`. Leaving the sheet in the head after unmount is kept, and is now defensible: the host drops the class on dispose, so every rule in it matches nothing until the next mount. The render check gains a case comparing `body` and `html` computed styles, while mounted and after dispose, against a page of the same application with no terminal on it, and asserting no rule of the injected sheet matches an element outside the host. Verified red both ways at this head: unscoped sheet moves `background-color` and `box-sizing`, and the inline theme write moves `background-color`. Payload 726195 -> 726363, sha256 9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the page mount's rules and frames their own oracles Round 3 blocks on evidence, not on shipped behaviour. Each item: H1. The scoping had no positive oracle: dropping the host class, or injecting an empty xterm sheet, left the render check green, because every assertion was about rules not escaping. The containment case now also reads four things off the live elements under the host — xterm's own `position: relative`, the viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width, and the overlay's `position: fixed`. Red both ways: no host class reds all four, an empty engine sheet reds the first. H3. `cancelDocumentFrames` had no witness: the only leak the timer case could see was the 550 ms hide timer, which its own module's stop cancels. There is now a case whose witness is a frame taken through `scheduleDocumentFrame` — the fit retry loop, with the surface hidden so the fit never commits and one frame is always owed at dispose — and it reds when only `cancelDocumentFrames` is removed. A unit covers the registry itself: a frame is held until it runs, a cancel takes back every pending one and then refuses to schedule, and a reset re-enables it. The two scheduling cases now assert on their own witness kind, so neither can stand in for the other, and the recorder judges a leak by whether the `#terminal-container` that was on the page at schedule time is still in the document — React unmounts on its own schedule, and a callback that runs while the first terminal is still up is not a leak. The timer witness moved from the scroll-indicator timer to the long-press timer, because the first needed a drained scrollback and raced the engine under load; its precondition caught that rather than passing. L1. The two seam docstrings each sit on their own function. L2. The parse-time census plants an element-read initialiser, which the statement filter cannot see, and an inert object literal, which a reader that flagged every initialiser would wrongly report. L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that never committed leaves two terminals and only one was reached. Deduplicated, because they are the same object whenever no swap is open, and pinned both ways. L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT. Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render check: 12 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the page document's dispose idempotent and owner-checked CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A handle outlives what it built - the component keeps one in a ref and React can run a cleanup after a later mount has started - and everything dispose touches is shared: the scope, the module sequences, window.__engineErrors. So a second call, or a call from a handle whose document had already been replaced, tore down the terminal that was on the screen and handed the page away while it was still in use. Each mount now carries a token, and dispose acts only when that token is still the live one. A token rather than the host element or its class: two mounts can be handed the same element, because the page remounts into a host React has reused, so an element is not an identity and the class says only that some document is using the host. The failed-mount path releases the page under the same check. Pinned both ways, red with the check removed: disposing twice leaves a terminal put back after the first teardown alone, and a stale handle disposed after a second document mounted changes nothing - the live markup stays, its terminal is not disposed, and the page is still refused to a third mount. Golden unchanged at 108,329 bytes; payload and hash unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a pending page mount be disposed before its import lands Round 4 on #21809. H1. The mount claimed the page before its dynamic import and handed back a promise, so a component cleanup that ran while the chunk was still in flight had nothing to dispose: the claim outlived the mount it was made for, and Reload — the recovery ruling 20 names — was refused as a second document. The claim, the markup and the handle are now made synchronously, `ready` settles on its own, and a mount disposed while its import was in flight releases without starting anything. Pinned in the render check by holding the document chunk 20 s past the 15 s readiness watchdog, clicking Reload and waiting for the second mount to become live; red at that wait before the change. M1. The frame case's precondition asserted that a frame had been asked for while the document owned the page, not that one was owed when it was disposed. The fit retry commits on its first attempt whenever the grid still measures, so a dispose between two refits owed nothing and agreed with an empty leak list for exactly the reason under test — one run in five. The refit and the unmount now share one discrete click, which React flushes before the event returns, and a mutation observer reads the registry at the instant the host is emptied. Five red runs without `cancelDocumentFrames`, all on the leak and none on the precondition, and five green with it. M2. Two mounts handed the same element, which is what the token is for: the other six cases use a different element each, so a host comparison passes all of them. L1. A throw inside the start sequence released the token but ran no stop, leaving the host-notify error listener installed until the next reset nulled its undo. The sequence now unwinds the starts that completed, in reverse, before it rethrows. L2. A render case comparing the window and document listeners the page holds with no terminal on it, before and after a mount, so a stop that forgets one is a failure rather than a second copy per terminal ever shown. L4. Separated the stacked docstrings in the parse-time-effects census. The render check's bundle, server, browser and page helpers move to their own fixture module: the cases are what is under review and the scratch route tree is not, and the file was 16 code lines under its cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): count the page document's leaked frames from dispose, not from detach CI's addendum to round 4's M1: the frame case failed with the fix present, `expected [ Array(1) ] to deeply equal []`, on a slower runner. What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every other frame the document asks for — the document has no other rAF call site. It is not an escape from the registry, so the registry is not what changes here. Why it was counted: React unmounts in two steps. The mutation phase detaches the host, and the passive cleanup that calls `dispose` runs after it — about 1 ms later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner shape this failed on. A frame served in that gap runs with a detached container while the document is still live and has not been asked to stop, and nothing could have taken it back: `cancelDocumentFrames` had not been called yet. The oracle judged by the captured container's connectedness, so it read the gap as a leak. It now counts only what runs after the last statement of `dispose`, which is the class coming off the host, observed on the element because React may have detached it already. The same reading fixes the other direction. The precondition is read at that same moment, and the witness is a refit re-armed from a frame of the test's own, so the document is owed a frame at the end of every frame the browser serves and a dispose cannot land where nothing is owed. The single refit the case used before bought one frame, and the retry loop commits on its first attempt whenever the grid still measures. Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two runs of three unthrottled, and in five of five with the CPU throttled 20x, where the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with `cancelDocumentFrames` removed, five red runs, all on the leak read and none on the precondition. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop a page mount that lost its claim before it writes the scope Round 5 on #21809. F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await import(...)` the whole body ran whatever had happened in the meantime: it overwrote the six seams, called `startPageDocumentModules` and added the resize listener, and only then did the caller's `.then` read the claim and throw the result away. Everything after that await is shared — the seams are fields on a module-singleton scope, and the start sequence resets that scope and installs the document's listeners — so a mount disposed while its chunk was in flight was writing over a mount that owns the page. The claim is now re-read the instant the import lands, before any of it, and the build returns null. `ready` for such a mount resolves rather than rejecting. Nothing failed: the caller asked for the terminal and then asked for it to go away, and the chunk arriving afterwards is not something for the error overlay to name. Before this it rejected with a TypeError from `startSelectionMenuButtons` reaching for an emptied host. F2. The rejection handler called `release()` unconditionally, emptying a host the mount may no longer own. It now releases only when the page is still its own. Pins, both red first. In happy-dom: mount, dispose, then await ready — no listener, timer or frame added while it resolves, the six seams unchanged, `terminalGeneration` unmoved because the start sequence never ran, and the page free for the next mount. Without the fix that case rejects with the `startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight case now reads the page's listeners with no terminal on it and compares them against a page that mounted once and disposed once; without the fix the abandoned mount leaves `window error` and `window resize` behind, because the second mount's scope reset nulls the first mount's reporter undo. The listener snapshot helper is shared with the mount-and-dispose case rather than written twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): give the render fixture's server and scratch tree back when it cannot start CodeRabbit on the render fixture, plus its note on `release`. The fixture. `chromium.launch` is the last step of the setup and the one that fails in practice — no Chromium on the machine, an `ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle server is listening and the scratch tree is on disk. Rejecting there left the caller without a handle, so `afterAll` had nothing to close and both stayed allocated; the listening socket is the one that bites, because an open server handle keeps the vitest worker alive after its last test has reported. The setup after `mkdtemp` is now wrapped, gives back whatever it managed to take, and rethrows the original error rather than anything the cleanup raised. The normal close path awaits the server-close callback instead of firing it. `release` in the page mount. The ownership check covered the claim but not the two lines that make the terminal disappear, so a release that skipped the claim would still empty the host and drop its class. The check now guards the whole function, and round 5's caller-side check is gone as a duplicate of it: one rule, inside the thing it governs. Both existing callers are unchanged in behaviour — the synchronous planting catch always owns the page, and the rejection handler was already guarded. Pinned red first. The new case points the launch at an executable that is not there, then asks the port the fixture actually served on for a connection and reads the scratch directories in the temp dir. Without the rollback the port still accepts and the scratch tree is still there; with it, neither. The port is recorded by wrapping the real `createBundleServer` rather than standing a double in front of it, and the case asserts a server was created at all, or the refusal would mean nothing. Two oracles were discarded on the way. `rejects.toThrow()` with no argument passes for a build that broke for its own reason, so the rejection is matched by message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not `TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both arms and agreed with everything; it also still lists the handle at the moment the close callback runs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): read the render fixture's rollback in a temp root of its own Two defects in the case I committed in cb1833e675, both found by running it. The anti-slop gate refuses module mocking, and it is right to: the case recorded the served port by mocking the harness module around the real `createBundleServer`. Gone, with no disable. Its replacement read the shared temp directory for the fixture's scratch prefix, which the render check next door writes to from a worker of its own. So the case watched that tree appear and be swept up mid-run and called it a change: one red in four alone, and red in the full suite, where the two run together. `TMPDIR` now points at a directory this worker made, so the fixture's scratch tree lands somewhere nothing else writes and what is left in there afterwards was left by the setup under test. The failed launch also leaves Playwright artifacts and a browser profile in there, which are Playwright's to clean, so the reading is filtered to the name the fixture gives its own trees. The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as Node spells it, and read a tick after the close callback, because the handle is still listed while that callback runs. Both halves now fail on their own without the thing they measure: with no rollback at all the socket count is one above its baseline, twice out of twice; with the rollback but no `rm`, the scratch tree is still there. Three green runs with both. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the started document to the mount in the turn that started it Round 6's two LOW items, and the pins for the owner-checked release. LOW 1. `started` was assigned in the `.then` after the build, a microtask later than the start sequence and the resize listener it installs. A dispose in that window found nothing started, skipped the teardown and released the page with the document still running on it. The build now takes an `adopt` callback and calls it as its last statement, inside the guarded region, so whoever has to undo the start is holding it before that turn ends. Pinned by queuing the dispose behind the document import the build awaits, which lands in exactly that window: without the change the started document's resize listener survives the dispose, five red runs out of five. The owner-checked release, which landed in 8b37221b57 without a pin of its own. The one path that reaches a mount's cleanup holding someone else's page is a rejected import: everywhere else the build re-reads the claim after its await and stops, but a rejection never gets that far. So the pin drives that — the chunk fails for the first mount only, the mount is disposed while pending, a second one is built into the same element as Reload does, and then the first rejection arrives. Without the guard inside `release` it empties the live mount's host: three red runs out of three, on the markup. It also disposes the abandoned handle a second time afterwards and asserts nothing moves, which is LOW 2's missing pin for round 5's F2. That case is its own file because the import has to fail before the mount module loads, and the mocking the failure needs is only permitted in `.test.ts` — the anti-slop override does not cover `.test.mjs`, which is what refused the port recording in the render fixture's case. It fails once, so the mount that replaces it gets real modules and is a live document worth protecting; its own resize listener is the witness that it started. Two oracles were dropped. Vitest reports its own message when a mock factory throws, not the one thrown, so which import failed is read from the factory's counter instead. And a counter of successful factory calls read zero even though the second mount got a working document, which measures vitest's caching rather than this code; the live mount's listener replaced it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the listener wrappers the mount pins install The mobile tests-typecheck ratchet was red on 63eb8a40ae: six TS7006 implicit `any` parameters in each of the two mount pins, from arrow functions assigned over `window.addEventListener` and `window.removeEventListener`. An overloaded method gives an assigned arrow no contextual parameter types, so each wrapper's `type`, `listener` and `options` were implicitly `any` under `tsconfig.test.json`, which the product typecheck does not read. Both wrappers now take their parameters from the bound original as `Parameters` and spread them through, so the signature is the real one rather than three widened parameters. No casts and no `any`. Re-verified that the change did not quietly disarm either pin, because a recorder that counted nothing would also go green: with `release` unguarded the rejection case still fails on the live mount's markup, and with the adopt deferred by a microtask the single-mount case still fails on the started document's resize listener surviving its dispose. The ratchet itself is the finding worth keeping. It is not part of the mobile `tsc` the rest of my gate set runs, and it had dropped out of that set when these folds began, so three reports listed the other ratchets and not this one. It is back in, and stays in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart Round 7's five items. 1. The queued-dispose case's precondition was vacuous. It read the host for a missing container, which dispose empties on every path, so a build that returned straight after its ownership check satisfied it. The wrapper now counts resize adds and the case asserts exactly one, which is the document having started. Red under that mutation, on the count. 2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that also refused replaced the error the caller needs — the reason the setup failed. The rollback is best-effort now and the original error is what comes back. 3. That cleanup stopped at the first throw, so a browser refusing to close took the socket and the scratch tree with it, which is the leak the rollback exists to prevent. Each of the three is asked independently and the first failure is rethrown after all three have been tried. 4. The rejection case restores its `window` patch in a `finally`, as its sibling does, so a failure part way through no longer leaves the patched functions behind for everything that runs after it. 5. `dispose` left `started` set. `send` reads it, and what it holds names the page's one set of document modules, so a stale handle could route a host command into whichever document is live next. Nulled, and pinned: the stale handle pings, and with the old code the *live* mount's `receive` answers `pong`, because the scope's seam belongs to it by then. The precondition is the live handle's own ping being answered, so the silence is the stale handle declining rather than the command doing nothing. Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup itself, reachable only by making a browser or a socket refuse to close, and standing something in front of Playwright to do it is what the anti-slop gate refuses in this file's suffix. The rollback's own pin still covers the path that matters, and both changes are read by it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../scripts/build-mobile-web-app-bundle.mjs | 5 +- .../scripts/mobile-web-app-render-harness.mjs | 170 + ...-web-app-session-terminal-closure.test.mjs | 116 + ...web-app-session-webview-consumers.test.mjs | 42 +- .../mobile-web-app-terminal-probe-route.mjs | 130 + ...mobile-web-app-terminal-render-fixture.mjs | 212 ++ ...e-web-app-terminal-render-fixture.test.mjs | 89 + .../mobile-web-app-terminal-render.test.mjs | 793 +++++ ...obile-web-terminal-engine-closure.test.mjs | 114 + mobile/.gitignore | 1 + .../build-terminal-document-fixture.mjs | 11 +- .../build-terminal-document-script.mjs | 55 +- .../scripts/build-terminal-webview-engine.mjs | 42 +- .../terminal-document-module-order.mjs | 38 +- mobile/src/terminal/TerminalWebView.tsx | 451 +-- mobile/src/terminal/TerminalWebView.web.tsx | 148 + .../document/document-frame-registry.test.ts | 73 + .../terminal/document/document-host-seams.ts | 86 + .../document/document-module-order.test.ts | 67 +- .../document-parse-time-effects.test.ts | 259 ++ .../src/terminal/document/document-scope.ts | 298 +- .../document/document-terminal-shape.ts | 116 + mobile/src/terminal/document/fit-scale.ts | 19 +- .../generated-document-region.test-support.ts | 6 +- .../terminal/document/host-message-router.ts | 9 +- mobile/src/terminal/document/host-notify.ts | 47 +- .../src/terminal/document/host-seams.test.ts | 263 ++ .../src/terminal/document/message-bridge.ts | 39 +- .../src/terminal/document/mode-mirroring.ts | 7 - .../src/terminal/document/mouse-click-drag.ts | 20 +- .../document/normal-buffer-smooth-scroll.ts | 9 +- .../page-document-module-order.test.ts | 77 + .../document/page-document-modules.ts | 116 + .../page-document-start-unwind.test.ts | 60 + mobile/src/terminal/document/query-reply.ts | 12 +- .../terminal/document/runtime-constants.ts | 19 +- .../document/selection-menu-buttons.ts | 61 +- .../terminal/document/selection-overlay.ts | 5 + .../document/selection-state-and-eviction.ts | 46 +- mobile/src/terminal/document/surface-swap.ts | 32 +- .../document/surface-touch-gestures.ts | 130 +- mobile/src/terminal/document/tap-dispatch.ts | 315 +- ...minal-document-equivalence.test-support.ts | 391 --- .../terminal-document-equivalence.test.ts | 225 -- .../document/terminal-document-flip.test.ts | 80 - .../terminal-document-tokens.test-support.ts | 94 - .../src/terminal/document/terminal-handle.ts | 10 - mobile/src/terminal/document/terminal-init.ts | 36 +- .../src/terminal/document/terminal-theme.ts | 3 +- mobile/src/terminal/document/text-scaling.ts | 39 +- .../terminal/document/viewport-transform.ts | 67 +- .../src/terminal/document/webgl-recovery.ts | 31 +- mobile/src/terminal/document/wheel-scroll.ts | 2 - .../src/terminal/terminal-document-golden.txt | 857 ++--- .../terminal-document-identity.test.ts | 8 +- .../terminal-document-pre-flip-script.txt | 2758 ----------------- ...minal-web-document-mount-rejection.test.ts | 85 + .../terminal/terminal-web-document-mount.ts | 308 ++ ...terminal-web-document-single-mount.test.ts | 324 ++ .../terminal-webview-consumer-census.test.ts | 85 + .../terminal/terminal-webview-engine.test.ts | 7 +- mobile/src/terminal/terminal-webview-html.ts | 8 + .../src/terminal/terminal-webview-html.web.ts | 20 + .../terminal-webview-html/document-markup.ts | 19 + .../terminal-webview-html/document-shell.ts | 148 +- .../document-style-scoping.test.ts | 74 + .../document-style-scoping.ts | 92 + .../terminal-webview-html/document-style.ts | 158 + .../terminal-webview-payload-hash.test.ts | 4 +- .../terminal-webview-ready-promises.ts | 118 + .../terminal/terminal-webview-reflow.test.ts | 13 +- .../terminal-webview-scroll-routing.test.ts | 35 +- .../terminal-webview-text-zoom.test.ts | 42 +- ...erminal-write-coalescer-boundaries.test.ts | 52 +- .../use-terminal-webview-controller.ts | 318 ++ mobile/web-entry/web-overrides.json | 8 + 76 files changed, 5962 insertions(+), 5165 deletions(-) create mode 100644 config/scripts/mobile-web-app-session-terminal-closure.test.mjs create mode 100644 config/scripts/mobile-web-app-terminal-probe-route.mjs create mode 100644 config/scripts/mobile-web-app-terminal-render-fixture.mjs create mode 100644 config/scripts/mobile-web-app-terminal-render-fixture.test.mjs create mode 100644 config/scripts/mobile-web-app-terminal-render.test.mjs create mode 100644 config/scripts/mobile-web-terminal-engine-closure.test.mjs create mode 100644 mobile/src/terminal/TerminalWebView.web.tsx create mode 100644 mobile/src/terminal/document/document-frame-registry.test.ts create mode 100644 mobile/src/terminal/document/document-host-seams.ts create mode 100644 mobile/src/terminal/document/document-parse-time-effects.test.ts create mode 100644 mobile/src/terminal/document/document-terminal-shape.ts create mode 100644 mobile/src/terminal/document/host-seams.test.ts create mode 100644 mobile/src/terminal/document/page-document-module-order.test.ts create mode 100644 mobile/src/terminal/document/page-document-modules.ts create mode 100644 mobile/src/terminal/document/page-document-start-unwind.test.ts delete mode 100644 mobile/src/terminal/document/terminal-document-equivalence.test-support.ts delete mode 100644 mobile/src/terminal/document/terminal-document-equivalence.test.ts delete mode 100644 mobile/src/terminal/document/terminal-document-flip.test.ts delete mode 100644 mobile/src/terminal/document/terminal-document-tokens.test-support.ts delete mode 100644 mobile/src/terminal/document/terminal-handle.ts delete mode 100644 mobile/src/terminal/terminal-document-pre-flip-script.txt create mode 100644 mobile/src/terminal/terminal-web-document-mount-rejection.test.ts create mode 100644 mobile/src/terminal/terminal-web-document-mount.ts create mode 100644 mobile/src/terminal/terminal-web-document-single-mount.test.ts create mode 100644 mobile/src/terminal/terminal-webview-consumer-census.test.ts create mode 100644 mobile/src/terminal/terminal-webview-html.web.ts create mode 100644 mobile/src/terminal/terminal-webview-html/document-markup.ts create mode 100644 mobile/src/terminal/terminal-webview-html/document-style-scoping.test.ts create mode 100644 mobile/src/terminal/terminal-webview-html/document-style-scoping.ts create mode 100644 mobile/src/terminal/terminal-webview-html/document-style.ts create mode 100644 mobile/src/terminal/terminal-webview-ready-promises.ts create mode 100644 mobile/src/terminal/use-terminal-webview-controller.ts diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index a5232639e88..30cceec9d53 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -394,10 +394,13 @@ export async function mobileWebAppRouteClosure(routeModule) { * C5.2 and C3.2 generate, derive theirs by the C1.6 method inside the mobile suite. The two are * not the same computation, and a divergence between them is a finding rather than noise. */ -export async function mobileWebAppModuleClosure(entryModules) { +export async function mobileWebAppModuleClosure(entryModules, { absWorkingDir } = {}) { const base = mobileWebAppBuildOptions(MOBILE_WEB_PAGE_ROUTES) const result = await esbuild.build({ ...base, + // A census that plants a module to show the walk would report it needs a tree of its own; the + // real ones never pass this and keep measuring `mobile/`. + ...(absWorkingDir ? { absWorkingDir } : {}), // Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a // `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure // the native switch no browser ever loads. diff --git a/config/scripts/mobile-web-app-render-harness.mjs b/config/scripts/mobile-web-app-render-harness.mjs index bece528b51d..5d5b13b879b 100644 --- a/config/scripts/mobile-web-app-render-harness.mjs +++ b/config/scripts/mobile-web-app-render-harness.mjs @@ -384,3 +384,173 @@ export async function createBundleServer({ outDir, cspHeader, transformChunk }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) return { server, origin: `http://127.0.0.1:${String(server.address().port)}` } } + +/** + * A handler of the page's own, installed before the bundle so the terminal meets a `window.onerror` + * that belongs to someone else. + * + * Reading `null` three times would pass on a terminal that assigned `null` over a real handler, + * which is the failure this seam exists to prevent. The sentinel is identity-checked in the page + * rather than marshalled out of it — a function does not survive `evaluate` — and it returns + * false so the browser still reports the error normally. + */ +export function installPageErrorSentinel() { + globalThis.__orcaSentinelCalls = [] + const sentinel = (message) => { + globalThis.__orcaSentinelCalls.push(String(message)) + return false + } + globalThis.__orcaSentinel = sentinel + window.onerror = sentinel +} + +/** + * Every animation frame and timer, tagged with the mount that scheduled it. + * + * Installed before the bundle loads, so the document's own scheduling goes through it. Each + * schedule remembers the `#terminal-container` that was on the page at the time; a callback that + * runs once that element has left the document is a frame or timer of the first mount firing + * into the second, which is the whole finding. The element rather than a counter the test bumps, + * because React unmounts on its own schedule and a callback that runs while the first terminal is + * still up is not a leak. Every schedule is kept, not just the ones still owed, so the test can + * say that there was something to leak before it says that nothing did. + */ +export function installSchedulerRecorder() { + globalThis.__orcaScheduler = { watching: false, scheduled: [], leaked: [] } + const state = globalThis.__orcaScheduler + const wrap = (schedule, kind) => + function (callback, ...rest) { + if (!state.watching || typeof callback !== 'function') { + return schedule(callback, ...rest) + } + // The line that called this, which is the script the work belongs to. Line 0 is the error's + // own header and line 1 is this wrapper. + const caller = ((new Error('scheduled').stack ?? '').split('\n')[2] ?? '').trim() + const container = document.getElementById('terminal-container') + // `fired` is what makes "owed" readable: a callback that has not run is still owed, whether + // it was cancelled or is merely waiting, and cancelling never sets it. + const entry = { kind, caller, owned: container !== null, fired: false } + state.scheduled.push(entry) + return schedule( + (...args) => { + entry.fired = true + if (container !== null && !container.isConnected) { + state.leaked.push(`${kind} from ${caller}`) + } + return callback(...args) + }, + ...rest + ) + } + globalThis.requestAnimationFrame = wrap( + globalThis.requestAnimationFrame.bind(globalThis), + 'frame' + ) + globalThis.setTimeout = wrap(globalThis.setTimeout.bind(globalThis), 'timer') + globalThis.setInterval = wrap(globalThis.setInterval.bind(globalThis), 'interval') +} + +/** Recorded before anything else runs, so a refusal during the page's own boot is counted. */ +/** + * Every window and document listener the page holds, by target, type and phase. + * + * Identity, not a tally: `addEventListener` with a listener the target already holds is a no-op in + * the DOM, and `removeEventListener` with one it does not hold is too, so counting calls would + * report leaks a browser does not have. The set is the live listeners, which is what a snapshot + * before and after a mount can be compared on. + */ +export function installListenerRecorder() { + const live = new Map() + globalThis.__orcaListeners = { + snapshot: () => + Object.fromEntries( + [...live.entries()] + .map(([key, listeners]) => [key, listeners.size]) + .filter(([, n]) => n > 0) + ) + } + const keyFor = (target, type, options) => { + const where = target === globalThis ? 'window' : target === document ? 'document' : null + if (where === null) { + return null + } + const capture = typeof options === 'object' && options !== null ? !!options.capture : !!options + return `${where} ${type}${capture ? ' capture' : ''}` + } + const add = EventTarget.prototype.addEventListener + const remove = EventTarget.prototype.removeEventListener + EventTarget.prototype.addEventListener = function (type, listener, options) { + const key = keyFor(this, type, options) + if (key !== null && listener) { + if (!live.has(key)) { + live.set(key, new Set()) + } + live.get(key).add(listener) + } + return add.call(this, type, listener, options) + } + EventTarget.prototype.removeEventListener = function (type, listener, options) { + const key = keyFor(this, type, options) + if (key !== null && listener) { + live.get(key)?.delete(listener) + } + return remove.call(this, type, listener, options) + } +} + +export function installCspViolationRecorder() { + globalThis.__orcaCspViolations = [] + document.addEventListener('securitypolicyviolation', (event) => { + globalThis.__orcaCspViolations.push( + `${event.violatedDirective}: ${event.blockedURI || 'inline'} @ ${event.sourceFile ?? '?'}:${String(event.lineNumber ?? 0)}` + ) + }) +} + +/** + * Every computed property of `html` and `body`, as one string each. + * + * The oracle for "the page mount styles only what it owns" is a page of the same application with + * no terminal on it, so the comparison is against another page rather than against a list of + * properties someone chose. A rule that escaped the host would have to move one of these. + */ +export async function readRootComputedStyles(page) { + return await page.evaluate(() => { + const read = (element) => { + const computed = getComputedStyle(element) + const entries = [] + for (const property of computed) { + entries.push(`${property}: ${computed.getPropertyValue(property)}`) + } + return entries.join('\n') + } + return { body: read(document.body), html: read(document.documentElement) } + }) +} + +/** + * What the terminal's injected sheet matches, and how much of it there is. + * + * The rule count is the precondition for the empty list: a sheet that was never planted, or one + * the browser refused, would match nothing for a reason that has nothing to do with scoping. + */ +export async function terminalStyleReach(page) { + return await page.evaluate(() => { + const sheet = [...document.styleSheets].find( + (one) => one.ownerNode?.id === 'orca-terminal-document-style' + ) + if (!sheet) { + return { rules: 0, outside: ['the terminal stylesheet is not in the head'] } + } + const host = document.querySelector('.orca-terminal-document-host') + const outside = [] + for (const rule of sheet.cssRules) { + for (const element of document.querySelectorAll(rule.selectorText)) { + if (!host || !host.contains(element)) { + outside.push(`${rule.selectorText} matched ${element.tagName}`) + } + } + } + return { rules: sheet.cssRules.length, outside } + }) +} diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs new file mode 100644 index 00000000000..34d51a42244 --- /dev/null +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -0,0 +1,116 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + textInputFontSizeOffenders, + unresolvedTextInputStyles +} from './mobile-web-app-text-input-font-size-seam.mjs' + +/** + * What putting the terminal on the page costs the session route's closure. + * + * The route is not served on the page until C7.7 — its module is still the native switch and + * there is no `.web.tsx` beside it — but the closure the bundler would walk is the same one, and + * the terminal is by far the largest thing in it. Measured here so the trade is a number rather + * than a claim, and so that a later change cannot quietly put the engine string back. + * + * Measured against `origin/main` at 9fbdfc592c, which is the merge base this branch now sits on: + * + * modules 4277 -> 4320 (+43) + * local modules 926 -> 970 (+44) + * minified bytes 3,868,833 -> 3,812,418 (-56,415) + * + * The route gets smaller. It sheds six modules — the native component, the 612 KiB engine string, + * the 105 KiB generated document script, the HTML module and the shell and close around it, all + * string literals of a program the page cannot run — and gains fifty: the document's own 39, the + * component, its mount, the stylesheet and markup, the two the controller split made, and xterm + * with its two addons behind them at 607,945 bytes minified ESM on their own. + * + * Two earlier readings of the same measurement, against the bases this branch sat on before: + * -47,255 at 51ae7b1b03 and -55,561 at 0ce0fc99a2. They differ because C7.1's own round-1 fold + * deleted `URL_TAP_WEBVIEW_JS` from a module only the page's component brings into this closure, + * so the saving lands on the after side and no base can show it. + */ + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const mobileDir = join(projectDir, 'mobile') + +const SESSION_ROUTE = 'app/h/[hostId]/session/[worktreeId].tsx' + +/** Gone with the WebView: string literals of a program the page has no way to run. */ +const SHED = [ + 'src/terminal/TerminalWebView.tsx', + 'src/terminal/terminal-webview-engine.generated.ts', + 'src/terminal/terminal-webview-document-script.generated.ts', + 'src/terminal/terminal-webview-html.ts', + 'src/terminal/terminal-webview-html/document-shell.ts', + 'src/terminal/terminal-webview-html/document-close.ts' +] + +/** The component, its mount, the stylesheet and the markup, and the modules the splits made. */ +const GAINED_OUTSIDE_THE_DOCUMENT = [ + 'src/terminal/TerminalWebView.web.tsx', + 'src/terminal/terminal-web-document-mount.ts', + 'src/terminal/terminal-webview-engine-css.generated.ts', + 'src/terminal/terminal-webview-html.web.ts', + 'src/terminal/terminal-webview-html/document-markup.ts', + 'src/terminal/terminal-webview-html/document-style.ts', + // The page's half of the stylesheet: the document-level rules are dropped and the rest is held + // under the host, so what the page injects can only reach what the terminal owns. + 'src/terminal/terminal-webview-html/document-style-scoping.ts', + 'src/terminal/terminal-webview-ready-promises.ts', + 'src/terminal/use-terminal-webview-controller.ts' +] + +const XTERM_PACKAGES = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl'] + +/** + * The 16 px seam's verdict for this route, which C7.5 must leave exactly where C7.2 left it. + * + * Design §3 counted nine inputs under the floor here and C7.2 moved all nine onto the seam, so the + * answer is now none. Asserted rather than left unmeasured because the terminal's own modules + * joining this closure is precisely the kind of change that could add a tenth unread. + */ +const EXPECTED_OFFENDERS = 0 + +const bundles = mobileWebAppDependenciesPresent() +const describeClosure = bundles ? describe : describe.skip + +describeClosure( + "the session route's page closure with the terminal on it", + () => { + it('gains the document, xterm and the addons, and sheds the engine string', async () => { + const { local, modules } = await mobileWebAppRouteClosure(SESSION_ROUTE) + for (const gone of SHED) { + expect(local, `${gone} is still in the closure`).not.toContain(gone) + } + for (const gained of GAINED_OUTSIDE_THE_DOCUMENT) { + expect(local, `${gained} is not in the closure`).toContain(gained) + } + for (const name of XTERM_PACKAGES) { + expect( + modules.some((module) => module.includes(`node_modules/${name}/`)), + `${name} is not in the closure` + ).toBe(true) + } + // The document, whole: every module the generator emits except the bridge, which ruling 19 + // keeps off the page because those `message` frames belong to the shell. + const documentModules = local.filter((module) => module.startsWith('src/terminal/document/')) + expect(documentModules.length).toBeGreaterThanOrEqual(36) + expect(documentModules).not.toContain('src/terminal/document/message-bridge.ts') + expect(documentModules).toContain('src/terminal/document/page-document-modules.ts') + }, 300_000) + + it('leaves the 16px seam census exactly where C7.2 left it', async () => { + const closure = await mobileWebAppRouteClosure(SESSION_ROUTE) + // Two preconditions, because zero offenders is what a walk that read nothing also reports: + // the seam's own web module has to be in the closure, and no style may be unresolved. + expect(closure.local).toContain('src/platform/text-input-font-size.web.ts') + expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([]) + expect(textInputFontSizeOffenders(mobileDir, closure)).toHaveLength(EXPECTED_OFFENDERS) + }, 300_000) + }, + 900_000 +) diff --git a/config/scripts/mobile-web-app-session-webview-consumers.test.mjs b/config/scripts/mobile-web-app-session-webview-consumers.test.mjs index 2828672be1e..47e132a0737 100644 --- a/config/scripts/mobile-web-app-session-webview-consumers.test.mjs +++ b/config/scripts/mobile-web-app-session-webview-consumers.test.mjs @@ -5,9 +5,14 @@ * where its consumer was, so a page does not go down over one — but nothing it was mounted for * works either, and the closure pays for a module that cannot do its job. * - * C7.6 gives the two editors the plain states they already degrade to (`rulings-ota-c7.md` ruling - * 8). The terminal is the third and is C7.5's, which drops the engine string and mounts xterm in - * the document; it is listed here rather than left unsaid so the list is the work remaining. + * C7.6 gave the two editors the plain states they already degrade to (`rulings-ota-c7.md` ruling + * 8) and left the terminal listed as the work remaining, which was C7.5's. C7.5 has done it: the + * page mounts xterm in the document and drops the engine string, so the list is now empty and + * this closure reaches that package from nowhere at all. + * + * An empty list is also what a scan that read nothing reports, so the control below no longer + * uses the list — it runs the same walk over three native modules that do import the package and + * over the three web siblings that replace them. */ import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -21,13 +26,21 @@ const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe. const SESSION = 'app/h/[hostId]/session/[worktreeId].tsx' -/** Still on the native component, and whose PR it is. */ -const REMAINING = ['src/terminal/TerminalWebView.tsx'] +/** Nothing: every consumer this closure had now resolves to a web sibling that needs no WebView. */ +const REMAINING = [] -/** The two this PR answered, whose `.web.tsx` the builder resolves instead. */ +/** The three answered, whose `.web.tsx` the builder resolves instead of the native file. */ const ANSWERED = [ 'src/components/MobileRichMarkdownEditor.web.tsx', - 'src/components/MobileHtmlPreview.web.tsx' + 'src/components/MobileHtmlPreview.web.tsx', + 'src/terminal/TerminalWebView.web.tsx' +] + +/** The native files behind those three, which do import the package. The scan's own control. */ +const NATIVE_CONSUMERS = [ + 'src/components/MobileRichMarkdownEditor.tsx', + 'src/components/MobileHtmlPreview.tsx', + 'src/terminal/TerminalWebView.tsx' ] const IMPORTS_WEBVIEW = /(?:from|import)\s*'[^']*react-native-webview'/ @@ -45,9 +58,15 @@ function webViewConsumers(closure) { describeClosure( 'the session closure and react-native-webview', () => { - it('reaches it from the terminal and from nothing else', async () => { + it('reaches it from nothing at all', async () => { const closure = await mobileWebAppRouteClosure(SESSION) expect(webViewConsumers(closure)).toEqual(REMAINING) + // The precondition an empty list needs: the walk read a closure, and read the very modules + // whose native halves are the ones that would have imported the package. + expect(closure.local.length).toBeGreaterThan(500) + for (const file of ANSWERED) { + expect(closure.local, file).toContain(file) + } }) it('resolves both editors to their web siblings, not to the native files', async () => { @@ -58,9 +77,10 @@ describeClosure( } }) - it('finds a consumer when there is one, so the list above is a measurement', async () => { - // The control: the same walk over the module the list names, which does import it. - expect(webViewConsumers({ local: REMAINING })).toEqual(REMAINING) + it('finds a consumer when there is one, so the empty list above is a measurement', () => { + // The control, run over the native files rather than over the list: with the list empty, + // walking it would compare nothing against nothing and pass on a scan that reads no file. + expect(webViewConsumers({ local: NATIVE_CONSUMERS })).toEqual(NATIVE_CONSUMERS) expect(webViewConsumers({ local: ANSWERED })).toEqual([]) }) }, diff --git a/config/scripts/mobile-web-app-terminal-probe-route.mjs b/config/scripts/mobile-web-app-terminal-probe-route.mjs new file mode 100644 index 00000000000..0e943d8c113 --- /dev/null +++ b/config/scripts/mobile-web-app-terminal-probe-route.mjs @@ -0,0 +1,130 @@ +/** + * The scratch route tree the terminal render check bundles, and the streams it drives. + * + * No route serves this screen until C7.7, so the component is reached through a route tree + * written to a temporary directory. That is a bundler entry and a page under test, not an + * assertion, and it is here so the check itself stays the list of things being measured. This + * step retires the moment the session route is registered. + */ + +export const COLS = 80 +export const ROWS = 24 +/** Design §2 measured the host's own chunker at 48 KiB, so the sample is at least one full one. */ +export const MIN_STREAM_BYTES = 48 * 1024 +/** Printed at the top of the stream and again at the end, so the read-back covers both edges. */ +export const FIRST_MARKER = 'ORCA-TERMINAL-RENDER-FIRST' +export const LAST_MARKER = 'ORCA-TERMINAL-RENDER-LAST' + +/** + * An escape-dense sample of at least 48 KiB: an SGR colour change every cell, an erase-to-end and + * an absolute cursor position per row. Built here rather than committed because it is a function + * of the grid, and a fixture sized from the constant it is meant to exercise proves nothing. + */ +export function escapeDenseStream() { + const esc = '\u001b' + const rows = [] + rows.push(`${esc}[2J${esc}[H${FIRST_MARKER}\r\n`) + let row = 2 + let bytes = rows[0].length + while (bytes < MIN_STREAM_BYTES) { + const cells = [] + for (let column = 0; column < COLS - 1; column++) { + const colour = 31 + ((row + column) % 7) + cells.push(`${esc}[${String(colour)};1m${String.fromCharCode(97 + ((row + column) % 26))}`) + } + const line = `${esc}[${String(row)};1H${esc}[K${cells.join('')}${esc}[0m\r\n` + rows.push(line) + bytes += line.length + row += 1 + } + rows.push(`${LAST_MARKER}\r\n`) + return rows.join('') +} + +/** + * The scratch route: the component under test, its handle and its notifies on `globalThis`. + * + * Written rather than committed because it is the bundler's entry and nothing else — a file under + * `mobile/app` would register a route the shell could open. `beforeinput` is recorded off the + * xterm helper textarea, which is design §8's cheap half of the IME question: it says what the + * browser reports for text entering a terminal on the page, and leaves a composing IME on a real + * keyboard to the device step it cannot answer. + */ +export function probeRouteSource(componentPath) { + return `import { useCallback, useEffect, useRef, useState } from 'react' +import { TextInput, View } from 'react-native' +import { TerminalWebView } from ${JSON.stringify(componentPath)} + +export default function TerminalProbeRoute() { + const handleRef = useRef(null) + const [mounted, setMounted] = useState(true) + const onSelectionCopy = useCallback((text) => { + globalThis.__orcaTerminalCopied = text + }, []) + const onWebReady = useCallback(() => { + globalThis.__orcaTerminalReady = true + }, []) + const onEngineError = useCallback((message) => { + globalThis.__orcaTerminalEngineErrors.push(message) + }, []) + useEffect(() => { + globalThis.__orcaTerminalEngineErrors = globalThis.__orcaTerminalEngineErrors ?? [] + globalThis.__orcaTerminalBeforeInput = [] + globalThis.__orcaTerminalProbe = { + init: (cols, rows, data) => handleRef.current?.init(cols, rows, data, false, []), + write: (data) => handleRef.current?.write(data), + selectAll: () => handleRef.current?.doSelectAll(), + measure: () => handleRef.current?.measureFitDimensions(), + awaitReady: () => handleRef.current?.awaitReady(), + setMounted: (next) => setMounted(next) + } + const onBeforeInput = (event) => { + globalThis.__orcaTerminalBeforeInput.push({ + inputType: event.inputType, + data: event.data === null ? null : String(event.data), + isComposing: !!event.isComposing + }) + } + document.addEventListener('beforeinput', onBeforeInput, true) + return () => document.removeEventListener('beforeinput', onBeforeInput, true) + }, []) + return ( + + {mounted ? ( + + ) : null} + {/* The shape the terminal's live input takes on the page: xterm's own textarea is inert by + the document's design, so this is where typed text arrives. */} + + + ) +} +` +} + +/** + * The same page with no terminal on it. + * + * The page entry already carries Zod, which probes for `new Function` and swallows the + * `EvalError`, so the shell's `script-src 'self'` records one refusal on any route before a line + * of terminal code runs. Comparing against this control is what makes "zero violations" a + * statement about the terminal rather than about the bundle it lives in. + */ +export const CONTROL_SOURCE = `import { View } from 'react-native' + +export default function ControlRoute() { + globalThis.__orcaTerminalControlMounted = true + return +} +` + +export const LAYOUT_SOURCE = `import { Slot } from 'expo-router' +export default function ProbeLayout() { + return +} +` diff --git a/config/scripts/mobile-web-app-terminal-render-fixture.mjs b/config/scripts/mobile-web-app-terminal-render-fixture.mjs new file mode 100644 index 00000000000..a8fba22d3e5 --- /dev/null +++ b/config/scripts/mobile-web-app-terminal-render-fixture.mjs @@ -0,0 +1,212 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright-core' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs' +import { + COLS, + CONTROL_SOURCE, + LAYOUT_SOURCE, + probeRouteSource, + ROWS +} from './mobile-web-app-terminal-probe-route.mjs' +import { + createBundleServer, + installCspViolationRecorder, + installListenerRecorder, + installPageErrorSentinel, + installSchedulerRecorder, + installShellDouble, + readBridgeFaultGrant, + readBridgeProtocolVersion, + readShellCsp +} from './mobile-web-app-render-harness.mjs' + +/** + * The scratch bundle the terminal render check runs against, and the two ways to open a page on it. + * + * Its own module because the check's cases are the thing under review and the server, the browser + * and the scratch route tree are not. Nothing here is module-scoped: the fixture holds what it + * built in the closures it hands back, so two of them could not read each other's browser. + */ + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const mobileDir = join(projectDir, 'mobile') + +export const PROBE_ROUTE = '/h/terminal-probe' +export const CONTROL_ROUTE = '/h/terminal-control' +const PAGE_ROUTE_PATTERNS = [PROBE_ROUTE, CONTROL_ROUTE] +const SHELL_SESSION_ID = 'terminal-render-session' +const SHELL_BUILD_ID = 'terminal-render-build' +const SHELL_HOST = { + id: 'terminal-render-host', + name: 'Terminal Render Host', + endpoint: 'ws://terminal-render', + lastConnected: 1 +} + +/** + * Everything the fixture allocated, in the reverse of the order it took it. + * + * Shared by the normal close and the rollback, because a setup that fell over halfway has exactly + * the same things to give back as one that ran to the end — it just has fewer of them. The server + * close is awaited rather than fired: it holds a listening socket, and a socket still open when + * the file finishes keeps the vitest worker alive after its last test has reported. + */ +async function closeTerminalRenderFixture({ browser, scratch, server }) { + // Each one is asked independently, because stopping at the first refusal is how the socket and + // the scratch tree survived in the first place: a browser that will not close would take the + // other two down with it. The first failure is what comes back, after all three have been tried. + const failures = [] + const attempt = async (close) => { + try { + await close() + } catch (error) { + failures.push(error) + } + } + await attempt(() => browser?.close()) + await attempt( + () => + server && + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + ) + await attempt(() => rm(scratch, { recursive: true, force: true })) + if (failures.length > 0) { + throw failures[0] + } +} + +/** + * Builds the bundle, serves it under the shell's own policy, and launches the browser. + * + * Nothing survives a setup that throws. The browser is launched last and is the step most likely + * to fail — no Chromium on the machine, an `ORCA_MOBILE_WEB_RENDER_BROWSER` that points nowhere — + * and by then the server is listening and the scratch tree is on disk. A caller that never got a + * handle back has nothing to close, so this closes them itself and rethrows what actually went + * wrong rather than whatever the cleanup might say. + */ +export async function startTerminalRenderFixture() { + const cspHeader = await readShellCsp() + const bridgeVersion = await readBridgeProtocolVersion() + const faultGrant = await readBridgeFaultGrant() + const scratch = await mkdtemp(join(tmpdir(), 'orca-c75-terminal-render-')) + let browser = null + let served = null + try { + const appDir = join(scratch, 'app') + const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(routeDir, { recursive: true }) + await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE) + // Extensionless, so the bundler resolves the `.web.tsx` sibling exactly as it would for a + // real route. Naming the `.tsx` would mount the WebView wrapper no browser can render. + await writeFile( + join(routeDir, 'terminal-probe.tsx'), + probeRouteSource(join(mobileDir, 'src', 'terminal', 'TerminalWebView')) + ) + await writeFile(join(routeDir, 'terminal-control.tsx'), CONTROL_SOURCE) + const built = await buildMobileWebAppBundle({ + appDir, + outDir: join(scratch, 'bundle'), + pageRoutes: [ + { pathname: PROBE_ROUTE, grants: [] }, + { pathname: CONTROL_ROUTE, grants: [] } + ] + }) + served = await createBundleServer({ outDir: built.outDir, cspHeader }) + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}) + }) + } catch (error) { + // Swallowed on purpose: what the caller needs is the reason the setup failed, and a cleanup + // that also refuses would replace it with something about a socket. The rollback is + // best-effort; the original error is the contract. + await closeTerminalRenderFixture({ browser, scratch, server: served?.server }).catch(() => {}) + throw error + } + const { origin } = served + + async function openPage( + pathname, + { errorSentinel = false, listeners = false, scheduler = false, beforeNavigate } = {} + ) { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + await beforeNavigate?.(page) + if (scheduler) { + await page.addInitScript(installSchedulerRecorder) + } + if (listeners) { + await page.addInitScript(installListenerRecorder) + } + await page.addInitScript(installCspViolationRecorder) + if (errorSentinel) { + await page.addInitScript(installPageErrorSentinel) + } + await page.addInitScript(installShellDouble, { + version: bridgeVersion, + sessionId: SHELL_SESSION_ID, + buildId: SHELL_BUILD_ID, + route: { pathname, params: {} }, + host: SHELL_HOST, + storage: {}, + faultGrant, + grants: [faultGrant], + pageRoutes: PAGE_ROUTE_PATTERNS, + replies: {} + }) + const errors = [] + page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(`console.error: ${message.text()}`) + } + }) + await page.goto(`${origin}/`, { waitUntil: 'load' }) + await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', { + timeout: 60_000, + polling: 250 + }) + return { errors, page } + } + + async function openTerminal(options) { + const opened = await openPage(PROBE_ROUTE, options) + await opened.page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + return opened + } + + return { + openPage, + openTerminal, + close: () => closeTerminalRenderFixture({ browser, scratch, server: served.server }) + } +} + +/** + * The markup, then `init`, then the engine. + * + * xterm is opened by the document's `init`, not by the mount: the component plants the elements + * and the modules read them, and the terminal appears on the first host command. So the order + * here is the order a session screen uses, and each step is waited for rather than assumed — + * `.xterm` before `init` would time out on a page that was working perfectly. + */ +export async function openProbeTerminal(page) { + await page.locator('#terminal-container').waitFor({ state: 'attached', timeout: 30_000 }) + await page.evaluate( + ([cols, rows]) => globalThis.__orcaTerminalProbe.init(cols, rows, ''), + [COLS, ROWS] + ) + // Attached rather than visible: the replacement surface is hidden until its writes drain, and + // the commit that reveals it is the last step of the same rAF chain `awaitReady` waits on. + await page.locator('#terminal-surface .xterm').waitFor({ state: 'attached', timeout: 30_000 }) + await page.evaluate(() => globalThis.__orcaTerminalProbe.awaitReady()) +} diff --git a/config/scripts/mobile-web-app-terminal-render-fixture.test.mjs b/config/scripts/mobile-web-app-terminal-render-fixture.test.mjs new file mode 100644 index 00000000000..a84fd579da6 --- /dev/null +++ b/config/scripts/mobile-web-app-terminal-render-fixture.test.mjs @@ -0,0 +1,89 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { startTerminalRenderFixture } from './mobile-web-app-terminal-render-fixture.mjs' + +/** + * What the render fixture gives back when it never finishes starting. + * + * The handle is the only way to close it, so a setup that throws before returning one leaves the + * caller nothing to call: `afterAll` has no fixture, and the listening socket and the scratch tree + * stay where they are. The socket is the part that bites — an open server handle keeps the vitest + * worker alive after its last test has reported, so the file hangs rather than failing. + * + * The browser is the step that fails in practice and the last one taken, so by then everything + * else is allocated. It is made to fail the way it actually does, by pointing the launch at an + * executable that is not there, rather than by standing a double in front of Playwright. + */ + +/** The name the fixture gives its scratch tree, which is the only thing in here it owns. */ +const SCRATCH_PREFIX = 'orca-c75-terminal-render-' +const describeFixture = mobileWebAppDependenciesPresent() ? describe : describe.skip + +let temporaryRoot = null +let realTemporaryRoot = null + +beforeAll(async () => { + // The fixture names its scratch tree after `os.tmpdir()`, and the render check next door names + // its own the same way in a worker of its own — so reading the shared temp directory reports + // that one appearing and being swept up mid-case, which is not this case's business. Pointing + // `TMPDIR` at a directory of this worker's own makes the reading exact: whatever is left in + // here afterwards was left by the setup under test. + temporaryRoot = await mkdtemp(join(tmpdir(), 'orca-c75-fixture-rollback-')) + realTemporaryRoot = process.env.TMPDIR + process.env.TMPDIR = temporaryRoot +}) + +afterAll(async () => { + if (realTemporaryRoot === undefined) { + delete process.env.TMPDIR + } else { + process.env.TMPDIR = realTemporaryRoot + } + await rm(temporaryRoot, { recursive: true, force: true }) +}) + +/** + * Listening sockets this process holds, which is the handle that keeps a worker alive. + * + * Spelled as Node spells it: filtering on `TCPSERVERWRAP` matches nothing and reads zero in both + * arms, which agrees with everything. And the handle is still listed while the close callback + * runs, so the reading is taken a tick later, once the loop has let it go. + */ +async function settledListeningSockets() { + await new Promise((resolve) => setTimeout(resolve, 50)) + return process.getActiveResourcesInfo().filter((resource) => resource === 'TCPServerWrap').length +} + +describeFixture('the terminal render fixture', () => { + it('takes back the server and the scratch tree when the browser will not start', async () => { + const socketsBefore = await settledListeningSockets() + const realBrowser = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + process.env.ORCA_MOBILE_WEB_RENDER_BROWSER = join(temporaryRoot, 'orca-c75-no-such-browser') + try { + // Named, not merely thrown, and this is also the precondition the two readings below need. + // A bare `toThrow` passes for a build that broke for its own reason, which would leave + // nothing serving and nothing on disk and agree with both assertions for the wrong reason. + // Reaching the launch at all means `createBundleServer` returned, because it is the + // statement before it. + await expect(startTerminalRenderFixture()).rejects.toThrow( + /Failed to launch chromium because executable doesn't exist/ + ) + } finally { + if (realBrowser === undefined) { + delete process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + } else { + process.env.ORCA_MOBILE_WEB_RENDER_BROWSER = realBrowser + } + } + + expect(await settledListeningSockets()).toBe(socketsBefore) + // The fixture's own trees, by the name it gives them. The launch that failed leaves Playwright + // artifacts and a browser profile in here too, and those are Playwright's to clean, not the + // rollback's. + const left = (await readdir(temporaryRoot)).filter((entry) => entry.startsWith(SCRATCH_PREFIX)) + expect(left).toEqual([]) + }, 600_000) +}) diff --git a/config/scripts/mobile-web-app-terminal-render.test.mjs b/config/scripts/mobile-web-app-terminal-render.test.mjs new file mode 100644 index 00000000000..c92835b9323 --- /dev/null +++ b/config/scripts/mobile-web-app-terminal-render.test.mjs @@ -0,0 +1,793 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + escapeDenseStream, + FIRST_MARKER, + LAST_MARKER, + MIN_STREAM_BYTES +} from './mobile-web-app-terminal-probe-route.mjs' +import { + CONTROL_ROUTE, + openProbeTerminal, + PROBE_ROUTE, + startTerminalRenderFixture +} from './mobile-web-app-terminal-render-fixture.mjs' +import { readRootComputedStyles, terminalStyleReach } from './mobile-web-app-render-harness.mjs' + +/** + * The page's terminal, in a real browser, under the policy the shell sends. + * + * Everything below the contract is new on the page: xterm is an import rather than a 612 KiB + * string in a WebView document, the document's modules run in the page's own realm, and the + * stylesheet and the elements they read by id are planted by the component. None of that is + * settled by a module test. What a browser settles is whether it opens at all under + * `script-src 'self'` with no `unsafe-inline` and no `unsafe-eval`, whether a real terminal byte + * stream reaches the buffer intact, and whether anything the page does is refused by the policy. + * + * The stream is deliberately escape-dense: colour changes, cursor moves and erases at every cell + * boundary, which is the shape that expands worst through the transport and the shape a TUI + * actually paints. It is read back through the document's own selection path — select all, then + * the Copy button the overlay carries — so the oracle is the component's `onSelectionCopy` prop + * and not a private reach into xterm. + * + * No route serves this screen until C7.7, so the component is bundled through a scratch route + * tree. That step retires the moment the session route is registered. + */ + +const bundles = mobileWebAppDependenciesPresent() +const describeRender = bundles ? describe : describe.skip + +let fixture = null +let controlCspViolations = [] +const stream = escapeDenseStream() +const openPage = (pathname, options) => fixture.openPage(pathname, options) +const openTerminal = (options) => fixture.openTerminal(options) + +beforeAll(async () => { + if (!bundles) { + return + } + fixture = await startTerminalRenderFixture() +}, 600_000) + +afterAll(async () => { + await fixture?.close() +}) + +/** Violations this page recorded that the control did not, which is the terminal's own account. */ +async function terminalCspViolations(page) { + const seen = await page.evaluate(() => globalThis.__orcaCspViolations) + const shared = new Set(controlCspViolations.map(stripAssetPath)) + return seen.map(stripAssetPath).filter((entry) => !shared.has(entry)) +} + +/** The asset name is a content hash and the port is per run; neither is part of the finding. */ +function stripAssetPath(entry) { + return entry.replace(/ @ .*$/, '') +} + +describeRender( + 'the terminal on the page', + () => { + it('records what the page refuses before any terminal is on it', async () => { + // Run first, and the two cases below subtract it, so their zero is the terminal's own + // account rather than the bundle's. A control that mounted nothing would report nothing for + // the wrong reason, so the route's own marker is the precondition. + const { page } = await openPage(CONTROL_ROUTE) + await page.waitForFunction(() => globalThis.__orcaTerminalControlMounted === true, { + timeout: 60_000, + polling: 100 + }) + controlCspViolations = await page.evaluate(() => globalThis.__orcaCspViolations) + console.log('[c7.5][csp-control]', JSON.stringify(controlCspViolations.map(stripAssetPath))) + // Nothing, which is a stronger fact than this case was built for. It first read + // `script-src: eval` — Zod probing for a JIT with `new Function` and swallowing the throw, + // so no page error and no console line reported it — and main's jitless banner closed that + // before this branch merged it. The subtraction stays: it is what makes the cases below say + // "the terminal added none" rather than "none were seen". + expect(controlCspViolations.map(stripAssetPath)).toEqual([]) + await page.close() + }, 300_000) + + it('opens xterm under the shipped policy and paints a dense stream into its buffer', async () => { + const { errors, page } = await openTerminal() + await openProbeTerminal(page) + const applied = await page.evaluate((data) => { + globalThis.__orcaTerminalProbe.write(data) + return data.length + }, stream) + expect(applied).toBeGreaterThanOrEqual(MIN_STREAM_BYTES) + + // Read back through the document's own path: select all, then the overlay's Copy button, + // which posts the buffer text to the component's onSelectionCopy prop. + await page.evaluate(() => globalThis.__orcaTerminalProbe.selectAll()) + await page.waitForFunction( + () => document.getElementById('selection-overlay')?.classList.contains('active') === true, + { timeout: 30_000, polling: 100 } + ) + await page.evaluate(() => document.getElementById('sel-menu-copy').click()) + await page.waitForFunction(() => typeof globalThis.__orcaTerminalCopied === 'string', { + timeout: 30_000, + polling: 100 + }) + const copied = await page.evaluate(() => globalThis.__orcaTerminalCopied) + console.log( + '[c7.5][stream]', + JSON.stringify({ appliedBytes: applied, readBackChars: copied.length }) + ) + expect(copied).toContain(FIRST_MARKER) + expect(copied).toContain(LAST_MARKER) + // The escapes were consumed by the parser rather than printed as text. + expect(copied).not.toContain('\u001b') + expect(copied).not.toContain('[31;1m') + + expect(await terminalCspViolations(page)).toEqual([]) + expect(await page.evaluate(() => globalThis.__orcaTerminalEngineErrors)).toEqual([]) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('leaves the page its own window.onerror across mount and dispose', async () => { + // The page installs a handler before the bundle loads, so the terminal meets one that is + // not its to take. Identity is checked in the page: the same function object at all three + // points, not merely a non-null one and not merely the same shape. + const { page } = await openTerminal({ errorSentinel: true }) + expect(await page.evaluate(() => window.onerror === globalThis.__orcaSentinel)).toBe(true) + await openProbeTerminal(page) + expect(await page.evaluate(() => window.onerror === globalThis.__orcaSentinel)).toBe(true) + + // Both reporters see the same uncaught error: the page keeps the one it installed, and the + // terminal's own listener still works. Without the second half the readings above would + // pass on a terminal that had simply stopped reporting. + await page.evaluate(() => { + setTimeout(() => { + throw new Error('orca-terminal-render-uncaught') + }, 0) + }) + const sawIt = (entries) => + entries.some((entry) => entry.includes('orca-terminal-render-uncaught')) + await page.waitForFunction( + () => + globalThis.__orcaTerminalEngineErrors.some((entry) => + entry.includes('orca-terminal-render-uncaught') + ), + { timeout: 30_000, polling: 100 } + ) + expect(sawIt(await page.evaluate(() => globalThis.__orcaSentinelCalls))).toBe(true) + + // Dispose takes the terminal's listener off and leaves the page's handler where it was. + await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false)) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + expect(await page.evaluate(() => window.onerror === globalThis.__orcaSentinel)).toBe(true) + const before = await page.evaluate(() => { + setTimeout(() => { + throw new Error('orca-terminal-render-after-dispose') + }, 0) + return globalThis.__orcaTerminalEngineErrors.length + }) + await page.waitForFunction( + () => + globalThis.__orcaSentinelCalls.some((entry) => + entry.includes('orca-terminal-render-after-dispose') + ), + { timeout: 30_000, polling: 100 } + ) + // The page's handler saw it and the terminal's did not, which is what dispose has to mean. + expect(await page.evaluate(() => globalThis.__orcaTerminalEngineErrors.length)).toBe(before) + await page.close() + }, 300_000) + + it('installs no window.onerror on a page that had none', async () => { + // The other half: with nothing installed the terminal must not leave one behind either, so + // a later consumer still finds the slot free. + const { page } = await openTerminal() + expect(await page.evaluate(() => window.onerror)).toBe(null) + await openProbeTerminal(page) + expect(await page.evaluate(() => window.onerror)).toBe(null) + await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false)) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + expect(await page.evaluate(() => window.onerror)).toBe(null) + await page.close() + }, 300_000) + + /** + * A terminal that is mounted, taken down and mounted again has to be a terminal again. + * + * The document's modules are ES modules: their bodies run once per page, so anything they did + * as they were parsed — reading their elements by id, installing the error reporter, adding + * listeners — a second mount would inherit from the first, pointing at elements that are no + * longer in the document. Nothing above the contract would notice: `onWebReady` still fires, + * because readiness is the component's own handshake and not a claim about the engine. + * + * So the assertions are about the live DOM and the live paths, not about readiness. + */ + /** The listeners the page holds with no terminal on it, which is what two mounts can differ by. */ + async function listenersWithNoTerminal(page) { + await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false)) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + return page.evaluate(() => globalThis.__orcaListeners.snapshot()) + } + + async function assertLiveTerminal(page, label) { + await page.locator('#terminal-surface .xterm').waitFor({ state: 'attached', timeout: 30_000 }) + expect( + await page.evaluate(() => document.querySelectorAll('#terminal-surface .xterm').length), + `${label}: xterm elements in the live DOM` + ).toBeGreaterThan(0) + + // The selection overlay is the document's own element, reached through the handle: it only + // activates if `handleMsg` is talking to the elements that are actually on the page. + await page.evaluate(() => globalThis.__orcaTerminalProbe.selectAll()) + await page.waitForFunction( + () => document.getElementById('selection-overlay')?.classList.contains('active') === true, + { timeout: 30_000, polling: 100 } + ) + + // And the reporter, which is the seam that is installed once per mount. + const marker = `orca-remount-${label}` + await page.evaluate((thrown) => { + globalThis.__orcaTerminalEngineErrors = [] + setTimeout(() => { + throw new Error(thrown) + }, 0) + }, marker) + await page.waitForFunction( + (thrown) => globalThis.__orcaTerminalEngineErrors.some((entry) => entry.includes(thrown)), + marker, + { timeout: 30_000, polling: 100 } + ) + } + + it('is a live terminal again after an unmount and a remount', async () => { + const { page } = await openTerminal() + await openProbeTerminal(page) + await assertLiveTerminal(page, 'first-mount') + + await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false)) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + await page.evaluate(() => { + globalThis.__orcaTerminalReady = false + globalThis.__orcaTerminalProbe.setMounted(true) + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + await assertLiveTerminal(page, 'remount') + await page.close() + }, 300_000) + + it('is a live terminal again after the user reloads a failed one', async () => { + // The other way a second mount happens, and the one a user reaches: the terminal fails + // before it is ready, the engine-error overlay appears, and Reload disposes the document + // and builds another inside the same component. Driven end to end rather than by calling + // the handler — an uncaught error before the first `init` is fatal by the document's own + // rule, which is what puts the overlay on screen. + const { page } = await openTerminal() + await page.locator('#terminal-container').waitFor({ state: 'attached', timeout: 30_000 }) + await page.evaluate(() => { + setTimeout(() => { + throw new Error('orca-terminal-render-fatal') + }, 0) + }) + const reload = page.getByText('Reload') + await reload.waitFor({ timeout: 30_000 }) + + await page.evaluate(() => { + globalThis.__orcaTerminalReady = false + }) + await reload.click() + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + await assertLiveTerminal(page, 'after-reload') + await page.close() + }, 300_000) + + it('names the cause when the document chunk will not load', async () => { + // The component reaches the document through a dynamic import, so the document is its own + // chunk and the chunk can fail: offline, a hashed filename that no longer exists after a + // deploy, a module that throws as it evaluates. That is a rejected promise and nothing + // else — no engine ran, so no `error` notify is ever posted. Without the rejection being + // routed it is an unhandled rejection and a blank frame until the 15 s readiness watchdog. + // + // The fault is the real one: the chunk is identified by what it carries and refused at the + // wire, rather than a stub swapped in for the mount. + // A string literal only `host-notify` carries, so the chunk is recognised by its contents + // rather than by a filename that is a content hash or by a declaration name a minifier + // renames. It has to be unique to the document: the route's own chunk carries the + // component, the controller and the notification dispatcher, and refusing that one would + // take the whole route down instead of the document. + const documentChunkMarker = 'terminal runtime error' + let aborted = null + const served = [] + const { page } = await openPage(PROBE_ROUTE, { + beforeNavigate: async (opened) => { + await opened.route('**/*.js', async (route) => { + const response = await route.fetch() + const body = await response.text() + served.push(route.request().url()) + if (aborted === null && body.includes(documentChunkMarker)) { + aborted = route.request().url() + await route.abort('failed') + return + } + await route.fulfill({ response, body }) + }) + } + }) + // The chunk is fetched when the component mounts, which is after the page entry is up, so + // the refusal is waited for rather than asserted on the way past. A run where nothing + // matched would otherwise fail below for the wrong reason. + await expect + .poll(() => aborted, { + timeout: 60_000, + message: `no served script carried the document; saw ${served.join(', ')}` + }) + .not.toBe(null) + await page.waitForFunction( + () => + (globalThis.__orcaTerminalEngineErrors ?? []).some((entry) => + entry.includes('terminal document failed to load') + ), + undefined, + { timeout: 60_000, polling: 100 } + ) + // And the user-visible half: the overlay, with its Reload, rather than a blank frame. + await page.getByText('Reload').waitFor({ timeout: 30_000 }) + await page.unrouteAll({ behavior: 'ignoreErrors' }) + await page.close() + }, 300_000) + + it('comes back from Reload while the chunk it is waiting on is still in flight', async () => { + // The other end of the case above: the chunk does not fail, it is merely slow — a cold CDN + // edge, a phone on a train. The document is reached by a dynamic import, so the mount is in + // flight while the 15 s readiness watchdog runs out and puts the overlay on the screen, and + // ruling 20 names that overlay's Reload as the way back. Reload is a second mount, so it is + // refused outright unless the first mount's cleanup could give the page back while its + // import was still unresolved — which is what the handle being synchronous is for. + // + // Held past the watchdog rather than mocked past it, because the window under test is the + // one between the claim and the import resolving, and only a real pending request has it. + const HOLD_MS = 20_000 + let held = null + const { page } = await openPage(PROBE_ROUTE, { + listeners: true, + beforeNavigate: async (opened) => { + await opened.route('**/*.js', async (route) => { + const response = await route.fetch() + const body = await response.text() + if (held === null && body.includes('terminal runtime error')) { + held = route.request().url() + await new Promise((resolve) => setTimeout(resolve, HOLD_MS)) + } + await route.fulfill({ response, body }) + }) + } + }) + await expect.poll(() => held, { timeout: 60_000 }).not.toBe(null) + // The watchdog, named: the overlay has to be the one the stall raises, not an engine error + // from somewhere else, or Reload would be answering a different question. + await page.waitForFunction( + () => + (globalThis.__orcaTerminalEngineErrors ?? []).some((entry) => + entry.includes('no ready signal') + ), + undefined, + { timeout: 60_000, polling: 100 } + ) + const reload = page.getByText('Reload') + await reload.waitFor({ timeout: 30_000 }) + expect( + await page.evaluate(() => globalThis.__orcaTerminalReady === true), + 'the first mount was still waiting on its chunk when Reload appeared' + ).toBe(false) + + await reload.click() + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + await assertLiveTerminal(page, 'reload-during-import') + + // And the mount the Reload abandoned has to have come to nothing. Its chunk arrives while + // the second mount is running on the same scope, so a build that resumed without re-reading + // the claim would install its listeners into this page and reset the live mount's scope, + // nulling the undo that takes the error reporter off. Read against a page that mounted once + // and disposed once: the abandoned mount is the only difference between them, so zero + // difference is the abandoned mount having touched nothing. + const afterAbandoned = await listenersWithNoTerminal(page) + const control = await openTerminal({ listeners: true }) + await openProbeTerminal(control.page) + expect(afterAbandoned).toEqual(await listenersWithNoTerminal(control.page)) + await control.page.close() + await page.unrouteAll({ behavior: 'ignoreErrors' }) + await page.close() + }, 300_000) + + it('leaves the page the listeners it found, across a mount and a dispose', async () => { + // Ruling 20 moved every install into a start function and ruling 21 gave each one a stop, + // and the document installs on `window` and `document` both: the resize refit, the error + // reporter, the tap and gesture listeners the surface modules arm. A stop that forgets one + // does not fail anything visible — the next mount simply adds a second copy, and the page + // accumulates a listener per terminal it has ever shown. + // + // The comparison is drawn across a second mount rather than against the bare page: the + // component mounts as the route does, so there is no moment before the first terminal to + // photograph. Both readings are taken with no terminal on the page, so a mount that leaks + // once leaks again and the two disagree. + const { page } = await openTerminal({ listeners: true }) + await openProbeTerminal(page) + const before = await listenersWithNoTerminal(page) + await page.evaluate(() => { + globalThis.__orcaTerminalReady = false + globalThis.__orcaTerminalProbe.setMounted(true) + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + const whileLive = await page.evaluate(() => globalThis.__orcaListeners.snapshot()) + const after = await listenersWithNoTerminal(page) + + // The precondition: a mount that installed nothing would satisfy the equality below for + // exactly the reason the case exists to refuse. + expect(whileLive, 'the mount installed listeners the dispose has to take back').not.toEqual( + before + ) + expect(after).toEqual(before) + await page.close() + }, 300_000) + + it('still reports runtime errors after a first mount spent the non-fatal budget', async () => { + // Ruling 21's finding, end to end. `reportEngineError` caps non-fatal notifies at five so a + // per-frame thrower cannot flood the host. That counter is the document's, not the mount's: + // a first terminal that spends it leaves the second one mute, reporting nothing however it + // fails, while every other signal — readiness, paint, selection — says the terminal is fine. + const { page } = await openTerminal() + await openProbeTerminal(page) + await page.evaluate(() => { + for (let index = 0; index < 6; index++) { + setTimeout(() => { + throw new Error(`orca-budget-burn-${String(index)}`) + }, 0) + } + }) + await page.waitForFunction( + () => + globalThis.__orcaTerminalEngineErrors.filter((entry) => + entry.includes('orca-budget-burn') + ).length >= 5, + { timeout: 30_000, polling: 100 } + ) + + await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false)) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + await page.evaluate(() => { + globalThis.__orcaTerminalReady = false + globalThis.__orcaTerminalProbe.setMounted(true) + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + + await page.evaluate(() => { + globalThis.__orcaTerminalEngineErrors = [] + setTimeout(() => { + throw new Error('orca-second-mount-error') + }, 0) + }) + await page.waitForFunction( + () => + globalThis.__orcaTerminalEngineErrors.some((entry) => + entry.includes('orca-second-mount-error') + ), + { timeout: 30_000, polling: 100 } + ) + await page.close() + }, 300_000) + + it('cancels the timers it armed, so none of the first mount fires into the second', async () => { + // The other half of the same rule. A timer the first terminal armed has no owner after + // dispose, and on the second mount it acts on the terminal that replaced it — hiding an + // indicator nobody raised. Frames are the case below, which provokes them deliberately; + // each asserts on its own witness so neither can stand in for the other. + // The document is its own chunk, and the point is what *it* scheduled: xterm's renderer + // schedules frames of its own that a disposed terminal simply ignores, and the browser + // cannot unschedule those. So the chunk is identified on the wire, by a literal only + // `host-notify` carries, and a leak is a callback that chunk scheduled. + let documentChunk = null + const { page } = await openPage(PROBE_ROUTE, { + scheduler: true, + beforeNavigate: async (opened) => { + await opened.route('**/*.js', async (route) => { + const response = await route.fetch() + const body = await response.text() + if (body.includes('terminal runtime error')) { + documentChunk = new URL(route.request().url()).pathname + } + await route.fulfill({ response, body }) + }) + } + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + expect(documentChunk, 'the document was served as its own chunk').not.toBe(null) + // Enough rows for a scrollback, so the wheel below reveals the scroll indicator: that is + // the document's longest-lived piece of scheduled work, a 550 ms timer to hide it again, + // which outlives an unmount even on a loaded machine. The same wheel leaves the + // smooth-scroll frame owed. Both are asked for in the task that tells the component to go. + // One touch on the surface arms the long-press timer: 500 ms, held on the scope, cancelled + // by `stopTapDispatch`. It is the document's own timer and it needs nothing rendered, so + // the provocation cannot race the engine — the precondition below says whether it landed. + await page.evaluate(() => { + globalThis.__orcaScheduler.watching = true + const surface = document.getElementById('terminal-surface') + surface.dispatchEvent( + new TouchEvent('touchstart', { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 1, target: surface, clientX: 100, clientY: 400 })], + changedTouches: [ + new Touch({ identifier: 1, target: surface, clientX: 100, clientY: 400 }) + ] + }) + ) + globalThis.__orcaTerminalProbe.setMounted(false) + }) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + await page.evaluate(() => { + globalThis.__orcaTerminalReady = false + globalThis.__orcaTerminalProbe.setMounted(true) + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + // Long enough for the slowest timer of the first mount to have fired if it survived. + await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000))) + const scheduler = await page.evaluate(() => globalThis.__orcaScheduler) + // The precondition: there was something to leak. A wheel that reached nothing would agree + // with the empty list below for the wrong reason. + expect( + scheduler.scheduled.filter( + (entry) => entry.owned && entry.kind === 'timer' && entry.caller.includes(documentChunk) + ).length + ).toBeGreaterThan(0) + expect( + scheduler.leaked.filter( + (entry) => entry.startsWith('timer ') && entry.includes(documentChunk) + ) + ).toEqual([]) + await page.unrouteAll({ behavior: 'ignoreErrors' }) + await page.close() + }, 300_000) + + it('takes back the frames it is owed, not only the timers', async () => { + // The timer case above is witnessed by a 550 ms timeout, which every module's own stop + // cancels by the handle the scope holds. A frame is the other shape: `applyFitScale` asks + // for one through the scope's registry and never holds its id, so `stopFitScale` can only + // bump the token it tests itself against — the frame still runs. Nothing but + // `cancelDocumentFrames` takes it back. + // + // Two things have to be pinned down for that to be readable, and the first version of this + // case had neither. + // + // The witness has to be owed whenever the dispose lands. A single refit is not: the retry + // loop commits on its first attempt whenever the grid still measures, so one resize buys + // one frame and a dispose after it owes nothing — which agrees with an empty leak list for + // exactly the reason under test, once in five runs. So the refit is re-armed from a frame + // of the test's own, which leaves the document owed a frame at the end of every frame the + // browser serves, and dispose cannot land inside one. + // + // And the leak has to be counted from the moment dispose returned, not from the moment the + // host element left the DOM. React unmounts in two steps: the mutation phase detaches the + // host, and the passive cleanup that calls `dispose` runs after it — 1 ms apart here, 20 to + // 35 ms apart with the CPU throttled 20x, which is the CI runner this failed on. A frame + // served in that gap runs with a detached container while the document is still live and + // has not been asked to stop, and no registry could take it back. It went through + // `scheduleDocumentFrame` like every other; the old oracle called it a leak because it + // judged by the container rather than by dispose. Only what runs after the last statement + // of `dispose` is the document keeping something it gave up. + let documentChunk = null + const { page } = await openPage(PROBE_ROUTE, { + scheduler: true, + beforeNavigate: async (opened) => { + await opened.route('**/*.js', async (route) => { + const response = await route.fetch() + const body = await response.text() + if (body.includes('terminal runtime error')) { + documentChunk = new URL(route.request().url()).pathname + } + await route.fulfill({ response, body }) + }) + } + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + expect(documentChunk, 'the document was served as its own chunk').not.toBe(null) + + await page.evaluate((chunk) => { + const state = globalThis.__orcaScheduler + state.disposed = null + state.watching = true + // `dispose` empties the host and drops its class last, after `cancelDocumentFrames`, so + // the class going is the moment it returned. Observed on the element rather than on the + // tree because React may have detached it already. + const host = document.querySelector('.orca-terminal-document-host') + const observer = new MutationObserver(() => { + if (state.disposed !== null || host.classList.contains('orca-terminal-document-host')) { + return + } + state.disposed = { + // A cancelled frame never runs, so it is still owed here. That is the point. + owed: state.scheduled.filter( + (entry) => entry.kind === 'frame' && !entry.fired && entry.caller.includes(chunk) + ).length, + leakedBefore: state.leaked.length + } + observer.disconnect() + }) + observer.observe(host, { attributes: true, attributeFilter: ['class'] }) + const pulse = () => { + if (state.disposed !== null) { + return + } + globalThis.dispatchEvent(new Event('resize')) + requestAnimationFrame(pulse) + } + requestAnimationFrame(pulse) + globalThis.setTimeout(() => globalThis.__orcaTerminalProbe.setMounted(false), 200) + }, documentChunk) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + await page.evaluate(() => { + globalThis.__orcaTerminalReady = false + globalThis.__orcaTerminalProbe.setMounted(true) + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000))) + + const scheduler = await page.evaluate(() => globalThis.__orcaScheduler) + expect( + scheduler.disposed?.owed, + 'the document owed a frame at the moment dispose returned' + ).toBeGreaterThan(0) + expect( + scheduler.leaked + .slice(scheduler.disposed.leakedBefore) + .filter((entry) => entry.startsWith('frame ') && entry.includes(documentChunk)) + ).toEqual([]) + await page.unrouteAll({ behavior: 'ignoreErrors' }) + await page.close() + }, 300_000) + + it('styles what it owns, and only that', async () => { + // The document's sheet says `*`, `html` and `body` because inside a WebView it owns the + // page. Appended to the head of a React Native Web application it owns nothing: those three + // selectors set the application's background, its overflow and every element's box model, + // on every screen the shell can show, and go on doing it after the terminal is gone. + // + // Ruling 19's shape: the page mount may style only what it owns. So the document-level + // rules are never injected and every remaining selector is held under the host's class. + // The oracle is a page of the same application with no terminal on it. + const control = await openPage(CONTROL_ROUTE) + const expected = await readRootComputedStyles(control.page) + await control.page.close() + + const { page } = await openTerminal() + await openProbeTerminal(page) + expect(await readRootComputedStyles(page), 'roots while the terminal is mounted').toEqual( + expected + ) + + // And nothing in the sheet reaches past the host, which is the rule the comparison above + // cannot see: a selector that matched something outside would not have to change `body`. + const mounted = await terminalStyleReach(page) + // The precondition: there are rules to escape with. + expect(mounted.rules).toBeGreaterThan(0) + expect(mounted.outside).toEqual([]) + + // The positive half, which the two above cannot give: a sheet that reached nothing at all + // would satisfy both of them. These are four things the terminal looks like only because + // the rules arrive — one from xterm's sheet, three from the document's own — read off the + // live elements rather than off the stylesheet text. + expect( + await page.evaluate(() => { + const host = document.querySelector('.orca-terminal-document-host') + const xterm = host.querySelector('.xterm') + const viewport = host.querySelector('.xterm-viewport') + const overlay = host.querySelector('#selection-overlay') + return { + // xterm's own sheet: the grid is positioned against this, and its rows are absolute. + xtermPosition: getComputedStyle(xterm).position, + // The document's: the terminal scrolls itself, so the viewport shows no scrollbar + // and reserves no width for one. + viewportOverflowY: getComputedStyle(viewport).overflowY, + viewportReservesScrollbar: viewport.offsetWidth !== viewport.clientWidth, + // The document's: the overlay sits in unscaled viewport coordinates above the grid. + overlayPosition: getComputedStyle(overlay).position + } + }) + ).toEqual({ + xtermPosition: 'relative', + viewportOverflowY: 'hidden', + viewportReservesScrollbar: false, + overlayPosition: 'fixed' + }) + + await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false)) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + expect(await readRootComputedStyles(page), 'roots after dispose').toEqual(expected) + // The sheet stays in the head for the next mount, and matches nothing until there is one. + const disposed = await terminalStyleReach(page) + expect(disposed.rules).toBe(mounted.rules) + expect(disposed.outside).toEqual([]) + await page.close() + }, 300_000) + + it('measures a fit through the handle and records what beforeinput reports', async () => { + const { page } = await openTerminal() + await openProbeTerminal(page) + + // The handle's own round trip: a measure is a command in and a notify back, and on the page + // both halves are direct calls rather than a bridge. Null would mean the document answered + // nothing, or answered a grid too small to fit. + const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.measure()) + expect(fit).not.toBeNull() + expect(fit.cols).toBeGreaterThanOrEqual(20) + expect(fit.rows).toBeGreaterThanOrEqual(8) + + // xterm's own textarea is inert by the document's design — `query-reply.ts` makes it + // read-only, untabbable and `inputmode=none` so touch and hardware keys go to the screen's + // input instead. Asserted rather than assumed, because it is why the probe below types + // somewhere else. + const textarea = await page.evaluate(() => { + const element = document.querySelector('#terminal-surface .xterm-helper-textarea') + return element === null + ? null + : { + readOnly: element.readOnly, + tabIndex: element.tabIndex, + inputMode: element.getAttribute('inputmode') + } + }) + expect(textarea).toEqual({ readOnly: true, tabIndex: -1, inputMode: 'none' }) + + // Design §8's cheap half of the IME question: what a browser reports for text entering a + // terminal on the page, which arrives at the screen's own input. A composing IME on a real + // soft keyboard is the device step, which this does not claim to answer. + await page.getByTestId('terminal-live-input').focus() + await page.keyboard.type('ab') + await page.waitForFunction(() => globalThis.__orcaTerminalBeforeInput.length >= 2, { + timeout: 30_000, + polling: 100 + }) + const beforeInput = await page.evaluate(() => globalThis.__orcaTerminalBeforeInput) + console.log('[c7.5][beforeinput]', JSON.stringify(beforeInput.slice(0, 4))) + expect(beforeInput.map((entry) => entry.inputType)).toContain('insertText') + expect(beforeInput.map((entry) => entry.data)).toContain('a') + expect(beforeInput.every((entry) => entry.isComposing === false)).toBe(true) + expect(await terminalCspViolations(page)).toEqual([]) + await page.close() + }, 300_000) + }, + 900_000 +) diff --git a/config/scripts/mobile-web-terminal-engine-closure.test.mjs b/config/scripts/mobile-web-terminal-engine-closure.test.mjs new file mode 100644 index 00000000000..2303e07df12 --- /dev/null +++ b/config/scripts/mobile-web-terminal-engine-closure.test.mjs @@ -0,0 +1,114 @@ +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { mobileWebAppModuleClosure } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' + +/** + * The 612 KiB xterm engine string, and where the page must never meet it. + * + * `terminal-webview-engine.generated.ts` is one minified IIFE of xterm plus two addons, built to + * be injected into the WebView's HTML document as text. On the page the same engine arrives as an + * import, so the string is 610 KiB of dead weight — the largest single module in the session + * route's closure, and unusable there besides, because the shell's CSP has `script-src 'self'` + * and no nested frame to load a document into. + * + * Nothing stops it entering: the module the document's `
' + + '
' + + '
' + + '
' + + '
' + +// Imported after the markup exists: ruling 20 leaves the module bodies inert, but the start +// sequence below reads the elements as the document does, and it has to find them. +let createTerminalDocumentScope: () => TerminalDocumentScope +let scope: TerminalDocumentScope +let handleMsg: typeof import('./host-message-router').handleMsg +let notify: typeof import('./host-notify').notify +let flog: typeof import('./viewport-transform').flog +let attachWebglAddon: typeof import('./webgl-recovery').attachWebglAddon + +beforeAll(async () => { + document.body.innerHTML = SURFACE_MARKUP + // The page's own entry and the page's own sequence, rather than a hand-picked subset: the + // elements `runtime-constants`, `surface-swap` and `selection-state-and-eviction` take are read + // in the one order both hosts run them in, and a module added to that order is covered here + // without this file being edited. + const pageModules = await import('./page-document-modules') + pageModules.startPageDocumentModules() + const documentScope = await import('./document-scope') + createTerminalDocumentScope = documentScope.createTerminalDocumentScope + scope = documentScope.scope + ;({ handleMsg } = await import('./host-message-router')) + ;({ notify } = await import('./host-notify')) + ;({ flog } = await import('./viewport-transform')) + ;({ attachWebglAddon } = await import('./webgl-recovery')) +}) + +function terminalDouble() { + const loaded: unknown[] = [] + let opened: HTMLElement | undefined + const terminal = { + cols: 80, + rows: 24, + options: { theme: {}, minimumContrastRatio: 3, fontSize: 13 }, + buffer: { active: { baseY: 0, viewportY: 0, cursorY: 0, length: 1, type: 'normal' } }, + get element() { + return opened + }, + unicode: { activeVersion: '6' }, + loaded, + write(_data: string, callback?: () => void) { + callback?.() + }, + open(element: HTMLElement) { + opened = element + }, + loadAddon: (addon: unknown) => loaded.push(addon), + attachCustomKeyEventHandler() {}, + onData: () => ({ dispose() {} }), + onLineFeed: () => ({ dispose() {} }), + onScroll: () => ({ dispose() {} }), + onWriteParsed: () => ({ dispose() {} }), + clear() {}, + reset() {}, + refresh() {}, + resize() {}, + selectAll() {}, + select() {}, + clearSelection() {}, + scrollLines() {}, + scrollToLine() {}, + scrollToBottom() {}, + dispose() {} + } + return terminal +} + +/** Restores every field a case assigns, so one of them cannot leave the singleton scope moved. */ +function withSeams(seams: Partial, run: () => void) { + const previous: Record = {} + for (const key of Object.keys(seams)) { + previous[key] = Object.getOwnPropertyDescriptor(scope, key)?.value + } + Object.assign(scope, seams) + try { + run() + } finally { + Object.assign(scope, previous) + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('the document host seams, by default', () => { + it('posts to the React Native bridge, reading it at call time', () => { + const postMessage = vi.fn<(data: string) => void>() + // Built before the global exists: the default must read the window when it posts, not when + // the scope was created, because the document's scope is built as its script is parsed. + const built = createTerminalDocumentScope() + vi.stubGlobal('ReactNativeWebView', { postMessage }) + built.postToHost({ type: 'ready', cols: 80, rows: 24 }) + expect(postMessage.mock.calls).toEqual([['{"type":"ready","cols":80,"rows":24}']]) + }) + + it('posts nothing when there is no bridge, which is the guard the document carried', () => { + expect(() => createTerminalDocumentScope().postToHost({ type: 'ready' })).not.toThrow() + }) + + it('builds the terminal from the engine bundle global', () => { + const constructed: Record[] = [] + function TerminalStub(this: unknown, options: Record) { + constructed.push(options) + } + vi.stubGlobal('Terminal', TerminalStub) + const term = createTerminalDocumentScope().createTerminal({ cols: 80, rows: 24 }) + expect(constructed).toEqual([{ cols: 80, rows: 24 }]) + expect(term).toBeInstanceOf(TerminalStub) + }) + + it('installs the runtime error reporter by taking window.onerror, and hands back its undo', () => { + const previous = window.onerror + try { + const report = () => {} + const uninstall = createTerminalDocumentScope().installErrorReporter(report) + expect(window.onerror).toBe(report) + // Ruling 20 made the install a per-mount act, so the seam owes the caller a way back. + uninstall() + expect(window.onerror).toBe(null) + } finally { + window.onerror = previous + } + }) + + it('paints the document roots, which is what owning the page means', () => { + // Inside the WebView the terminal's theme is the page's own background, so the document sets + // it on `html` and `body`. On the page those belong to the application, which is why this is + // a field: the render check holds that neither root moves while a terminal is mounted. + const roots = [document.documentElement, document.body] + const previous = roots.map((element) => element.style.background) + try { + createTerminalDocumentScope().paintDocumentBackground('rgb(1, 2, 3)') + expect(roots.map((element) => element.style.background)).toEqual([ + 'rgb(1, 2, 3)', + 'rgb(1, 2, 3)' + ]) + } finally { + roots.forEach((element, index) => { + element.style.background = previous[index]! + }) + } + }) + + it('builds each addon from its engine global, and answers null when the engine has none', () => { + const built = createTerminalDocumentScope() + expect(built.createUnicode11Addon()).toBe(null) + expect(built.createWebglAddon()).toBe(null) + class Unicode11Addon { + dispose() {} + } + class WebglAddon { + dispose() {} + } + vi.stubGlobal('Unicode11Addon', { Unicode11Addon }) + vi.stubGlobal('WebglAddon', { WebglAddon }) + expect(built.createUnicode11Addon()).toBeInstanceOf(Unicode11Addon) + expect(built.createWebglAddon()).toBeInstanceOf(WebglAddon) + }) +}) + +describe('the document host seams, once the page sets them', () => { + it('routes every notify to the field and nothing to the bridge', () => { + const postMessage = vi.fn<(data: string) => void>() + vi.stubGlobal('ReactNativeWebView', { postMessage }) + const posted: Record[] = [] + withSeams({ postToHost: (message) => posted.push(message) }, () => { + notify({ type: 'pong', pingId: 7 }) + flog('probe', { n: 1 }) + }) + expect(posted).toEqual([ + { type: 'pong', pingId: 7 }, + { type: 'log', tag: '[fit]probe', payload: { n: 1 } } + ]) + // The whole reason the seam exists: on the page this object belongs to the shell. + expect(postMessage).not.toHaveBeenCalled() + }) + + it('routes a host message into the document and builds the engine from the fields', () => { + const terminal = terminalDouble() + const options: Record[] = [] + const unicodeAddon = { dispose() {} } + const webglAddon = { dispose() {} } + const posted: Record[] = [] + withSeams( + { + createTerminal: (created) => { + options.push(created) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the double implements every member `init` reaches; the calls below are what check it. + return terminal as unknown as ReturnType + }, + createUnicode11Addon: () => unicodeAddon, + createWebglAddon: () => webglAddon, + postToHost: (message) => posted.push(message) + }, + () => { + handleMsg({ type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false }) + expect(options).toHaveLength(1) + expect(options[0]!.cols).toBe(80) + expect(terminal.loaded).toContain(webglAddon) + expect(terminal.loaded).toContain(unicodeAddon) + expect(terminal.unicode.activeVersion).toBe('11') + // The other direction: a document-side report reaches the page's sink, not the bridge. + handleMsg({ type: 'ping', id: 3 }) + expect(posted).toContainEqual({ type: 'pong', pingId: 3 }) + } + ) + }) + + it('leaves window.onerror alone when the host installs the reporter its own way', () => { + // The page's case, which is the whole reason this one is a field: on a page that object is + // not the terminal's to take. A host that installs its reporter elsewhere must leave it null. + const previous = window.onerror + window.onerror = null + const installed: unknown[] = [] + try { + const built = createTerminalDocumentScope() + const undos: unknown[] = [] + built.installErrorReporter = (report) => { + installed.push(report) + return () => undos.push(report) + } + built.installErrorReporter(() => {})() + expect(installed).toHaveLength(1) + expect(undos).toHaveLength(1) + expect(window.onerror).toBe(null) + } finally { + window.onerror = previous + } + }) + + it('reports no webgl addon as a DOM-renderer fallback rather than as a failure', () => { + withSeams({ createWebglAddon: () => null }, () => { + expect(attachWebglAddon(true)).toBe(false) + }) + }) +}) diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts index 2141b7cfa3b..4246e6f8360 100644 --- a/mobile/src/terminal/document/message-bridge.ts +++ b/mobile/src/terminal/document/message-bridge.ts @@ -30,24 +30,23 @@ export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage | } } -window.addEventListener('message', handleIncomingMessage) - -document.addEventListener('message', handleIncomingMessage) - -window.addEventListener('resize', function () { - // Why: viewport changed (keyboard open/close, orientation, RN container - // size update). Re-fit so the scale matches the new vpWidth — without - // this, opening the keyboard leaves the terminal at the old scale even - // though there's now less vertical room and the fit ratio may differ. - applyFitScale('window-resize') - adjustRowsForViewport() - repositionOverlay() - clampPan() - updateTransform() -}) - -if (window.Terminal) { - notify({ type: 'web-ready' }) -} else { - reportEngineError('terminal engine missing', 'xterm failed to load', true) +export function startMessageBridge() { + window.addEventListener('message', handleIncomingMessage) + document.addEventListener('message', handleIncomingMessage) + window.addEventListener('resize', function () { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize') + adjustRowsForViewport() + repositionOverlay() + clampPan() + updateTransform() + }) + if (window.Terminal) { + notify({ type: 'web-ready' }) + } else { + reportEngineError('terminal engine missing', 'xterm failed to load', true) + } } diff --git a/mobile/src/terminal/document/mode-mirroring.ts b/mobile/src/terminal/document/mode-mirroring.ts index c5471bb8db4..fde043316ea 100644 --- a/mobile/src/terminal/document/mode-mirroring.ts +++ b/mobile/src/terminal/document/mode-mirroring.ts @@ -37,10 +37,3 @@ export function emitModesIfChanged() { }) } } -scope.lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false -} diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts index aed11e99f5f..611bce805e3 100644 --- a/mobile/src/terminal/document/mouse-click-drag.ts +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -20,8 +20,6 @@ export type TerminalMouseGesture = { dismissedSelection: boolean } -let mouseGesture: TerminalMouseGesture | null = null - // One report per transition, built with the same encoding ladder as // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' // when the mode does not report this transition (x10 has no release, only @@ -81,8 +79,8 @@ export function mouseReportCellKey(clientX: number, clientY: number) { } export function abandonMouseGesture() { - const gesture = mouseGesture - mouseGesture = null + const gesture = scope.mouseGesture + scope.mouseGesture = null if (!gesture) { return } @@ -141,7 +139,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { } // Why: a pointerup lost outside the WebView must not leave the previous // gesture latched (tracking press with no release) when the next one lands. - if (mouseGesture) { + if (scope.mouseGesture) { abandonMouseGesture() } // Why: mouse pointers have no implicit capture; without it a drag that @@ -151,7 +149,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { targetSurface.setPointerCapture(e.pointerId) } } catch {} - mouseGesture = { + scope.mouseGesture = { startX: e.clientX, startY: e.clientY, lastX: e.clientX, @@ -165,7 +163,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { // Why: touch parity — pressing outside the pill dismisses the current // selection; the same press may still start a new drag selection. cancelSelect() - mouseGesture.dismissedSelection = true + scope.mouseGesture.dismissedSelection = true } }, true @@ -174,7 +172,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { targetSurface.addEventListener( 'pointermove', function (e) { - const gesture = mouseGesture + const gesture = scope.mouseGesture if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') { return } @@ -220,11 +218,11 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { targetSurface.addEventListener( 'pointerup', function (e) { - const gesture = mouseGesture + const gesture = scope.mouseGesture if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) { return } - mouseGesture = null + scope.mouseGesture = null if (gesture.mode === 'cancelled' || !scope.term) { return } @@ -274,7 +272,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { targetSurface.addEventListener( 'touchstart', function () { - if (mouseGesture) { + if (scope.mouseGesture) { abandonMouseGesture() } }, diff --git a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts index e1ee6dd63e3..6d737fe1581 100644 --- a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -1,6 +1,6 @@ import { getCellHeight } from './fit-scale' import { getTotalScale, updateScrollIndicator } from './viewport-transform' -import { scope } from './document-scope' +import { scope, scheduleDocumentFrame } from './document-scope' export function clampNormalScrollLines(lines: number) { if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) { @@ -77,7 +77,7 @@ export function enqueueNormalBufferScrollDelta(deltaY: number) { // Why: dense terminal rows are expensive to repaint. Coalesce touchmove // deltas into one xterm row-scroll per frame instead of repainting from // the input event stream. - scope.normalScrollFrameId = requestAnimationFrame(function () { + scope.normalScrollFrameId = scheduleDocumentFrame(function () { scope.normalScrollFrameId = null const delta = scope.pendingNormalScrollDeltaY scope.pendingNormalScrollDeltaY = 0 @@ -100,3 +100,8 @@ export function resetSmoothScrollOffset() { scope.smoothScrollOffsetY = 0 updateScrollIndicator(false) } + +/** Ruling 21: the smooth-scroll frame, which would otherwise scroll the next mount's buffer. */ +export function stopNormalBufferSmoothScroll() { + resetSmoothScrollOffset() +} diff --git a/mobile/src/terminal/document/page-document-module-order.test.ts b/mobile/src/terminal/document/page-document-module-order.test.ts new file mode 100644 index 00000000000..01611200d1c --- /dev/null +++ b/mobile/src/terminal/document/page-document-module-order.test.ts @@ -0,0 +1,77 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { terminalDocumentStartCalls } from '../../../scripts/build-terminal-document-script.mjs' +import { + TERMINAL_DOCUMENT_HOST_SEAMS_MODULE, + TERMINAL_DOCUMENT_MODULE_ORDER, + TERMINAL_DOCUMENT_SCOPE_MODULE +} from '../../../scripts/terminal-document-module-order.mjs' + +/** + * The page runs the document's modules in the order the WebView's script runs them. + * + * It has to: the document is one function scope, so `runtime-constants` taking the surface before + * `surface-swap` captures it is not a dependency the graph records. Inside the WebView the + * generator reads the order from one file; on the page the order is the import list in + * `page-document-modules.ts`, and nothing but this holds the two together. A formatter that sorted + * that list, or a module added to the generator and not to the page, would leave both sides green + * and the page running a different program. + * + * Read as text rather than by importing the module, because importing it would run the document + * against an empty body and prove only that the file parses. + */ +const pageEntry = readFileSync(new URL('./page-document-modules.ts', import.meta.url), 'utf8') + +/** `message-bridge` is ruling 19's exclusion: on the page those frames belong to the shell. */ +const EXCLUDED = ['message-bridge'] + +function importedModules(): string[] { + return [...pageEntry.matchAll(/^import (?:\{[^}]*\} from )?'\.\/([a-z0-9-]+)'$/gm)].map( + (match) => match[1]! + ) +} + +describe('the page entry for the terminal document', () => { + it('imports every module the generator emits, in the same order, minus the bridge', () => { + expect(importedModules()).toEqual([ + TERMINAL_DOCUMENT_HOST_SEAMS_MODULE, + TERMINAL_DOCUMENT_SCOPE_MODULE, + ...TERMINAL_DOCUMENT_MODULE_ORDER.filter((name) => !EXCLUDED.includes(name)) + ]) + }) + + it('names its exclusion, and the exclusion is a module the generator does emit', () => { + for (const name of EXCLUDED) { + expect(TERMINAL_DOCUMENT_MODULE_ORDER).toContain(name) + expect(importedModules()).not.toContain(name) + } + }) + + it('calls the same start sequence the generated document calls, minus the bridge', async () => { + // Ruling 20's other half. The import list above only proves the page reaches the same + // modules; what runs is the call sequence, and the generator writes its own from the same + // sources. A module that grows a start function and is not called here would leave the page + // with an element nobody read. + const sequence = [...pageEntry.matchAll(/^ {2,4}(start[A-Za-z]+)\(\)$/gm)].map( + (match) => match[1]! + ) + const emitted = await terminalDocumentStartCalls([ + TERMINAL_DOCUMENT_HOST_SEAMS_MODULE, + TERMINAL_DOCUMENT_SCOPE_MODULE, + ...TERMINAL_DOCUMENT_MODULE_ORDER + ]) + expect(sequence.length).toBeGreaterThan(0) + expect(sequence).toEqual(emitted.filter((name) => name !== 'startMessageBridge')) + }) + + it('would report a reordered list', () => { + // The precondition for the first case: a matcher that found nothing would agree with an empty + // expectation just as happily. Swapping the first two names must break it. + const [first, second, ...rest] = importedModules() + expect([second, first, ...rest]).not.toEqual([ + TERMINAL_DOCUMENT_HOST_SEAMS_MODULE, + TERMINAL_DOCUMENT_SCOPE_MODULE, + ...TERMINAL_DOCUMENT_MODULE_ORDER.filter((name) => !EXCLUDED.includes(name)) + ]) + }) +}) diff --git a/mobile/src/terminal/document/page-document-modules.ts b/mobile/src/terminal/document/page-document-modules.ts new file mode 100644 index 00000000000..a9fcfeed0e9 --- /dev/null +++ b/mobile/src/terminal/document/page-document-modules.ts @@ -0,0 +1,116 @@ +/** + * The document's modules, and the two sequences that start and stop them. + * + * The document is one function scope, not a dependency graph: `runtime-constants` takes the + * surface, `surface-swap` captures the surface it was handed, and `selection-state-and-eviction` + * takes the overlay elements. Ruling 20 moved those out of the module bodies into start + * functions; ruling 21 moved every mutable binding onto the scope, so what is left at module top + * level is constants, functions and types. Importing this file therefore does nothing at all. + * + * That is what makes a second mount a second terminal. ES module bodies run once per page, so a + * remount re-imports nothing: it resets the scope, then runs the same start sequence the WebView's + * generated script runs once at parse, against the markup the host has just replanted. + * + * `message-bridge` is deliberately absent (ruling 19). It installs `window`/`document` `message` + * listeners, and on the page those frames belong to the shell: the document would read a bridge + * envelope as a terminal command. The component calls `handleMsg` instead, and re-arms the one + * other thing that module does, the window-resize refit. + * + * `page-document-module-order.test.ts` holds these lists against + * `scripts/terminal-document-module-order.mjs`, so the page and the WebView cannot run different + * programs and a reordering edit cannot pass unread. + */ +import './document-host-seams' +import { cancelDocumentFrames, resetTerminalDocumentScope } from './document-scope' +import { startRuntimeConstants } from './runtime-constants' +import './query-reply' +import { startSurfaceSwap } from './surface-swap' +import { startTextScaling } from './text-scaling' +import { stopViewportTransform } from './viewport-transform' +import './terminal-theme' +import { stopFitScale } from './fit-scale' +import './mouse-mode-decset-scan' +import './write-queue' +import { startWebglRecovery, stopWebglRecovery } from './webgl-recovery' +import { stopTerminalInit } from './terminal-init' +import './reflow' +import { startHostNotify, stopHostNotify } from './host-notify' +import './host-message-router' +import { startSelectionStateAndEviction } from './selection-state-and-eviction' +import './mode-mirroring' +import './keyboard-avoidance-metrics' +import './term-observers' +import './viewport-cell' +import './mouse-report-cell' +import './mouse-input-encoding' +import { stopNormalBufferSmoothScroll } from './normal-buffer-smooth-scroll' +import './cell-geometry' +import './path-tap' +import './url-tap' +import './osc-link-tap' +import './surface-tap' +import './selection-range' +import { stopSelectionOverlay } from './selection-overlay' +import { startTapDispatch, stopTapDispatch } from './tap-dispatch' +import './wheel-scroll' +import './mouse-click-drag' +import { startSelectionMenuButtons } from './selection-menu-buttons' +import { startSurfaceTouchGestures, stopSurfaceTouchGestures } from './surface-touch-gestures' + +/** + * The scope's reset and every module's start function, in module order: what the WebView's + * document runs once as its script is parsed, run here once per mount. + */ +export function startPageDocumentModules() { + resetTerminalDocumentScope() + // Unwound if one of them throws: a start that completed has already taken a listener or + // installed the reporter, and leaving those behind would outlive the mount that never happened. + // Only the starts with an undo need recording; the rest write scope fields the next reset + // overwrites. + const undo: (() => void)[] = [] + try { + startRuntimeConstants() + startSurfaceSwap() + startTextScaling() + startWebglRecovery() + undo.unshift(stopWebglRecovery) + startHostNotify() + undo.unshift(stopHostNotify) + startSelectionStateAndEviction() + startTapDispatch() + undo.unshift(stopTapDispatch) + startSelectionMenuButtons() + startSurfaceTouchGestures() + undo.unshift(stopSurfaceTouchGestures) + } catch (error) { + for (const stop of undo) { + stop() + } + throw error + } +} + +/** + * The undo, in reverse module order: every listener that outlives the host element, and every + * frame, timer and retry a module scheduled (ruling 21). + */ +export function stopPageDocumentModules() { + // Last frames first: a module's own stop nulls the handle it holds, and this takes back every + // frame the document is still owed, including the ones no module tracks by id. + cancelDocumentFrames() + stopSurfaceTouchGestures() + stopTapDispatch() + stopSelectionOverlay() + stopNormalBufferSmoothScroll() + stopHostNotify() + stopTerminalInit() + stopWebglRecovery() + stopFitScale() + stopViewportTransform() +} + +export { scope } from './document-scope' +export { handleMsg } from './host-message-router' +export { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale' +export { repositionOverlay } from './selection-overlay' +export { updateTransform } from './viewport-transform' diff --git a/mobile/src/terminal/document/page-document-start-unwind.test.ts b/mobile/src/terminal/document/page-document-start-unwind.test.ts new file mode 100644 index 00000000000..c2a557aa50d --- /dev/null +++ b/mobile/src/terminal/document/page-document-start-unwind.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' + +/** + * A start sequence that throws leaves nothing of itself behind. + * + * The starts are not all writes to the scope: `startHostNotify` installs the host's error + * reporter and `startTapDispatch` takes four document listeners. If one of the later starts + * throws, the mount fails and its handle releases the page — but the reporter and the listeners + * are already installed, and nothing else would reach them: the next mount's reset nulls the undo + * the install handed back, so the listener would stay for the life of the tab. + * + * The provocation is the document's own markup with the selection menu missing, which is what + * `startSelectionMenuButtons` reads and the only thing it does. + */ +const MARKUP_WITHOUT_THE_MENU = + '
' + + '
' + + '
' + + '
' + +describe('the page start sequence', () => { + it('unwinds the starts that completed when a later one throws', async () => { + document.body.innerHTML = MARKUP_WITHOUT_THE_MENU + const { startPageDocumentModules } = await import('./page-document-modules') + const previous = window.onerror + window.onerror = null + try { + expect(() => startPageDocumentModules()).toThrow() + // `startHostNotify` ran and installed the default reporter, which takes `window.onerror`. + // The unwind is the only thing that gives it back: the next mount's reset nulls the undo it + // handed out, so an install left standing here is permanent. + expect(window.onerror).toBe(null) + } finally { + window.onerror = previous + } + }) + + it('would have installed one, so the null above is a measurement', async () => { + // The precondition. With the menu present the same sequence completes, and the reporter it + // installs is exactly what the case above asserts was taken back. + document.body.innerHTML = MARKUP_WITHOUT_THE_MENU.replace( + '
', + '
' + + '
' + ) + const { startPageDocumentModules, stopPageDocumentModules } = + await import('./page-document-modules') + const previous = window.onerror + window.onerror = null + try { + startPageDocumentModules() + expect(window.onerror).not.toBe(null) + stopPageDocumentModules() + expect(window.onerror).toBe(null) + } finally { + window.onerror = previous + } + }) +}) diff --git a/mobile/src/terminal/document/query-reply.ts b/mobile/src/terminal/document/query-reply.ts index 70509735b2c..ac9001d5199 100644 --- a/mobile/src/terminal/document/query-reply.ts +++ b/mobile/src/terminal/document/query-reply.ts @@ -18,20 +18,16 @@ export type QueryReplyTerminal = { onData: (listener: (data: string) => void) => TerminalDocumentDisposable } -// Written from four places, all of them here, so it is this module's state rather than the -// document's and stays a local. -let terminalDataRepliesEnabled = false - export function resetTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = false + scope.terminalDataRepliesEnabled = false } export function resumeTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = true + scope.terminalDataRepliesEnabled = true } export function forwardTerminalDataReply(data: string) { - if (terminalDataRepliesEnabled) { + if (scope.terminalDataRepliesEnabled) { notify({ type: 'terminal-data', bytes: data }) } } @@ -39,7 +35,7 @@ export function forwardTerminalDataReply(data: string) { export function enqueueTerminalDataReplyBoundary(gen: number) { enqueueWriteBoundary(function () { if (gen === scope.terminalGeneration) { - terminalDataRepliesEnabled = true + scope.terminalDataRepliesEnabled = true } }) } diff --git a/mobile/src/terminal/document/runtime-constants.ts b/mobile/src/terminal/document/runtime-constants.ts index a283c629ff8..c28079779f2 100644 --- a/mobile/src/terminal/document/runtime-constants.ts +++ b/mobile/src/terminal/document/runtime-constants.ts @@ -6,18 +6,7 @@ import { scope } from './document-scope' * All eight are read by other parts of the script, so all eight are scope fields; the document * shell opens the function they live in and `document-close.ts` closes it. */ -scope.surface = document.getElementById('terminal-surface') -scope.ESC = String.fromCharCode(27) -scope.C1_CSI = String.fromCharCode(155) -scope.CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa) -scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -scope.CLAUDE_STATUS_DOT_PATTERN = new RegExp( - scope.CLAUDE_STATUS_DOT + - '[' + - scope.TEXT_PRESENTATION_SELECTOR + - scope.EMOJI_PRESENTATION_SELECTOR + - ']*', - 'g' -) -scope.statusDotPendingSelector = false + +export function startRuntimeConstants() { + scope.surface = document.getElementById('terminal-surface') +} diff --git a/mobile/src/terminal/document/selection-menu-buttons.ts b/mobile/src/terminal/document/selection-menu-buttons.ts index 9601cdb3bdf..26b9f1b457c 100644 --- a/mobile/src/terminal/document/selection-menu-buttons.ts +++ b/mobile/src/terminal/document/selection-menu-buttons.ts @@ -3,34 +3,35 @@ import { notify } from './host-notify' import { cancelSelect } from './selection-range' import { repositionOverlay } from './selection-overlay' -scope.btnCopy!.addEventListener('click', function (e) { - e.preventDefault() - e.stopPropagation() - if (!scope.term) { - return - } - const text = scope.term.getSelection ? scope.term.getSelection() : '' - if (text && text.length > 0) { - notify({ type: 'selection', text: text }) - } else { - cancelSelect() - } -}) - -scope.btnSelAll!.addEventListener('click', function (e) { - e.preventDefault() - e.stopPropagation() - if (!scope.term) { - return - } - try { - scope.term.selectAll() - const b = scope.term.buffer.active - scope.sel = { - anchor: { col: 0, row: 0 }, - focus: { col: scope.term.cols - 1, row: b.length - 1 }, - activeHandle: null +export function startSelectionMenuButtons() { + scope.btnCopy!.addEventListener('click', function (e) { + e.preventDefault() + e.stopPropagation() + if (!scope.term) { + return } - repositionOverlay() - } catch {} -}) + const text = scope.term.getSelection ? scope.term.getSelection() : '' + if (text && text.length > 0) { + notify({ type: 'selection', text: text }) + } else { + cancelSelect() + } + }) + scope.btnSelAll!.addEventListener('click', function (e) { + e.preventDefault() + e.stopPropagation() + if (!scope.term) { + return + } + try { + scope.term.selectAll() + const b = scope.term.buffer.active + scope.sel = { + anchor: { col: 0, row: 0 }, + focus: { col: scope.term.cols - 1, row: b.length - 1 }, + activeHandle: null + } + repositionOverlay() + } catch {} + }) +} diff --git a/mobile/src/terminal/document/selection-overlay.ts b/mobile/src/terminal/document/selection-overlay.ts index 24e0edcecbe..d179535c49d 100644 --- a/mobile/src/terminal/document/selection-overlay.ts +++ b/mobile/src/terminal/document/selection-overlay.ts @@ -139,3 +139,8 @@ export function handleDragMove(handle: string, clientX: number, clientY: number) } // Latching document-level touch dispatcher: see tap-dispatch.ts. + +/** Ruling 21: the edge-scroll interval, which outlives the selection that started it. */ +export function stopSelectionOverlay() { + stopEdgeScroll() +} diff --git a/mobile/src/terminal/document/selection-state-and-eviction.ts b/mobile/src/terminal/document/selection-state-and-eviction.ts index a97c1b9cb72..c42cd99cca1 100644 --- a/mobile/src/terminal/document/selection-state-and-eviction.ts +++ b/mobile/src/terminal/document/selection-state-and-eviction.ts @@ -6,55 +6,36 @@ import { scope } from './document-scope' // ============================================================ // SELECTION MODE (long-press → handles → Copy) // ============================================================ -scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u -scope.LONG_PRESS_MS = 500 -scope.LONG_PRESS_SLOP = 10 + // Why: a tap that opens a link/path must survive small finger jitter. The // long-press slop (10px) only cancels the press-to-select timer; reusing it // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap // window so deliberate scrolls/pans still don't fire a tap. -scope.TAP_SLOP = 24 -scope.TAP_MAX_MS = 700 -scope.EDGE_SCROLL_PX = 40 -scope.EDGE_SCROLL_INTERVAL = 60 - -scope.selectionOverlay = document.getElementById('selection-overlay') -scope.handleStart = document.getElementById('sel-handle-start') -scope.handleEnd = document.getElementById('sel-handle-end') -scope.selMenu = document.getElementById('sel-menu') -scope.btnCopy = document.getElementById('sel-menu-copy') -scope.btnSelAll = document.getElementById('sel-menu-all') // mode: 'navigate' | 'select' -scope.selMode = 'navigate' -scope.sel = null // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } -scope.longPressTimer = null -scope.longPressOrigin = null // {x,y, identifier} + +// { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } + +// {x,y, identifier} // Why: tap detection is tracked separately from the long-press timer so a // small jitter that cancels the press-to-select timer does not also cancel // the tap (which opens links/paths). {x,y,t,identifier} or null once the // gesture is disqualified as a tap (moved too far or held too long). -scope.tapCandidate = null -scope.edgeScrollTimer = null -scope.edgeScrollDir = 0 -scope.edgeScrollClientX = 0 -scope.edgeScrollClientY = 0 -// Eviction watchdog: linesEverWritten counts onLineFeed since last init. +// Eviction watchdog: linesEverWritten counts onLineFeed since the last init. // Once buffer is full, every onLineFeed evicts the top row in xterm and // we mirror that by decrementing stored absolute rows. -let linesEverWritten = 0 export function resetEvictionCounter() { - linesEverWritten = 0 + scope.linesEverWritten = 0 } export function isBufferFull() { if (!scope.term) { return false } - return linesEverWritten >= 5000 + (scope.term.rows || 0) + return scope.linesEverWritten >= 5000 + (scope.term.rows || 0) } export function checkEviction() { @@ -69,7 +50,7 @@ export function checkEviction() { } export function logFeedAndEvict() { - linesEverWritten++ + scope.linesEverWritten++ if (scope.initialOscLinkEvictionReady && isBufferFull()) { scope.initialOscLinkRowOffset += 1 } @@ -80,3 +61,12 @@ export function logFeedAndEvict() { repositionOverlay() } } + +export function startSelectionStateAndEviction() { + scope.selectionOverlay = document.getElementById('selection-overlay') + scope.handleStart = document.getElementById('sel-handle-start') + scope.handleEnd = document.getElementById('sel-handle-end') + scope.selMenu = document.getElementById('sel-menu') + scope.btnCopy = document.getElementById('sel-menu-copy') + scope.btnSelAll = document.getElementById('sel-menu-all') +} diff --git a/mobile/src/terminal/document/surface-swap.ts b/mobile/src/terminal/document/surface-swap.ts index 26082493cc3..3f942694c30 100644 --- a/mobile/src/terminal/document/surface-swap.ts +++ b/mobile/src/terminal/document/surface-swap.ts @@ -9,31 +9,24 @@ export type TerminalSurfaceSwap = { nextSurface: HTMLElement } -// Why: phone-fit startup can issue several init() calls before xterm finishes -// replaying. Track the last painted surface separately from its replacement. -let committedTerm: TerminalDocumentTerminal | null = null -let committedSurface = scope.surface -scope.pendingTerm = null -let pendingSurface: HTMLElement | null = null - export function beginTerminalSurfaceSwap() { // Why: a superseded hidden replacement must not remain between the last // painted surface and the newest one, or the newest commits below the viewport. - if (pendingSurface) { + if (scope.pendingSurface) { try { - pendingSurface.remove() + scope.pendingSurface.remove() } catch {} if (scope.pendingTerm) { try { scope.pendingTerm.dispose() } catch {} } - pendingSurface = null + scope.pendingSurface = null scope.pendingTerm = null } const swap = { - oldTerm: committedTerm, - oldSurface: committedSurface, + oldTerm: scope.committedTerm, + oldSurface: scope.committedSurface, nextSurface: document.createElement('div') } disposeTermObservers() @@ -44,7 +37,7 @@ export function beginTerminalSurfaceSwap() { swap.nextSurface.style.top = '0' document.getElementById('terminal-container')!.appendChild(swap.nextSurface) scope.surface = swap.nextSurface - pendingSurface = swap.nextSurface + scope.pendingSurface = swap.nextSurface attachSurfaceEventHandlers(scope.surface) swap.oldSurface!.removeAttribute('id') return swap @@ -62,8 +55,15 @@ export function commitTerminalSurfaceSwap( if (swap.oldTerm) { swap.oldTerm.dispose() } - committedTerm = nextTerm - committedSurface = swap.nextSurface + scope.committedTerm = nextTerm + scope.committedSurface = swap.nextSurface scope.pendingTerm = null - pendingSurface = null + scope.pendingSurface = null +} + +// Why: phone-fit startup can issue several init() calls before xterm finishes replaying, so the +// last painted surface is tracked apart from its replacement — on the scope (ruling 21), because +// the page mounts this module more than once and a second mount must not inherit the first's. +export function startSurfaceSwap() { + scope.committedSurface = scope.surface } diff --git a/mobile/src/terminal/document/surface-touch-gestures.ts b/mobile/src/terminal/document/surface-touch-gestures.ts index 072186a14db..2eeb34bfaa9 100644 --- a/mobile/src/terminal/document/surface-touch-gestures.ts +++ b/mobile/src/terminal/document/surface-touch-gestures.ts @@ -1,4 +1,4 @@ -import { scope } from './document-scope' +import { scope, scheduleDocumentFrame } from './document-scope' import { clampPan, getCellHeight } from './fit-scale' import { notify } from './host-notify' import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag' @@ -17,7 +17,7 @@ import { attachSurfaceWheelHandler } from './wheel-scroll' type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean } /** The live touch gesture: the last point, the velocity, and the pinch it may be in. */ -type TerminalTouchState = { +export type TerminalTouchState = { lastX: number lastY: number lastTime: number @@ -31,20 +31,6 @@ type TerminalTouchState = { pinchSurfY: number } -const ts: TerminalTouchState = { - lastX: 0, - lastY: 0, - lastTime: 0, - velY: 0, - accumDelta: 0, - momentumId: null, - isPinching: false, - pinchDist: 0, - pinchScale: 0, - pinchSurfX: 0, - pinchSurfY: 0 -} - export function updateTouchVelocity(deltaY: number, dt: number) { if (dt <= 0) { return @@ -55,7 +41,10 @@ export function updateTouchVelocity(deltaY: number, dt: number) { } // Why: touchmove cadence is uneven in WebView. Blend recent samples so // momentum launch doesn't inherit a one-frame spike or stall. - ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45 + scope.touchGesture.velY = + scope.touchGesture.velY === 0 + ? instantVelocity + : scope.touchGesture.velY * 0.55 + instantVelocity * 0.45 } export function getDistance(a: Touch, b: Touch) { @@ -97,27 +86,27 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface if (dispatcherShouldBlockSurface()) { return } - if (ts.momentumId) { - cancelAnimationFrame(ts.momentumId) - ts.momentumId = null + if (scope.touchGesture.momentumId) { + cancelAnimationFrame(scope.touchGesture.momentumId) + scope.touchGesture.momentumId = null } if (e.touches.length === 2) { - ts.isPinching = true + scope.touchGesture.isPinching = true scope.smoothScrollOffsetY = 0 - ts.pinchDist = getDistance(e.touches[0], e.touches[1]) - ts.pinchScale = scope.userScale + scope.touchGesture.pinchDist = getDistance(e.touches[0], e.touches[1]) + scope.touchGesture.pinchScale = scope.userScale const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 const total = getTotalScale() - ts.pinchSurfX = (mx - scope.panX) / total - ts.pinchSurfY = (my - scope.panY) / total + scope.touchGesture.pinchSurfX = (mx - scope.panX) / total + scope.touchGesture.pinchSurfY = (my - scope.panY) / total } else if (e.touches.length === 1) { - ts.isPinching = false - ts.lastX = e.touches[0].clientX - ts.lastY = e.touches[0].clientY - ts.lastTime = Date.now() - ts.velY = 0 - ts.accumDelta = 0 + scope.touchGesture.isPinching = false + scope.touchGesture.lastX = e.touches[0].clientX + scope.touchGesture.lastY = e.touches[0].clientY + scope.touchGesture.lastTime = Date.now() + scope.touchGesture.velY = 0 + scope.touchGesture.accumDelta = 0 } }, { capture: true, passive: true } @@ -136,28 +125,31 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface e.stopPropagation() if (e.touches.length === 2) { - ts.isPinching = true + scope.touchGesture.isPinching = true const dist = getDistance(e.touches[0], e.touches[1]) const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 - const ratio = dist / ts.pinchDist + const ratio = dist / scope.touchGesture.pinchDist // Why: userScale is a CSS multiplier on the current font size; bound it so // the resulting apparent size (currentTextScale × userScale) stays within // the preset range, since release snaps to one of those presets. const loScale = scope.MIN_TEXT_SCALE / scope.currentTextScale const hiScale = scope.MAX_TEXT_SCALE / scope.currentTextScale - scope.userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio)) + scope.userScale = Math.max( + loScale, + Math.min(hiScale, scope.touchGesture.pinchScale * ratio) + ) const total = getTotalScale() - scope.panX = mx - ts.pinchSurfX * total - scope.panY = my - ts.pinchSurfY * total + scope.panX = mx - scope.touchGesture.pinchSurfX * total + scope.panY = my - scope.touchGesture.pinchSurfY * total clampPan() updateTransform() - } else if (e.touches.length === 1 && !ts.isPinching) { + } else if (e.touches.length === 1 && !scope.touchGesture.isPinching) { const x = e.touches[0].clientX, y = e.touches[0].clientY const now = Date.now(), - dt = now - ts.lastTime + dt = now - scope.touchGesture.lastTime // Why: pan horizontally only when content overflows the viewport (larger // than fit) — same check clampPan() uses. Vertical always drives buffer @@ -168,32 +160,32 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface scope.term.element && scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1 ) { - scope.panX += x - ts.lastX + scope.panX += x - scope.touchGesture.lastX clampPan() updateTransform() } - const deltaY = ts.lastY - y - ts.lastTime = now + const deltaY = scope.touchGesture.lastY - y + scope.touchGesture.lastTime = now if (shouldRouteScrollToTerminalInput()) { updateTouchVelocity(deltaY, dt) resetSmoothScrollOffset() const effectiveCellH = getCellHeight() * getTotalScale() - ts.accumDelta += deltaY - const lines = Math.trunc(ts.accumDelta / effectiveCellH) + scope.touchGesture.accumDelta += deltaY + const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH) if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH + scope.touchGesture.accumDelta -= lines * effectiveCellH routeScrollLines(lines, x, y) } } else { if (enqueueNormalBufferScrollDelta(deltaY)) { updateTouchVelocity(deltaY, dt) } else { - ts.velY = 0 + scope.touchGesture.velY = 0 } } - ts.lastX = x - ts.lastY = y + scope.touchGesture.lastX = x + scope.touchGesture.lastY = y } }, { capture: true, passive: false } @@ -209,8 +201,8 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface return } - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false + if (scope.touchGesture.isPinching && e.touches.length < 2) { + scope.touchGesture.isPinching = false // Why: a finished pinch snaps to the nearest preset and becomes the new // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way // to set the text size. The CSS pinch zoom (userScale) is reset; the real @@ -227,45 +219,45 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface notify({ type: 'haptic', kind: 'selection' }) } if (e.touches.length === 1) { - ts.lastX = e.touches[0].clientX - ts.lastY = e.touches[0].clientY - ts.lastTime = Date.now() - ts.velY = 0 - ts.accumDelta = 0 + scope.touchGesture.lastX = e.touches[0].clientX + scope.touchGesture.lastY = e.touches[0].clientY + scope.touchGesture.lastTime = Date.now() + scope.touchGesture.velY = 0 + scope.touchGesture.accumDelta = 0 } return } if (e.touches.length === 0) { - let vel = ts.velY + let vel = scope.touchGesture.velY const FRICTION = 0.972 const MIN_VEL = 0.012 function momentumStep() { vel *= FRICTION if (Math.abs(vel) < MIN_VEL) { - ts.momentumId = null + scope.touchGesture.momentumId = null return } const delta = vel * 16 if (shouldRouteScrollToTerminalInput()) { resetSmoothScrollOffset() const effectiveCellH = getCellHeight() * getTotalScale() - ts.accumDelta += delta - const lines = Math.trunc(ts.accumDelta / effectiveCellH) + scope.touchGesture.accumDelta += delta + const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH) if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH - routeScrollLines(lines, ts.lastX, ts.lastY) + scope.touchGesture.accumDelta -= lines * effectiveCellH + routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY) } } else { if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null + scope.touchGesture.momentumId = null return } } - ts.momentumId = requestAnimationFrame(momentumStep) + scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep) } if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep) + scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep) } } }, @@ -273,4 +265,14 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface ) } -attachSurfaceEventHandlers(scope.surface!) +export function startSurfaceTouchGestures() { + attachSurfaceEventHandlers(scope.surface!) +} + +/** Ruling 21: the momentum loop, which would keep scrolling into the terminal that replaced it. */ +export function stopSurfaceTouchGestures() { + if (scope.touchGesture.momentumId !== null) { + cancelAnimationFrame(scope.touchGesture.momentumId) + scope.touchGesture.momentumId = null + } +} diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts index 8e0b07c4bbd..8ca6f36c4d3 100644 --- a/mobile/src/terminal/document/tap-dispatch.ts +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -20,13 +20,6 @@ export type TerminalTouchDispatch = { /** An element a target can be tested against; a method so a real element satisfies it. */ type TerminalDocumentTargetContainer = { contains(other: EventTarget | null): boolean } -const dispatch: TerminalTouchDispatch = { - mode: 'idle', - touchId: null, - touchIds: null, - longPressFingerInsideOverlay: false -} - export function touchById(touches: TouchList, id: number | null) { for (let i = 0; i < touches.length; i++) { if (touches[i].identifier === id) { @@ -81,168 +74,180 @@ export function touchSlopExceeded(t: Touch) { // Why: existing surface handlers stay attached to surface but we wrap // their entry to no-op when the dispatcher latches into select-drag. export function dispatcherShouldBlockSurface() { - return dispatch.mode === 'select-drag' + return scope.touchDispatch.mode === 'select-drag' } -document.addEventListener( - 'touchstart', - function (e) { - const t = e.touches[0] - const target = e.target - const onHandle = target === scope.handleStart || target === scope.handleEnd - const inOverlay = targetInside(target, scope.selectionOverlay) - const inSurface = targetInside(target, scope.surface) - // Why: clear any stale tap candidate up front; only a fresh single-finger - // surface touch (below) re-arms it, so handle drags / pinches / dismiss - // taps never resolve as a link tap on touchend. - scope.tapCandidate = null +/** + * The options each document handler is registered with, named so `stopTapDispatch` takes it off + * with the identical `capture` flag it went on with. + */ +const CAPTURE_ACTIVE = { capture: true, passive: false } +const CAPTURE_PASSIVE = { capture: true, passive: true } - if (e.touches.length === 2) { - // pinch latch - if (scope.selMode === 'select') { - notify({ type: 'mobile-clip-cancel-by-pinch' }) - cancelSelect() - } - dispatch.mode = 'pinch' - dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier] - clearLongPress() - return - } +function onDocumentTouchStart(e: TouchEvent) { + const t = e.touches[0] + const target = e.target + const onHandle = target === scope.handleStart || target === scope.handleEnd + const inOverlay = targetInside(target, scope.selectionOverlay) + const inSurface = targetInside(target, scope.surface) + // Why: clear any stale tap candidate up front; only a fresh single-finger + // surface touch (below) re-arms it, so handle drags / pinches / dismiss + // taps never resolve as a link tap on touchend. + scope.tapCandidate = null - if (onHandle && scope.selMode === 'select') { - // start handle drag - const handleName = target === scope.handleStart ? 'start' : 'end' - scope.sel!.activeHandle = handleName - dispatch.mode = 'select-drag' - dispatch.touchId = t.identifier - e.preventDefault() - return - } - - if (inOverlay) { - // tap on menu pill — let the buttons' own handlers fire - return - } - - if (inSurface && scope.selMode === 'select') { - // Why: tap-to-dismiss matches native iOS/Android — touching outside the - // selection clears it. We cancel immediately and latch to 'surface' so - // the same gesture still drives scroll/pan without a second touch. + if (e.touches.length === 2) { + // pinch latch + if (scope.selMode === 'select') { + notify({ type: 'mobile-clip-cancel-by-pinch' }) cancelSelect() - dispatch.mode = 'surface' - dispatch.touchId = t.identifier + } + scope.touchDispatch.mode = 'pinch' + scope.touchDispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier] + clearLongPress() + return + } + + if (onHandle && scope.selMode === 'select') { + // start handle drag + const handleName = target === scope.handleStart ? 'start' : 'end' + scope.sel!.activeHandle = handleName + scope.touchDispatch.mode = 'select-drag' + scope.touchDispatch.touchId = t.identifier + e.preventDefault() + return + } + + if (inOverlay) { + // tap on menu pill — let the buttons' own handlers fire + return + } + + if (inSurface && scope.selMode === 'select') { + // Why: tap-to-dismiss matches native iOS/Android — touching outside the + // selection clears it. We cancel immediately and latch to 'surface' so + // the same gesture still drives scroll/pan without a second touch. + cancelSelect() + scope.touchDispatch.mode = 'surface' + scope.touchDispatch.touchId = t.identifier + return + } + + if (inSurface) { + scope.touchDispatch.mode = 'surface' + scope.touchDispatch.touchId = t.identifier + scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier } + armLongPress(t) + } +} + +function onDocumentTouchMove(e: TouchEvent) { + if (scope.touchDispatch.mode === 'select-drag') { + const t = touchById(e.touches, scope.touchDispatch.touchId) + if (!t || !scope.sel || !scope.sel.activeHandle) { return } - - if (inSurface) { - dispatch.mode = 'surface' - dispatch.touchId = t.identifier - scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier } - armLongPress(t) - } - }, - { capture: true, passive: false } -) - -document.addEventListener( - 'touchmove', - function (e) { - if (dispatch.mode === 'select-drag') { - const t = touchById(e.touches, dispatch.touchId) - if (!t || !scope.sel || !scope.sel.activeHandle) { - return + e.preventDefault() + handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY) + return + } + if (scope.touchDispatch.mode === 'surface' || scope.touchDispatch.mode === 'pinch') { + // long-press slop check + if (scope.longPressTimer && e.touches.length === 1) { + if (touchSlopExceeded(e.touches[0])) { + clearLongPress() } - e.preventDefault() - handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY) - return } - if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { - // long-press slop check - if (scope.longPressTimer && e.touches.length === 1) { - if (touchSlopExceeded(e.touches[0])) { - clearLongPress() + // Why: disqualify the tap only once the finger travels past TAP_SLOP + // (a scroll/pan), independent of the long-press timer — so a tap that + // jitters under TAP_SLOP still opens the link/path under the finger. + if (scope.tapCandidate && e.touches.length === 1) { + const mt = e.touches[0] + if (mt.identifier === scope.tapCandidate.identifier) { + const dx = Math.abs(mt.clientX - scope.tapCandidate.x) + const dy = Math.abs(mt.clientY - scope.tapCandidate.y) + if (dx + dy > scope.TAP_SLOP) { + scope.tapCandidate = null } } - // Why: disqualify the tap only once the finger travels past TAP_SLOP - // (a scroll/pan), independent of the long-press timer — so a tap that - // jitters under TAP_SLOP still opens the link/path under the finger. - if (scope.tapCandidate && e.touches.length === 1) { - const mt = e.touches[0] - if (mt.identifier === scope.tapCandidate.identifier) { - const dx = Math.abs(mt.clientX - scope.tapCandidate.x) - const dy = Math.abs(mt.clientY - scope.tapCandidate.y) - if (dx + dy > scope.TAP_SLOP) { - scope.tapCandidate = null - } - } - } else if (e.touches.length !== 1) { - scope.tapCandidate = null - } - // existing surface handler will run from its own listener - } - }, - { capture: true, passive: false } -) - -document.addEventListener( - 'touchend', - function (e) { - if (dispatch.mode === 'select-drag') { - if (scope.sel) { - scope.sel.activeHandle = null - } - stopEdgeScroll() - dispatch.mode = 'idle' - dispatch.touchId = null - return - } - if (dispatch.mode === 'pinch') { - if (e.touches.length < 2) { - dispatch.mode = e.touches.length === 1 ? 'surface' : 'idle' - dispatch.touchIds = null - if (e.touches.length === 1) { - dispatch.touchId = e.touches[0].identifier - } - } - return - } - if (dispatch.mode === 'surface') { - // Why: fire the tap from the tap-candidate origin (survives jitter under - // TAP_SLOP) rather than longPressOrigin, which the press-to-select slop - // can null mid-tap — that was dropping URL/file taps that moved a few px. - if ( - e.touches.length === 0 && - scope.tapCandidate && - scope.selMode !== 'select' && - Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS - ) { - notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true) - } - clearLongPress() + } else if (e.touches.length !== 1) { scope.tapCandidate = null - if (e.touches.length === 0) { - dispatch.mode = 'idle' - dispatch.touchId = null + } + // existing surface handler will run from its own listener + } +} + +function onDocumentTouchEnd(e: TouchEvent) { + if (scope.touchDispatch.mode === 'select-drag') { + if (scope.sel) { + scope.sel.activeHandle = null + } + stopEdgeScroll() + scope.touchDispatch.mode = 'idle' + scope.touchDispatch.touchId = null + return + } + if (scope.touchDispatch.mode === 'pinch') { + if (e.touches.length < 2) { + scope.touchDispatch.mode = e.touches.length === 1 ? 'surface' : 'idle' + scope.touchDispatch.touchIds = null + if (e.touches.length === 1) { + scope.touchDispatch.touchId = e.touches[0].identifier } } - }, - { capture: true, passive: true } -) - -document.addEventListener( - 'touchcancel', - function () { + return + } + if (scope.touchDispatch.mode === 'surface') { + // Why: fire the tap from the tap-candidate origin (survives jitter under + // TAP_SLOP) rather than longPressOrigin, which the press-to-select slop + // can null mid-tap — that was dropping URL/file taps that moved a few px. + if ( + e.touches.length === 0 && + scope.tapCandidate && + scope.selMode !== 'select' && + Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS + ) { + notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true) + } clearLongPress() scope.tapCandidate = null - stopEdgeScroll() - if (dispatch.mode === 'select-drag') { - if (scope.sel) { - scope.sel.activeHandle = null - } + if (e.touches.length === 0) { + scope.touchDispatch.mode = 'idle' + scope.touchDispatch.touchId = null } - dispatch.mode = 'idle' - dispatch.touchId = null - dispatch.touchIds = null - }, - { capture: true, passive: true } -) + } +} + +function onDocumentTouchCancel() { + clearLongPress() + scope.tapCandidate = null + stopEdgeScroll() + if (scope.touchDispatch.mode === 'select-drag') { + if (scope.sel) { + scope.sel.activeHandle = null + } + } + scope.touchDispatch.mode = 'idle' + scope.touchDispatch.touchId = null + scope.touchDispatch.touchIds = null +} + +/** + * The dispatcher's four document listeners, per mount (ruling 20). + * + * They are on `document` rather than on the surface, so unlike every surface handler they outlive + * the host element a remount replaces — which is exactly why the undo below exists. + */ +export function startTapDispatch() { + document.addEventListener('touchstart', onDocumentTouchStart, CAPTURE_ACTIVE) + document.addEventListener('touchmove', onDocumentTouchMove, CAPTURE_ACTIVE) + document.addEventListener('touchend', onDocumentTouchEnd, CAPTURE_PASSIVE) + document.addEventListener('touchcancel', onDocumentTouchCancel, CAPTURE_PASSIVE) +} + +export function stopTapDispatch() { + document.removeEventListener('touchstart', onDocumentTouchStart, CAPTURE_ACTIVE) + document.removeEventListener('touchmove', onDocumentTouchMove, CAPTURE_ACTIVE) + document.removeEventListener('touchend', onDocumentTouchEnd, CAPTURE_PASSIVE) + document.removeEventListener('touchcancel', onDocumentTouchCancel, CAPTURE_PASSIVE) + clearLongPress() +} diff --git a/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts b/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts deleted file mode 100644 index 4a799bb1f25..00000000000 --- a/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { - describeToken, - readScriptTokens, - type DocumentToken -} from './terminal-document-tokens.test-support' - -/** - * Whether two versions of the in-WebView document script are the same program, allowing only the - * scope qualifier that moving it into modules requires. - * - * C7.1 turns the document's one 2,758-line IIFE into modules the web page can import. A variable - * the script assigns across what became a module boundary cannot stay a free variable — assigning - * an imported binding is a syntax error — so those become fields of one scope object, 73 - * declaration sites in all, and every read and write of them gains a qualifier. Nothing else about - * the program may change. - * - * Byte comparison cannot make that claim once the source is formatter-owned: `oxfmt` writes the - * repository's style, which drops the semicolons the hand-written document carries, so the emitted - * text necessarily differs on almost every line for reasons that are not the refactor. Tokens are - * the level where the claim is exactly true. Semicolons are excluded for the same reason they moved - * — they are the formatter's, not the program's — and comments never reach the stream. - * - * This is deliberately stricter than "it still runs": a reordered statement, a changed literal, a - * dropped `!`, a renamed local, all diverge here and are reported with the token index and both - * sides, so the flip commit is reviewed by running this rather than by reading a 515-line diff. - */ -/** - * The differences moving the script into modules is allowed to make, each counted on its own. - * - * Eight classes and no others. Six are the repository's own rules and the printer rewriting the - * document's ES5 style the moment its source is a linted module — measured over the whole script, - * not assumed: `curly` braces 279 brace-less bodies, `no-unused-vars` unbinds 36 catch clauses, 373 - * `var` declarators become `const` or `let`, `unicorn/prefer-number-properties` moves 17 globals - * onto `Number`, the printer spells out 4 shorthand properties whose value gained a qualifier, and - * it stops renaming 7 bindings that are no longer shadows. The other two are the move itself: 609 - * qualified references and 73 declarations onto the scope. Semicolons and whitespace are the - * formatter's and never reach the token stream at all. - * - * Counted separately because the flip commit pins each number: a total would let one class absorb - * another, which is exactly the drift the pin exists to catch. - */ -export type TerminalDocumentNormalisations = { - /** `name` became `.name`; the declaration stayed where it was. */ - readonly qualifiedReferences: number - /** - * `var name` became `.name`; the declaration moved onto the scope object. A `var` - * with several declarators counts once per declarator, because each becomes its own assignment. - */ - readonly scopeFieldDeclarations: number - /** `var` became `const` or `let`, the binding staying local to the emitted script. */ - readonly rebindings: number - /** A brace-less `if`/`else`/`for`/`while` body gained its braces. */ - readonly bracedBodies: number - /** `catch (e)` became `catch`, the unused binding dropped. */ - readonly unboundCatches: number - /** A global numeric function became its `Number` property. */ - readonly numberProperties: number - /** `{ name: name }` was shorthand; qualifying the value spells the property out again. */ - readonly shorthandProperties: number - /** - * An inner binding that shadowed a document variable stopped being a shadow once that variable - * moved onto the scope, so the printer stopped renaming it. - */ - readonly unshadowedNames: number -} - -/** - * The bindings the printer renamed on the baseline and leaves alone in the modules, listed. - * - * A parameter named for a document variable shadowed it while both lived in one function scope, so - * the printer gave the inner one a decimal suffix; once the outer name is a scope field there is no - * shadow and the inner one keeps its own name. Listed rather than matched by shape: a rule that - * accepted any `name2` facing `name` would also accept an unrelated rename that happens to end in a - * digit, which is a changed program, not a normalisation. - * - * One entry covers all seven sites the whole script has: the `term` parameter of - * `attachTerminalQueryReplyBridge` in `query-reply.ts` and its six uses. - */ -const UNSHADOWED_RENAMES: readonly { - readonly baseline: string - readonly generated: string - readonly module: string -}[] = [{ baseline: 'term2', generated: 'term', module: 'query-reply' }] - -/** Whether this exact baseline-to-generated pair is one of the listed unshadowed renames. */ -function isListedUnshadowedRename(baseline: string, generated: string): boolean { - return UNSHADOWED_RENAMES.some( - (entry) => entry.baseline === baseline && entry.generated === generated - ) -} - -/** - * The globals `unicorn/prefer-number-properties` moves onto `Number`. - * - * Measured over the whole script: seventeen sites, and the rule is the only one of its kind that - * appears often enough to be worth matching. Each is equivalent here because every call is already - * behind a `typeof … === 'number'` check or is parsing a string, which is what the `Number` form - * does with no coercion of its own. - */ -const NUMBER_GLOBALS = new Set(['isFinite', 'isNaN', 'parseInt', 'parseFloat']) - -export type TerminalDocumentEquivalence = - | { readonly equivalent: true; readonly normalisations: TerminalDocumentNormalisations } - | { readonly equivalent: false; readonly reason: string } - -/** - * `baseline` is the script as it stood before the move, `candidate` the one the modules generate. - * - * The qualifier is read from `qualifier`, not assumed, so the test names the object it expects and - * a rename cannot quietly satisfy this. - */ -/** The statement heads `curly` braces: everything whose body may be a single unbraced statement. */ -const BRACEABLE_HEAD_KEYWORDS = new Set(['if', 'for', 'while']) - -/** - * Whether the `{` at `open` is the body of a braceable head rather than some other block. - * - * `else` and `do` are followed by their body directly. The rest put a parenthesised head first, so - * the `)` is walked back to its `(` and the keyword before that is what decides. Without this a - * bare block anywhere in the generated script would be absorbed as a linter-added body, when it is - * a statement the baseline does not have. - */ -function isBraceableHeadBody(tokens: readonly DocumentToken[], open: number): boolean { - const previous = tokens[open - 1] - if (previous === undefined) { - return false - } - if (previous.label === 'else' || previous.label === 'do') { - return true - } - if (previous.label !== ')') { - return false - } - let depth = 0 - for (let i = open - 1; i >= 0; i--) { - const label = tokens[i]?.label - if (label === ')') { - depth += 1 - continue - } - if (label === '(') { - depth -= 1 - if (depth === 0) { - return BRACEABLE_HEAD_KEYWORDS.has(tokens[i - 1]?.label ?? '') - } - } - } - return false -} - -/** The index of the `}` closing the `{` at `open`, or -1 when the generated script has none. */ -function matchingCloseIndex(tokens: readonly DocumentToken[], open: number): number { - let depth = 0 - for (let i = open; i < tokens.length; i++) { - const label = tokens[i]?.label - if (label === '{') { - depth += 1 - continue - } - if (label === '}') { - depth -= 1 - if (depth === 0) { - return i - } - } - } - return -1 -} - -export function compareTerminalDocumentScripts( - baseline: string, - candidate: string, - qualifier: string -): TerminalDocumentEquivalence { - const baselineTokens = readScriptTokens(baseline, 'the baseline') - if (!baselineTokens.ok) { - return { equivalent: false, reason: baselineTokens.reason } - } - const candidateTokens = readScriptTokens(candidate, 'the generated script') - if (!candidateTokens.ok) { - return { equivalent: false, reason: candidateTokens.reason } - } - const before = baselineTokens.tokens - const after = candidateTokens.tokens - let qualifiedReferences = 0 - let scopeFieldDeclarations = 0 - let rebindings = 0 - let bracedBodies = 0 - let unboundCatches = 0 - let numberProperties = 0 - let shorthandProperties = 0 - let unshadowedNames = 0 - // The generated index each inserted `{` expects its `}` at, innermost last. Recording the index - // rather than counting means an absorbed close is the one that closes that body and no other. - const insertedBraceCloses: number[] = [] - let lastMatched: DocumentToken | undefined - let left = 0 - let right = 0 - while (left < before.length && right < after.length) { - const expected = before[left] - const actual = after[right] - // Ahead of the equality check on purpose: the baseline's next token is a `}` too wherever a - // braced body ends a block, and this index is known to close the inserted body, so matching - // them as a pair would consume the wrong one and leave the counts right for the wrong reason. - if (actual.label === '}' && insertedBraceCloses.at(-1) === right) { - insertedBraceCloses.pop() - right += 1 - continue - } - if (expected.label === actual.label && expected.text === actual.text) { - lastMatched = expected - left += 1 - right += 1 - continue - } - // `term2` -> `term`: the printer disambiguated a shadowed binding on the baseline side, and - // qualifying the outer name removed the shadow, so the inner one keeps its own name. - if ( - expected.label === 'name' && - actual.label === 'name' && - isListedUnshadowedRename(expected.text, actual.text) - ) { - unshadowedNames += 1 - lastMatched = actual - left += 1 - right += 1 - continue - } - // `{ name }` -> `{ name: .name }`: the printer writes the baseline's shorthand back - // as one token, and qualifying the value makes the property name unavoidable again. - if ( - actual.label === ':' && - lastMatched?.label === 'name' && - after[right + 1]?.label === 'name' && - after[right + 1]?.text === qualifier && - after[right + 2]?.label === '.' && - after[right + 3]?.text === lastMatched.text - ) { - shorthandProperties += 1 - right += 4 - continue - } - // `name` -> `.name`, three tokens for one. - if (isQualified(after, right, expected, qualifier)) { - qualifiedReferences += 1 - left += 1 - right += 3 - continue - } - // `parseInt` -> `Number.parseInt`, the same shape under a different object. - if (NUMBER_GLOBALS.has(expected.text) && isQualified(after, right, expected, 'Number')) { - numberProperties += 1 - left += 1 - right += 3 - continue - } - // `var name` -> `.name`: the declaration itself moved onto the scope object. - if ( - expected.label === 'var' && - before[left + 1] !== undefined && - isQualified(after, right, before[left + 1], qualifier) - ) { - scopeFieldDeclarations += 1 - left += 2 - right += 3 - continue - } - // `var a = 1, b = 2` where both moved onto the scope: the comma introduces the second - // declaration, which is written as its own assignment. - if ( - expected.label === ',' && - before[left + 1] !== undefined && - isQualified(after, right, before[left + 1], qualifier) - ) { - scopeFieldDeclarations += 1 - left += 2 - right += 3 - continue - } - if (expected.label === 'var' && isBlockScopedKeyword(actual)) { - rebindings += 1 - lastMatched = actual - left += 1 - right += 1 - continue - } - // `catch (e) {` -> `catch {`: three baseline tokens the linted form does not carry. - if ( - lastMatched?.label === 'catch' && - expected.label === '(' && - before[left + 1]?.label === 'name' && - before[left + 2]?.label === ')' && - actual.label === '{' - ) { - unboundCatches += 1 - left += 3 - continue - } - // `if (a) b;` -> `if (a) { b; }`: the body the repository's `curly` rule braced. Only a - // braceable head's body qualifies, and only that body's own close is absorbed. - if (actual.label === '{' && isBraceableHeadBody(after, right)) { - const close = matchingCloseIndex(after, right) - if (close !== -1) { - bracedBodies += 1 - insertedBraceCloses.push(close) - right += 1 - continue - } - } - return { - equivalent: false, - reason: `token ${left}: expected ${describeToken(expected)}, generated ${describeToken(actual)}` - } - } - // A body braced at the very end of the script leaves its close after the baseline has run out. - while (insertedBraceCloses.at(-1) === right && after[right]?.label === '}') { - insertedBraceCloses.pop() - right += 1 - } - if (left !== before.length || right !== after.length) { - return { - equivalent: false, - reason: `length: ${before.length - left} token(s) left in the baseline, ${after.length - right} in the generated script` - } - } - if (insertedBraceCloses.length !== 0) { - return { - equivalent: false, - reason: `${insertedBraceCloses.length} inserted brace(s) never closed` - } - } - return { - equivalent: true, - normalisations: { - qualifiedReferences, - scopeFieldDeclarations, - rebindings, - bracedBodies, - unboundCatches, - numberProperties, - shorthandProperties, - unshadowedNames - } - } -} - -/** - * Whether a token is the `const` or `let` a `var` became. - * - * `let` is contextual outside strict mode, so acorn reports it as a name rather than as a keyword; - * matching on the label alone would refuse every `let` the linter introduced. - */ -function isBlockScopedKeyword(token: DocumentToken): boolean { - return token.label === 'const' || (token.label === 'name' && token.text === 'let') -} - -/** Whether the generated stream reads `.` where the baseline read `expected`. */ -function isQualified( - after: DocumentToken[], - right: number, - expected: DocumentToken, - qualifier: string -): boolean { - return ( - after[right]?.label === 'name' && - after[right]?.text === qualifier && - after[right + 1]?.label === '.' && - after[right + 2]?.label === expected.label && - after[right + 2]?.text === expected.text - ) -} - -/** - * The hand-written script out of the whole document, which is the part C7.1 moves. - * - * Read by locating the generated engine rather than by an index into the text, so a slice added - * above or below it does not silently shift what gets compared. - */ -export function readTerminalDocumentScript(document: string, engineJs: string): string { - const opener = `` - const start = document.indexOf(opener) - if (start === -1) { - throw new Error('the document does not carry the generated engine script') - } - const scriptStart = document.indexOf('') - if (scriptStart === -1 || scriptEnd <= scriptStart) { - throw new Error('the document does not carry a hand-written script after the engine') - } - return document.slice(scriptStart + ' diff --git a/mobile/src/terminal/terminal-document-identity.test.ts b/mobile/src/terminal/terminal-document-identity.test.ts index 0f4bf152bd7..c3c2836d01a 100644 --- a/mobile/src/terminal/terminal-document-identity.test.ts +++ b/mobile/src/terminal/terminal-document-identity.test.ts @@ -6,7 +6,8 @@ import { TERMINAL_DOCUMENT_FIXTURE_PATH, terminalDocumentFixture } from '../../scripts/build-terminal-document-fixture.mjs' -import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' +import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated' +import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { XTERM_HTML } from './terminal-webview-html' /** @@ -23,6 +24,11 @@ import { XTERM_HTML } from './terminal-webview-html' * kept the document it had. Regenerate the fixture with * `node scripts/build-terminal-document-fixture.mjs` only when the emitted document was meant to * change; the diff in that commit is the evidence, and reviewing it is the point. + * + * It is also the only standing pin on the document now. `terminal-document-flip.test.ts` compared + * the modules against the pre-flip script and held exactly while no module changed, so it was the + * proof of the flip rather than a fence; the first lane that had to change a module retired it. + * A golden that moves without its diff listed in the commit message is a blocking finding. */ const fixture = readFileSync(TERMINAL_DOCUMENT_FIXTURE_PATH, 'utf8') diff --git a/mobile/src/terminal/terminal-document-pre-flip-script.txt b/mobile/src/terminal/terminal-document-pre-flip-script.txt deleted file mode 100644 index 1252cd0ac6b..00000000000 --- a/mobile/src/terminal/terminal-document-pre-flip-script.txt +++ /dev/null @@ -1,2758 +0,0 @@ - -(function() { - var surface = document.getElementById('terminal-surface'); - var ESC = String.fromCharCode(27); - var C1_CSI = String.fromCharCode(155); - var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa); - var TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e); - var EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f); - var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g'); - var statusDotPendingSelector = false; - var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; - var term = null; - var terminalDataRepliesEnabled = false; - - function resetTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = false; - } - - function resumeTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = true; - } - - function forwardTerminalDataReply(data) { - if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data }); - } - - function enqueueTerminalDataReplyBoundary(gen) { - enqueueWriteBoundary(function() { - if (gen === terminalGeneration) terminalDataRepliesEnabled = true; - }); - } - - function attachTerminalQueryReplyBridge(term, gen) { - // Why: parser replies require stdin enabled, but mobile input is owned by - // native controls. Keep xterm's textarea inert for touch/hardware keys. - try { - term.attachCustomKeyEventHandler(function() { return false; }); - if (term.textarea) { - term.textarea.readOnly = true; - term.textarea.tabIndex = -1; - term.textarea.setAttribute('inputmode', 'none'); - } - } catch (e) {} - try { - termObserverDisposables.push(term.onData(function(data) { - forwardTerminalDataReply(data); - })); - } catch (e) {} - // Why: live output can queue before initial replay finishes. Enable replies - // at the replay boundary so those live queries are answered, never replayed ones. - enqueueTerminalDataReplyBoundary(gen); - } - - - // Why: phone-fit startup can issue several init() calls before xterm finishes - // replaying. Track the last painted surface separately from its replacement. - var committedTerm = null; - var committedSurface = surface; - var pendingTerm = null; - var pendingSurface = null; - - function beginTerminalSurfaceSwap() { - // Why: a superseded hidden replacement must not remain between the last - // painted surface and the newest one, or the newest commits below the viewport. - if (pendingSurface) { - try { pendingSurface.remove(); } catch (e) {} - if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {} - pendingSurface = null; - pendingTerm = null; - } - var swap = { - oldTerm: committedTerm, - oldSurface: committedSurface, - nextSurface: document.createElement('div') - }; - disposeTermObservers(); - swap.nextSurface.id = 'terminal-surface'; - swap.nextSurface.style.visibility = 'hidden'; - swap.nextSurface.style.position = 'absolute'; - swap.nextSurface.style.left = '0'; - swap.nextSurface.style.top = '0'; - document.getElementById('terminal-container').appendChild(swap.nextSurface); - surface = swap.nextSurface; - pendingSurface = swap.nextSurface; - attachSurfaceEventHandlers(surface); - swap.oldSurface.removeAttribute('id'); - return swap; - } - - function commitTerminalSurfaceSwap(swap, nextTerm) { - swap.nextSurface.style.visibility = 'visible'; - swap.nextSurface.style.position = ''; - swap.nextSurface.style.left = ''; - swap.nextSurface.style.top = ''; - swap.oldSurface.remove(); - if (swap.oldTerm) swap.oldTerm.dispose(); - committedTerm = nextTerm; - committedSurface = swap.nextSurface; - pendingTerm = null; - pendingSurface = null; - } - - var scrollIndicator = document.getElementById('scroll-indicator'); - var scrollThumb = document.getElementById('scroll-thumb'); - var scrollIndicatorHideTimer = null; - var writeQueue = []; - var writeQueueHead = 0; - var writesDraining = false; - var afterDrainCallbacks = []; - var termObserverDisposables = []; - var ready = false; - // Why: init() flips ready false on every re-init (live width reflow included) - // while the old surface stays visible; a document-scoped latch drives the - // fatal/non-fatal decision so a transient reflow cannot blank a live terminal. - var everReady = false; - var currentScale = 1; - // Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a - // gesture only; it resets to 1 on release. The persistent "text size" is the - // real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it - // reflows the grid: a bigger cell means fewer columns fit, and RN re-measures - // and resizes the PTY (terminal.updateViewport) so the shell rewraps to the - // new width. A finished pinch snaps to the nearest preset and reports it to RN. - var userScale = 1; - var BASE_FONT_PX = 13; - var MIN_FONT_PX = 6; - var MIN_FIT_COLS = 20; - var currentTextScale = 1; - var TEXT_SCALE_PRESETS = [0.5,0.75,1,1.25,1.5,2]; - var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]; - var MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]; - function snapToTextScalePreset(value) { - var best = TEXT_SCALE_PRESETS[0], bestDelta = Infinity; - for (var i = 0; i < TEXT_SCALE_PRESETS.length; i++) { - var delta = Math.abs(TEXT_SCALE_PRESETS[i] - value); - if (delta < bestDelta) { bestDelta = delta; best = TEXT_SCALE_PRESETS[i]; } - } - return best; - } - function fontPxForScale(scale) { - return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale)); - } - function isIOSWebView() { - if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true; - return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; - } - // Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can - // fall to a non-monospace face; lead with the ui-monospace generic to avoid that. - var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace'; - var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS; - // Why: change the real font size, then resize the grid to fit the viewport at - // the new cell metrics so the text shows at its true size immediately. RN's - // refit (measure → updateViewport) then makes the server reflow the PTY to the - // same column count so the shell rewraps. cell metrics update on the frame - // after fontSize changes, so the resize/fit is deferred one rAF. - function applyTextScale(scale) { - currentTextScale = scale; - if (!term) return; - var px = fontPxForScale(scale); - if (term.options.fontSize === px) return; - term.options.fontSize = px; - requestAnimationFrame(function() { - if (!term) return; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW > 0 && cellH > 0) { - var cols = Math.floor(window.innerWidth / cellW); - if (cols < MIN_FIT_COLS) return; - var rows = Math.max(8, Math.floor(window.innerHeight / cellH)); - term.resize(cols, rows); - emitKeyboardAvoidanceMetrics(); - } - applyFitScale('text-scale'); - }); - } - var panX = 0, panY = 0; - var smoothScrollOffsetY = 0; - var pendingNormalScrollDeltaY = 0; - var normalScrollFrameId = null; - var initRows = 24; - var terminalGeneration = 0; - var defaultTheme = {"background":"#1a1b26","foreground":"#c0caf5","cursor":"#c0caf5","cursorAccent":"#1a1b26","selectionBackground":"#33467c","selectionForeground":"#c0caf5","black":"#15161e","red":"#f7768e","green":"#9ece6a","yellow":"#e0af68","blue":"#7aa2f7","magenta":"#bb9af7","cyan":"#7dcfff","white":"#a9b1d6","brightBlack":"#414868","brightRed":"#f7768e","brightGreen":"#9ece6a","brightYellow":"#e0af68","brightBlue":"#7aa2f7","brightMagenta":"#bb9af7","brightCyan":"#7dcfff","brightWhite":"#c0caf5"}; - var terminalThemeInput = null; - var terminalTheme = defaultTheme; - var terminalMinimumContrastRatio = 3; - var webglAddon = null; - var webglRecoveryTimer = null; - var activeAltScreenSnapshot = false; - var trackedMouseTrackingMode = 'none'; - var sgrMouseMode = false; - var sgrMousePixelsMode = false; - var initialOscLinks = [], initialOscLinkRowOffset = 0; - var initialOscLinkEvictionReady = false; - var mouseModeScanTail = ''; - var handledMessageIds = []; - // Why: after init() the initial scrollback applyFitScale may have run - // against an empty buffer (or one without the widest line yet). Re-fit - // once when the first live data chunk arrives so a wider line that pushes - // scrollWidth past the previously-measured value gets re-scaled to fit. - var firstDataPending = false; - - // Diagnostic logger — bridges WebView console.log to RN via postMessage. - // Tag with [fit] so it's easy to filter in the Expo/Metro logs. - function flog(tag, payload) { - try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'log', tag: '[fit]' + tag, payload: payload - })); - } - } catch (e) {} - } - - function getCellWidth() { - if (!term || !term._core) return 0; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.width || 0; - } - return 0; - } - - // Why: width measurement strategy. - // 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses - // to lay out and is independent of buffer content. It's the "logical - // width" of the terminal grid. - // 2. Fall back to term.element.scrollWidth — the actual rendered DOM - // width — only when cellWidth isn't available yet (renderer not - // initialized). This is content-dependent (reflects widest row), - // but better than nothing. - // 3. If both are 0, return 1 (no scale change). The retry loop in - // applyFitScale will keep trying until one is positive. - function computeFitScale() { - if (!term) return 1; - var cellW = getCellWidth(); - var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0); - if (termWidth <= 0) return 1; - var vpWidth = window.innerWidth; - return Math.min(1, vpWidth / termWidth); - } - - function getTotalScale() { return currentScale * userScale; } - - function updateTransform() { - surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')'; - updateScrollIndicator(false); - if (selMode === 'select') repositionOverlay(); - } - - function updateScrollIndicator(reveal) { - if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return; - var buffer = term.buffer.active; - var maxViewportY = buffer.baseY || 0; - if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) { - scrollIndicator.classList.remove('visible'); - return; - } - var trackHeight = Math.max(0, window.innerHeight - 8); - var totalRows = maxViewportY + (term.rows || 0); - if (trackHeight <= 0 || totalRows <= 0) return; - var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows); - var maxTop = Math.max(0, trackHeight - thumbHeight); - var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0; - scrollThumb.style.height = thumbHeight + 'px'; - scrollThumb.style.transform = 'translateY(' + top + 'px)'; - if (!reveal) return; - scrollIndicator.classList.add('visible'); - if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer); - scrollIndicatorHideTimer = setTimeout(function() { - scrollIndicator.classList.remove('visible'); - scrollIndicatorHideTimer = null; - }, 550); - } - - - var DARK_BG_MIN_CONTRAST = 3; - var LIGHT_BG_MIN_CONTRAST = 4.5; - // Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark). - var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 }; - - function parseTerminalBackgroundRgba(value) { - if (typeof value !== 'string') return null; - var v = value.trim().toLowerCase(); - if (!v) return null; - if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 }; - if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 }; - if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }; - var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); - if (hex) { - var h = hex[1]; - var ch; - if (h.length === 3 || h.length === 4) { - ch = h.split('').map(function (p) { return parseInt(p + p, 16); }); - } else { - ch = []; - for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16)); - } - return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 }; - } - var rgb = v.match(/^rgba?\(([^)]+)\)$/); - if (!rgb) return null; - var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\s/]+/); - parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; }); - if (parts.length < 3) return null; - var channel = function (p) { - var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p); - return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null; - }; - var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]); - if (r === null || g === null || b === null) return null; - var a = 1; - if (parts[3] !== undefined) { - var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]); - a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1; - } - return { r: r, g: g, b: b, a: a }; - } - - function terminalRelativeLuminance(rgb) { - var lin = function (c) { - var n = c / 255; - return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); - }; - return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b); - } - - function terminalContrastRatio(a, b) { - var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b); - return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); - } - - // Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override". - function normalizeTerminalContrastOverride(value) { - if (typeof value !== 'number' || !isFinite(value)) return null; - return Math.min(21, Math.max(1, value)); - } - - // Pick the xterm minimumContrastRatio floor from the composed terminal background. - // Unparseable input defaults to the dark floor so agent output never stays invisible. - function resolveTerminalContrastFloor(background) { - var color = parseTerminalBackgroundRgba(background); - if (!color) return DARK_BG_MIN_CONTRAST; - var composited = color.a < 1 - ? { - r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)), - g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)), - b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a)) - } - : color; - var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >= - terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited); - return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST; - } - - function normalizeTerminalTheme(input) { - var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object' - ? input.theme - : null; - if (!source) return defaultTheme; - var next = {}; - var keys = Object.keys(defaultTheme); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - if (typeof source[key] === 'string') next[key] = source[key]; - } - return Object.assign({}, defaultTheme, next); - } - - function applyTerminalTheme(input) { - terminalThemeInput = input; - terminalTheme = normalizeTerminalTheme(input); - var background = terminalTheme.background || '#1a1b26'; - document.documentElement.style.background = background; - document.body.style.background = background; - // Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754); - // an older host omits the field and the luminance gate stays authoritative. - var publishedFloor = normalizeTerminalContrastOverride( - input && typeof input === 'object' ? input.minimumContrastRatio : undefined - ); - terminalMinimumContrastRatio = - publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor; - if (term) { - term.options.theme = terminalTheme; - term.options.minimumContrastRatio = terminalMinimumContrastRatio; - } - } - - - function getCellHeight() { - if (!term || !term._core) return 15; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.height || 15; - } - return 15; - } - - // Why: clamp pan so the terminal content always covers the viewport - // when zoomed in. When content is smaller than viewport in a - // dimension, pin to top-left (no floating in the middle). - function clampPan() { - if (!term || !term.element) return; - var ts = getTotalScale(); - var cw = term.element.scrollWidth * ts; - var ch = term.element.scrollHeight * ts; - var vpW = window.innerWidth; - var vpH = window.innerHeight; - if (cw > vpW) { - panX = Math.min(0, Math.max(vpW - cw, panX)); - } else { - panX = 0; - } - if (ch > vpH) { - panY = Math.min(0, Math.max(vpH - ch, panY)); - } else { - panY = 0; - } - } - - // Why: intentional no-op. Mobile replays a live PTY snapshot then applies - // live cursor-relative chunks from that same PTY; resizing only the WebView - // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or - // overlap. Kept as a no-op so its call sites stay legible. - function adjustRowsForViewport() {} - - // Why: cold-start fit. After init() opens xterm, the renderer needs - // several frames before cell dimensions are computed. Reading too early - // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM - // not laid out), and computeFitScale returns 1 → no zoom. - // - // Gate: cellWidth × cols is the canonical "logical width" of the grid - // and reflects xterm's layout decision, independent of buffer content. - // We commit when cellWidth becomes positive (renderer ready). Fallback: - // if cellWidth never becomes available, gate on stable positive - // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) - // so a backgrounded WebView never spins forever. - var FIT_RETRY_MAX_FRAMES = 60; - var fitRetryToken = 0; - function applyFitScale(reason) { - if (!term || !term.element) return; - var token = ++fitRetryToken; - var attempts = 0; - var lastScrollWidth = -1; - function attempt() { - if (token !== fitRetryToken) return; - if (!term || !term.element) return; - attempts++; - var cellW = getCellWidth(); - if (cellW > 0 && term.cols > 0) { - commitFitScale(reason, attempts, 'cellW'); - return; - } - var w = term.element.scrollWidth; - if (w > 0 && w === lastScrollWidth) { - commitFitScale(reason, attempts, 'stableSW'); - return; - } - lastScrollWidth = w; - if (attempts >= FIT_RETRY_MAX_FRAMES) { - flog('commit-timeout', { - reason: reason, - attempts: attempts, - cellW: cellW, - scrollWidth: w, - cols: term.cols - }); - commitFitScale(reason, attempts, 'timeout'); - return; - } - requestAnimationFrame(attempt); - } - requestAnimationFrame(attempt); - } - - function commitFitScale(reason, attempts, gate) { - if (!term || !term.element) return; - var preSnapScale = computeFitScale(); - currentScale = preSnapScale; - // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar - // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents - // a second applyFitScale from observing a "no-op needed" state. - if (currentScale >= 0.95) currentScale = 1; - userScale = 1; - panX = 0; - panY = 0; - smoothScrollOffsetY = 0; - updateTransform(); - adjustRowsForViewport(); - - var cellW = getCellWidth(); - var sw = term.element.scrollWidth; - var vpW = window.innerWidth; - var expectedW = cellW * term.cols; - var suspect = - currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom - if (suspect) { - flog('commit-SUSPECT', { - reason: reason, - attempts: attempts, - gate: gate, - preSnapScale: preSnapScale, - finalScale: currentScale, - cellW: cellW, - cols: term.cols, - expectedW: expectedW, - scrollWidth: sw, - vpWidth: vpW - }); - } - repositionOverlay(); - } - - function isAltScreenActive(data) { - if (typeof data !== 'string') return false; - var on = data.lastIndexOf(ESC + '[?1049h'); - var off = data.lastIndexOf(ESC + '[?1049l'); - return on !== -1 && on > off; - } - - function normalizeInitialData(data) { - if (!isAltScreenActive(data)) return data; - var on = data.lastIndexOf(ESC + '[?1049h'); - // Why: SerializeAddon can include normal-buffer scrollback before the - // active alternate-screen snapshot. Replaying both into a fresh mobile - // xterm duplicates TUI frames and can flatten SGR attributes. - return on > 0 ? data.slice(on) : data; - } - - function updateMouseModeFromData(data) { - if (typeof data !== 'string' || data.length === 0) return; - var input = mouseModeScanTail + data; - mouseModeScanTail = extractMouseModeScanTail(input); - var re = new RegExp(ESC + 'c|' + ESC + '\\[\\?([0-9;]+)([hl])|' + C1_CSI + '\\?([0-9;]+)([hl])', 'g'); - var match; - while ((match = re.exec(input)) !== null) { - if (match[0] === ESC + 'c') { - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - continue; - } - var enabled = (match[2] || match[4]) === 'h'; - var params = (match[1] || match[3]).split(';'); - for (var i = 0; i < params.length; i++) { - if (params[i] === '') continue; - var param = Number(params[i]); - if (!Number.isInteger(param)) continue; - if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; - if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; - if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; - if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; - if (param === 1006) { - sgrMouseMode = enabled; - sgrMousePixelsMode = false; - } - if (param === 1016) { - sgrMouseMode = false; - sgrMousePixelsMode = enabled; - } - } - } - } - - function resetWriteQueue() { - writeQueue = []; - writeQueueHead = 0; - } - - function isStatusDotPresentationSelector(value) { - return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; - } - - function endsWithStatusDotPresentationSequence(data) { - var i = data.length - 1; - while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; - return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; - } - - // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. - function normalizeStatusDotPresentation(data) { - if (typeof data !== 'string' || data.length === 0) return data; - if (statusDotPendingSelector) { - statusDotPendingSelector = false; - var strippedPendingSelectors = false; - while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); - strippedPendingSelectors = data.length === 0; - if (strippedPendingSelectors) { - statusDotPendingSelector = true; - return ''; - } - } - var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); - statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); - return normalized; - } - - function enqueueWrite(data) { - writeQueue.push(normalizeStatusDotPresentation(data)); - } - - function enqueueWriteBoundary(callback) { - writeQueue.push(callback); - } - - function nextQueuedWrite() { - if (writeQueueHead >= writeQueue.length) { - resetWriteQueue(); - return undefined; - } - var next = writeQueue[writeQueueHead]; - writeQueue[writeQueueHead] = undefined; - writeQueueHead++; - // Why: high-throughput terminals can enqueue faster than xterm parses; - // compact consumed slots so drain work stays O(1) without retaining old chunks. - if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { - writeQueue = writeQueue.slice(writeQueueHead); - writeQueueHead = 0; - } - return next; - } - - function disposeTermObservers() { - var disposables = termObserverDisposables; - termObserverDisposables = []; - for (var i = 0; i < disposables.length; i++) { - try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} - } - } - - function extractMouseModeScanTail(input) { - var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); - if (start === -1) return ''; - var tail = input.slice(start); - // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. - // Keep parser state far beyond normal mode lists while still bounding memory. - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; - if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; - if (tail.indexOf(ESC + '[?') === 0) { - return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; - } - if (tail.indexOf(C1_CSI + '?') === 0) { - return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; - } - return ''; - } - - function pumpWrites(gen) { - if (!ready || !term || writesDraining || gen !== terminalGeneration) return; - var next = nextQueuedWrite(); - if (typeof next !== 'string') { - if (typeof next === 'function') return next(), pumpWrites(gen); - var callbacks = afterDrainCallbacks; - afterDrainCallbacks = []; - for (var i = 0; i < callbacks.length; i++) callbacks[i](); - return; - } - writesDraining = true; - // Why: xterm.write() parses asynchronously. Row adjustment/resizing must - // wait until replayed SGR attributes have landed in the buffer. - term.write(next, function() { - if (gen !== terminalGeneration) return; - writesDraining = false; - pumpWrites(gen); - }); - } - - function afterWritesDrained(callback) { - afterDrainCallbacks.push(callback); - pumpWrites(terminalGeneration); - } - - - function refreshTerminalSurface() { - if (!term) return; - try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {} - } - - function cancelWebglContextRecovery() { - if (!webglRecoveryTimer) return; - clearTimeout(webglRecoveryTimer); - webglRecoveryTimer = null; - } - - function attachWebglAddon(allowRecovery) { - if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false; - var addon = null; - try { - addon = new window.WebglAddon.WebglAddon(); - webglAddon = addon; - if (addon.onContextLoss) addon.onContextLoss(function() { - if (webglAddon !== addon) return; - flog('webgl-context-loss', { retry: allowRecovery }); - webglAddon = null; - try { addon.dispose(); } catch (e) {} - refreshTerminalSurface(); - if (!allowRecovery) return; - // Why: one delayed retry handles transient iOS context loss without - // entering a GPU crash loop; a second loss stays on the DOM renderer. - cancelWebglContextRecovery(); - var recoveryTerm = term; - var recoveryGeneration = terminalGeneration; - webglRecoveryTimer = setTimeout(function() { - webglRecoveryTimer = null; - if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return; - attachWebglAddon(false); - }, 100); - }); - term.loadAddon(addon); - if (!allowRecovery) { - try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {} - refreshTerminalSurface(); - } - return true; - } catch (e) { - flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) }); - if (webglAddon === addon) webglAddon = null; - try { if (addon) addon.dispose(); } catch (disposeError) {} - refreshTerminalSurface(); - return false; - } - } - - document.addEventListener('visibilitychange', function() { - if (document.visibilityState !== 'visible') return; - // Why: iOS may restore the xterm model while discarding GPU pixels/theme - // paint state, so visibility must rebuild the atlas and repaint every row. - applyTerminalTheme(terminalThemeInput); - try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {} - refreshTerminalSurface(); - }); - - - function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { - if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; - // Why: a width-reflow re-stream rewraps the same content at new cols. - // Distance-from-bottom (rows) is the only stable anchor across reflow, - // since line counts and cell positions change. null = stay pinned to bottom. - var prevB = preserveScroll && term && term.buffer && term.buffer.active ? term.buffer.active : null; - var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; - terminalGeneration++; - var gen = terminalGeneration; - // Why: snapshot replay can contain old queries whose replies must never - // re-enter the live PTY. Each replacement terminal earns authority anew. - resetTerminalDataReplyAuthority(); - cancelWebglContextRecovery(); - webglAddon = null; - ready = false; - resetWriteQueue(); - statusDotPendingSelector = false; - writesDraining = false; - afterDrainCallbacks = []; - initRows = rows || 24; - firstDataPending = true; - smoothScrollOffsetY = 0; - wheelAccumDeltaY = 0; - mouseModeScanTail = ''; - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - var replayData = normalizeInitialData(initialData); - // Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the - // mirrored modes aligned with exactly what this mobile xterm replays. - updateMouseModeFromData(replayData); - activeAltScreenSnapshot = isAltScreenActive(replayData); - initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []; - initialOscLinkRowOffset = 0; - initialOscLinkEvictionReady = false; - var surfaceSwap = beginTerminalSurfaceSwap(); - var nextSurface = surfaceSwap.nextSurface; - - applyTerminalTheme(nextTheme); - term = new Terminal({ - cols: cols || 80, - rows: rows || 24, - theme: terminalTheme, - minimumContrastRatio: terminalMinimumContrastRatio, - fontFamily: terminalFontFamily, - fontSize: fontPxForScale(currentTextScale), - fontWeight: '300', - fontWeightBold: '500', - scrollback: 5000, - // Why: xterm suppresses parser-generated query replies when disableStdin - // is true. Native accepts only validated reply grammars from onData. - disableStdin: false, - cursorBlink: false, - cursorStyle: "bar", - // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. - showCursorImmediately: true, - // A full inactive cell remains visible under the terminal's phone-fit scale. - cursorInactiveStyle: "block", - convertEol: false, - allowProposedApi: true - }); - var nextTerm = term; - pendingTerm = nextTerm; - term.open(surface); - attachWebglAddon(true); - if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} - if (typeof replayData === 'string' && replayData.length > 0) { - // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output. - enqueueWrite(ESC + '[0m' + replayData); - } - - // Why: reset eviction tracking + attach observers for the new term. - resetEvictionCounter(); - cancelSelect(); - attachTermObservers(); - attachTerminalQueryReplyBridge(term, gen); - - requestAnimationFrame(function() { - if (gen !== terminalGeneration) return; - ready = true; - everReady = true; - afterWritesDrained(function() { - if (gen !== terminalGeneration) return; - commitTerminalSurfaceSwap(surfaceSwap, nextTerm); - // Why: restore the reader's place after the rewrapped buffer replays. - // Replay lands at bottom, so only act when they were scrolled up (rows>0). - if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) { - try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {} - } - captureInitialOscLinkTexts(); - initialOscLinkRowOffset = 0; - initialOscLinkEvictionReady = true; - applyFitScale('init-replay'); - notify({ type: 'ready', cols: cols, rows: rows }); - }); - }); - } - - function write(data) { - updateMouseModeFromData(data); - enqueueWrite(data); - pumpWrites(terminalGeneration); - // Why: first live data chunk after init may widen the buffer past - // what the post-replay applyFitScale measured. Re-fit once after this - // chunk drains to catch the wider line. Subsequent chunks don't re-fit - // (the user's manual zoom is sticky after that). - if (firstDataPending) { - firstDataPending = false; - var gen = terminalGeneration; - afterWritesDrained(function() { - if (gen !== terminalGeneration) return; - applyFitScale('first-data'); - }); - } - } - - function resize(cols, rows) { - if (!term) return; - initRows = rows || initRows; - term.resize(cols || term.cols, rows || term.rows); - emitKeyboardAvoidanceMetrics(); - applyFitScale('resize-msg'); - notify({ type: 'ready', cols: cols, rows: rows }); - } - - // reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines). - - // Why: rewrap the local xterm buffer (scrollback included) to a new width - // after a server PTY reflow. Skip the alternate screen: those snapshots are - // fully repainted by the PTY and a local resize there can drop SGR attributes - // (see init's alt-screen handling), which shows as white text. - function reflow(cols, rows) { - if (!term || isAlternateBufferActive()) return; - var nextCols = cols || term.cols; - var nextRows = rows || term.rows; - if (nextCols === term.cols && nextRows === term.rows) return; - var buffer = term.buffer.active; - // Why: anchor reflow on whether the user was pinned to the live bottom so - // their scroll position survives the rewrap — if they were scrolled up, - // hold the same distance from the bottom; if at the bottom, stay there. - var wasAtBottom = buffer.viewportY >= buffer.baseY; - var distanceFromBottom = buffer.baseY - buffer.viewportY; - initRows = nextRows; - term.resize(nextCols, nextRows); - var rewrapped = term.buffer.active; - if (wasAtBottom) { - term.scrollToBottom(); - } else { - term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY); - } - applyFitScale('reflow-msg'); - updateScrollIndicator(false); - emitKeyboardAvoidanceMetrics(); - } - - - function notify(msg) { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify(msg)); - } - } - - function engineErrorText(err) { - if (!err) return ''; - if (typeof err === 'string') return err; - if (err && typeof err.message === 'string') return err.message; - try { return String(err); } catch (e) { return ''; } - } - - function chromeVersionText() { - var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/); - return match ? 'Chrome ' + match[1] : 'Chrome version unknown'; - } - - var nonFatalErrorNotifies = 0; - - function reportEngineError(context, err, fatal) { - var isFatal = fatal === undefined ? !everReady : !!fatal; - if (!isFatal) { - // Why: a constructed-but-degraded engine can throw per frame; cap - // non-fatal notifies so RN isn't flooded. Fatal reports always emit. - nonFatalErrorNotifies++; - if (nonFatalErrorNotifies > 5) return; - } - var parts = [context]; - var errText = engineErrorText(err); - if (errText) parts.push(errText); - if (window.__engineErrors && window.__engineErrors.length) { - parts.push('captured: ' + window.__engineErrors.join(' | ')); - } - parts.push(chromeVersionText()); - notify({ - type: 'error', - fatal: isFatal, - message: parts.join(' - ') - }); - } - - window.onerror = function(msg, source, line, column, err) { - if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg)); - reportEngineError('terminal runtime error', err || msg); - }; - - function measureFitDimensions(containerHeightPx, retriesLeft) { - if (typeof retriesLeft !== 'number') retriesLeft = 30; - // Why: init and measure are posted back-to-back from React, but - // init has an async rAF chain. A measure that runs synchronously - // after init can find term null, disposed, lacking element, or - // with cells size 0. Retry the whole gate for ~500ms. - var notReady = !term || !term.element; - var cellWidth = 0; - var cellHeight = 0; - if (!notReady) { - var core = term._core; - if (core && core._renderService && core._renderService.dimensions) { - cellWidth = core._renderService.dimensions.css.cell.width; - cellHeight = core._renderService.dimensions.css.cell.height; - } - } - if (notReady || cellWidth <= 0 || cellHeight <= 0) { - if (retriesLeft > 0) { - requestAnimationFrame(function() { - measureFitDimensions(containerHeightPx, retriesLeft - 1); - }); - return; - } - flog('measure-fail', { - notReady: notReady, - cellWidth: cellWidth, - cellHeight: cellHeight, - retriesLeft: retriesLeft - }); - notify({ type: 'measure-result', cols: null, rows: null }); - return; - } - var vpWidth = window.innerWidth; - // Why: prefer the container height passed from React Native over - // window.innerHeight. The RN layout system knows the exact pixel - // height of the terminal frame after the accessory/input bars are - // subtracted, whereas innerHeight can overstate the visible area - // due to layout timing or safe-area insets. - var vpHeight = (typeof containerHeightPx === 'number' && containerHeightPx > 0) - ? containerHeightPx - : window.innerHeight; - var cols = Math.floor(vpWidth / cellWidth); - if (cols < MIN_FIT_COLS) { - flog('measure-skip-small-width', { - vpWidth: vpWidth, - cellWidth: cellWidth, - cols: cols - }); - notify({ type: 'measure-result', cols: null, rows: null }); - return; - } - // Why: the rows we report become the PTY's actual row count after the - // server fits to viewport, and xterm renders exactly that many lines - // anchored top-left of the WebView. Subtracting rows here would leave - // dead xterm-background space at the bottom of the container and make - // the last PTY rows visually appear above an "invisible line." Any - // safety margin between the prompt and the accessory bar must come - // from RN layout (terminalFrame's flex bounds), not from undersizing - // the PTY. - var rows = Math.max(8, Math.floor(vpHeight / cellHeight)); - notify({ type: 'measure-result', cols: cols, rows: rows }); - } - - function handleMsg(msg) { - if (typeof msg.id === 'number') { - if (handledMessageIds.indexOf(msg.id) !== -1) return; - handledMessageIds.push(msg.id); - if (handledMessageIds.length > 256) handledMessageIds.shift(); - } - if (msg.type === 'ping') { - notify({ type: 'pong', pingId: msg.id }); - } else if (msg.type === 'init') { - init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks); - } else if (msg.type === 'set-font-scale') { - // Why: ignore RN echoing back the value a pinch just set (msg.fontScale === - // currentTextScale) so the post-pinch state isn't reset; only apply changes. - if (typeof msg.fontScale === 'number' && msg.fontScale > 0 && msg.fontScale !== currentTextScale) { - userScale = 1; - panX = 0; - panY = 0; - applyTextScale(msg.fontScale); - } - } else if (msg.type === 'resize') { - resize(msg.cols, msg.rows); - } else if (msg.type === 'reflow') { reflow(msg.cols, msg.rows); - } else if (msg.type === 'write') { - write(msg.data); - } else if (msg.type === 'clear') { - terminalGeneration++; - resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary. - statusDotPendingSelector = false; - afterDrainCallbacks = []; - writesDraining = false; - mouseModeScanTail = ''; - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - initialOscLinks = []; - initialOscLinkRowOffset = 0; - initialOscLinkEvictionReady = false; - if (term) { term.clear(); term.reset(); } - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - resetEvictionCounter(); - if (selMode === 'select') { - notify({ type: 'selection-evicted' }); - cancelSelect(); - } - } else if (msg.type === 'measure') { - measureFitDimensions(msg.containerHeight); - } else if (msg.type === 'reset-zoom') { - applyFitScale('reset-zoom-msg'); - } else if (msg.type === 'set-theme') { - applyTerminalTheme(msg.terminalTheme); - } else if (msg.type === 'cancel-select') { - if (selMode === 'select') cancelSelect(); - } else if (msg.type === 'do-select-all') { - if (term) { - try { - term.selectAll(); - var b = term.buffer.active; - if (selMode !== 'select') { - selMode = 'select'; - selectionOverlay.classList.add('active'); - notify({ type: 'set-select-mode', enabled: true }); - } - sel = { - anchor: { col: 0, row: 0 }, - focus: { col: term.cols - 1, row: b.length - 1 }, - activeHandle: null - }; - repositionOverlay(); - } catch (e) {} - } - } - } - - // ============================================================ - // SELECTION MODE (long-press → handles → Copy) - // ============================================================ - var WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u; - var LONG_PRESS_MS = 500; - var LONG_PRESS_SLOP = 10; - // Why: a tap that opens a link/path must survive small finger jitter. The - // long-press slop (10px) only cancels the press-to-select timer; reusing it - // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale - // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap - // window so deliberate scrolls/pans still don't fire a tap. - var TAP_SLOP = 24; - var TAP_MAX_MS = 700; - var EDGE_SCROLL_PX = 40; - var EDGE_SCROLL_INTERVAL = 60; - - var selectionOverlay = document.getElementById('selection-overlay'); - var handleStart = document.getElementById('sel-handle-start'); - var handleEnd = document.getElementById('sel-handle-end'); - var selMenu = document.getElementById('sel-menu'); - var btnCopy = document.getElementById('sel-menu-copy'); - var btnSelAll = document.getElementById('sel-menu-all'); - - // mode: 'navigate' | 'select' - var selMode = 'navigate'; - var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } - var longPressTimer = null; - var longPressOrigin = null; // {x,y, identifier} - // Why: tap detection is tracked separately from the long-press timer so a - // small jitter that cancels the press-to-select timer does not also cancel - // the tap (which opens links/paths). {x,y,t,identifier} or null once the - // gesture is disqualified as a tap (moved too far or held too long). - var tapCandidate = null; - var edgeScrollTimer = null; - var edgeScrollDir = 0; - var edgeScrollClientX = 0; - var edgeScrollClientY = 0; - - // Eviction watchdog: linesEverWritten counts onLineFeed since last init. - // Once buffer is full, every onLineFeed evicts the top row in xterm and - // we mirror that by decrementing stored absolute rows. - var linesEverWritten = 0; - - function resetEvictionCounter() { linesEverWritten = 0; } - - function isBufferFull() { - if (!term) return false; - return linesEverWritten >= 5000 + (term.rows || 0); - } - - function checkEviction() { - if (selMode !== 'select' || !sel) return; - var oldest = Math.min(sel.anchor.row, sel.focus.row); - if (oldest < 0) { - notify({ type: 'selection-evicted' }); - cancelSelect(); - } - } - - function logFeedAndEvict() { - linesEverWritten++; - if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; - if (selMode === 'select' && sel && isBufferFull()) { - sel.anchor.row -= 1; - sel.focus.row -= 1; - checkEviction(); - repositionOverlay(); - } - } - - function emitModesIfChanged() { - if (!term) return; - var bp = !!(term.modes && term.modes.bracketedPasteMode); - var alt = false; - var mouseTrackingMode = getMouseTrackingMode(); - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - if ( - bp !== lastEmittedModes.bracketedPasteMode || - alt !== lastEmittedModes.altScreen || - mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || - sgrMouseMode !== lastEmittedModes.sgrMouseMode || - sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode - ) { - lastEmittedModes = { - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }; - notify({ - type: 'modes', - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }); - } - } - var lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - - - function lineHasVisibleContent(line, cell) { - if (line.translateToString(true).trim().length > 0) return true; - if (!cell || !line.getCell) return false; - var limit = Math.min(term.cols || 0, line.length || 0); - for (var x = 0; x < limit; x++) { - var current = line.getCell(x, cell); - if (!current) continue; - if (!current.isBgDefault() || current.isInverse()) return true; - if (typeof current.isUnderline === 'function' && current.isUnderline()) return true; - if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true; - if (typeof current.isOverline === 'function' && current.isOverline()) return true; - } - return false; - } - - function computeContentBottomRow() { - if (!term || !term.buffer || !term.buffer.active) return 0; - var buffer = term.buffer.active; - var top = buffer.viewportY || 0; - var cell = buffer.getNullCell ? buffer.getNullCell() : null; - for (var y = (term.rows || 0) - 1; y >= 0; y--) { - try { - var line = buffer.getLine(top + y); - if (line && lineHasVisibleContent(line, cell)) return y; - } catch (e) {} - } - return 0; - } - - function emitKeyboardAvoidanceMetrics() { - if (!term) return; - var alt = false; - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - notify({ - type: 'keyboard-avoidance-metrics', - cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, - contentBottomRow: alt ? 0 : computeContentBottomRow(), - rows: term.rows || 0, - altScreen: alt - }); - } - - - function attachTermObservers() { - if (!term) return; - disposeTermObservers(); - try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} - try { - termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); - } catch (e) {} - // Why: emit modes on every parsed write so RN's mirror stays current - // without round-trip; covers \x1b[?2004h/l and alt-screen toggles. - try { - if (term.onWriteParsed) { - termObserverDisposables.push(term.onWriteParsed(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - })); - } - } catch (e) {} - // Initial emit once buffer settles. - afterWritesDrained(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - }); - } - - function viewportToCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var col = Math.floor(sx / cellW); - var viewportRow = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (viewportRow < 0) viewportRow = 0; - if (viewportRow > term.rows - 1) viewportRow = term.rows - 1; - var viewportY = term.buffer.active.viewportY; - return { col: col, row: viewportRow + viewportY }; - } - - - function viewportToMouseReportCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - if (typeof clientX !== 'number') clientX = window.innerWidth / 2; - if (typeof clientY !== 'number') clientY = window.innerHeight / 2; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var maxX = Math.max(0, term.cols * cellW - 1); - var maxY = Math.max(0, term.rows * cellH - 1); - if (sx < 0) sx = 0; - if (sx > maxX) sx = maxX; - if (sy < 0) sy = 0; - if (sy > maxY) sy = maxY; - var col = Math.floor(sx / cellW); - var row = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (row < 0) row = 0; - if (row > term.rows - 1) row = term.rows - 1; - return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; - } - - - function isAlternateBufferActive() { - try { - return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'); - } catch (e) { - return false; - } - } - - function getMouseTrackingMode() { - try { - if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') { - var mode = term.modes.mouseTrackingMode; - if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode; - return 'none'; - } - } catch (e) {} - if ( - trackedMouseTrackingMode === 'x10' || - trackedMouseTrackingMode === 'vt200' || - trackedMouseTrackingMode === 'drag' || - trackedMouseTrackingMode === 'any' - ) { - return trackedMouseTrackingMode; - } - return 'none'; - } - - function repeatSequence(sequence, count) { - var out = ''; - for (var i = 0; i < count; i++) out += sequence; - return out; - } - - function buildArrowScrollSequence(lines) { - var prefix = '['; - try { - if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O'; - } catch (e) {} - return ESC + prefix + (lines < 0 ? 'A' : 'B'); - } - - function buildMouseWheelSequence(lines, clientX, clientY) { - var cell = viewportToMouseReportCell(clientX, clientY); - if (!cell) return ''; - var eventCode = lines < 0 ? 64 : 65; - if (sgrMousePixelsMode) { - if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; - return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M'; - } - if (sgrMouseMode) { - // Why: xterm increments zero-based mouse cells before encoding reports. - var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - return ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M'; - } - // Why: xterm increments zero-based mouse cells before encoding reports. - var button = eventCode + 32; - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - // Why: non-SGR mouse bytes above ASCII are not preserved reliably through - // the mobile JSON/RPC string path. Fall back to keys for wide terminals. - if (button > 126 || col > 126 || row > 126) return ''; - return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isSafeSgrMouseCoordinate(value) { - return Number.isInteger(value) && value >= 0 && value <= 9999; - } - - function buildMouseClickInput(clientX, clientY) { - var mouseTrackingMode = getMouseTrackingMode(); - if (!isClickMouseTrackingMode(mouseTrackingMode)) return ''; - var cell = viewportToMouseReportCell(clientX, clientY); - if (!cell) return ''; - if (sgrMousePixelsMode) { - // Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions. - var pixelX = cell.x; - var pixelY = cell.y; - if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) return ''; - var pixelPress = ESC + '[<0;' + pixelX + ';' + pixelY + 'M'; - if (mouseTrackingMode === 'x10') return pixelPress; - return pixelPress + ESC + '[<0;' + pixelX + ';' + pixelY + 'm'; - } - if (sgrMouseMode) { - // Why: xterm increments zero-based mouse cells before encoding reports. - var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; - if (mouseTrackingMode === 'x10') return sgrPress; - return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; - } - // Why: non-SGR click coordinates use printable ASCII bytes on the mobile - // bridge; unsafe wide-terminal cells must not turn into corrupted input. - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - if (col > 126 || row > 126) return ''; - var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); - if (mouseTrackingMode === 'x10') return press; - return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isClickMouseTrackingMode(mode) { - return mode !== 'none'; - } - - function isWheelMouseTrackingMode(mode) { - return mode !== 'none' && mode !== 'x10'; - } - - function shouldRouteScrollToTerminalInput() { - return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); - } - - function buildMouseWheelScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var sequence = buildMouseWheelSequence(lines, clientX, clientY); - if (!sequence) return ''; - return repeatSequence(sequence, count); - } - - function buildTuiScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var mouseTrackingMode = getMouseTrackingMode(); - var sequence = ''; - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - sequence = buildMouseWheelSequence(lines, clientX, clientY); - } - if (!sequence) sequence = buildArrowScrollSequence(lines); - return repeatSequence(sequence, count); - } - - function routeScrollLines(lines, clientX, clientY) { - if (!term || lines === 0) return; - var mouseTrackingMode = getMouseTrackingMode(); - var alternateBufferActive = isAlternateBufferActive(); - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - // Why: xterm sends wheel events to mouse-aware TUIs before considering - // scrollback, even if the app stays on the normal buffer. - var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); - if (mouseInput) { - notify({ type: 'terminal-input', bytes: mouseInput }); - return; - } - // Why: default mouse encoding can be unrepresentable in our ASCII-safe - // RPC path on wide terminals. Send bounded arrows instead of local - // scrollback/no-op while a mouse-aware app owns scroll gestures. - var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); - if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); - return; - } - if (alternateBufferActive) { - // Why: alternate-screen TUIs own their scroll state and xterm has no - // scrollback there, so mobile scroll gestures must become terminal input. - var input = buildTuiScrollInput(lines, clientX, clientY); - if (input) notify({ type: 'terminal-input', bytes: input }); - return; - } - term.scrollLines(lines); - } - - function clampNormalScrollLines(lines) { - if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; - var buffer = term.buffer.active; - if (lines > 0) { - return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY)); - } - return Math.max(lines, -buffer.viewportY); - } - - function canScrollNormalBufferDelta(deltaY) { - if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false; - var buffer = term.buffer.active; - if (deltaY > 0) return buffer.viewportY < buffer.baseY; - return buffer.viewportY > 0; - } - - function applyNormalBufferScrollDelta(deltaY) { - if (!term || deltaY === 0) return false; - var effectiveCellH = getCellHeight() * getTotalScale(); - if (effectiveCellH <= 0) return false; - if (!canScrollNormalBufferDelta(deltaY)) { - resetSmoothScrollOffset(); - return false; - } - smoothScrollOffsetY -= deltaY; - var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); - if (lines !== 0) { - var applied = clampNormalScrollLines(lines); - if (applied !== 0) { - term.scrollLines(applied); - // Why: xterm's renderer is row-based. Buffer touch pixels and only - // commit whole rows so TUI canvas layers do not shimmer between - // fractional transforms and xterm repaints. - smoothScrollOffsetY += applied * effectiveCellH; - } - if (applied !== lines) smoothScrollOffsetY = 0; - } - var limit = effectiveCellH - 1; - if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit; - if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit; - updateScrollIndicator(true); - return true; - } - - function enqueueNormalBufferScrollDelta(deltaY) { - if (!term || deltaY === 0) return false; - if (!canScrollNormalBufferDelta(deltaY)) { - resetSmoothScrollOffset(); - return false; - } - pendingNormalScrollDeltaY += deltaY; - if (normalScrollFrameId !== null) return true; - // Why: dense terminal rows are expensive to repaint. Coalesce touchmove - // deltas into one xterm row-scroll per frame instead of repainting from - // the input event stream. - normalScrollFrameId = requestAnimationFrame(function() { - normalScrollFrameId = null; - var delta = pendingNormalScrollDeltaY; - pendingNormalScrollDeltaY = 0; - if (!applyNormalBufferScrollDelta(delta)) { - resetSmoothScrollOffset(); - } - }); - return true; - } - - function resetSmoothScrollOffset() { - pendingNormalScrollDeltaY = 0; - if (normalScrollFrameId !== null) { - cancelAnimationFrame(normalScrollFrameId); - normalScrollFrameId = null; - } - if (smoothScrollOffsetY === 0) return; - smoothScrollOffsetY = 0; - updateScrollIndicator(false); - } - - function cellToViewportPx(col, absRow) { - if (!term) return { x: 0, y: 0 }; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - var viewportRow = absRow - term.buffer.active.viewportY; - var sx = col * cellW; - var sy = viewportRow * cellH; - var total = getTotalScale(); - return { x: sx * total + panX, y: sy * total + panY }; - } - - function getLineText(absRow) { - if (!term) return ''; - var line = term.buffer.active.getLine(absRow); - if (!line) return ''; - return line.translateToString(false); - } - - // Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a - // tap's CELL column no longer equals the STRING index that url/path matchers use. - // Convert by measuring the string length up to the tapped cell (the count of - // string chars before it). Without this, taps on lines with a leading wide char - // (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss. - function cellColToStringIndex(absRow, col) { - if (!term) return col; - var line = term.buffer.active.getLine(absRow); - if (!line) return col; - return line.translateToString(false, 0, col).length; - } - - // File-path-under-tap detection (matchFilePathAtColumn). See - // terminal-path-tap-injected.ts; mirrors the unit-tested terminal-path-tap.ts. - - var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g; - var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g; - var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 }; - var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 }; - - function parsePathLineCol(value) { - var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value); - if (!m) return null; - var pathText = m[1]; - var last = pathText.charAt(pathText.length - 1); - if (!pathText || last === '/' || last === '\\') return null; - var line = m[2] ? parseInt(m[2], 10) : null; - var column = m[3] ? parseInt(m[3], 10) : null; - if ((line !== null && line < 1) || (column !== null && column < 1)) return null; - return { pathText: pathText, line: line, column: column }; - } - - function trimPathBoundaryPunctuation(raw, rawStart) { - var start = 0, end = raw.length; - while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1; - while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1; - if (start >= end) return null; - return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }; - } - - function hasSeparatorAfterWhitespace(text) { - var sawWhitespace = false; - for (var i = 0; i < text.length; i++) { - var ch = text.charAt(i); - if (/\s/.test(ch)) { sawWhitespace = true; continue; } - if (sawWhitespace && (ch === '/' || ch === '\\')) return true; - } - return false; - } - - function trimSpacedPathTrailingProse(range, col) { - // A line-end extension token only extends the span when the added segment - // is path-like (contains a separator) — prose must not be swallowed. - var selected = null; - var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g; - var match; - while ((match = extensionPrefixPattern.exec(range.text)) !== null) { - var end = match.index + match[0].length; - var text = range.text.slice(0, end); - if (countPathStarts(text) > 1) continue; - if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) { - selected = text; - } - } - if (selected) { - if (col !== undefined && col >= range.startIndex + selected.length) return null; - return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length }; - } - var text = range.text.replace(/\s+$/, ''); - return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length }; - } - - function countPathStarts(text) { - var count = 0; - var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g; - while (pathStartPattern.exec(text) !== null) count += 1; - return count; - } - - function hasSpacedPathExtension(text) { - var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length }); - if (!range) return false; - var trimmed = range.text.replace(/\s+$/, ''); - return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed); - } - - function matchSpacedFilePathAtColumn(lineText, col) { - SPACED_PATH_RE.lastIndex = 0; - var match; - while ((match = SPACED_PATH_RE.exec(lineText)) !== null) { - var trimmed = trimPathBoundaryPunctuation(match[0], match.index); - if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue; - var candidate = trimSpacedPathTrailingProse(trimmed, col); - if (!candidate) continue; - if (col < candidate.startIndex || col >= candidate.endIndex) continue; - var parsed = parsePathLineCol(candidate.text); - if (parsed) return parsed; - } - return null; - } - - function matchFilePathAtColumn(lineText, col) { - var spaced = matchSpacedFilePathAtColumn(lineText, col); - if (spaced) return spaced; - FILE_PATH_RE.lastIndex = 0; - var match; - while ((match = FILE_PATH_RE.exec(lineText)) !== null) { - var raw = match[0]; - if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; } - var trimmed = trimPathBoundaryPunctuation(raw, match.index); - if (!trimmed) continue; - if (col < trimmed.startIndex || col >= trimmed.endIndex) continue; - var parsed = parsePathLineCol(trimmed.text); - if (parsed) return parsed; - } - return null; - } - - // Returns the path candidate under the tap, or null. Query-only so the tap - // handler can try file detection before forwarding a mouse click — which lets - // file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/ - // getLineText from the host script scope. - function filePathAtViewportPoint(originX, originY) { - var tapCell = viewportToCell(originX, originY); - if (!tapCell) return null; - // Map the cell column to a string index so wide chars (emoji/CJK) earlier on - // the line don't shift the match column off the tapped path. - return matchFilePathAtColumn( - getLineText(tapCell.row), - cellColToStringIndex(tapCell.row, tapCell.col) - ); - } - - - var URL_TAP_RE_SOURCE = "\\bhttps?:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"':,.!?{}|\\\\^~[\\]`()<>]"; - var FILE_URL_TAP_RE_SOURCE = "\\bfile:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"',!?{}|\\\\^~[\\]`()<>]"; - var URL_TAP_MAX_LENGTH = 2048; - function findUrlAtColumn(lineText, col) { - return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE); - } - function findFileUrlAtColumn(lineText, col) { - return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE); - } - function findTerminalUrlAtColumn(lineText, col, source) { - if (typeof lineText !== 'string' || lineText.length === 0) return null; - var re = new RegExp(source, 'gi'); - var match; - while ((match = re.exec(lineText)) !== null) { - var end = match.index + match[0].length; - if (match[0].length <= URL_TAP_MAX_LENGTH && col >= match.index && col < end) return match[0]; - if (match[0].length === 0) re.lastIndex++; - } - return null; - } - function fileUrlAtViewportPoint(clientX, clientY) { - var cell = viewportToCell(clientX, clientY); - if (!cell) return null; - return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); - } - function urlAtViewportPoint(clientX, clientY) { - var cell = viewportToCell(clientX, clientY); - if (!cell) return null; - // Map the cell column to a string index so wide chars earlier on the line - // don't shift the match column off the tapped URL. - return findUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); - } - - // Why: OSC 8 links can render as labels like "#1234"; the URI lives in - // xterm's internal link service, so every access is guarded and falls through. - function oscLinkService() { - try { - var core = term && term._core; - if (!core) return null; - return core._oscLinkService - || (core._inputHandler && core._inputHandler._oscLinkService) - || null; - } catch (e) { return null; } - } - function oscLinkAtViewportPoint(clientX, clientY) { - try { - var cell = viewportToCell(clientX, clientY); - if (!cell) return null; - var line = term.buffer.active.getLine(cell.row); - if (!line) return null; - var urlId = oscLinkIdAtCell(line, cell.col); - if (!urlId) return initialOscLinkAtCell(cell.row, cell.col); - var svc = oscLinkService(); - if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col); - var data = svc.getLinkData(urlId); - var uri = data && data.uri; - return terminalOscLinkTarget(uri); - } catch (e) { return null; } - } - function initialOscLinkAtCell(row, col) { - for (var i = 0; i < initialOscLinks.length; i++) { - var link = initialOscLinks[i]; - if (!link || typeof link.uri !== 'string') continue; - if (link.row < initialOscLinkRowOffset) continue; - var shiftedRow = link.row - initialOscLinkRowOffset; - if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri); - } - return null; - } - function terminalOscLinkTarget(uri) { - if (typeof uri !== 'string') return null; - if (/^https?:/i.test(uri)) return { kind: 'url', url: uri }; - var fileTap = resolveTerminalOscFileTap(uri); - return fileTap ? { kind: 'file', fileTap: fileTap } : null; - } - function resolveTerminalOscFileTap(uri) { - return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri); - } - function resolveTerminalFileUrlTap(uri) { - var parsed; - try { - parsed = new URL(uri); - } catch (e) { - return null; - } - if (parsed.protocol !== 'file:') return null; - var filePath; - try { - filePath = decodeURIComponent(parsed.pathname || ''); - } catch (e) { - return null; - } - if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) { - filePath = '//' + parsed.hostname + filePath; - } else if (/^\/[A-Za-z]:\//.test(filePath)) { - filePath = filePath.slice(1); - } - if (!filePath) return null; - var hashTarget = parseFileUrlLineHash(parsed.hash || ''); - if (hashTarget) { - return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }; - } - if (/%3a/i.test(parsed.pathname || '')) { - return { pathText: filePath, line: null, column: null }; - } - return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null }; - } - function isLocalFileUriHostname(hostname) { - var normalized = String(hostname).toLowerCase(); - return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]'; - } - function parseOscPathLikeTarget(value) { - if (!/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(value)) return null; - return parsePathLineCol(value); - } - function parseFileUrlLineHash(hash) { - var match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash); - if (!match) return null; - var line = parseInt(match[1], 10); - var column = match[2] ? parseInt(match[2], 10) : null; - if (line < 1 || (column !== null && column < 1)) return null; - return { line: line, column: column }; - } - function parseFilePathTrailingLineTarget(filePath) { - var match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath); - if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\') return null; - var line = parseInt(match[2], 10); - var column = match[3] ? parseInt(match[3], 10) : null; - if (line < 1 || (column !== null && column < 1)) return null; - return { pathText: match[1], line: line, column: column }; - } - function captureInitialOscLinkTexts() { - if (!Array.isArray(initialOscLinks)) return; - for (var i = 0; i < initialOscLinks.length; i++) { - var link = initialOscLinks[i]; - if (!link || typeof link.text === 'string') continue; - link.text = initialOscLinkTextAtRow(link, link.row); - } - } - function initialOscLinkTextStillMatches(link, row) { - if (typeof link.text !== 'string') return false; - return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text; - } - function initialOscLinkTextAtRow(link, row) { - try { - var lineText = getLineText(row); - var start = cellColToStringIndex(row, link.startCol); - var end = cellColToStringIndex(row, link.endCol); - return lineText.slice(start, end); - } catch (e) { - return ''; - } - } - function oscLinkIdAtCell(line, col) { - try { - var bufCell = line.getCell(col); - return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0; - } catch (e) { return 0; } - } - - function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) { - var tappedOscLink = oscLinkAtViewportPoint(originX, originY); - if (tappedOscLink && tappedOscLink.kind === 'file') { - notify({ - type: 'terminal-file-tap', - pathText: tappedOscLink.fileTap.pathText, - line: tappedOscLink.fileTap.line, - column: tappedOscLink.fileTap.column - }); - return; - } - var tappedFileUrl = fileUrlAtViewportPoint(originX, originY); - var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null; - if (tappedFileUrlPath) { - notify({ - type: 'terminal-file-tap', - pathText: tappedFileUrlPath.pathText, - line: tappedFileUrlPath.line, - column: tappedFileUrlPath.column - }); - return; - } - var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY); - if (tappedUrl) { - notify({ type: 'open-url', url: tappedUrl }); - return; - } - var tappedPath = filePathAtViewportPoint(originX, originY); - if (tappedPath) { - notify({ - type: 'terminal-file-tap', - pathText: tappedPath.pathText, - line: tappedPath.line, - column: tappedPath.column - }); - return; - } - var clickInput = buildMouseClickInput(originX, originY); - if (clickInput) { - notify({ type: 'terminal-input', bytes: clickInput }); - } - // Touch still needs native input focus after the TUI consumes its mouse click. - if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { - notify({ type: 'terminal-tap' }); - } - } - - - function seedWordSelection(col, absRow) { - var line = getLineText(absRow); - if (!line) { - sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null }; - applyXtermSelection(); - return; - } - var s = col; - var e = col; - if (col >= 0 && col < line.length && WORD_RE.test(line[col])) { - while (s > 0 && WORD_RE.test(line[s - 1])) s--; - while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++; - } - sel = { - anchor: { col: s, row: absRow }, - focus: { col: e, row: absRow }, - activeHandle: null - }; - applyXtermSelection(); - } - - function isStartFirst(a, b) { - if (a.row !== b.row) return a.row < b.row; - return a.col <= b.col; - } - - function selRange() { - if (!sel) return null; - if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus }; - return { start: sel.focus, end: sel.anchor }; - } - - function applyXtermSelection() { - if (!term || !sel) return; - var r = selRange(); - if (!r) return; - // Why: term.select(col, row, length) takes a buffer-absolute row, - // not a viewport-relative one. Subtracting viewportY here drifts the - // selection by the scrollback height — handles render where the user - // pressed (their math is independent), but xterm highlights an - // off-screen scrollback region and copies the wrong text. - var length; - if (r.start.row === r.end.row) { - length = Math.max(1, r.end.col - r.start.col + 1); - } else { - var first = term.cols - r.start.col; - var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols; - var last = r.end.col + 1; - length = first + middle + last; - } - try { term.select(r.start.col, r.start.row, length); } catch (e) {} - } - - function cancelSelect() { - selMode = 'navigate'; - sel = null; - stopEdgeScroll(); - if (term) { - try { term.clearSelection(); } catch (e) {} - // Why: some xterm renderers cache cells and skip repaint on - // clearSelection alone, leaving the previously-highlighted cells - // visually selected. Force a full refresh so the selection layer - // actually clears on screen. - try { term.refresh(0, term.rows - 1); } catch (e) {} - } - selectionOverlay.classList.remove('active'); - notify({ type: 'set-select-mode', enabled: false }); - } - - function enterSelect(col, absRow) { - selMode = 'select'; - seedWordSelection(col, absRow); - selectionOverlay.classList.add('active'); - notify({ type: 'set-select-mode', enabled: true }); - notify({ type: 'haptic', kind: 'selection' }); - repositionOverlay(); - } - - function repositionOverlay() { - if (selMode !== 'select' || !sel || !term) return; - var r = selRange(); - var sPx = cellToViewportPx(r.start.col, r.start.row); - var ePx = cellToViewportPx(r.end.col + 1, r.end.row); - var cellH = getCellHeight() * getTotalScale(); - // Why: native iOS pattern — start handle anchors at the TOP of the - // first selected cell (dot above, stem covers the cell going down); - // end handle anchors at the BOTTOM of the last selected cell (dot - // below, stem covers the cell going up). - handleStart.style.left = sPx.x + 'px'; - handleStart.style.top = sPx.y + 'px'; - handleEnd.style.left = ePx.x + 'px'; - handleEnd.style.top = (ePx.y + cellH) + 'px'; - var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight; - var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight; - handleStart.style.visibility = startVisible ? 'visible' : 'hidden'; - handleEnd.style.visibility = endVisible ? 'visible' : 'hidden'; - var menuCenterX, menuY, vTransform, marginTop; - if (startVisible && sPx.y > 56) { - menuCenterX = sPx.x; menuY = sPx.y; - vTransform = 'translateY(-100%)'; - marginTop = '-12px'; - } else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) { - menuCenterX = ePx.x; menuY = ePx.y + cellH; - vTransform = 'translateY(0)'; - marginTop = '12px'; - } else { - // selection covers full viewport — pin to visible center - menuCenterX = window.innerWidth / 2; - menuY = window.innerHeight / 2; - vTransform = 'translateY(-50%)'; - marginTop = '0'; - } - // Why: clamp horizontally so the pill stays fully visible when the - // selection sits near a screen edge. We position via plain left - // (no horizontal translate) so the clamp math is straightforward. - selMenu.style.transform = vTransform; - selMenu.style.marginTop = marginTop; - selMenu.style.top = menuY + 'px'; - selMenu.style.left = '0px'; - var EDGE_MARGIN = 8; - var menuW = selMenu.offsetWidth || 0; - var minLeft = EDGE_MARGIN; - var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN); - var desiredLeft = menuCenterX - menuW / 2; - var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft)); - selMenu.style.left = clampedLeft + 'px'; - } - - function syncSelectionHandleToViewportPoint(handle, clientX, clientY) { - var c = viewportToCell(clientX, clientY); - if (!c || !sel) return false; - if (handle === 'start') sel.anchor = c; - else sel.focus = c; - applyXtermSelection(); - return true; - } - - function syncEdgeScrollSelectionEndpoint() { - if (!sel || !sel.activeHandle) return false; - // Why: WebView may not emit new touchmove events while a handle is held - // at the edge; resample the stored finger point after each viewport scroll. - return syncSelectionHandleToViewportPoint( - sel.activeHandle, - edgeScrollClientX, - edgeScrollClientY - ); - } - - function startEdgeScroll(dir) { - if (edgeScrollDir === dir) return; - stopEdgeScroll(); - edgeScrollDir = dir; - edgeScrollTimer = setInterval(function() { - if (!term || edgeScrollDir === 0) return; - var beforeY = term.buffer.active.viewportY; - term.scrollLines(edgeScrollDir); - var afterY = term.buffer.active.viewportY; - if (beforeY === afterY) { - notify({ type: 'haptic', kind: 'edge-bump' }); - stopEdgeScroll(); - return; - } - syncEdgeScrollSelectionEndpoint(); - repositionOverlay(); - }, EDGE_SCROLL_INTERVAL); - } - - function stopEdgeScroll() { - if (edgeScrollTimer) { - clearInterval(edgeScrollTimer); - edgeScrollTimer = null; - } - edgeScrollDir = 0; - } - - function handleDragMove(handle, clientX, clientY) { - edgeScrollClientX = clientX; - edgeScrollClientY = clientY; - if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) return; - repositionOverlay(); - if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1); - else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1); - else stopEdgeScroll(); - } - - // Latching document-level touch dispatcher: see - // terminal-webview-tap-dispatch-injected.ts (extracted for max-lines). - - // ============================================================ - // LATCHING TOUCH DISPATCHER (document-level) - // ============================================================ - var dispatch = { mode: 'idle', touchId: null, touchIds: null, longPressFingerInsideOverlay: false }; - - function touchById(touches, id) { - for (var i = 0; i < touches.length; i++) { - if (touches[i].identifier === id) return touches[i]; - } - return null; - } - - function targetInside(target, el) { - if (!target || !el) return false; - return el.contains(target); - } - - function clearLongPress() { - if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } - longPressOrigin = null; - } - - function armLongPress(touch) { - longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier }; - longPressTimer = setTimeout(function() { - longPressTimer = null; - if (!longPressOrigin) return; - var c = viewportToCell(longPressOrigin.x, longPressOrigin.y); - if (!c) return; - enterSelect(c.col, c.row); - }, LONG_PRESS_MS); - } - - function touchSlopExceeded(t) { - if (!longPressOrigin) return false; - var dx = Math.abs(t.clientX - longPressOrigin.x); - var dy = Math.abs(t.clientY - longPressOrigin.y); - return (dx + dy) > LONG_PRESS_SLOP; - } - - // Why: existing surface handlers stay attached to surface but we wrap - // their entry to no-op when the dispatcher latches into select-drag. - function dispatcherShouldBlockSurface() { - return dispatch.mode === 'select-drag'; - } - - document.addEventListener('touchstart', function(e) { - var t = e.touches[0]; - var target = e.target; - var onHandle = target === handleStart || target === handleEnd; - var inOverlay = targetInside(target, selectionOverlay); - var inSurface = targetInside(target, surface); - // Why: clear any stale tap candidate up front; only a fresh single-finger - // surface touch (below) re-arms it, so handle drags / pinches / dismiss - // taps never resolve as a link tap on touchend. - tapCandidate = null; - - if (e.touches.length === 2) { - // pinch latch - if (selMode === 'select') { - notify({ type: 'mobile-clip-cancel-by-pinch' }); - cancelSelect(); - } - dispatch.mode = 'pinch'; - dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]; - clearLongPress(); - return; - } - - if (onHandle && selMode === 'select') { - // start handle drag - var handleName = (target === handleStart) ? 'start' : 'end'; - sel.activeHandle = handleName; - dispatch.mode = 'select-drag'; - dispatch.touchId = t.identifier; - e.preventDefault(); - return; - } - - if (inOverlay) { - // tap on menu pill — let the buttons' own handlers fire - return; - } - - if (inSurface && selMode === 'select') { - // Why: tap-to-dismiss matches native iOS/Android — touching outside the - // selection clears it. We cancel immediately and latch to 'surface' so - // the same gesture still drives scroll/pan without a second touch. - cancelSelect(); - dispatch.mode = 'surface'; - dispatch.touchId = t.identifier; - return; - } - - if (inSurface) { - dispatch.mode = 'surface'; - dispatch.touchId = t.identifier; - tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }; - armLongPress(t); - } - }, { capture: true, passive: false }); - - document.addEventListener('touchmove', function(e) { - if (dispatch.mode === 'select-drag') { - var t = touchById(e.touches, dispatch.touchId); - if (!t || !sel || !sel.activeHandle) return; - e.preventDefault(); - handleDragMove(sel.activeHandle, t.clientX, t.clientY); - return; - } - if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { - // long-press slop check - if (longPressTimer && e.touches.length === 1) { - if (touchSlopExceeded(e.touches[0])) clearLongPress(); - } - // Why: disqualify the tap only once the finger travels past TAP_SLOP - // (a scroll/pan), independent of the long-press timer — so a tap that - // jitters under TAP_SLOP still opens the link/path under the finger. - if (tapCandidate && e.touches.length === 1) { - var mt = e.touches[0]; - if (mt.identifier === tapCandidate.identifier) { - var dx = Math.abs(mt.clientX - tapCandidate.x); - var dy = Math.abs(mt.clientY - tapCandidate.y); - if (dx + dy > TAP_SLOP) tapCandidate = null; - } - } else if (e.touches.length !== 1) { - tapCandidate = null; - } - // existing surface handler will run from its own listener - } - }, { capture: true, passive: false }); - - document.addEventListener('touchend', function(e) { - if (dispatch.mode === 'select-drag') { - if (sel) sel.activeHandle = null; - stopEdgeScroll(); - dispatch.mode = 'idle'; - dispatch.touchId = null; - return; - } - if (dispatch.mode === 'pinch') { - if (e.touches.length < 2) { - dispatch.mode = (e.touches.length === 1) ? 'surface' : 'idle'; - dispatch.touchIds = null; - if (e.touches.length === 1) dispatch.touchId = e.touches[0].identifier; - } - return; - } - if (dispatch.mode === 'surface') { - // Why: fire the tap from the tap-candidate origin (survives jitter under - // TAP_SLOP) rather than longPressOrigin, which the press-to-select slop - // can null mid-tap — that was dropping URL/file taps that moved a few px. - if ( - e.touches.length === 0 && - tapCandidate && - selMode !== 'select' && - Date.now() - tapCandidate.t <= TAP_MAX_MS - ) { - notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); - } - clearLongPress(); - tapCandidate = null; - if (e.touches.length === 0) { - dispatch.mode = 'idle'; - dispatch.touchId = null; - } - } - }, { capture: true, passive: true }); - - document.addEventListener('touchcancel', function() { - clearLongPress(); - tapCandidate = null; - stopEdgeScroll(); - if (dispatch.mode === 'select-drag') { - if (sel) sel.activeHandle = null; - } - dispatch.mode = 'idle'; - dispatch.touchId = null; - dispatch.touchIds = null; - }, { capture: true, passive: true }); - - - // External mouse / trackpad scroll: see - // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). - - var wheelAccumDeltaY = 0; - - function wheelEventPixelDeltaY(e) { - var delta = e.deltaY; - if (typeof delta !== 'number' || !isFinite(delta) || delta === 0) return 0; - // DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas - // for external mouse wheels, iOS trackpads report pixels. - if (e.deltaMode === 1) return delta * getCellHeight() * getTotalScale(); - if (e.deltaMode === 2) return delta * window.innerHeight; - return delta; - } - - function attachSurfaceWheelHandler(targetSurface) { - targetSurface.addEventListener('wheel', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - // Why: xterm's own wheel handler scrolls its hidden viewport or emits - // cursor keys through onData, which the mobile query-reply gate drops. - // Claim the event so indirect pointers share the touch scroll router. - e.preventDefault(); - e.stopPropagation(); - - // Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than - // firing cursor keys at the TUI; two-finger pinch still drives text size. - if (e.ctrlKey) return; - - var deltaY = wheelEventPixelDeltaY(e); - if (deltaY === 0) return; - - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - if (!(effectiveCellH > 0)) return; - wheelAccumDeltaY += deltaY; - var lines = Math.trunc(wheelAccumDeltaY / effectiveCellH); - if (lines !== 0) { - wheelAccumDeltaY -= lines * effectiveCellH; - routeScrollLines(lines, e.clientX, e.clientY); - } - return; - } - wheelAccumDeltaY = 0; - enqueueNormalBufferScrollDelta(deltaY); - }, { capture: true, passive: false }); - } - - - // External mouse click/drag: see - // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). - - var mouseGesture = null; - - // One report per transition, built with the same encoding ladder as - // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' - // when the mode does not report this transition (x10 has no release, only - // drag/any report motion) or the cell is not encodable. - function buildMouseButtonReport(kind, clientX, clientY) { - var mouseTrackingMode = getMouseTrackingMode(); - if (mouseTrackingMode === 'none') return ''; - if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return ''; - if (kind === 'release' && mouseTrackingMode === 'x10') return ''; - var cell = viewportToMouseReportCell(clientX, clientY); - if (!cell) return ''; - var sgrButton = kind === 'motion' ? 32 : 0; - var sgrFinal = kind === 'release' ? 'm' : 'M'; - if (sgrMousePixelsMode) { - if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; - return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal; - } - if (sgrMouseMode) { - // Why: xterm increments zero-based mouse cells before encoding reports. - var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal; - } - var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32; - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - // Why: non-SGR mouse bytes above ASCII are not preserved reliably through - // the mobile JSON/RPC string path; drop instead of corrupting input. - if (col > 126 || row > 126) return ''; - return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function mouseReportCellKey(clientX, clientY) { - var cell = viewportToMouseReportCell(clientX, clientY); - return cell ? cell.col + ',' + cell.row : null; - } - - function abandonMouseGesture() { - var gesture = mouseGesture; - mouseGesture = null; - if (!gesture) return; - if (gesture.mode === 'tracking') { - // Why: the press report already went to the TUI; a lost pointer must not - // leave the button latched down on the far side. - var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY); - if (release) notify({ type: 'terminal-input', bytes: release }); - } else if (gesture.mode === 'selecting') { - if (sel) sel.activeHandle = null; - stopEdgeScroll(); - } - } - - function beginMouseDrag(gesture) { - gesture.moved = true; - if (getMouseTrackingMode() !== 'none') { - gesture.mode = 'tracking'; - gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY); - var press = buildMouseButtonReport('press', gesture.startX, gesture.startY); - if (press) notify({ type: 'terminal-input', bytes: press }); - return; - } - var anchor = viewportToCell(gesture.startX, gesture.startY); - if (!anchor) { - gesture.mode = 'cancelled'; - return; - } - // Why: mouse drags select character-anchored ranges like desktop terminals, - // not the word-seeded long-press selection; reuse the touch handle-drag - // plumbing (edge scroll included) by acting as a live 'end' handle. - gesture.mode = 'selecting'; - selMode = 'select'; - sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }; - selectionOverlay.classList.add('active'); - notify({ type: 'set-select-mode', enabled: true }); - applyXtermSelection(); - repositionOverlay(); - } - - function attachSurfaceMouseClickDragHandler(targetSurface) { - targetSurface.addEventListener('pointerdown', function(e) { - if (e.pointerType !== 'mouse' || e.button !== 0) return; - if (dispatcherShouldBlockSurface() || !term) return; - // Why: a pointerup lost outside the WebView must not leave the previous - // gesture latched (tracking press with no release) when the next one lands. - if (mouseGesture) abandonMouseGesture(); - // Why: mouse pointers have no implicit capture; without it a drag that - // leaves the surface drops pointermove/pointerup and strands the gesture. - try { - if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId); - } catch (err) {} - mouseGesture = { - startX: e.clientX, startY: e.clientY, - lastX: e.clientX, lastY: e.clientY, - lastCellKey: null, - moved: false, - mode: 'pending', - dismissedSelection: false - }; - if (selMode === 'select') { - // Why: touch parity — pressing outside the pill dismisses the current - // selection; the same press may still start a new drag selection. - cancelSelect(); - mouseGesture.dismissedSelection = true; - } - }, true); - - targetSurface.addEventListener('pointermove', function(e) { - var gesture = mouseGesture; - if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; - if (!term) return; - gesture.lastX = e.clientX; - gesture.lastY = e.clientY; - if ((e.buttons & 1) === 0) { - // Why: a pointerup lost outside the WebView (capture unavailable) must - // end the gesture here, or a tracked press stays latched at the TUI. - // Coordinates first, so the synthesized release lands where the - // pointer re-entered rather than at the previous cell. - abandonMouseGesture(); - return; - } - if (!gesture.moved) { - var dx = Math.abs(e.clientX - gesture.startX); - var dy = Math.abs(e.clientY - gesture.startY); - if (dx + dy <= TAP_SLOP) return; - beginMouseDrag(gesture); - } - if (gesture.mode === 'tracking') { - // Why: one motion report per cell keeps drags bounded by grid size, not - // by pointermove cadence, so the RN rate limiter is never the bottleneck. - var cellKey = mouseReportCellKey(e.clientX, e.clientY); - if (cellKey && cellKey !== gesture.lastCellKey) { - gesture.lastCellKey = cellKey; - var motion = buildMouseButtonReport('motion', e.clientX, e.clientY); - if (motion) notify({ type: 'terminal-input', bytes: motion }); - } - } else if (gesture.mode === 'selecting') { - handleDragMove('end', e.clientX, e.clientY); - } - }, true); - - targetSurface.addEventListener('pointerup', function(e) { - var gesture = mouseGesture; - if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return; - mouseGesture = null; - if (gesture.mode === 'cancelled' || !term) return; - if (gesture.mode === 'tracking') { - var release = buildMouseButtonReport('release', e.clientX, e.clientY); - if (release) notify({ type: 'terminal-input', bytes: release }); - return; - } - if (gesture.mode === 'selecting') { - if (sel) sel.activeHandle = null; - stopEdgeScroll(); - repositionOverlay(); - return; - } - if (dispatcherShouldBlockSurface()) return; - // Why: a dismissing tap only clears the selection (touch parity); it must - // not also open a link or focus the keyboard underneath. - if (gesture.dismissedSelection) return; - // Pointer clicks keep their current link, file, TUI mouse, and focus priority. - notifyTerminalSurfaceTap(e.clientX, e.clientY, false); - }, true); - - targetSurface.addEventListener('pointercancel', function(e) { - if (e.pointerType !== 'mouse') return; - abandonMouseGesture(); - }, true); - - // Why: Android input injection can pair a mouse-flavored pointerdown with - // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, - // the document touch dispatcher owns the gesture. - targetSurface.addEventListener('touchstart', function() { - if (mouseGesture) abandonMouseGesture(); - }, true); - } - - - btnCopy.addEventListener('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - if (!term) return; - var text = term.getSelection ? term.getSelection() : ''; - if (text && text.length > 0) { - notify({ type: 'selection', text: text }); - } else { - cancelSelect(); - } - }); - - btnSelAll.addEventListener('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - if (!term) return; - try { - term.selectAll(); - var b = term.buffer.active; - sel = { - anchor: { col: 0, row: 0 }, - focus: { col: term.cols - 1, row: b.length - 1 }, - activeHandle: null - }; - repositionOverlay(); - } catch (err) {} - }); - - var ts = { - lastX: 0, lastY: 0, lastTime: 0, velY: 0, - accumDelta: 0, momentumId: null, isPinching: false, - pinchDist: 0, pinchScale: 0, pinchSurfX: 0, pinchSurfY: 0 - }; - - function updateTouchVelocity(deltaY, dt) { - if (dt <= 0) return; - var instantVelocity = deltaY / dt; - if (!isFinite(instantVelocity)) return; - // Why: touchmove cadence is uneven in WebView. Blend recent samples so - // momentum launch doesn't inherit a one-frame spike or stall. - ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45; - } - - function getDistance(a, b) { - var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; - return Math.sqrt(dx * dx + dy * dy); - } - - function attachSurfaceEventHandlers(targetSurface) { - if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return; - targetSurface.__orcaSurfaceHandlersAttached = true; - // Why: init() swaps in a new hidden surface to avoid flicker; each - // replacement needs gesture handlers or tab-switch replays stop scrolling. - targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true); - targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); - - attachSurfaceWheelHandler(targetSurface); - attachSurfaceMouseClickDragHandler(targetSurface); - - targetSurface.addEventListener('touchstart', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (ts.momentumId) { - cancelAnimationFrame(ts.momentumId); - ts.momentumId = null; - } - if (e.touches.length === 2) { - ts.isPinching = true; - smoothScrollOffsetY = 0; - ts.pinchDist = getDistance(e.touches[0], e.touches[1]); - ts.pinchScale = userScale; - var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; - var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; - var total = getTotalScale(); - ts.pinchSurfX = (mx - panX) / total; - ts.pinchSurfY = (my - panY) / total; - } else if (e.touches.length === 1) { - ts.isPinching = false; - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; - } - }, { capture: true, passive: true }); - - targetSurface.addEventListener('touchmove', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - e.preventDefault(); - e.stopPropagation(); - - if (e.touches.length === 2) { - ts.isPinching = true; - var dist = getDistance(e.touches[0], e.touches[1]); - var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; - var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; - - var ratio = dist / ts.pinchDist; - // Why: userScale is a CSS multiplier on the current font size; bound it so - // the resulting apparent size (currentTextScale × userScale) stays within - // the preset range, since release snaps to one of those presets. - var loScale = MIN_TEXT_SCALE / currentTextScale; - var hiScale = MAX_TEXT_SCALE / currentTextScale; - userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio)); - - var total = getTotalScale(); - panX = mx - ts.pinchSurfX * total; - panY = my - ts.pinchSurfY * total; - clampPan(); - updateTransform(); - - } else if (e.touches.length === 1 && !ts.isPinching) { - var x = e.touches[0].clientX, y = e.touches[0].clientY; - var now = Date.now(), dt = now - ts.lastTime; - - // Why: pan horizontally only when content overflows the viewport (larger - // than fit) — same check clampPan() uses. Vertical always drives buffer - // scroll so scrollback stays reachable at any text size; calling the - // never-defined contentWiderThanViewport() here threw and killed all - // single-finger scrolling, scrollback included. - if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { - panX += x - ts.lastX; - clampPan(); - updateTransform(); - } - - var deltaY = ts.lastY - y; - ts.lastTime = now; - if (shouldRouteScrollToTerminalInput()) { - updateTouchVelocity(deltaY, dt); - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += deltaY; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, x, y); - } - } else { - if (enqueueNormalBufferScrollDelta(deltaY)) { - updateTouchVelocity(deltaY, dt); - } else { - ts.velY = 0; - } - } - ts.lastX = x; - ts.lastY = y; - } - }, { capture: true, passive: false }); - - targetSurface.addEventListener('touchend', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false; - // Why: a finished pinch snaps to the nearest preset and becomes the new - // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way - // to set the text size. The CSS pinch zoom (userScale) is reset; the real - // size change reflows columns and RN persists + resizes the PTY to match. - var target = snapToTextScalePreset(currentTextScale * userScale); - var changed = target !== currentTextScale; - userScale = 1; - panX = 0; panY = 0; - applyTextScale(target); - updateTransform(); - notify({ type: 'font-scale-changed', fontScale: target }); - if (changed) notify({ type: 'haptic', kind: 'selection' }); - if (e.touches.length === 1) { - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; - } - return; - } - - if (e.touches.length === 0) { - var vel = ts.velY; - var FRICTION = 0.972; - var MIN_VEL = 0.012; - function momentumStep() { - vel *= FRICTION; - if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } - var delta = vel * 16; - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += delta; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, ts.lastX, ts.lastY); - } - } else { - if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null; - return; - } - } - ts.momentumId = requestAnimationFrame(momentumStep); - } - if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep); - } - } - }, { capture: true, passive: true }); - } - - attachSurfaceEventHandlers(surface); - - function handleIncomingMessage(e) { - var msg; - try { - msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; - } catch (ex) { - return; - } - try { - handleMsg(msg); - } catch(ex) { - reportEngineError( - msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', - ex, - msg && msg.type === 'init' && !everReady - ); - } - } - - window.addEventListener('message', handleIncomingMessage); - - document.addEventListener('message', handleIncomingMessage); - - window.addEventListener('resize', function() { - // Why: viewport changed (keyboard open/close, orientation, RN container - // size update). Re-fit so the scale matches the new vpWidth — without - // this, opening the keyboard leaves the terminal at the old scale even - // though there's now less vertical room and the fit ratio may differ. - applyFitScale('window-resize'); - adjustRowsForViewport(); - repositionOverlay(); - clampPan(); - updateTransform(); - }); - - if (window.Terminal) { - notify({ type: 'web-ready' }); - } else { - reportEngineError('terminal engine missing', 'xterm failed to load', true); - } -})(); diff --git a/mobile/src/terminal/terminal-web-document-mount-rejection.test.ts b/mobile/src/terminal/terminal-web-document-mount-rejection.test.ts new file mode 100644 index 00000000000..e874851fc24 --- /dev/null +++ b/mobile/src/terminal/terminal-web-document-mount-rejection.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' + +/** + * What a mount whose chunk never arrived is allowed to touch on its way out. + * + * The one path where a mount reaches its own cleanup holding a page that belongs to someone else. + * Everywhere else the build reads the claim again after its import and stops, but a rejected + * import never gets that far: the failure arrives at the mount's error handler directly, and by + * then the overlay's Reload may already have built a second document into the same element. A + * release that emptied the host anyway would blank the terminal on the screen and hand the page + * back while its document ran on. + * + * Its own file because making the import fail is the only way to reach this, and the mock has to + * be in place before the mount module is loaded. It fails once, so the second mount gets the real + * modules and can be a live document to protect. + */ + +const { chunk } = vi.hoisted(() => ({ chunk: { failures: 0 } })) +vi.mock('./document/page-document-modules', async (importOriginal) => { + if (chunk.failures === 0) { + chunk.failures += 1 + throw new Error('orca-document-chunk-failed') + } + return importOriginal() +}) + +const { mountTerminalWebDocument } = await import('./terminal-web-document-mount') + +const HOST_CLASS = 'orca-terminal-document-host' + +describe('a page mount whose document chunk failed', () => { + it('leaves the document that replaced it alone', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + let resizeListeners = 0 + const realAdd = window.addEventListener.bind(window) + const realRemove = window.removeEventListener.bind(window) + // Parameters taken from the bound original, so the wrapper carries the real signature rather + // than three implicit `any`s the tests typecheck refuses. + window.addEventListener = (...added: Parameters) => { + resizeListeners += added[0] === 'resize' ? 1 : 0 + realAdd(...added) + } + window.removeEventListener = (...removed: Parameters) => { + resizeListeners -= removed[0] === 'resize' ? 1 : 0 + realRemove(...removed) + } + + // Put back whatever happens, as the sibling case does: a failure part way through would + // otherwise leave the patched functions on `window` for everything that runs after it. + try { + const abandoned = mountTerminalWebDocument(host, () => {}) + abandoned.dispose() + // The same element, as React hands it back on the overlay's Reload. + const live = mountTerminalWebDocument(host, () => {}) + // The message is the mocking layer's, not the one thrown, so the two counters are what say + // which import did what: the abandoned mount's failed, and the live mount's did not. + await expect(abandoned.ready).rejects.toThrow() + expect(chunk.failures, 'the abandoned mount is the one whose chunk failed').toBe(1) + + expect(host.querySelector('#terminal-container')).not.toBe(null) + expect(host.classList.contains(HOST_CLASS)).toBe(true) + // Still claimed, so the release did not hand the page back either. + expect(() => mountTerminalWebDocument(host, () => {})).toThrow( + 'the terminal document is already mounted on this page' + ) + + await live.ready + // The other half of the precondition: the mount that replaced it is a real started document, + // not a second casualty. Its resize listener is the one the start sequence adds. + expect(resizeListeners, 'the live mount started its document').toBe(1) + // And disposing the abandoned handle a second time changes nothing. + abandoned.dispose() + expect(host.querySelector('#terminal-container')).not.toBe(null) + expect(host.classList.contains(HOST_CLASS)).toBe(true) + live.dispose() + expect(host.querySelector('#terminal-container')).toBe(null) + expect(resizeListeners, 'and it took its listener back on the way out').toBe(0) + } finally { + window.addEventListener = realAdd + window.removeEventListener = realRemove + } + }) +}) diff --git a/mobile/src/terminal/terminal-web-document-mount.ts b/mobile/src/terminal/terminal-web-document-mount.ts new file mode 100644 index 00000000000..2482c263bc6 --- /dev/null +++ b/mobile/src/terminal/terminal-web-document-mount.ts @@ -0,0 +1,308 @@ +import { Terminal } from '@xterm/xterm' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { WebglAddon } from '@xterm/addon-webgl' +import type { TerminalDocumentWebglAddon } from './document/document-terminal-shape' +import { TERMINAL_DOCUMENT_ELEMENT_STYLE, TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' +import { scopeStyleToHost } from './terminal-webview-html/document-style-scoping' +import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated' +import type { TerminalWebViewCommand } from './terminal-webview-messages' + +/** + * The terminal document, mounted in the page instead of in a WebView. + * + * Same program: the modules the WebView's script is generated from, started here in the order the + * generator emits them. What the WebView's HTML gave them — the stylesheet, the elements they read + * by id, the engine on `window` and a `postMessage` back to React Native — this supplies instead, + * through the six scope seams and the host's own element. + * + * Ruling 20 is what makes a remount work. ES module bodies run once per page, so the second mount + * re-imports nothing: every element read, listener and reporter install lives in a start function, + * and this runs that sequence per mount against the markup it has just replanted. `dispose` takes + * back the three that outlive the host element. + * + * The import is still dynamic, because the page bundle must not carry the document into every + * route that never opens a terminal. + */ + +export type TerminalWebDocument = { + /** Hands one host command to the document, as `postMessage` does inside the WebView. */ + send: (command: TerminalWebViewCommand & { id: number }) => void + dispose: () => void + /** + * Settles when the document is live, or rejects with what stopped it. + * + * The handle itself is returned before this: the document is reached by a dynamic import, and a + * caller that had to await the import to get a handle would have nothing to dispose while the + * import was in flight. That is not a corner — a slow chunk is what the readiness watchdog is + * for, and the overlay's Reload is what ruling 20 names as the way out of it. + * + * A mount disposed before its import landed resolves rather than rejecting. Nothing failed: + * the caller asked for the terminal and then asked for it to go away, and the chunk arriving + * afterwards is not an error to report. The caller learns which it got from `dispose` being + * the thing it called, not from this. + */ + ready: Promise +} + +const STYLE_ELEMENT_ID = 'orca-terminal-document-style' + +/** The class the host carries, and the prefix every injected rule is held under. */ +const HOST_CLASS = 'orca-terminal-document-host' + +/** + * The stylesheet, planted in the head once per page and reaching only inside the host. + * + * ` -
-
-
-
-
-
-
-
- - -
-
+${TERMINAL_DOCUMENT_MARKUP} -` +function makeJsonResponse(body: unknown, status = 200): Response { + return makeResponse(JSON.stringify(body), status) +} -const USAGE_PAGE_NO_MONTHLY = ` +const STATUS_WITH_MONTHLY = { + access: { + meters: { + fiveHour: { + resetsAt: '2026-04-24T14:00:00.000Z', + limitMicroCents: '1000', + usedMicroCents: '300' + }, + week: { + resetsAt: '2026-05-01T12:00:00.000Z', + limitMicroCents: '1000', + usedMicroCents: '510' + }, + month: { + resetsAt: '2026-05-24T12:00:00.000Z', + limitMicroCents: '1000', + usedMicroCents: '890' + } + } + } +} + +const STATUS_NO_MONTHLY = { + access: { + meters: { + fiveHour: { + resetsAt: '2026-04-24T13:00:00.000Z', + limitMicroCents: '100', + usedMicroCents: '10' + }, + week: { + resetsAt: '2026-04-25T12:00:00.000Z', + limitMicroCents: '100', + usedMicroCents: '20' + } + } + } +} + +const LEGACY_USAGE_PAGE = ` ` const WORKSPACES_RESPONSE = 'id: "wrk_TESTWORKSPACEID123"' +function requestedUrls(): string[] { + return netFetchMock.mock.calls.map(([url]) => String(url)) +} + describe('fetchOpenCodeGoRateLimits', () => { beforeEach(() => { vi.useFakeTimers() @@ -79,7 +118,7 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(netFetchMock).not.toHaveBeenCalled() }) - it('returns error when cookie has no auth or __Host-auth name', async () => { + it('returns error when cookie has no known auth name', async () => { const result = await fetchOpenCodeGoRateLimits('session=abc123; other=xyz') expect(result.status).toBe('error') @@ -105,8 +144,17 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(normalizeCookieInput('__Host-auth=token')).toBe('__Host-auth=token') }) + it('leaves __Host-console_session=... unchanged', () => { + expect(normalizeCookieInput('__Host-console_session=consoleTok')).toBe( + '__Host-console_session=consoleTok' + ) + }) + it('leaves multi-pair cookie headers unchanged', () => { expect(normalizeCookieInput('auth=tok; other=val')).toBe('auth=tok; other=val') + expect(normalizeCookieInput('auth=tok; __Host-console_session=consoleTok')).toBe( + 'auth=tok; __Host-console_session=consoleTok' + ) }) it('trims surrounding whitespace before wrapping', () => { @@ -123,7 +171,7 @@ describe('fetchOpenCodeGoRateLimits', () => { it('accepts a bare token (auto-wraps to auth=)', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) const result = await fetchOpenCodeGoRateLimits('Fe26.2**baretoken') @@ -136,7 +184,7 @@ describe('fetchOpenCodeGoRateLimits', () => { it('uses GET /_server?id= with correct headers for workspaces', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) await fetchOpenCodeGoRateLimits('auth=mytoken') @@ -156,7 +204,7 @@ describe('fetchOpenCodeGoRateLimits', () => { it('uses an isolated session cookie jar and clears it after fetching', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) await fetchOpenCodeGoRateLimits('auth=mytoken') @@ -210,9 +258,9 @@ describe('fetchOpenCodeGoRateLimits', () => { it('applies configured proxy settings once to the isolated session', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) const proxySettings = { httpProxyUrl: 'http://proxy.example:8080', @@ -244,55 +292,61 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(netFetchMock).not.toHaveBeenCalled() }) - it('fetches usage from /workspace//go after resolving workspace ID', async () => { + it('fetches usage from /console/api/go/status with x-org-id and never scrapes /workspace//go', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) await fetchOpenCodeGoRateLimits('auth=mytoken') + expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false) expect(netFetchMock).toHaveBeenNthCalledWith( 2, - 'https://opencode.ai/workspace/wrk_TESTWORKSPACEID123/go', - expect.objectContaining({ method: 'GET' }) + CONSOLE_STATUS_URL, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-org-id': 'wrk_TESTWORKSPACEID123', + Accept: 'application/json' + }) + }) ) + expect(netFetchMock.mock.calls[1][1].headers).not.toHaveProperty('Cookie') }) - it('returns ok with session, weekly, and monthly windows', async () => { + it('returns ok with session, weekly, and monthly windows from JSON meters', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) - const now = Date.now() const result = await fetchOpenCodeGoRateLimits('auth=mytoken') expect(result.status).toBe('ok') expect(result.error).toBeNull() - expect(result.session).toEqual({ usedPercent: 30, windowMinutes: 300, - resetsAt: now + 7200 * 1000, + resetsAt: Date.parse('2026-04-24T14:00:00.000Z'), resetDescription: null }) expect(result.weekly).toEqual({ usedPercent: 51, - windowMinutes: 10080, - resetsAt: now + 259200 * 1000, + windowMinutes: 10_080, + resetsAt: Date.parse('2026-05-01T12:00:00.000Z'), resetDescription: null }) expect(result.monthly).toEqual({ usedPercent: 89, - windowMinutes: 43200, - resetsAt: now + 1296000 * 1000, + windowMinutes: 43_200, + resetsAt: Date.parse('2026-05-24T12:00:00.000Z'), resetDescription: null }) }) - it('returns ok with null monthly when monthlyUsage is absent', async () => { + it('returns ok with null monthly when the month meter is absent', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_NO_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_NO_MONTHLY)) const result = await fetchOpenCodeGoRateLimits('auth=mytoken') @@ -303,13 +357,24 @@ describe('fetchOpenCodeGoRateLimits', () => { }) it('caps usedPercent at 100 and floors at 0', async () => { - const page = ` - rollingUsage: { usagePercent: 150, resetInSec: 3600 } - weeklyUsage: { usagePercent: -5, resetInSec: 86400 } - ` - netFetchMock - .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(page)) + netFetchMock.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)).mockResolvedValueOnce( + makeJsonResponse({ + access: { + meters: { + fiveHour: { + resetsAt: '2026-04-24T13:00:00.000Z', + limitMicroCents: '100', + usedMicroCents: '150' + }, + week: { + resetsAt: '2026-04-25T12:00:00.000Z', + limitMicroCents: '100', + usedMicroCents: '-5' + } + } + } + }) + ) const result = await fetchOpenCodeGoRateLimits('auth=token') @@ -318,77 +383,73 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(result.weekly?.usedPercent).toBe(0) }) - it('parses React Flight wire format with $R[N]= assignment tokens', async () => { - // Real format from opencode.ai — keys have $R[N]= between the colon and brace. - const page = ` - rollingUsage:$R[21]={status:"ok",resetInSec:1337,usagePercent:42}, - weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:68} - ` + it('does not treat the old HTML usage page as success', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(page)) + .mockResolvedValueOnce(makeResponse(LEGACY_USAGE_PAGE)) - const result = await fetchOpenCodeGoRateLimits('auth=token') + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') - expect(result.status).toBe('ok') - expect(result.session?.usedPercent).toBe(42) - expect(result.weekly?.usedPercent).toBe(68) - }) - - it('skips null occurrences and finds the real data block for monthlyUsage', async () => { - // Regression: on refresh, monthlyUsage:null appeared BEFORE the real - // monthlyUsage:$R[N]={usagePercent:89,...} in a different component's props. - // Parser must skip the null and find the data block. - const page = ` - rollingUsage:$R[21]={status:"ok",resetInSec:18000,usagePercent:0}, - weeklyUsage:$R[22]={status:"ok",resetInSec:57781,usagePercent:51}, - monthlyUsage:null,timeMonthlyUsageUpdated:null, - monthlyUsage:$R[28]={status:"ok",resetInSec:1214779,usagePercent:89} - ` - netFetchMock - .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(page)) - - const result = await fetchOpenCodeGoRateLimits('auth=token') - - expect(result.status).toBe('ok') - expect(result.monthly?.usedPercent).toBe(89) - expect(result.monthly?.resetsAt).toBe(Date.now() + 1214779 * 1000) - }) - - it('returns null monthly when all monthlyUsage occurrences are null', async () => { - const page = ` - rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10}, - weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20}, - monthlyUsage:null,timeMonthlyUsageUpdated:null - ` - netFetchMock - .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(page)) - - const result = await fetchOpenCodeGoRateLimits('auth=token') - - expect(result.status).toBe('ok') - expect(result.monthly).toBeNull() + expect(result.status).toBe('error') + expect(result.error).toBe('Could not parse usage data') + expect(result.session).toBeNull() }) it('skips workspace lookup when workspaceIdOverride is provided', async () => { - netFetchMock.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false) expect(netFetchMock).toHaveBeenCalledWith( - 'https://opencode.ai/workspace/wrk_OVERRIDE123/go', - expect.anything() + CONSOLE_STATUS_URL, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ 'x-org-id': 'wrk_OVERRIDE123' }) + }) ) expect(result.status).toBe('ok') }) + it('keeps __Host-console_session and drops unrelated cookie names', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + + await fetchOpenCodeGoRateLimits( + 'session=secret; __Host-console_session=consoleTok; tracking=xyz; auth=realtoken', + 'wrk_OVERRIDE123' + ) + + expect(cookiesSetMock).toHaveBeenCalledTimes(2) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: '__Host-console_session', value: 'consoleTok' }) + ) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'auth', value: 'realtoken' }) + ) + expect(cookiesSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'session' })) + expect(cookiesSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'tracking' })) + }) + + it('accepts a console session cookie without wrapping it as auth=', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=consoleTok', + 'wrk_OVERRIDE123' + ) + + expect(result.status).toBe('ok') + expect(cookiesSetMock).toHaveBeenCalledTimes(1) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: '__Host-console_session', value: 'consoleTok' }) + ) + }) + it('filters cookie to auth name only', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) await fetchOpenCodeGoRateLimits('session=secret; auth=realtoken; tracking=xyz') @@ -426,7 +487,7 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(result.error).toMatch(/No workspace ID found/) }) - it('returns error on non-ok usage page response', async () => { + it('returns error on non-ok usage response', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeResponse('Not Found', 404)) @@ -434,18 +495,31 @@ describe('fetchOpenCodeGoRateLimits', () => { const result = await fetchOpenCodeGoRateLimits('auth=mytoken') expect(result.status).toBe('error') - expect(result.error).toBe('Usage page fetch failed (404)') + expect(result.error).toBe('Usage fetch failed (404)') }) - it('returns error when usage data cannot be parsed from page', async () => { + it('tells the user to include __Host-console_session when usage fetch returns 401', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse('no usage data here')) + .mockResolvedValueOnce(makeResponse('Unauthorized', 401)) const result = await fetchOpenCodeGoRateLimits('auth=mytoken') expect(result.status).toBe('error') - expect(result.error).toBe('Could not parse usage data from page') + expect(result.error).toBe( + 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)' + ) + }) + + it('returns error when usage data cannot be parsed', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse('{"access":{}}')) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('error') + expect(result.error).toBe('Could not parse usage data') }) it('never logs the cookie in error messages', async () => { diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index f2ea68b7588..1d9699bec88 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -1,24 +1,25 @@ import type { Session } from 'electron' import { randomUUID } from 'node:crypto' import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { clearOpenCodeSessionCookies, createOpenCodeRequestSession, OPENCODE_BASE_URL } from './opencode-go-request-session' -import { parseSubscriptionFromPageText } from './opencode-go-page-scraper' +import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing' const OPENCODE_SERVER_URL = 'https://opencode.ai/_server' +const OPENCODE_GO_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/go/status` const API_TIMEOUT_MS = 15_000 // Server-function hash for the workspaces endpoint — stable identifier used by // the opencode.ai SST/TanStack router server-fn protocol. const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f' -// Only these cookie names carry session auth on opencode.ai. Sending unrelated -// cookies pollutes the header and can expose sensitive data from other sites. -const AUTH_COOKIE_NAMES = new Set(['auth', '__Host-auth']) +// Closed allowlist: only known opencode.ai auth cookies. Console Go usage is +// authed by __Host-console_session; /_server workspace discovery still uses auth. +const AUTH_COOKIE_NAMES = new Set(['auth', '__Host-auth', '__Host-console_session']) // Why: users may paste just the token value (e.g. "Fe26.2**...") instead of // the full cookie header ("auth=Fe26.2**..."). Auto-wrapping avoids a confusing @@ -29,7 +30,7 @@ export function normalizeCookieInput(raw: string): string { return trimmed } // Already a valid cookie header: has multiple pairs or starts with known name. - if (trimmed.includes(';') || /^(?:auth|__Host-auth)=/i.test(trimmed)) { + if (trimmed.includes(';') || /^(?:auth|__Host-auth|__Host-console_session)=/i.test(trimmed)) { return trimmed } // Only wrap if it looks like an Iron Session seal (starts with Fe26.2**) @@ -73,19 +74,6 @@ function parseWorkspaceIds(text: string): string[] { return ids } -function makeWindow( - usedPercent: number, - resetInSec: number, - windowMinutes: number -): RateLimitWindow { - return { - usedPercent, - windowMinutes, - resetsAt: Date.now() + resetInSec * 1000, - resetDescription: null - } -} - export async function fetchOpenCodeGoRateLimits( cookie: string, workspaceIdOverride?: string, @@ -229,47 +217,43 @@ async function fetchOpenCodeGoRateLimitsWithSession( } } - // Step 2: Robust workspace resolution. Try each candidate ID until one returns 200 OK - // and valid usage data. Each candidate gets its own timeout so a slow or - // hung candidate cannot starve the rest. + // Why: /workspace//go now 302s to console login. Usage is JSON at + // /console/api/go/status, scoped by x-org-id and authed by the console session. let lastError = '' for (const candidateId of ids) { try { - const usagePageUrl = `${OPENCODE_BASE_URL}/workspace/${candidateId}/go` - const pageRes = await openCodeSession.fetch(usagePageUrl, { + const statusRes = await openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { method: 'GET', headers: { - Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + Accept: 'application/json', Origin: OPENCODE_BASE_URL, - Referer: OPENCODE_BASE_URL + Referer: `${OPENCODE_BASE_URL}/console/${candidateId}/go`, + 'x-org-id': candidateId }, signal: AbortSignal.timeout(API_TIMEOUT_MS) }) - if (!pageRes.ok) { - lastError = `Usage page fetch failed (${pageRes.status})` + if (!statusRes.ok) { + lastError = + statusRes.status === 401 + ? 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)' + : `Usage fetch failed (${statusRes.status})` continue } - const pageText = await pageRes.text() - const parsed = parseSubscriptionFromPageText(pageText) + const parsed = parseOpenCodeGoStatusPayload(await statusRes.text()) if (parsed) { - const monthly = - parsed.monthlyUsagePercent !== null && parsed.monthlyResetInSec !== null - ? makeWindow(parsed.monthlyUsagePercent, parsed.monthlyResetInSec, 43200) // 30d - : null - return { provider: 'opencode-go', - session: makeWindow(parsed.rollingUsagePercent, parsed.rollingResetInSec, 300), - weekly: makeWindow(parsed.weeklyUsagePercent, parsed.weeklyResetInSec, 10080), - monthly, + session: parsed.session, + weekly: parsed.weekly, + monthly: parsed.monthly, updatedAt: Date.now(), error: null, status: 'ok' } } - lastError = 'Could not parse usage data from page' + lastError = 'Could not parse usage data' } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' lastError = message diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index bf7656856d5..b1be5e1a305 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -167,4 +167,13 @@ describe('AccountsPane', () => { markup.slice(markup.lastIndexOf(' { + const markup = renderPane(getDefaultSettings('/tmp')) + + expect(markup).toContain('__Host-console_session') + expect(markup).toContain('auth=…; __Host-console_session=…') + expect(markup).toContain('auth cookie still covers workspace discovery') + expect(markup).not.toContain('Fe26.2**… token or auth=Fe26.2**… header') + }) }) diff --git a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx index 6bf03df51f4..5d3a8953eca 100644 --- a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx +++ b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx @@ -101,10 +101,10 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel): 'OpenCode Go Session Cookie' )} description={translate( - 'auto.components.settings.AccountsPane.b2b1aa936d', - 'Paste your opencode.ai session cookie for rate limit fetching.' + 'auto.components.settings.AccountsPane.0335bd31d5', + 'Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.' )} - keywords={['opencode', 'cookie', 'session', 'rate limit', 'status bar']} + keywords={['opencode', 'cookie', 'session', 'console', 'rate limit', 'status bar']} className="space-y-2" >