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 01/31] 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 02/31] 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 03/31] 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 04/31] 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 05/31] 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 06/31] 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 07/31] 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 08/31] 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 09/31] 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 10/31] 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 11/31] 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 12/31] 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 13/31] 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 14/31] 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 15/31] 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 16/31] 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 17/31] 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 18/31] 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 19/31] 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 20/31] 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 21/31] 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 22/31] 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 23/31] 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 24/31] 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 25/31] 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 26/31] 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 27/31] 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 28/31] 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 29/31] 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 30/31] 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('