diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3b415f60d580..289dcec99597 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1717,14 +1717,14 @@ "providers": ["local", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "ssh"], - "coverageNotes": "Drives the real Store against a temp profile, the real remoteWorkspace:setForConnectedTargets handler, the real session projection and the real pull-side merge. The relay transport is faked at the multiplexer boundary, so no live SSH host or relay is exercised; the partition routing and projection code under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.", + "coverageNotes": "Drives the real Store against a temp profile, main's real session-write export trigger, the real session projection and the real pull-side merge. The relay transport is faked at the multiplexer boundary, so no live SSH host or relay is exercised; the partition routing and projection code under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/12721", "https://github.com/stablyai/orca/issues/18173", "https://github.com/stablyai/orca/blob/main/src/shared/workspace-session-partition-owner.ts" ], "invariant": "Every workspace the `ssh:` partition names is hydrated at boot and published to the host, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it. Adoption never destroys what it was not told about: a host row the partition holds nothing for cannot replace a populated base row, and a bare id the read found contested is gap-filled rather than replaced, because that is exactly the id where `local` and `ssh:` are not one workspace written twice. Tab-, pane- and file-keyed rows are recovered through the same indexes the split routed them by. An explicit empty tab row written by this build survives the round trip as a present empty row and is declined by the shared seeding predicate. Boot enumerates the partitions persistence actually holds rather than inferring them from the repo catalog, so a target whose only workspace is a folder is read like any other; a folder workspace routes to the same partition main's RuntimeWorkspaceSessionController writes it to, so one workspace is never written into two stores; and a workspace adopted out of a partition routes back to that partition even on a boot whose repo catalog cannot name the host, so the write cannot re-strand what the read just reunited. `contested` means a repo id the catalog registers on more than one host, or two SSH partitions naming one id - never the mere co-presence of 'local' and `ssh:`, which is the repair's own input.", - "oracle": "Assert the renderer routes an SSH folder workspace to `ssh:`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", + "oracle": "Assert the renderer routes an SSH folder workspace to `ssh:`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:` holds the real one, with a second populated SSH partition present. Agree with the host, write the runtime-authored tabs into `ssh:`, and assert main's export carries them to the host snapshot rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts" ], @@ -13732,7 +13732,7 @@ "providers": ["ssh", "remote-runtime", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["ssh", "remote-runtime"], - "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, superseded readiness and placement waits release immediately, delayed worktree placement keeps only the latest arrival's waiter per target, stale arrivals cannot hydrate or publish push status, an incoming snapshot revokes replace-session upload authority before preparation yields, already-captured uploads revalidate that exact authority after local persistence settles, and main rejects their applied revision when a newer host snapshot arrives before IPC admission or while queued. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, superseded readiness and placement waits release immediately, delayed worktree placement keeps only the latest arrival's waiter per target, stale arrivals cannot hydrate or publish push status, main exports only under the revision and host observation the window last agreed on and rejects an export when a newer host snapshot arrives before admission or while queued, and with two desktops on one relay an import exports nothing while a local split exports once. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-3107", "https://github.com/stablyai/orca/pull/12664", @@ -13751,14 +13751,14 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-cache.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", @@ -13813,7 +13813,7 @@ "src/renderer/src/hooks/direct-ssh-state-routing.test.ts", "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts", - "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx", + "src/main/ipc/remote-workspace-two-desktops.test.ts", "src/main/ipc/remote-workspace-cache.test.ts", "src/main/ipc/remote-workspace.test.ts", "src/main/ipc/remote-workspace-patch-queue.test.ts", @@ -14151,13 +14151,10 @@ ] }, { - "file": "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx", + "file": "src/main/ipc/remote-workspace-two-desktops.test.ts", "assertions": [ - "an unsolicited snapshot synchronously revokes its target's upload authority before preparation awaits", - "a session write while snapshot capture is pending cannot replace the host's newly cached tabs", - "an upload captured before snapshot arrival is revalidated after its pending local write and cannot overwrite the incoming revision", - "same-lineage local writes remain uploadable when an earlier overlapping result advances the renderer's acknowledged revision", - "a transient unavailable result retains its observed host token and revision so the next local edit retries" + "with two desktops on one relay, a local split exports exactly once and the peer's import plus its save-back export nothing", + "a desktop whose import conflicted exports nothing until a whole pull agrees again" ] }, { @@ -14248,13 +14245,13 @@ "summary": "Three main-process remote-workspace files and 17 tests passed, including token-A rejection after same-revision observations before admission and while queued, eviction/refetch fail-closed behavior, same-client overlap at bases 7 then 8, revision-zero compatibility, reset-relay fallback, and bounded cache lineage." }, { - "date": "2026-08-29", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 10.44, - "summary": "Three renderer remote-workspace files and 28 tests passed, including acknowledged observation-token propagation, incoming-lineage upload cancellation, same-lineage overlapping upload continuation, transient-unavailable retry authority, and latest-only snapshot arrival fencing." + "durationSeconds": 2, + "summary": "Three files and 22 tests passed after main took over SSH workspace export, including two desktops on one relay where an import and its save-back export nothing, a local split exports once, and a conflicted import keeps that desktop out of exports." }, { "date": "2026-08-29", @@ -14266,13 +14263,13 @@ "summary": "Two focused files and 26 tests passed, including pane-scoped rendering and recovery, sibling-error retention, split-close pane-id churn cleanup, current-transport multi-error clearing, dismissal re-admission, multi-line deduplication, and 8-message/24-line/4,000-character bounds." }, { - "date": "2026-08-29", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 10.7, - "summary": "Fourteen direct-SSH files and 155 tests passed, including 32-arrival placement and readiness bursts with one listener/timer maximum, immediate supersession and stop cleanup, latest-only hydration, stale-push fencing, pending-capture and in-flight-write upload exclusion, and existing retry, authority, hydration, split-pane, and detach contracts." + "durationSeconds": 9, + "summary": "Fourteen direct-SSH files and 160 tests passed after main took over SSH workspace export, including stale-push fencing, revision-zero seeding through main, and the existing retry, authority, hydration, split-pane and detach contracts." }, { "date": "2026-08-23", @@ -14311,13 +14308,13 @@ "summary": "Eleven fault-injection and recovery-UI files and 127 tests passed, covering suspended heartbeat clocks, stale socket, PTY, and create generations, canonical pre-ready close recovery with one replacement subscription, cold and repeated PTY reattachment, authoritative health, bounded PTY and terminal-create recovery, post-probe timeout clipping, manually retryable create cutoff, accurate capability-probe failures, same-PTY manual reconnect, pane-state cleanup, fatal error deduplication, stable create identity, cross-process PTY adoption, and fail-closed legacy inventory." }, { - "date": "2026-07-28", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 15.8, - "summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning." + "durationSeconds": 9, + "summary": "Fourteen direct-SSH files and 160 tests passed after main took over SSH workspace export, including stale-push fencing, revision-zero seeding through main, and the existing retry, authority, hydration, split-pane and detach contracts." }, { "date": "2026-07-28", diff --git a/src/main/ipc/remote-workspace-export-test-harness.ts b/src/main/ipc/remote-workspace-export-test-harness.ts new file mode 100644 index 000000000000..ba592a07025b --- /dev/null +++ b/src/main/ipc/remote-workspace-export-test-harness.ts @@ -0,0 +1,122 @@ +import { ipcMain } from 'electron' +import { vi } from 'vitest' +import type { Store } from '../persistence' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspaceExportAuthority, + RemoteWorkspacePeerImport, + RemoteWorkspacePushStatusEvent +} from '../../shared/remote-workspace-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { registerRemoteWorkspaceHandlers } from './remote-workspace' + +export const EMPTY_WORKSPACE_SESSION: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} +} + +export type RemoteWorkspaceExportDriver = { + handlers: Map unknown> + /** Every push-status report main sent, in order. */ + pushes: RemoteWorkspacePushStatusEvent[] + /** A window's pull of `targetId` with nothing to import. */ + agree: ( + targetId: string, + authority: RemoteWorkspaceExportAuthority, + outcome?: RemoteWorkspacePeerImport['outcome'] + ) => void + importPeer: (pull: RemoteWorkspacePeerImport) => void + /** A session write by anything other than an import, patched onto the one partition. */ + write: (patch: Partial) => void + readSession: () => WorkspaceSessionState + /** Resolves once `count` more push reports have arrived. */ + nextPushes: (count?: number) => Promise +} + +/** + * Registers the remote-workspace handlers against a one-partition store fake whose session writes + * reach main's export trigger, and records the push reports main sends the window. + */ +export function createRemoteWorkspaceExportDriver( + store: Partial, + /** The window's `remoteWorkspace:changed` listener. */ + onChanged: (event: RemoteWorkspaceChangedEvent) => void = () => {} +): RemoteWorkspaceExportDriver { + const handlers = new Map unknown>() + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) + }) + let session = EMPTY_WORKSPACE_SESSION + const writeListeners = new Set<() => void>() + const pushes: RemoteWorkspacePushStatusEvent[] = [] + const waiters = new Set<() => void>() + const window = { + isDestroyed: () => false, + webContents: { + send: ( + channel: string, + event: RemoteWorkspacePushStatusEvent | RemoteWorkspaceChangedEvent + ) => { + if (channel === 'remoteWorkspace:changed' && 'snapshot' in event) { + onChanged(event) + } else if (channel === 'remoteWorkspace:pushStatus' && 'authority' in event) { + pushes.push(event) + for (const waiter of waiters) { + waiter() + } + } + } + } + } + const fakeStore = { + getSshTarget: () => undefined, + ...store, + getWorkspaceSession: (hostId?: string | null) => + !hostId || hostId === 'local' ? session : EMPTY_WORKSPACE_SESSION, + patchWorkspaceSession: (patch: Partial) => { + session = { ...session, ...patch } + for (const listener of writeListeners) { + listener() + } + }, + onWorkspaceSessionWritten: (listener: () => void) => { + writeListeners.add(listener) + return () => writeListeners.delete(listener) + } + } + registerRemoteWorkspaceHandlers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every Store member the remote-workspace handlers read. + fakeStore as unknown as Store, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the window fake exposes only the webContents.send the handlers call. + () => window as never, + { readMachineName: () => 'Build server' } + ) + const importPeer = (pull: RemoteWorkspacePeerImport): void => { + void handlers.get('remoteWorkspace:importPeerTopology')?.(null, pull) + } + return { + handlers, + pushes, + agree: (targetId, authority, outcome = 'synced') => + importPeer({ targetId, ...authority, outcome, session: {} }), + importPeer, + write: (patch) => fakeStore.patchWorkspaceSession(patch), + readSession: () => session, + nextPushes: (count = 1) => { + const target = pushes.length + count + return new Promise((resolve) => { + const check = (): void => { + if (pushes.length >= target) { + waiters.delete(check) + resolve(pushes.slice(target - count, target)) + } + } + waiters.add(check) + check() + }) + } + } +} diff --git a/src/main/ipc/remote-workspace-export.ts b/src/main/ipc/remote-workspace-export.ts new file mode 100644 index 000000000000..238243b90095 --- /dev/null +++ b/src/main/ipc/remote-workspace-export.ts @@ -0,0 +1,193 @@ +import type { Store } from '../persistence' +import type { SshTarget } from '../../shared/ssh-types' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { + RemoteWorkspaceExportAuthority, + RemoteWorkspaceObservedPatchResult, + RemoteWorkspacePeerImport, + RemoteWorkspacePushStatusEvent, + RemoteWorkspaceSession +} from '../../shared/remote-workspace-types' +import { createRepoRowExecutionHostLookup } from '../../shared/worktree-execution-host-resolution' +import { getActiveMultiplexer, getSshConnectionStore } from './ssh' +import { + createWorktreeOwnerResolver, + createWorktreeTargetResolver, + exportSessionForTarget, + persistedSessionForTarget, + type WorktreeOwnerResolver +} from './remote-workspace-target-session-export' +import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue' +import { getRemoteSnapshot, patchRemoteWorkspaceSession } from './remote-workspace-relay-sync' +import { + cachedRemoteWorkspaceSnapshotAuthorizesRevision, + getCachedRemoteWorkspaceSnapshot +} from './remote-workspace-snapshot-cache' +import { remoteWorkspaceSessionsMatch } from './remote-workspace-snapshot-normalization' + +/** + * What this desktop and a host agree the host holds; null when the host's copy is unknown. It holds + * only over the connection it was reached on: after a reconnect the host may have moved on, and + * this desktop's own writes in the gap (exits retired by a relay's death) are not the host's news. + */ +type HostAgreement = { + authority: RemoteWorkspaceExportAuthority + session: RemoteWorkspaceSession | null + connection: SshChannelMultiplexer | undefined +} + +export type RemoteWorkspaceExports = { + /** Records a window's pull; the import's own writes must already be in the store. */ + recordPull: (pull: Omit) => void + /** Run after every session write: exports to each agreed host whose share of it changed. */ + exportChanged: () => void +} + +/** + * Exports this desktop's session to its SSH hosts. An export goes only when what this desktop holds + * for a host differs from what the two last agreed on, so an import (which sets the agreement) and + * the mirror apply it causes never export, while a local change exports once. + */ +export function createRemoteWorkspaceExports( + store: Store, + sendPushStatus: (event: RemoteWorkspacePushStatusEvent) => void +): RemoteWorkspaceExports { + const agreements = new Map() + let exportQueued = false + + const readWorktreeOwners = (): WorktreeOwnerResolver => + createWorktreeOwnerResolver(createRepoRowExecutionHostLookup(store.getRepos())) + const sessionForTarget = ( + resolveWorktreeOwner: WorktreeOwnerResolver, + targetId: string + ): RemoteWorkspaceSession => + exportSessionForTarget( + createWorktreeTargetResolver(resolveWorktreeOwner), + targetId, + persistedSessionForTarget(store, targetId, resolveWorktreeOwner) + ) + + const exportTo = async ( + target: SshTarget, + session: RemoteWorkspaceSession, + agreement: HostAgreement + ): Promise => { + if ( + agreements.get(target.id) !== agreement || + agreement.connection !== getActiveMultiplexer(target.id) || + (agreement.session && remoteWorkspaceSessionsMatch(session, agreement.session)) + ) { + return + } + let result: RemoteWorkspaceObservedPatchResult | null + try { + result = await patchUnderAgreement(target, session, agreement.authority) + } catch (error) { + if (agreements.get(target.id) !== agreement) { + return + } + sendPushStatus({ + targetId: target.id, + authority: agreement.authority, + result: null, + error: error instanceof Error ? error.message : 'Workspace upload failed' + }) + return + } + // A pull that landed meanwhile is newer than this export's outcome. + if (agreements.get(target.id) !== agreement) { + return + } + if (result?.ok) { + const { revision, hostObservationToken } = result.snapshot + agreements.set(target.id, { + ...agreement, + authority: { revision, hostObservationToken }, + session + }) + } else if (result?.reason === 'stale-revision') { + // The host moved on; the window's next pull agrees again. + agreements.delete(target.id) + } + sendPushStatus({ targetId: target.id, authority: agreement.authority, result }) + } + + const exportAll = async (): Promise => { + const targets = + getSshConnectionStore() + ?.listTargets() + .filter((target) => agreements.has(target.id) && getActiveMultiplexer(target.id)) ?? [] + if (targets.length === 0) { + return + } + // One catalog read and one ownership resolution per worktree, shared by every target. + const resolveWorktreeOwner = readWorktreeOwners() + await Promise.all( + targets.map((target) => { + const session = sessionForTarget(resolveWorktreeOwner, target.id) + // Each target has its own revision stream; one slow relay must not block another. + return queueRemoteWorkspacePatch(target.id, async () => { + const agreement = agreements.get(target.id) + return agreement ? exportTo(target, session, agreement) : undefined + }) + }) + ) + } + + const exportChanged = (): void => { + // A write burst in one turn exports once. + if (exportQueued) { + return + } + exportQueued = true + queueMicrotask(() => { + exportQueued = false + void exportAll() + }) + } + + const recordPull: RemoteWorkspaceExports['recordPull'] = ({ + targetId, + revision, + hostObservationToken, + outcome + }) => { + if (outcome === 'conflict') { + agreements.delete(targetId) + return + } + // An import agrees on what this desktop now holds, so it never exports back. When the window + // kept local state the host lacks, agree on the host's own copy instead, so that state exports + // once, as the window's save-back did. + const session = + outcome === 'kept-local' + ? (getCachedRemoteWorkspaceSnapshot(targetId)?.session ?? null) + : sessionForTarget(readWorktreeOwners(), targetId) + agreements.set(targetId, { + authority: { revision, hostObservationToken }, + session, + connection: getActiveMultiplexer(targetId) + }) + exportChanged() + } + + return { recordPull, exportChanged } +} + +/** Patches only while the host is still at the revision and observation this desktop agreed on. */ +async function patchUnderAgreement( + target: SshTarget, + session: RemoteWorkspaceSession, + authority: RemoteWorkspaceExportAuthority +): Promise { + const current = getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) + if ( + !current || + current.hostObservationToken !== authority.hostObservationToken || + !cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, authority.revision) + ) { + const latest = getCachedRemoteWorkspaceSnapshot(target.id) ?? current + return latest ? { ok: false, reason: 'stale-revision', snapshot: latest } : null + } + return patchRemoteWorkspaceSession(target, session) +} diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index d82ba5e845a5..a9b54b29d3fb 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -36,9 +36,12 @@ vi.mock('./remote-workspace-events', () => ({ import { _resetRemoteWorkspaceCachesForTests, - handleRemoteWorkspaceNotification, - registerRemoteWorkspaceHandlers + handleRemoteWorkspaceNotification } from './remote-workspace' +import { + createRemoteWorkspaceExportDriver, + type RemoteWorkspaceExportDriver +} from './remote-workspace-export-test-harness' import { CLIENT_ID } from './remote-workspace-client-identity' import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue' import { @@ -73,16 +76,20 @@ function patchSession(params: Record): RemoteWorkspaceSession { return (params.patch as { session: RemoteWorkspaceSession }).session } -describe('remoteWorkspace:setForConnectedTargets patch queue', () => { - const handlers = new Map unknown>() +describe('main export patch queue', () => { + let driver: RemoteWorkspaceExportDriver const muxByTargetId = new Map }>() const getRepoMock = vi.fn() // Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one. const KNOWN_REPO_IDS = ['repo-target-1', 'repo-target-2', 'repo-reset', 'repo-newer'] - const store = { + const store: Partial = { getRepo: getRepoMock, - getRepos: () => KNOWN_REPO_IDS.map((repoId) => getRepoMock(repoId)).filter(Boolean) - } as unknown as Store + getRepos: () => + KNOWN_REPO_IDS.flatMap((repoId) => { + const repo = getRepoMock(repoId) + return repo ? [repo] : [] + }) + } const target: SshTarget = { id: 'target-1', @@ -94,12 +101,8 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { beforeEach(() => { _resetRemoteWorkspaceCachesForTests() - handlers.clear() muxByTargetId.clear() vi.mocked(ipcMain.handle).mockReset() - vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { - handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) - }) vi.mocked(ipcMain.removeHandler).mockReset() getSshConnectionStoreMock.mockReset() getSshConnectionStoreMock.mockReturnValue({ @@ -123,22 +126,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { getActiveMultiplexerMock.mockImplementation((targetId: string) => muxByTargetId.get(targetId)) registerRemoteWorkspaceNotificationHandlerMock.mockClear() - registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) + driver = createRemoteWorkspaceExportDriver(store) }) - async function callSetForConnectedTargets(args: { - session: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - }): Promise { - const handler = handlers.get('remoteWorkspace:setForConnectedTargets') - if (!handler) { - throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') - } - return handler(null, args) - } - function observeSnapshot(targetId: string, value: RemoteWorkspaceSnapshot): string { return rememberRemoteWorkspaceSnapshot(targetId, value).hostObservationToken } @@ -151,7 +141,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { return cached.hostObservationToken } - it('serializes overlapping writes for the same target so they use fresh base revisions', async () => { + it('serializes overlapping exports for the same target so they use fresh base revisions', async () => { let currentRevision = 7 let releaseFirstPatch!: () => void const firstPatchCanFinish = new Promise((resolve) => { @@ -204,43 +194,32 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - const first = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + const pushes = driver.nextPushes(2) + driver.write(sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a')) await vi.waitFor(() => expect(patchBaseRevisions).toEqual([7])) - const second = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace-b', 'tab-b'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.write(sessionWithTab('repo-target-1::/remote/workspace-b', 'tab-b')) await new Promise((resolve) => setTimeout(resolve, 0)) expect(patchBaseRevisions).toEqual([7]) releaseFirstPatch() - await expect(Promise.all([first, second])).resolves.toMatchObject([ - [ - { - targetId: 'target-1', - result: { - ok: true, - snapshot: { revision: 8, hostObservationToken: observationToken } - } + await expect(pushes).resolves.toMatchObject([ + { + targetId: 'target-1', + result: { + ok: true, + snapshot: { revision: 8, hostObservationToken: observationToken } } - ], - [ - { - targetId: 'target-1', - result: { - ok: true, - snapshot: { revision: 9, hostObservationToken: observationToken } - } + }, + { + targetId: 'target-1', + authority: { revision: 8 }, + result: { + ok: true, + snapshot: { revision: 9, hostObservationToken: observationToken } } - ] + } ]) expect(patchBaseRevisions).toEqual([7, 8]) }) @@ -272,16 +251,13 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { snapshot: remoteSnapshot, sourceClientId: 'other-client' }) - const result = await callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.write(sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab')) + const result = await driver.nextPushes() expect(result).toMatchObject([ { @@ -296,7 +272,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { expect(request).not.toHaveBeenCalled() }) - it('rejects a renderer upload when a host snapshot arrives while it is queued', async () => { + it('drops a queued export when a host snapshot arrives while the first one is in flight', async () => { const remoteSnapshot = snapshot( { activeWorktreePath: '/other-device', @@ -344,21 +320,12 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - const first = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/first', 'first-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-target-1::/remote/first', 'first-local-tab')) await vi.waitFor(() => expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1) ) - const queued = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/queued', 'queued-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.write(sessionWithTab('repo-target-1::/remote/queued', 'queued-local-tab')) await new Promise((resolve) => setTimeout(resolve, 0)) handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { @@ -367,14 +334,16 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { }) releasePatch() - await expect(Promise.all([first, queued])).resolves.toMatchObject([ - [{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }], - [{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }] + await expect(driver.nextPushes()).resolves.toMatchObject([ + { targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } } ]) + // The stale reply ended the agreement; the queued export waits for the window's next pull. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(driver.pushes).toHaveLength(1) expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1) }) - it('rejects a queued upload after a same-revision host observation replaces its lineage', async () => { + it('rejects a queued export after a same-revision host observation replaces its lineage', async () => { const baseline = snapshot( { activeWorktreePath: '/baseline', @@ -422,12 +391,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { }) await blockerDidStart - const queued = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + const queued = driver.nextPushes() + driver.write(sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab')) await new Promise((resolve) => setTimeout(resolve, 0)) handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { snapshot: replacement, @@ -469,14 +435,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { }) muxByTargetId.set('target-1', { request }) - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - ).resolves.toMatchObject([ + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab')) + await expect(driver.nextPushes()).resolves.toMatchObject([ { targetId: 'target-1', result: { ok: false, reason: 'stale-revision', snapshot: { revision: 7 } } @@ -485,7 +446,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { expect(request.mock.calls.map(([method]) => method)).toEqual(['workspace.get']) }) - it('patches independent hydrated targets concurrently', async () => { + it('patches independent agreed targets concurrently', async () => { const secondTarget: SshTarget = { id: 'target-2', label: 'Target 2', @@ -557,33 +518,28 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { const firstObservationToken = observeSnapshot('target-1', previousSnapshot) const secondObservationToken = observeSnapshot('target-2', previousSnapshot) - const resultPromise = callSetForConnectedTargets({ - session: { - ...sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), - tabsByWorktree: { - 'repo-target-1::/remote/workspace-a': [ - { - id: 'tab-a', - type: 'terminal', - title: 'Shell A', - worktreeId: 'repo-target-1::/remote/workspace-a' - } as never - ], - 'repo-target-2::/remote/workspace-b': [ - { - id: 'tab-b', - type: 'terminal', - title: 'Shell B', - worktreeId: 'repo-target-2::/remote/workspace-b' - } as never - ] - } - }, - hydratedTargetIds: ['target-1', 'target-2'], - expectedRevisionsByTargetId: { 'target-1': 7, 'target-2': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': firstObservationToken, - 'target-2': secondObservationToken + driver.agree('target-1', { revision: 7, hostObservationToken: firstObservationToken }) + driver.agree('target-2', { revision: 7, hostObservationToken: secondObservationToken }) + const resultPromise = driver.nextPushes(2) + driver.write({ + ...sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), + tabsByWorktree: { + 'repo-target-1::/remote/workspace-a': [ + { + id: 'tab-a', + type: 'terminal', + title: 'Shell A', + worktreeId: 'repo-target-1::/remote/workspace-a' + } as never + ], + 'repo-target-2::/remote/workspace-b': [ + { + id: 'tab-b', + type: 'terminal', + title: 'Shell B', + worktreeId: 'repo-target-2::/remote/workspace-b' + } as never + ] } }) @@ -595,9 +551,10 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) releaseFirstPatch() + // The fast target reports first: neither waited on the other. await expect(resultPromise).resolves.toMatchObject([ - { targetId: 'target-1', result: { ok: true } }, - { targetId: 'target-2', result: { ok: true } } + { targetId: 'target-2', result: { ok: true } }, + { targetId: 'target-1', result: { ok: true } } ]) }) @@ -678,14 +635,11 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-reset::/remote/workspace', 'tab-reset'), - hydratedTargetIds: ['target-reset'], - expectedRevisionsByTargetId: { 'target-reset': 7 }, - expectedHostObservationTokensByTargetId: { 'target-reset': observationToken } - }) - ).resolves.toMatchObject([{ targetId: 'target-reset', result: { ok: true } }]) + driver.agree('target-reset', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-reset::/remote/workspace', 'tab-reset')) + await expect(driver.nextPushes()).resolves.toMatchObject([ + { targetId: 'target-reset', result: { ok: true } } + ]) expect(patchBaseRevisions).toEqual([7, 0]) }) @@ -760,14 +714,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-newer::/remote/workspace', 'tab-local'), - hydratedTargetIds: ['target-newer'], - expectedRevisionsByTargetId: { 'target-newer': 7 }, - expectedHostObservationTokensByTargetId: { 'target-newer': observationToken } - }) - ).resolves.toMatchObject([ + driver.agree('target-newer', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-newer::/remote/workspace', 'tab-local')) + await expect(driver.nextPushes()).resolves.toMatchObject([ { targetId: 'target-newer', result: { ok: false, reason: 'stale-revision' } } ]) expect(patchBaseRevisions).toEqual([7]) diff --git a/src/main/ipc/remote-workspace-snapshot-normalization.ts b/src/main/ipc/remote-workspace-snapshot-normalization.ts index 749d83cc0663..e35e2c8b6272 100644 --- a/src/main/ipc/remote-workspace-snapshot-normalization.ts +++ b/src/main/ipc/remote-workspace-snapshot-normalization.ts @@ -96,11 +96,13 @@ export function remoteWorkspaceSessionMatchesSnapshot( snapshot: RemoteWorkspaceSnapshot | undefined, session: RemoteWorkspaceSession ): boolean { - if (!snapshot) { - return false - } - return isDeepStrictEqual( - normalizeRemoteSession(snapshot.session), - normalizeRemoteSession(session) - ) + return snapshot !== undefined && remoteWorkspaceSessionsMatch(snapshot.session, session) +} + +/** Equal once empty optional projection fields and absent ones read the same. */ +export function remoteWorkspaceSessionsMatch( + a: RemoteWorkspaceSession, + b: RemoteWorkspaceSession +): boolean { + return isDeepStrictEqual(normalizeRemoteSession(a), normalizeRemoteSession(b)) } diff --git a/src/main/ipc/remote-workspace-stale-resync.test.ts b/src/main/ipc/remote-workspace-stale-resync.test.ts index b87a9a9893f3..d27bf042cfff 100644 --- a/src/main/ipc/remote-workspace-stale-resync.test.ts +++ b/src/main/ipc/remote-workspace-stale-resync.test.ts @@ -65,7 +65,12 @@ function snapshot(revision: number, tabId: string): RemoteWorkspaceSnapshot { describe('workspace.stale resync', () => { const sent: RemoteWorkspaceChangedEvent[] = [] const request = vi.fn() - const store = { getRepo: vi.fn(), getWorkspaceSession: vi.fn() } as unknown as Store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resync reads no store member; registration only subscribes to session writes. + const store = { + getRepo: vi.fn(), + getWorkspaceSession: vi.fn(), + onWorkspaceSessionWritten: vi.fn(() => () => {}) + } as unknown as Store beforeEach(() => { sent.length = 0 diff --git a/src/main/ipc/remote-workspace-two-desktops.test.ts b/src/main/ipc/remote-workspace-two-desktops.test.ts new file mode 100644 index 000000000000..c2a9618576eb --- /dev/null +++ b/src/main/ipc/remote-workspace-two-desktops.test.ts @@ -0,0 +1,330 @@ +/** + * Blocking (plan §4.1a "SSH no export loop"): two desktops share one relay. An import, and the + * window's save-back of what it applied, export nothing; a local split on either desktop exports + * exactly once; a conflicted import keeps that desktop out of exports. + * + * Each desktop is its own module graph (own snapshot cache, patch queue and CLIENT_ID), as two + * processes would be. The relay is an in-memory workspace with revisions. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspaceSession, + RemoteWorkspaceSnapshot +} from '../../shared/remote-workspace-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { importRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' +import type * as RemoteWorkspaceModule from './remote-workspace' +import type * as ExportDriverModule from './remote-workspace-export-test-harness' + +type Relay = { + revision: number + session: RemoteWorkspaceSession + patches: { clientId: string; baseRevision: number }[] + listeners: ((snapshot: RemoteWorkspaceSnapshot, clientId: string) => void)[] +} + +const { relay } = vi.hoisted(() => { + const relay: Relay = { + revision: 0, + session: { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }, + patches: [], + listeners: [] + } + return { relay } +}) + +const TARGET: SshTarget = { + id: 'target-1', + label: 'Build box', + host: 'build.example.com', + port: 22, + username: 'dev' +} +const WORKTREE_PATH = '/srv/app' +const WORKTREE_ID = `repo-1::${WORKTREE_PATH}` + +type RelayPatchParams = { + baseRevision: number + clientId: string + patch: { session: RemoteWorkspaceSession } +} + +function relaySnapshot(): RemoteWorkspaceSnapshot { + return { + namespace: 'workspace', + revision: relay.revision, + updatedAt: relay.revision, + schemaVersion: 1, + session: structuredClone(relay.session) + } +} + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() } +})) +vi.mock('./remote-workspace-events', () => ({ + registerRemoteWorkspaceNotificationHandler: () => () => {} +})) +vi.mock('./ssh', () => { + // One connection for the whole suite: an agreement holds only over the connection it was made on. + const connection = { + request: async (method: string, { baseRevision, clientId, patch }: RelayPatchParams) => { + if (method === 'workspace.get') { + return relaySnapshot() + } + relay.patches.push({ clientId, baseRevision }) + if (baseRevision !== relay.revision) { + return { + ok: false, + reason: 'stale-revision', + snapshot: relaySnapshot() + } + } + relay.revision += 1 + relay.session = structuredClone(patch.session) + const snapshot = relaySnapshot() + for (const listener of relay.listeners) { + listener(snapshot, clientId) + } + return { ok: true, snapshot } + } + } + return { + getSshConnectionStore: () => ({ + listTargets: () => [TARGET], + getTarget: () => TARGET + }), + getActiveMultiplexer: () => connection + } +}) + +type Desktop = { + driver: ExportDriverModule.RemoteWorkspaceExportDriver + clientId: string + /** Set to make this desktop's next imports conflicted (unplaceable host tabs). */ + conflicted: boolean + /** Tabs the window created during a download and has not saved yet; its merge keeps them. */ + unsavedTabs: WorkspaceSessionState['tabsByWorktree'][string] + imports: number +} + +function tab(id: string): WorkspaceSessionState['tabsByWorktree'][string][number] { + return { + id, + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function withSplit( + session: WorkspaceSessionState, + tabId: string, + direction: 'vertical' | 'horizontal' = 'vertical' +): WorkspaceSessionState { + return { + ...session, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [tabId]: { + root: { + type: 'split', + direction, + first: { type: 'leaf', leafId: `${tabId}-a` }, + second: { type: 'leaf', leafId: `${tabId}-b` } + }, + activeLeafId: `${tabId}-a`, + expandedLeafId: null + } + } + } +} + +async function bootDesktop(): Promise { + vi.resetModules() + const remoteWorkspace: typeof RemoteWorkspaceModule = await import('./remote-workspace') + const { CLIENT_ID } = await import('./remote-workspace-client-identity') + const remoteWorkspaceCache = await import('./remote-workspace-snapshot-cache') + const { createRemoteWorkspaceExportDriver }: typeof ExportDriverModule = + await import('./remote-workspace-export-test-harness') + remoteWorkspace._resetRemoteWorkspaceCachesForTests() + const repo: Repo = { + id: 'repo-1', + path: '/srv', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + connectionId: TARGET.id + } + const desktop: Desktop = { + clientId: CLIENT_ID, + conflicted: false, + unsavedTabs: [], + imports: 0, + // The window's importer: the host snapshot's projection plus the unsaved tabs the merge keeps, + // committed through main, then saved back once the window has applied it. + driver: createRemoteWorkspaceExportDriver( + { getRepos: () => [repo] }, + (event: RemoteWorkspaceChangedEvent) => { + if (event.sourceClientId === CLIENT_ID) { + return + } + const imported = importRemoteWorkspaceSession(event.snapshot.session, { + resolveWorktreeId: (path) => (path === WORKTREE_PATH ? WORKTREE_ID : null), + executionHostId: `ssh:${TARGET.id}` + }) + const hostTabs = imported.tabsByWorktree[WORKTREE_ID] ?? [] + const keptTabs = desktop.unsavedTabs.filter( + (local) => !hostTabs.some((host) => host.id === local.id) + ) + desktop.unsavedTabs = [] + const patch = { + tabsByWorktree: { ...imported.tabsByWorktree, [WORKTREE_ID]: [...hostTabs, ...keptTabs] }, + terminalLayoutsByTabId: imported.terminalLayoutsByTabId + } + desktop.imports += 1 + desktop.driver.importPeer({ + targetId: TARGET.id, + revision: event.snapshot.revision, + hostObservationToken: event.snapshot.hostObservationToken, + outcome: desktop.conflicted ? 'conflict' : keptTabs.length > 0 ? 'kept-local' : 'synced', + session: patch + }) + desktop.driver.write(patch) + } + ) + } + relay.listeners.push((snapshot, sourceClientId) => + remoteWorkspace.handleRemoteWorkspaceNotification(TARGET.id, 'workspace.changed', { + snapshot, + sourceClientId + }) + ) + // Connect: the window pulls and agrees with the host. + await desktop.driver.handlers.get('remoteWorkspace:get')?.(null, { targetId: TARGET.id }) + const observed = remoteWorkspaceCache.getCachedRemoteWorkspaceSnapshot(TARGET.id) + if (!observed) { + throw new Error('connect did not observe the host') + } + desktop.driver.agree(TARGET.id, observed) + return desktop +} + +async function settle(): Promise { + for (let turn = 0; turn < 5; turn += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe('two desktops, one relay', () => { + let a: Desktop + let b: Desktop + + beforeEach(async () => { + relay.revision = 3 + relay.session = { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: { [WORKTREE_PATH]: [] }, + terminalLayoutsByTabId: {} + } + relay.patches = [] + relay.listeners = [] + a = await bootDesktop() + b = await bootDesktop() + await settle() + }) + + it('exports a local split once, and the peer import plus its save-back exports nothing', async () => { + expect(relay.patches).toEqual([]) + + a.driver.write( + withSplit( + { + ...a.driver.readSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } + }, + 'tab-a' + ) + ) + await settle() + + expect(relay.patches).toEqual([{ clientId: a.clientId, baseRevision: 3 }]) + expect(b.imports).toBe(1) + + b.driver.write(withSplit(b.driver.readSession(), 'tab-a', 'horizontal')) + await settle() + + expect(relay.patches).toEqual([ + { clientId: a.clientId, baseRevision: 3 }, + { clientId: b.clientId, baseRevision: 4 } + ]) + expect(a.imports).toBe(1) + expect(relay.revision).toBe(5) + }) + + it('uploads a tab created during a download once, and the desktops converge', async () => { + // B stands on the shared worktree and A does not, so each merge keeps a different active + // worktree; that difference alone must not bounce uploads between them. + b.driver.write({ activeRepoId: 'repo-1', activeWorktreeId: WORKTREE_ID }) + await settle() + expect(relay.patches).toEqual([{ clientId: b.clientId, baseRevision: 3 }]) + relay.patches = [] + a.imports = 0 + b.unsavedTabs = [tab('tab-b')] + + a.driver.write( + withSplit( + { + ...a.driver.readSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } + }, + 'tab-a' + ) + ) + await settle() + + expect(relay.patches).toEqual([ + { clientId: a.clientId, baseRevision: 4 }, + { clientId: b.clientId, baseRevision: 5 } + ]) + expect(relay.session.tabsByWorktreePath[WORKTREE_PATH]?.map(({ id }) => id)).toEqual([ + 'tab-a', + 'tab-b' + ]) + expect(a.imports).toBe(1) + expect(b.imports).toBe(1) + }) + + it('keeps a desktop whose import conflicted out of exports', async () => { + b.conflicted = true + a.driver.write( + withSplit( + { + ...a.driver.readSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } + }, + 'tab-a' + ) + ) + await settle() + expect(b.imports).toBe(1) + + b.driver.write(withSplit(b.driver.readSession(), 'tab-a', 'horizontal')) + await settle() + + expect(relay.patches).toEqual([{ clientId: a.clientId, baseRevision: 3 }]) + }) +}) diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index cc542920dd83..f1d83f3a7424 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -52,10 +52,11 @@ vi.mock('./remote-workspace-events', () => ({ registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock })) +import { _resetRemoteWorkspaceCachesForTests } from './remote-workspace' import { - _resetRemoteWorkspaceCachesForTests, - registerRemoteWorkspaceHandlers -} from './remote-workspace' + createRemoteWorkspaceExportDriver, + type RemoteWorkspaceExportDriver +} from './remote-workspace-export-test-harness' import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { @@ -76,6 +77,8 @@ const baseSession = { terminalLayoutsByTabId: {} } as WorkspaceSessionState +type PatchParams = { patch: { session: RemoteWorkspaceSession } } + const targets: SshTarget[] = [ { id: 'target-1', @@ -164,29 +167,56 @@ describe('remoteWorkspaceSessionMatchesSnapshot', () => { }) }) -describe('remoteWorkspace:setForConnectedTargets', () => { - const handlers = new Map unknown>() +describe('main exports a session write to the hosts it agrees with', () => { const requestByTargetId = new Map>() const muxByTargetId = new Map }>() const getRepoMock = vi.fn() - const getWorkspaceSessionMock = vi.fn() // Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one. - const getReposMock = vi.fn(() => [getRepoMock('repo-target-1')].filter(Boolean)) - const store = { - getRepo: getRepoMock, - getRepos: getReposMock, - getWorkspaceSession: getWorkspaceSessionMock - } as unknown as Store + const getReposMock = vi.fn(() => { + const repo = getRepoMock('repo-target-1') + return repo ? [repo] : [] + }) + let driver: RemoteWorkspaceExportDriver + + const sessionWithTab: WorkspaceSessionState = { + activeRepoId: 'repo-target-1', + activeWorktreeId: 'repo-target-1::/repo', + activeTabId: 'tab-store', + tabsByWorktree: { + 'repo-target-1::/repo': [ + { + id: 'tab-store', + title: 'Store shell', + ptyId: 'pty-store', + worktreeId: 'repo-target-1::/repo', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: {} + } + + const emptyRemoteSession: RemoteWorkspaceSession = { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + } + + function patchRequests(targetId: string): unknown[][] { + return (requestByTargetId.get(targetId)?.mock.calls ?? []).filter( + ([method]) => method === 'workspace.patch' + ) + } beforeEach(() => { _resetRemoteWorkspaceCachesForTests() - handlers.clear() requestByTargetId.clear() muxByTargetId.clear() vi.mocked(ipcMain.handle).mockReset() - vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { - handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) - }) vi.mocked(ipcMain.removeHandler).mockReset() getSshConnectionStoreMock.mockReset() getSshConnectionStoreMock.mockReturnValue({ @@ -195,8 +225,6 @@ describe('remoteWorkspace:setForConnectedTargets', () => { }) getRepoMock.mockReset() getReposMock.mockClear() - getWorkspaceSessionMock.mockReset() - getWorkspaceSessionMock.mockReturnValue(baseSession) getRepoMock.mockImplementation((repoId: string) => repoId === 'repo-target-1' ? ({ @@ -213,7 +241,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { getActiveMultiplexerMock.mockImplementation((targetId: string) => { let mux = muxByTargetId.get(targetId) if (!mux) { - const request = vi.fn().mockImplementation((method: string) => { + const request = vi.fn().mockImplementation((method: string, params: PatchParams) => { if (method === 'workspace.get') { return Promise.resolve( snapshot({ @@ -226,12 +254,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { } return Promise.resolve({ ok: true, - snapshot: snapshot({ - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }) + snapshot: snapshot(params.patch.session, 8) }) }) mux = { request } @@ -241,209 +264,257 @@ describe('remoteWorkspace:setForConnectedTargets', () => { return mux }) registerRemoteWorkspaceNotificationHandlerMock.mockClear() - - registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) + driver = createRemoteWorkspaceExportDriver({ getRepo: getRepoMock, getRepos: getReposMock }) }) - async function callSetForConnectedTargets(args: { - session?: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - }): Promise { - const handler = handlers.get('remoteWorkspace:setForConnectedTargets') - if (!handler) { - throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') - } - return handler(null, args) - } - async function observeTarget(targetId: string): Promise { - const handler = handlers.get('remoteWorkspace:get') - if (!handler) { - throw new Error('remoteWorkspace:get handler was never registered') - } - const observed = await handler(null, { targetId }) + const observed = await driver.handlers.get('remoteWorkspace:get')?.(null, { targetId }) if (!observed || typeof observed !== 'object' || !('hostObservationToken' in observed)) { throw new Error(`remoteWorkspace:get did not observe ${targetId}`) } return observed as RemoteWorkspaceObservedSnapshot } - it('reads the repo catalog once per publish, not once per worktree', async () => { + async function agreeWith(targetId: string): Promise { + const { revision, hostObservationToken } = await observeTarget(targetId) + driver.agree(targetId, { revision, hostObservationToken }) + } + + it('reads the repo catalog once per export, not once per worktree', async () => { // `store.getRepos()` re-hydrates every repo row. The export asks "is this worktree mine?" once // per worktree, so reading the catalog inside that callback multiplied hydration by the // worktree count — 413 on the session that surfaced this. + await agreeWith('target-1') + await Promise.resolve() + getReposMock.mockClear() const worktrees = Object.fromEntries( Array.from({ length: 12 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) ) - getWorkspaceSessionMock.mockReturnValue({ - ...baseSession, - tabsByWorktree: worktrees - } as WorkspaceSessionState) - const observed = await observeTarget('target-1') - getReposMock.mockClear() - await callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': observed.revision }, - expectedHostObservationTokensByTargetId: { - 'target-1': observed.hostObservationToken - } - }) + driver.write({ ...baseSession, tabsByWorktree: worktrees }) + await driver.nextPushes() expect(getReposMock).toHaveBeenCalledTimes(1) }) - it('resolves each worktree ownership once for the whole publish, not once per target', async () => { + it('resolves each worktree ownership once for the whole export, not once per target', async () => { // Ownership is a function of the repo catalog alone; only the final `=== targetId` differs, so // exporting to N targets used to repeat the identical resolution N times per worktree key. + for (const target of targets) { + await agreeWith(target.id) + } + await Promise.resolve() + getReposMock.mockClear() + resolveWorktreeExecutionHostCalls.count = 0 const worktrees = Object.fromEntries( Array.from({ length: 6 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) ) - getWorkspaceSessionMock.mockReturnValue({ - ...baseSession, - tabsByWorktree: worktrees - } as WorkspaceSessionState) - const observed = await Promise.all(targets.map((target) => observeTarget(target.id))) - getReposMock.mockClear() - resolveWorktreeExecutionHostCalls.count = 0 - await callSetForConnectedTargets({ - hydratedTargetIds: targets.map((target) => target.id), - expectedRevisionsByTargetId: Object.fromEntries( - targets.map((target, index) => [target.id, observed[index].revision]) - ), - expectedHostObservationTokensByTargetId: Object.fromEntries( - targets.map((target, index) => [target.id, observed[index].hostObservationToken]) - ) - }) + driver.write({ ...baseSession, tabsByWorktree: worktrees }) + await driver.nextPushes() expect(getReposMock).toHaveBeenCalledTimes(1) - // 6 worktree keys resolved once each, regardless of how many targets are published to. + // 6 worktree keys resolved once each, regardless of how many targets are exported to. expect(resolveWorktreeExecutionHostCalls.count).toBe(6) }) - it('skips the session and repo-catalog reads when no hydrated target is connected', async () => { - // A hydrated but disconnected target leaves nothing to project onto, so hoisting the catalog - // read must not make the idle path pay for a full repo hydration it never used before. + it('skips the session and repo-catalog reads when no agreed target is connected', async () => { + // A disconnected target leaves nothing to project onto, so a session write must not pay for a + // full repo hydration it never uses. + driver.agree('target-1', { revision: 7, hostObservationToken: 'token' }) getActiveMultiplexerMock.mockReturnValue(undefined) + await Promise.resolve() getReposMock.mockClear() - getWorkspaceSessionMock.mockClear() - await expect( - callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': 'token' } - }) - ).resolves.toEqual([]) + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(getReposMock).not.toHaveBeenCalled() - expect(getWorkspaceSessionMock).not.toHaveBeenCalled() + expect(driver.pushes).toEqual([]) }) - it('does not write without an explicit non-empty hydrated target set', async () => { - await expect(callSetForConnectedTargets({ session: baseSession })).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: [] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1', 42] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1'] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 } - }) - ).resolves.toEqual([]) - - expect(getSshConnectionStoreMock).not.toHaveBeenCalled() + it('exports nothing to a host this desktop has no valid agreement with', async () => { + driver.importPeer({ + targetId: 'target-1', + revision: -1, + hostObservationToken: 'token', + outcome: 'synced', + session: {} + }) + driver.importPeer({ + targetId: 'target-1', + revision: 7, + hostObservationToken: '', + outcome: 'synced', + session: {} + }) + + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(getActiveMultiplexerMock).not.toHaveBeenCalled() + expect(driver.pushes).toEqual([]) }) - it('writes only to explicitly hydrated connected targets', async () => { - const observation = await observeTarget('target-1') - const result = await callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1', 'missing-target'], - expectedRevisionsByTargetId: { 'target-1': 7, 'missing-target': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': observation.hostObservationToken, - 'missing-target': 'unreachable-target-observation' - } - }) + it('writes only to agreed connected targets', async () => { + await agreeWith('target-1') + driver.agree('missing-target', { revision: 7, hostObservationToken: 'unreachable' }) + + driver.write(sessionWithTab) + const [push] = await driver.nextPushes() - expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }]) - expect(getActiveMultiplexerMock).toHaveBeenCalledWith('target-1') + expect(push).toMatchObject({ targetId: 'target-1', result: { ok: true } }) expect(getActiveMultiplexerMock).not.toHaveBeenCalledWith('target-2') - expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( - 'workspace.patch', - expect.objectContaining({ - patch: expect.objectContaining({ kind: 'replace-session' }) - }) - ) + expect(patchRequests('target-1')).toEqual([ + [ + 'workspace.patch', + expect.objectContaining({ patch: expect.objectContaining({ kind: 'replace-session' }) }) + ] + ]) expect(requestByTargetId.get('target-2')).toBeUndefined() }) - it('can export from the persisted store session when no session argument is provided', async () => { - getWorkspaceSessionMock.mockReturnValue({ - activeRepoId: 'repo-target-1', - activeWorktreeId: 'repo-target-1::/repo', - activeTabId: 'tab-store', - tabsByWorktree: { - 'repo-target-1::/repo': [ - { - id: 'tab-store', - title: 'Store shell', - ptyId: 'pty-store', - worktreeId: 'repo-target-1::/repo' - } as never - ] - }, - terminalLayoutsByTabId: {} - }) + it('exports the persisted store session', async () => { + await agreeWith('target-1') - const observation = await observeTarget('target-1') - await callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': observation.hostObservationToken - } - }) + driver.write(sessionWithTab) + await driver.nextPushes() - expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( - 'workspace.patch', - expect.objectContaining({ - patch: expect.objectContaining({ - session: expect.objectContaining({ - activeWorktreePath: '/repo', - activeTabId: 'tab-store' + expect(patchRequests('target-1')).toEqual([ + [ + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + activeWorktreePath: '/repo', + activeTabId: 'tab-store' + }) }) }) - }) - ) + ] + ]) }) - it('does not invalidate an upload authority when an unchanged snapshot is polled', async () => { + it('does not invalidate an agreement when an unchanged snapshot is polled', async () => { const first = await observeTarget('target-1') const second = await observeTarget('target-1') expect(second.hostObservationToken).toBe(first.hostObservationToken) + driver.agree('target-1', first) - const result = await callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': first.revision }, - expectedHostObservationTokensByTargetId: { - 'target-1': first.hostObservationToken - } + driver.write(sessionWithTab) + + await expect(driver.nextPushes()).resolves.toMatchObject([ + { targetId: 'target-1', result: { ok: true } } + ]) + }) + + it('exports nothing for an import, and once for the next local change', async () => { + const observed = await observeTarget('target-1') + driver.importPeer({ + ...observed, + targetId: 'target-1', + outcome: 'synced', + session: sessionWithTab }) + // The window saves back what it applied from the import. + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(patchRequests('target-1')).toEqual([]) + + driver.write({ ...sessionWithTab, activeTabId: null }) + await driver.nextPushes() + expect(patchRequests('target-1')).toHaveLength(1) + }) + + it('keeps the agreement after an unavailable relay, so the next local change retries', async () => { + await agreeWith('target-1') + requestByTargetId + .get('target-1') + ?.mockImplementationOnce(async () => ({ ok: false, reason: 'unavailable' })) - expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }]) + driver.write(sessionWithTab) + await expect(driver.nextPushes()).resolves.toMatchObject([ + { result: { ok: false, reason: 'unavailable' } } + ]) + driver.write({ ...sessionWithTab, activeTabId: null }) + await expect(driver.nextPushes()).resolves.toMatchObject([{ result: { ok: true } }]) + + expect(patchRequests('target-1')).toHaveLength(2) + }) + + it('exports nothing over a new connection until a pull on it agrees', async () => { + // A relay restart retires the dead PTYs' tabs here before the window re-pulls; exporting + // that under the old connection's agreement erased the tabs the pull would have restored. + await agreeWith('target-1') + muxByTargetId.delete('target-1') + + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(patchRequests('target-1')).toEqual([]) + + await agreeWith('target-1') + driver.write({ ...sessionWithTab, activeTabId: null }) + await driver.nextPushes() + expect(patchRequests('target-1')).toHaveLength(1) + }) + + it('reports nothing for an export a newer pull superseded', async () => { + const observed = await observeTarget('target-1') + driver.agree('target-1', observed) + let finishPatch: (result: unknown) => void = () => {} + const request = requestByTargetId.get('target-1') + request?.mockImplementationOnce( + () => + new Promise((resolve) => { + finishPatch = resolve + }) + ) + + driver.write(sessionWithTab) + await vi.waitFor(() => expect(patchRequests('target-1')).toHaveLength(1)) + driver.importPeer({ ...observed, targetId: 'target-1', outcome: 'synced', session: {} }) + finishPatch({ ok: false, reason: 'stale-revision', snapshot: snapshot(emptyRemoteSession, 9) }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(driver.pushes).toEqual([]) + }) + + it('keeps a conflicted target out of exports until a whole pull agrees again', async () => { + const observed = await observeTarget('target-1') + driver.agree('target-1', observed) + driver.agree('target-1', observed, 'conflict') + + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(patchRequests('target-1')).toEqual([]) + + driver.agree('target-1', observed) + driver.write({ ...sessionWithTab, activeTabId: null }) + await driver.nextPushes() + expect(patchRequests('target-1')).toHaveLength(1) + }) + + it.each([ + ['seeds an empty host from a desktop that has tabs for it', true], + ['leaves an empty host alone when this desktop has nothing for it', false] + ])('%s', async (_name, hasTabs) => { + const request = vi.fn(async (method: string, params: Partial) => + method === 'workspace.get' + ? snapshot(emptyRemoteSession, 0) + : { ok: true, snapshot: snapshot(params.patch?.session ?? emptyRemoteSession, 1) } + ) + muxByTargetId.set('target-1', { request }) + requestByTargetId.set('target-1', request) + driver.write(hasTabs ? sessionWithTab : baseSession) + const observed = await observeTarget('target-1') + expect(observed.revision).toBe(0) + + driver.agree('target-1', observed, 'kept-local') + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(patchRequests('target-1')).toHaveLength(hasTabs ? 1 : 0) + expect(driver.pushes).toMatchObject( + hasTabs ? [{ targetId: 'target-1', authority: { revision: 0 }, result: { ok: true } }] : [] + ) }) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 43441b381bee..2ca5b9c38885 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -5,31 +5,25 @@ import { REMOTE_WORKSPACE_CHANGED_NOTIFICATION, REMOTE_WORKSPACE_STALE_NOTIFICATION, type RemoteWorkspaceChangedEvent, - type RemoteWorkspaceObservedPatchResult, - type RemoteWorkspaceObservedSnapshot + type RemoteWorkspaceObservedSnapshot, + type RemoteWorkspacePeerImport, + type RemoteWorkspacePushStatusEvent } from '../../shared/remote-workspace-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { createRepoRowExecutionHostLookup } from '../../shared/worktree-execution-host-resolution' -import { - createWorktreeOwnerResolver, - createWorktreeTargetResolver, - exportSessionForTarget, - persistedSessionForTarget -} from './remote-workspace-target-session-export' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' +import { importPeerTopology } from '../persistence/terminal-topology/terminal-topology-commit' +import { createRemoteWorkspaceExports } from './remote-workspace-export' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' import { CLIENT_ID, type RemoteWorkspaceClientNameSource } from './remote-workspace-client-identity' import { listRemoteWorkspaceConnectedClients } from './remote-workspace-connected-clients' import { clearRemoteWorkspacePatchTails, - getRemoteWorkspacePatchTailCount, - queueRemoteWorkspacePatch + getRemoteWorkspacePatchTailCount } from './remote-workspace-patch-queue' -import { getRemoteSnapshot, patchRemoteWorkspaceSession } from './remote-workspace-relay-sync' +import { getRemoteSnapshot } from './remote-workspace-relay-sync' import { - cachedRemoteWorkspaceSnapshotAuthorizesRevision, clearRemoteWorkspaceSnapshotCache, - getCachedRemoteWorkspaceSnapshot, getRemoteWorkspaceSnapshotCacheSize, rememberLocallyPatchedRemoteWorkspaceSnapshot, rememberRemoteWorkspaceSnapshot @@ -42,6 +36,7 @@ import { let mainWindowGetter: (() => BrowserWindow | null) | null = null let unregisterRemoteWorkspaceNotifications: (() => void) | null = null +let unsubscribeSessionWrites: (() => void) | null = null export function _resetRemoteWorkspaceCachesForTests(): void { clearRemoteWorkspaceSnapshotCache() @@ -59,51 +54,21 @@ export function _getRemoteWorkspaceCacheSizesForTests(): { } } -function getExplicitHydratedTargetIds(value: unknown): Set | null { - if ( - !Array.isArray(value) || - value.length === 0 || - value.some((targetId) => typeof targetId !== 'string' || targetId.length === 0) - ) { - return null - } - return new Set(value) -} - -function getExpectedTargetRevisions( - value: unknown, - targetIds: ReadonlySet -): Map | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const revisions = new Map() - for (const targetId of targetIds) { - const revision = (value as Record)[targetId] - if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0) { - return null - } - revisions.set(targetId, revision) - } - return revisions -} - -function getExpectedHostObservationTokens( - value: unknown, - targetIds: ReadonlySet -): Map | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const tokens = new Map() - for (const targetId of targetIds) { - const token = (value as Record)[targetId] - if (typeof token !== 'string' || token.length === 0 || token.length > 128) { - return null - } - tokens.set(targetId, token) - } - return tokens +/** A malformed pull records nothing, so the target stays out of exports until a valid one. */ +function isValidPeerImport(pull: RemoteWorkspacePeerImport): boolean { + return ( + typeof pull.targetId === 'string' && + pull.targetId.length > 0 && + Number.isSafeInteger(pull.revision) && + pull.revision >= 0 && + typeof pull.hostObservationToken === 'string' && + pull.hostObservationToken.length > 0 && + pull.hostObservationToken.length <= 128 && + ['synced', 'kept-local', 'conflict'].includes(pull.outcome) && + typeof pull.session === 'object' && + pull.session !== null && + !Array.isArray(pull.session) + ) } function sendRemoteWorkspaceChanged( @@ -122,6 +87,13 @@ function sendRemoteWorkspaceChanged( } } +function sendRemoteWorkspacePushStatus(event: RemoteWorkspacePushStatusEvent): void { + const win = mainWindowGetter?.() + if (win && !win.isDestroyed()) { + win.webContents.send('remoteWorkspace:pushStatus', event) + } +} + export function handleRemoteWorkspaceNotification( targetId: string, method: string, @@ -167,8 +139,11 @@ export function registerRemoteWorkspaceHandlers( unregisterRemoteWorkspaceNotifications = registerRemoteWorkspaceNotificationHandler( handleRemoteWorkspaceNotification ) + const exports = createRemoteWorkspaceExports(store, sendRemoteWorkspacePushStatus) + unsubscribeSessionWrites?.() + unsubscribeSessionWrites = store.onWorkspaceSessionWritten(exports.exportChanged) ipcMain.removeHandler('remoteWorkspace:get') - ipcMain.removeHandler('remoteWorkspace:setForConnectedTargets') + ipcMain.removeHandler('remoteWorkspace:importPeerTopology') ipcMain.removeHandler('remoteWorkspace:listEnabledConnectedTargets') ipcMain.removeHandler('remoteWorkspace:listConnectedClients') ipcMain.removeHandler('remoteWorkspace:clientId') @@ -181,91 +156,18 @@ export function registerRemoteWorkspaceHandlers( return getRemoteSnapshot(target) }) + // The window imports a host's snapshot through main: only that host's partition is written, and + // the agreement it sets keeps the import from exporting back. ipcMain.handle( - 'remoteWorkspace:setForConnectedTargets', - async ( - _event, - args: { - session?: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - } - ) => { - const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds) - if (!hydratedTargetIds) { - // Why: an omitted hydration set used to broadcast one session to every - // SSH target, overwriting unrelated remote workspace snapshots. - return [] - } - const expectedRevisions = getExpectedTargetRevisions( - args.expectedRevisionsByTargetId, - hydratedTargetIds - ) - if (!expectedRevisions) { - return [] + 'remoteWorkspace:importPeerTopology', + (_event, pull: RemoteWorkspacePeerImport | undefined) => { + if (!pull || !isValidPeerImport(pull)) { + return } - const expectedHostObservationTokens = getExpectedHostObservationTokens( - args.expectedHostObservationTokensByTargetId, - hydratedTargetIds - ) - if (!expectedHostObservationTokens) { - return [] + if (!isFrozenOrcadSourceSessionPartition(store, toSshExecutionHostId(pull.targetId))) { + importPeerTopology(store, pull.targetId, pull.session) } - const targets = - getSshConnectionStore() - ?.listTargets() - .filter( - (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) - ) ?? [] - - if (targets.length === 0) { - // Nothing to project onto, so skip the session and repo-catalog reads entirely. - return [] - } - - // One repo read, and ownership resolutions shared across targets: neither depends on the - // target. The publish fallback's catalog attribution reads the same lookup for the same - // reason — building it per target re-hydrates every repo row once per connected host. - const resolveWorktreeOwner = createWorktreeOwnerResolver( - createRepoRowExecutionHostLookup(store.getRepos()) - ) - const resolveWorktreeTarget = createWorktreeTargetResolver(resolveWorktreeOwner) - const results = await Promise.all( - targets.map(async (target) => { - // Why: each target has its own revision stream. Keep same-target - // writes queued, but do not let one slow relay block others. - const session = exportSessionForTarget( - resolveWorktreeTarget, - target.id, - args.session ?? persistedSessionForTarget(store, target.id, resolveWorktreeOwner) - ) - const result = await queueRemoteWorkspacePatch(target.id, async () => { - const current = - getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) - const expectedRevision = expectedRevisions.get(target.id) - const expectedHostObservationToken = expectedHostObservationTokens.get(target.id) - if ( - !current || - expectedRevision === undefined || - expectedHostObservationToken === undefined || - current.hostObservationToken !== expectedHostObservationToken || - !cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, expectedRevision) - ) { - const latest = getCachedRemoteWorkspaceSnapshot(target.id) ?? current - return latest - ? ({ ok: false, reason: 'stale-revision', snapshot: latest } as const) - : null - } - return patchRemoteWorkspaceSession(target, session) - }) - return result ? { targetId: target.id, result } : null - }) - ) - return results.filter( - (entry): entry is { targetId: string; result: RemoteWorkspaceObservedPatchResult } => - entry !== null - ) + exports.recordPull(pull) } ) diff --git a/src/main/ipc/ssh-host-partition-session-export.test.ts b/src/main/ipc/ssh-host-partition-session-export.test.ts index 1ec5167f866b..050d9281b2f6 100644 --- a/src/main/ipc/ssh-host-partition-session-export.test.ts +++ b/src/main/ipc/ssh-host-partition-session-export.test.ts @@ -1,10 +1,8 @@ import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' /** - * What the remote-workspace export publishes when the renderer omits `session`, against the real - * `Store`. + * What main's remote-workspace export publishes after a session write, against the real `Store`. * - * The shipping debounced writer takes that fallback on every session write, and it used to read the - * 'local' blob alone — so an SSH worktree whose tabs the main-process runtime had written to + * The export reads main's persisted session, and it used to read the 'local' blob alone — so an SSH worktree whose tabs the main-process runtime had written to * `ssh:` was projected as an explicit empty tab list. The upload is a * `replace-session` patch, which turns that absence into deletion on the host (#12721, #18173). * @@ -139,24 +137,23 @@ beforeEach(() => { [target, otherTarget].find((candidate) => candidate.id === targetId) }) getActiveMultiplexerMock.mockReset() + const connection = { + request: (method: string, params: Record) => { + if (method === 'workspace.get') { + return Promise.resolve(hostSnapshot) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: params.patch crosses the IPC boundary as unknown; this suite only ever sends a session patch. + const patch = params.patch as { session: RemoteWorkspaceSession } + hostSnapshot = { + ...hostSnapshot, + revision: hostSnapshot.revision + 1, + session: patch.session + } + return Promise.resolve({ ok: true, snapshot: hostSnapshot }) + } + } getActiveMultiplexerMock.mockImplementation((targetId: string) => - targetId === TARGET_ID - ? { - request: (method: string, params: Record) => { - if (method === 'workspace.get') { - return Promise.resolve(hostSnapshot) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: params.patch crosses the IPC boundary as unknown; this suite only ever sends a session patch. - const patch = params.patch as { session: RemoteWorkspaceSession } - hostSnapshot = { - ...hostSnapshot, - revision: hostSnapshot.revision + 1, - session: patch.session - } - return Promise.resolve({ ok: true, snapshot: hostSnapshot }) - } - } - : undefined + targetId === TARGET_ID ? connection : undefined ) }) @@ -174,13 +171,6 @@ function createStrandedStore(): InstanceType { ...getDefaultWorkspaceSession(), tabsByWorktree: { [WORKTREE_ID]: [] } }) - store.setWorkspaceSession( - { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } - }, - SSH_HOST_ID - ) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), @@ -195,11 +185,12 @@ function createStrandedStore(): InstanceType { return store } -async function publishToConnectedTarget(store: InstanceType): Promise { +/** The window agrees with the host, then the runtime writes its tab into `ssh:`. */ +async function publishRuntimeWrite(store: InstanceType): Promise { registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) const get = ipcHandlers.get('remoteWorkspace:get') - const set = ipcHandlers.get('remoteWorkspace:setForConnectedTargets') - if (!get || !set) { + const importPeerTopology = ipcHandlers.get('remoteWorkspace:importPeerTopology') + if (!get || !importPeerTopology) { throw new Error('remote workspace handlers were never registered') } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the remote-workspace get handler answers with this revision/token pair; the IPC return type is unknown. @@ -207,19 +198,28 @@ async function publishToConnectedTarget(store: InstanceType): Prom revision: number hostObservationToken: string } - await set(null, { - // The shipping debounced writer omits `session` and relies on the main-side fallback. - hydratedTargetIds: [TARGET_ID], - expectedRevisionsByTargetId: { [TARGET_ID]: observed.revision }, - expectedHostObservationTokensByTargetId: { [TARGET_ID]: observed.hostObservationToken } + await importPeerTopology(null, { + targetId: TARGET_ID, + ...observed, + outcome: 'synced', + session: {} }) + const revisionBefore = hostSnapshot.revision + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } + }, + SSH_HOST_ID + ) + await vi.waitFor(() => expect(hostSnapshot.revision).toBe(revisionBefore + 1)) } -describe('remoteWorkspace:setForConnectedTargets session fallback', () => { +describe('main export reads the target ssh partition', () => { it('publishes tabs the runtime persisted into the target ssh partition', async () => { const store = createStrandedStore() - await publishToConnectedTarget(store) + await publishRuntimeWrite(store) expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]?.map((tab) => tab.id)).toEqual([ 'tab-runtime' @@ -231,8 +231,41 @@ describe('remoteWorkspace:setForConnectedTargets session fallback', () => { // publishing one for a populated worktree is what destroyed the host's copy on every launch. const store = createStrandedStore() - await publishToConnectedTarget(store) + await publishRuntimeWrite(store) expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]).not.toEqual([]) }) }) + +describe('a window import writes only its target partition', () => { + it('cannot change local or other-host rows, nor roll back a main write not yet mirrored', async () => { + const store = createStrandedStore() + registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) + // Main's own local write, which the window has not received yet. + const localWorktreeId = 'repo-local::/home/me/proj' + const localTab = { ...runtimeAuthoredTab(), id: 'tab-main', worktreeId: localWorktreeId } + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + tabsByWorktree: { + ...store.getWorkspaceSession().tabsByWorktree, + [localWorktreeId]: [localTab] + } + }) + const localBefore = store.getWorkspaceSession() + const otherBefore = store.getWorkspaceSession(OTHER_SSH_HOST_ID) + + await ipcHandlers.get('remoteWorkspace:importPeerTopology')?.(null, { + targetId: TARGET_ID, + revision: 4, + hostObservationToken: 'observation-4', + outcome: 'synced', + session: { tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } } + }) + + expect(store.getWorkspaceSession()).toEqual(localBefore) + expect(store.getWorkspaceSession(OTHER_SSH_HOST_ID)).toEqual(otherBefore) + expect( + store.getWorkspaceSession(SSH_HOST_ID).tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id) + ).toEqual(['tab-runtime']) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts index 8b086c56e394..75df4b8371a5 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts @@ -4,7 +4,7 @@ import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surfac import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' import type { TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' -import { closeLeafOrTab } from './terminal-topology-commit' +import { closeLeafOrTab, importPeerTopology } from './terminal-topology-commit' // An ssh: partition, so a leaked host id or worktree path would show in the span. const HOST_ID: ExecutionHostId = 'ssh:target-1' @@ -149,4 +149,20 @@ describe('persistence.terminal-topology span', () => { expect(records[0].attributes).toMatchObject({ 'topology.outcome': 'threw' }) expect(records[0].exit).toMatchObject({ _tag: 'Failure' }) }) + + it("commits an SSH import into that target's partition alone", () => { + const patchWorkspaceSession = vi.fn() + importPeerTopology({ patchWorkspaceSession }, 'target-1', { tabsByWorktree: {} }) + importPeerTopology({ patchWorkspaceSession }, 'target-1', {}) + + expect(patchWorkspaceSession.mock.calls).toEqual([[{ tabsByWorktree: {} }, HOST_ID]]) + expect(records.map((record) => record.attributes)).toEqual([ + { + kind: 'persistence', + 'topology.kind': 'import_peer_topology', + 'topology.outcome': 'committed' + }, + { kind: 'persistence', 'topology.kind': 'import_peer_topology', 'topology.outcome': 'noop' } + ]) + }) }) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 741a723a9fb8..0089f7e27315 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -1,5 +1,5 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import type { ExecutionHostId } from '../../../shared/execution-host' +import { toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { PersistedState } from '../../../shared/persisted-state-types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { structuralValuesEqual } from '../../../shared/structural-value-equality' @@ -12,7 +12,10 @@ import type { TerminalLayoutSetResult } from '../../../shared/terminal-layout-set' import type { TerminalLeafBindRequest } from '../../../shared/terminal-leaf-bind' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' import type { PtyBindingPersistenceOperations } from '../loading-store/pty-binding-persistence' import { startSpan } from '../../observability/tracer' import { @@ -20,6 +23,7 @@ import { type TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' +import type { Store } from '../loading-store/store' import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' import { planTerminalLayoutSet } from './terminal-layout-set' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' @@ -29,7 +33,12 @@ import { assignWorkspaceSessionPartition } from './terminal-topology-membership' // move here later. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ -type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' | 'set_layout' +type TerminalTopologyCommitKind = + | 'close_leaf' + | 'close_tab' + | 'move_leaf' + | 'set_layout' + | 'import_peer_topology' export function closeLeafOrTab( commit: TerminalSurfaceCloseCommit @@ -91,6 +100,25 @@ export function setLayout( ) } +/** A window's pull from an SSH host, written only to that host's partition, its worktrees' home. */ +export function importPeerTopology( + store: Pick, + targetId: string, + session: WorkspaceSessionPatch +): void { + traced( + 'import_peer_topology', + () => { + if (Object.keys(session).length === 0) { + return { value: undefined, persist: false } + } + store.patchWorkspaceSession(session, toSshExecutionHostId(targetId)) + return { value: undefined } + }, + () => undefined + )() +} + /** A sleeping agent's record lands beside its tab, in its worktree's home partition. */ export function sleepLeaf( session: WorkspaceSessionState, diff --git a/src/preload/api/remote-workspace-bridge.ts b/src/preload/api/remote-workspace-bridge.ts index 2a7ca528d211..a5a7bab8ad0a 100644 --- a/src/preload/api/remote-workspace-bridge.ts +++ b/src/preload/api/remote-workspace-bridge.ts @@ -1,11 +1,13 @@ import { ipcRenderer } from 'electron' -import type { RemoteWorkspaceChangedEvent } from '../../shared/remote-workspace-types' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspacePushStatusEvent +} from '../../shared/remote-workspace-types' import type { PreloadApi } from '../api-types' export const remoteWorkspaceApi = { get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), - setForConnectedTargets: (args) => - ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), + importPeerTopology: (pull) => ipcRenderer.invoke('remoteWorkspace:importPeerTopology', pull), listEnabledConnectedTargets: () => ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), listConnectedClients: (args) => ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), @@ -15,5 +17,11 @@ export const remoteWorkspaceApi = { callback(data) ipcRenderer.on('remoteWorkspace:changed', listener) return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) + }, + onPushStatus: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspacePushStatusEvent) => + callback(data) + ipcRenderer.on('remoteWorkspace:pushStatus', listener) + return () => ipcRenderer.removeListener('remoteWorkspace:pushStatus', listener) } } satisfies PreloadApi['remoteWorkspace'] diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index f000b25dc4ff..1818e71b7d8f 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -21,8 +21,9 @@ import type { import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport, + RemoteWorkspacePushStatusEvent } from '../../shared/remote-workspace-types' export type WorkspaceSessionApi = { @@ -70,17 +71,15 @@ export type WorkspaceSessionApi = { } remoteWorkspace: { get: (args: { targetId: string }) => Promise - setForConnectedTargets: (args: { - session?: WorkspaceSessionState - hydratedTargetIds?: string[] - expectedRevisionsByTargetId: Record - expectedHostObservationTokensByTargetId: Record - }) => Promise<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]> + /** Commits a pull's session writes in main and records what this desktop and the host agree on. */ + importPeerTopology: (pull: RemoteWorkspacePeerImport) => Promise listEnabledConnectedTargets: () => Promise listConnectedClients: (args?: { targetIds?: string[] }) => Promise<{ targetId: string; clients: RemoteWorkspaceConnectedClient[] }[]> clientId: () => Promise onChanged: (callback: (event: RemoteWorkspaceChangedEvent) => void) => () => void + /** Main's report of each export it ran. */ + onPushStatus: (callback: (event: RemoteWorkspacePushStatusEvent) => void) => () => void } } diff --git a/src/relay/relay-runtime-owned-process-shutdown.test.ts b/src/relay/relay-runtime-owned-process-shutdown.test.ts index 6cdef3ed1fce..97303d1897d9 100644 --- a/src/relay/relay-runtime-owned-process-shutdown.test.ts +++ b/src/relay/relay-runtime-owned-process-shutdown.test.ts @@ -13,6 +13,7 @@ function fixture() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shutdown paths only read the owners stubbed here. const runtime = Object.assign(Object.create(RelayRuntimeServices.prototype), { agentExecHandler: { dispose: agents }, + workspaceSessionHandler: { close: vi.fn() }, skillInstallHandler: { dispose: skill }, aiVaultService: { dispose: vault }, responseStreams: { disposeAllAndWait: responses }, diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index 1325e792d078..6b8a923410d6 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -33,6 +33,7 @@ export class RelayRuntimeServices { readonly gitHandler: GitHandler readonly skillInstallHandler: SkillInstallHandler readonly agentExecHandler: AgentExecHandler + private readonly workspaceSessionHandler: WorkspaceSessionHandler private readonly responseStreams: GitResponseStreamRegistry private readonly aiVaultService: ReturnType | null private readonly sessionSearch: { dispose(): void } | null @@ -81,7 +82,7 @@ export class RelayRuntimeServices { const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) const portScanHandler = new PortScanHandler(dispatcher) this.agentExecHandler = new AgentExecHandler(dispatcher) - const workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) + this.workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined this.aiVaultService = hostPlatform ? createRelayAiVaultService(homedir(), hostPlatform) : null @@ -107,7 +108,7 @@ export class RelayRuntimeServices { externalAutomationsHandler, portScanHandler, this.agentExecHandler, - workspaceSessionHandler, + this.workspaceSessionHandler, new AiVaultHandler(dispatcher, { hostPlatform, service: this.aiVaultService ?? undefined @@ -125,6 +126,9 @@ export class RelayRuntimeServices { // Why: answering a stream's request does not prove its detached pumps, descriptors or children // are gone; only these registry drains do. async disposeOwnedProcesses(): Promise { + // The PTY teardown that follows retires every pane on each client; recording that here would + // erase the tabs the clients restore from this snapshot once they reconnect. + this.workspaceSessionHandler.close() const owned = await Promise.allSettled([ this.agentExecHandler.dispose(), this.responseStreams.disposeAllAndWait(), @@ -158,6 +162,7 @@ export class RelayRuntimeServices { } reopenOwnedProcesses(): void { + this.workspaceSessionHandler.reopen() this.agentExecHandler.reopen() this.responseStreams.reopen() this.fsHandler.reopen() diff --git a/src/relay/workspace-session-handler.test.ts b/src/relay/workspace-session-handler.test.ts index 7c3917e9c2a5..38985b1a9337 100644 --- a/src/relay/workspace-session-handler.test.ts +++ b/src/relay/workspace-session-handler.test.ts @@ -35,6 +35,7 @@ describe('WorkspaceSessionHandler', () => { let baseDir: string let dispatcher: RelayDispatcher let written: Buffer[] + let handler: WorkspaceSessionHandler beforeEach(() => { baseDir = mkdtempSync(join(tmpdir(), 'orca-workspace-session-')) @@ -42,7 +43,7 @@ describe('WorkspaceSessionHandler', () => { dispatcher = new RelayDispatcher((data) => { written.push(Buffer.from(data)) }) - new WorkspaceSessionHandler(dispatcher, baseDir) + handler = new WorkspaceSessionHandler(dispatcher, baseDir) }) afterEach(() => { @@ -50,6 +51,35 @@ describe('WorkspaceSessionHandler', () => { rmSync(baseDir, { recursive: true, force: true }) }) + it('records no patch while the relay shuts down, and records again once it reopens', async () => { + // A shutting-down relay kills its PTYs; the clients' retirement of those panes must not erase + // the tabs they restore from this snapshot on reconnect. + const patch = (id: number): Promise => + sendRequest( + dispatcher, + 'workspace.patch', + { + namespace: 'ns', + baseRevision: 0, + clientId: 'client-a', + patch: { kind: 'replace-session', session: { tabsByWorktreePath: {} } } + }, + id + ) + const response = (id: number): unknown => + decodeJsonFrames(written).find( + (frame) => typeof frame === 'object' && frame !== null && 'id' in frame && frame.id === id + ) + + handler.close() + await patch(1) + expect(response(1)).toMatchObject({ result: { ok: false, reason: 'unavailable' } }) + + handler.reopen() + await patch(2) + expect(response(2)).toMatchObject({ result: { ok: true, snapshot: { revision: 1 } } }) + }) + it('stores snapshots atomically and rejects stale revisions', async () => { const session = { activeWorktreePath: '/repo/worktree', diff --git a/src/relay/workspace-session-handler.ts b/src/relay/workspace-session-handler.ts index f9b3ef2e5077..f4dcbf7b5ed6 100644 --- a/src/relay/workspace-session-handler.ts +++ b/src/relay/workspace-session-handler.ts @@ -55,6 +55,7 @@ function sanitizeClientId(value: string): string { export class WorkspaceSessionHandler { private readonly clientsByNamespace = new Map>() + private closed = false constructor( private dispatcher: RelayDispatcher, @@ -125,7 +126,19 @@ export class WorkspaceSessionHandler { return this.read(sanitizeNamespace(params.namespace)) } + /** Refuses patches while the relay shuts down; a deferred shutdown reopens. */ + close(): void { + this.closed = true + } + + reopen(): void { + this.closed = false + } + private async patch(params: Record): Promise { + if (this.closed) { + return { ok: false, reason: 'unavailable', message: 'Relay is shutting down' } + } const namespace = sanitizeNamespace(params.namespace) const current = this.read(namespace) const baseRevision = Number(params.baseRevision) diff --git a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx deleted file mode 100644 index 3b65dbd380f3..000000000000 --- a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx +++ /dev/null @@ -1,496 +0,0 @@ -// @vitest-environment happy-dom - -/** - * The data-safety end of STA-3593. Hydrating a target authorises workspace uploads - * (`use-app-session-persistence.ts`), an upload is a `replace-session` patch - * (`remote-workspace-relay-sync.ts`), and the host applies that by wholesale replacement. So a - * client that hydrated on a snapshot whose tab rows it could not place would upload its incomplete - * projection and DELETE the host tabs it failed to adopt. - * - * That is the asymmetry the design turns on: a suppressed upload is deferred and recoverable, a - * wiped host snapshot is not. This asserts the upload itself never names an unplaced target — - * through the real persistence gate, not the hydrated flag — so it still bites if the gating moves. - */ -import { cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot -} from '../../../shared/remote-workspace-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import type { DirectSshPreparationInput } from '../hooks/direct-ssh-reconnect-coordinator' -import { createRemoteWorkspaceTargetSync } from '../hooks/remote-workspace-target-sync' -import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' -import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -import { useAppStore } from '@/store' -import { useAppSessionPersistence } from './use-app-session-persistence' -import * as workspaceSessionHostPersistence from '../lib/workspace-session-host-persistence' - -const TARGET_ID = 'target-a' -const REPO_ROOT = '/srv/proj' -const HOST_PATH = `${REPO_ROOT}/alpha` -const WORKTREE_ID = `repo-a::${HOST_PATH}` -/** Long enough that any deferred retry or debounce would have fired if one existed. */ -const SETTLE_MS = 12_000 -/** Longer than the post-apply session-write suppression in remote-workspace-snapshot-apply.ts. */ -const WRITE_SUPPRESSION_MS = 1_500 -const DEBOUNCE_MS = 300 - -const owner: DirectSshAuthority = { - targetId: TARGET_ID, - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 1 -} - -function snapshot( - revision = 4, - hostObservationToken = `observation-${revision}` -): RemoteWorkspaceObservedSnapshot { - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken, - session: { - activeWorktreePath: HOST_PATH, - activeTabId: 'T1', - tabsByWorktreePath: { - [HOST_PATH]: [0, 1].map((index) => ({ - id: `T${index + 1}`, - worktreePath: HOST_PATH, - ptyId: `pty-T${index + 1}`, - title: `Terminal ${index + 1}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: index + 1 - })) - }, - terminalLayoutsByTabId: {} - } - } -} - -type UploadArgs = { - hydratedTargetIds?: string[] - expectedRevisionsByTargetId?: Record - expectedHostObservationTokensByTargetId?: Record - session?: unknown -} -type UploadResponse = { targetId: string; result: RemoteWorkspaceObservedPatchResult }[] -const uploads = vi.fn(async (_args: UploadArgs): Promise => []) - -type Deferred = { - promise: Promise - resolve: (value: T) => void -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -function installWindowApi(sessionPatch = vi.fn(async () => {})): void { - ;(window as unknown as { api: unknown }).api = { - session: { patch: sessionPatch, set: vi.fn(async () => {}) }, - remoteWorkspace: { setForConnectedTargets: uploads }, - app: { stageBeforeUnloadSync: vi.fn() }, - ui: { onWindowCloseRequested: vi.fn(() => () => {}) } - } -} - -function seedStore(withHostCatalog: boolean): void { - useAppStore.setState({ - ...useAppStore.getInitialState(), - workspaceSessionReady: true, - hydrationSucceeded: true, - repos: [ - { - id: 'repo-a', - path: REPO_ROOT, - displayName: 'Proj', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_ID, - executionHostId: `ssh:${TARGET_ID}` - } as never - ], - worktreesByRepo: withHostCatalog - ? { - 'repo-a': [ - makeWorktree({ - id: WORKTREE_ID, - repoId: 'repo-a', - path: HOST_PATH, - hostId: `ssh:${TARGET_ID}` - } as never) - ] - } - : {}, - remoteWorkspaceHydratedTargetIds: new Set(), - remoteWorkspaceSyncStatusByTargetId: {}, - tabsByWorktree: {}, - reconnectPersistedTerminals: (async () => {}) as never - }) -} - -function preparationInput( - authority: DirectSshAuthority, - reason: 'workspace-snapshot', - snapshotRevision: number -): DirectSshPreparationInput { - return { - ...authority, - catalogRevision: 1, - repoRefs: [{ repoId: 'repo-a', executionHostId: `ssh:${TARGET_ID}` }], - authorityRequirement: 'required', - reason, - snapshotRevision - } -} - -function createSync( - capturePreparationInput = async ( - authority: DirectSshAuthority, - reason: 'workspace-snapshot', - snapshotRevision: number - ): Promise => preparationInput(authority, reason, snapshotRevision) -) { - return createRemoteWorkspaceTargetSync({ - store: useAppStore, - remoteWorkspace: { - get: vi.fn(async () => snapshot()), - setForConnectedTargets: uploads as never - }, - getCurrentAuthority: () => owner, - isPreparationTokenCurrent: () => true, - capturePreparationInput, - prepareOnly: async (input) => ({ - status: 'degraded' as const, - token: { - authority: owner, - catalogRevision: 1, - repoFingerprint: 'fp', - authorityRequirement: 'required' as const, - snapshotRevision: input.snapshotRevision ?? null, - outcome: 'degraded' as const - }, - repoOutcomes: { - complete: 0, - 'non-authoritative': 1, - 'timed-out': 0, - 'cancel-budget-exhausted': 0, - canceled: 0, - stale: 0, - rejected: 0 - }, - lineageOutcome: 'degraded' as const - }), - finalizeHydratedTerminals: () => 0 - }) -} - -// The initial full write must settle before measured upload responses are installed. -async function renderSettledPersistence() { - const pendingWrites: Promise[] = [] - const persistByHost = workspaceSessionHostPersistence.patchWorkspaceSessionByHost - const observeWrite = vi.spyOn(workspaceSessionHostPersistence, 'patchWorkspaceSessionByHost') - observeWrite.mockImplementation((...args) => { - const result = persistByHost(...args) - pendingWrites.push(result.written) - return result - }) - try { - installWindowApi() - const persistence = renderHook(() => useAppSessionPersistence()) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(pendingWrites).toHaveLength(1) - await Promise.all(pendingWrites) - expect(uploads).not.toHaveBeenCalled() - return persistence - } finally { - await Promise.allSettled(pendingWrites) - observeWrite.mockRestore() - } -} - -/** A local edit the persistence subscriber must want to write, then let it settle. */ -async function touchSessionAndSettle(marker: string): Promise { - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - useAppStore.setState({ activeTabId: marker }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) -} - -async function flushMicrotasks(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} - -function uploadedTargetIds(): string[] { - return uploads.mock.calls.flatMap((call) => call[0]?.hydratedTargetIds ?? []) -} - -function authorizeUploadsAtRevision(revision: number): void { - useAppStore.setState({ - remoteWorkspaceHydratedTargetIds: new Set([TARGET_ID]), - remoteWorkspaceSyncStatusByTargetId: { - [TARGET_ID]: { - phase: 'synced', - direction: 'pull', - revision, - hostObservationToken: `observation-${revision}` - } - } - }) -} - -let nextCaseTime = Date.now() - -beforeEach(() => { - vi.useFakeTimers({ now: nextCaseTime }) - uploads.mockReset() - uploads.mockResolvedValue([]) - installWindowApi() -}) - -afterEach(async () => { - try { - cleanup() - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - expect(isDirectSshRemoteWorkspaceApplyInProgress()).toBe(false) - } finally { - // The shared apply deadline must not outlive a rollback of this fixture's clock. - nextCaseTime = Date.now() - vi.useRealTimers() - } -}) - -describe('uploads from a client that could not place the host tabs', () => { - it('cancels a captured upload when a snapshot arrives during its local write', async () => { - seedStore(true) - authorizeUploadsAtRevision(3) - const pendingLocalWrite = deferred() - const sessionPatch = vi.fn(() => pendingLocalWrite.promise) - installWindowApi(sessionPatch) - const persistence = renderHook(() => useAppSessionPersistence()) - - useAppStore.setState({ activeTabId: 'before-snapshot' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(sessionPatch).toHaveBeenCalled() - - const pendingCapture = deferred() - const sync = createSync(() => pendingCapture.promise) - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - pendingLocalWrite.resolve() - await Promise.resolve() - await Promise.resolve() - - expect( - uploadedTargetIds(), - 'an upload captured before the incoming revision overwrote its cached tabs' - ).not.toContain(TARGET_ID) - - sync.stop() - pendingCapture.resolve(preparationInput(owner, 'workspace-snapshot', 4)) - await pendingApply - persistence.unmount() - }) - - it('excludes an incoming snapshot target while preparation is pending', async () => { - seedStore(true) - authorizeUploadsAtRevision(3) - const persistence = renderHook(() => useAppSessionPersistence()) - const pendingCapture = deferred() - const sync = createSync(() => pendingCapture.promise) - - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - await touchSessionAndSettle('capture-pending') - - expect( - uploadedTargetIds(), - 'the cached incoming revision was overwritten before its tabs could be applied' - ).not.toContain(TARGET_ID) - - pendingCapture.resolve(preparationInput(owner, 'workspace-snapshot', 4)) - await pendingApply - sync.stop() - persistence.unmount() - }) - - it('issues no upload for a target whose host tabs it could not place', async () => { - seedStore(false) - const persistence = renderHook(() => useAppSessionPersistence()) - const sync = createSync() - - // Deliberately not asserting the placement verdict first: the oracle is the upload, and a - // precondition on the verdict would fail ahead of it and hide whether the upload gate holds. - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - await vi.advanceTimersByTimeAsync(10_000) - await pendingApply - expect( - useAppStore.getState().tabsByWorktree[WORKTREE_ID], - 'the host named two terminals and this client placed neither' - ).toBeUndefined() - - // Before the client has settled: it is not authoritative for this target yet. - await touchSessionAndSettle('mid-chain') - expect( - uploadedTargetIds(), - 'a client that adopted none of the host tabs uploaded over them' - ).not.toContain(TARGET_ID) - - // Still no upload once everything has settled: not adopting is not permission to overwrite the host. - await vi.advanceTimersByTimeAsync(SETTLE_MS) - expect( - useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]?.phase, - 'a client that placed none of the host tabs reported a healthy sync' - ).not.toBe('synced') - await touchSessionAndSettle('settled') - expect( - uploadedTargetIds(), - 'settling authorised a replace-session upload built from an incomplete projection' - ).not.toContain(TARGET_ID) - - sync.stop() - persistence.unmount() - }) - - it('does upload for a target whose host tabs it did place', async () => { - seedStore(true) - const persistence = renderHook(() => useAppSessionPersistence()) - const sync = createSync() - - await sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - // Adoption is the observable outcome; the apply reports nothing back by design. - expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ - 'T1', - 'T2' - ]) - - await touchSessionAndSettle('placed') - - // Without this the suppression assertions above would pass on a harness that never uploads. - expect(uploadedTargetIds()).toContain(TARGET_ID) - expect(uploads).toHaveBeenCalledWith( - expect.objectContaining({ - expectedRevisionsByTargetId: { [TARGET_ID]: 4 }, - expectedHostObservationTokensByTargetId: { - [TARGET_ID]: 'observation-4' - } - }) - ) - - sync.stop() - persistence.unmount() - }) - - it('keeps a later same-lineage upload after the earlier result advances the revision', async () => { - seedStore(true) - const persistence = await renderSettledPersistence() - authorizeUploadsAtRevision(7) - const secondLocalWrite = deferred() - let localWriteCount = 0 - const sessionPatch = vi.fn(() => { - localWriteCount += 1 - return localWriteCount === 2 ? secondLocalWrite.promise : Promise.resolve() - }) - const firstUpload = deferred() - uploads.mockImplementationOnce(() => firstUpload.promise) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(9, 'observation-7') } - } - ]) - installWindowApi(sessionPatch) - - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - useAppStore.setState({ activeTabId: 'first-local-write' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(uploads).toHaveBeenCalledOnce() - - useAppStore.setState({ activeTabId: 'second-local-write' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(sessionPatch).toHaveBeenCalledTimes(2) - expect(uploads).toHaveBeenCalledOnce() - - firstUpload.resolve([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(8, 'observation-7') } - } - ]) - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 8, - hostObservationToken: 'observation-7' - }) - - secondLocalWrite.resolve() - await flushMicrotasks() - expect(uploads).toHaveBeenCalledTimes(2) - expect(uploads.mock.calls[1][0]).toMatchObject({ - expectedRevisionsByTargetId: { [TARGET_ID]: 7 }, - expectedHostObservationTokensByTargetId: { [TARGET_ID]: 'observation-7' } - }) - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 9, - hostObservationToken: 'observation-7' - }) - - persistence.unmount() - }) - - it('retains transient upload authority so the next local edit retries', async () => { - seedStore(true) - const persistence = await renderSettledPersistence() - authorizeUploadsAtRevision(7) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: false, reason: 'unavailable', message: 'temporary relay failure' } - } - ]) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(8, 'observation-7') } - } - ]) - - await touchSessionAndSettle('transient-failure') - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'offline', - revision: 7, - hostObservationToken: 'observation-7' - }) - - await touchSessionAndSettle('retry-after-transient-failure') - await flushMicrotasks() - expect(uploads).toHaveBeenCalledTimes(2) - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 8, - hostObservationToken: 'observation-7' - }) - - persistence.unmount() - }) -}) diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 6574438265de..ee36a2c89467 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -4,7 +4,6 @@ import { isDirectSshRemoteWorkspaceApplyInProgress, onDirectSshRemoteWorkspaceApplyWindowClosed } from '../hooks/remote-workspace-snapshot-apply' -import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -37,84 +36,13 @@ import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_ABORTED_EVENT, ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' -import type { AppState } from '../store/types' -import type { DirectSshLayoutEdit } from '../store/terminals/terminal-state' -import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' -import { applyRemoteWorkspacePushStatus } from '../hooks/remote-workspace-push-status' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 -type RemoteWorkspaceUploadAuthority = { - targetId: string - revision: number - updatedAt?: number - hostObservationToken: string -} - -function captureRemoteWorkspaceUploadAuthorities( - state: AppState -): RemoteWorkspaceUploadAuthority[] { - return Array.from(state.remoteWorkspaceHydratedTargetIds).flatMap((targetId) => { - const syncStatus = state.remoteWorkspaceSyncStatusByTargetId[targetId] - const revision = syncStatus?.revision - const hostObservationToken = syncStatus?.hostObservationToken - if ( - syncStatus?.phase === 'conflict' || - typeof revision !== 'number' || - !Number.isSafeInteger(revision) || - revision < 0 || - typeof hostObservationToken !== 'string' || - hostObservationToken.length === 0 - ) { - return [] - } - return [ - { - targetId, - revision, - updatedAt: syncStatus.updatedAt, - hostObservationToken - } - ] - }) -} - -function remoteWorkspaceUploadAuthorityIsCurrent( - state: AppState, - authority: RemoteWorkspaceUploadAuthority -): boolean { - const status = state.remoteWorkspaceSyncStatusByTargetId[authority.targetId] - return ( - state.remoteWorkspaceHydratedTargetIds.has(authority.targetId) && - status?.phase !== 'conflict' && - status?.hostObservationToken === authority.hostObservationToken - ) -} - -function captureUploadedDirectSshLayoutEdits( - pendingLayoutEdits: AppState['pendingDirectSshLayoutEditsByTabId'], - targetId: string, - result: RemoteWorkspaceObservedPatchResult | undefined -): Record { - if (!result?.ok) { - return {} - } - return Object.fromEntries( - Object.entries(pendingLayoutEdits).flatMap(([tabId, entry]) => { - const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] - return entry.targetId === targetId && - uploaded && - terminalLayoutNodeEqual(entry.root, uploaded.root) - ? [[tabId, entry]] - : [] - }) - ) -} - /** - * Writes durable renderer session state to disk: the debounced per-host writer, the remote - * workspace upload chain, and the synchronous shutdown checkpoint. + * Writes durable renderer session state to disk: the debounced per-host writer and the + * synchronous shutdown checkpoint. Main exports what it holds to SSH hosts. */ export function useAppSessionPersistence(): void { useEffect(() => registerUpdaterBeforeUnloadBypass(), []) @@ -126,79 +54,9 @@ export function useAppSessionPersistence(): void { shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed, onPersistError: (error) => console.warn('[session] Session patch failed:', error), - persist: ({ patch }) => { - const state = useAppStore.getState() - // Why: route each host's worktree-scoped slice to its own partition; the upload chain below orders after the local write. - const { localWrite, written } = patchWorkspaceSessionByHost( - window.api.session, - patch, - state - ) - const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state) - const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId - if (uploadAuthorities.length > 0) { - void (async () => { - try { - await localWrite - const currentState = useAppStore.getState() - const currentAuthorities = uploadAuthorities.filter((authority) => - remoteWorkspaceUploadAuthorityIsCurrent(currentState, authority) - ) - if (currentAuthorities.length === 0) { - return - } - const hydratedTargetIds = currentAuthorities.map(({ targetId }) => targetId) - const expectedRevisionsByTargetId = Object.fromEntries( - currentAuthorities.map(({ targetId, revision }) => [targetId, revision]) - ) - const expectedHostObservationTokensByTargetId = Object.fromEntries( - currentAuthorities.map(({ targetId, hostObservationToken }) => [ - targetId, - hostObservationToken - ]) - ) - const results = await window.api.remoteWorkspace?.setForConnectedTargets({ - hydratedTargetIds, - expectedRevisionsByTargetId, - expectedHostObservationTokensByTargetId - }) - const resultState = useAppStore.getState() - const currentAuthorityByTargetId = new Map( - currentAuthorities.map((authority) => [authority.targetId, authority]) - ) - for (const { targetId, result } of results ?? []) { - const authority = currentAuthorityByTargetId.get(targetId) - if (authority && remoteWorkspaceUploadAuthorityIsCurrent(resultState, authority)) { - if (result?.ok) { - resultState.acknowledgeDirectSshLayoutEdits( - captureUploadedDirectSshLayoutEdits(pendingLayoutEdits, targetId, result) - ) - } - applyRemoteWorkspacePushStatus(resultState, targetId, result, authority) - } - } - } catch (err) { - const errorState = useAppStore.getState() - for (const authority of uploadAuthorities) { - if (!remoteWorkspaceUploadAuthorityIsCurrent(errorState, authority)) { - continue - } - const currentStatus = - errorState.remoteWorkspaceSyncStatusByTargetId[authority.targetId] - errorState.setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'error', - direction: 'push', - revision: currentStatus?.revision ?? authority.revision, - updatedAt: currentStatus?.updatedAt ?? authority.updatedAt, - hostObservationToken: authority.hostObservationToken, - message: err instanceof Error ? err.message : 'Workspace upload failed' - }) - } - } - })() - } - return written - } + // Why: route each host's worktree-scoped slice to its own partition. + persist: ({ patch }) => + patchWorkspaceSessionByHost(window.api.session, patch, useAppStore.getState()) }) }, []) diff --git a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts b/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts index 3884704d2a1e..ba6576448fc5 100644 --- a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts +++ b/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest' import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport } from '../../../../shared/remote-workspace-types' import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' import type { AppState } from '../../store/types' @@ -135,15 +135,9 @@ export function appState(overrides: Record = {}): AppState { export function createHarness( state: AppState, - get: (args: { targetId: string }) => Promise, - patchResult: RemoteWorkspaceObservedPatchResult = { ok: true, snapshot: snapshot(1) } + get: (args: { targetId: string }) => Promise ) { - const setForConnectedTargets = vi.fn(async () => [ - { - targetId: owner.targetId, - result: patchResult - } - ]) + const importPeerTopology = vi.fn(async (_pull: RemoteWorkspacePeerImport) => {}) let current = true let catalogRevision = 1 const stateListeners = new Set<(current: AppState, previous: AppState) => void>() @@ -191,7 +185,7 @@ export function createHarness( return () => stateListeners.delete(listener) } }, - remoteWorkspace: { get, setForConnectedTargets }, + remoteWorkspace: { get, importPeerTopology }, getCurrentAuthority: () => (current ? owner : null), isPreparationTokenCurrent: (candidate) => current && candidate.catalogRevision === catalogRevision, @@ -201,7 +195,7 @@ export function createHarness( }) return { sync, - setForConnectedTargets, + importPeerTopology, publishState, capturePreparationInput, prepareOnly, diff --git a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts index 161d6eaf576a..5a05d75e183e 100644 --- a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts @@ -1,4 +1,5 @@ import { useAppStore } from '../../store' +import { applyRemoteWorkspacePushStatusEvent } from '../remote-workspace-push-status' import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' export function registerRemoteWorkspaceIpcBridge( @@ -45,6 +46,9 @@ export function registerRemoteWorkspaceIpcBridge( }) }) })() - }) + }), + window.api.remoteWorkspace.onPushStatus((event) => + applyRemoteWorkspacePushStatusEvent(useAppStore.getState(), event) + ) ) } diff --git a/src/renderer/src/hooks/remote-workspace-push-status-event.test.ts b/src/renderer/src/hooks/remote-workspace-push-status-event.test.ts new file mode 100644 index 000000000000..093090e570f4 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-push-status-event.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RemoteWorkspacePushStatusEvent } from '../../../shared/remote-workspace-types' +import { i18n } from '@/i18n/i18n' +import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization' +import { appState, snapshot } from './__tests__/remote-workspace-target-sync-test-harness' +import { applyRemoteWorkspacePushStatusEvent } from './remote-workspace-push-status' + +const TOKEN = snapshot(4).hostObservationToken + +function hydratedState(overrides: Parameters[0] = {}) { + return appState({ + remoteWorkspaceHydratedTargetIds: new Set(['target-a']), + remoteWorkspaceSyncStatusByTargetId: { + 'target-a': { phase: 'synced', revision: 4, hostObservationToken: TOKEN } + }, + ...overrides + }) +} + +function pushed(result: RemoteWorkspacePushStatusEvent['result']): RemoteWorkspacePushStatusEvent { + return { + targetId: 'target-a', + authority: { revision: 4, hostObservationToken: TOKEN }, + result + } +} + +describe('applyRemoteWorkspacePushStatusEvent', () => { + it('acknowledges a pending layout edit the host now holds, and only that one', () => { + const held = { targetId: 'target-a', root: null } + const otherRoot = { + targetId: 'target-a', + root: { type: 'leaf' as const, leafId: '11111111-1111-4111-8111-111111111111' } + } + const state = hydratedState({ + pendingDirectSshLayoutEditsByTabId: { 'tab-a': held, 'tab-b': otherRoot } + }) + const uploaded = snapshot(5) + uploaded.session.terminalLayoutsByTabId = { + 'tab-a': { root: null, activeLeafId: null, expandedLeafId: null }, + 'tab-b': { root: null, activeLeafId: null, expandedLeafId: null } + } + + applyRemoteWorkspacePushStatusEvent(state, pushed({ ok: true, snapshot: uploaded })) + + expect(state.acknowledgeDirectSshLayoutEdits).toHaveBeenCalledWith({ 'tab-a': held }) + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledWith( + 'target-a', + expect.objectContaining({ phase: 'synced', direction: 'push', revision: 5 }) + ) + }) + + it.each([ + ['stale-revision', 'Workspace changed on another device'], + ['unavailable', 'Remote workspace sync unavailable'] + ] as const)('localizes the %s export fallback', async (reason, message) => { + const previousLanguage = i18n.language + await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE) + try { + const state = hydratedState() + + applyRemoteWorkspacePushStatusEvent(state, pushed({ ok: false, reason })) + + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith( + 'target-a', + expect.objectContaining({ message: `[${message}]` }) + ) + } finally { + await i18n.changeLanguage(previousLanguage) + } + }) + + it('reports a thrown export as an error', () => { + const state = hydratedState() + + applyRemoteWorkspacePushStatusEvent(state, { ...pushed(null), error: 'socket closed' }) + + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledWith( + 'target-a', + expect.objectContaining({ phase: 'error', direction: 'push', message: 'socket closed' }) + ) + }) + + it.each([ + ['mid-pull', { remoteWorkspaceHydratedTargetIds: new Set() }], + [ + 'conflicted', + { + remoteWorkspaceSyncStatusByTargetId: { + 'target-a': { phase: 'conflict' as const, revision: 4, hostObservationToken: TOKEN } + } + } + ], + [ + 'observing another host', + { + remoteWorkspaceSyncStatusByTargetId: { + 'target-a': { phase: 'synced' as const, revision: 4, hostObservationToken: 'other' } + } + } + ] + ])('ignores a report while the window is %s', (_name, overrides) => { + const acknowledgeDirectSshLayoutEdits = vi.fn() + const state = hydratedState({ ...overrides, acknowledgeDirectSshLayoutEdits }) + + applyRemoteWorkspacePushStatusEvent(state, pushed({ ok: true, snapshot: snapshot(5) })) + + expect(state.setRemoteWorkspaceSyncStatus).not.toHaveBeenCalled() + expect(acknowledgeDirectSshLayoutEdits).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-push-status.ts b/src/renderer/src/hooks/remote-workspace-push-status.ts index 4a3792114817..0008286da87a 100644 --- a/src/renderer/src/hooks/remote-workspace-push-status.ts +++ b/src/renderer/src/hooks/remote-workspace-push-status.ts @@ -1,5 +1,9 @@ -import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' +import type { + RemoteWorkspaceObservedPatchResult, + RemoteWorkspacePushStatusEvent +} from '../../../shared/remote-workspace-types' import { translate } from '@/i18n/i18n' +import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import type { AppState } from '../store/types' export type RemoteWorkspacePushAuthority = { @@ -73,3 +77,48 @@ export function applyRemoteWorkspacePushStatus( }) } } + +/** + * Main's report of an export it ran. Ignored once the window no longer holds that host's agreement: + * mid-pull, after a conflict, or against another host observation. + */ +export function applyRemoteWorkspacePushStatusEvent( + store: AppState, + { targetId, authority, result, error }: RemoteWorkspacePushStatusEvent +): void { + const status = store.remoteWorkspaceSyncStatusByTargetId[targetId] + if ( + !store.remoteWorkspaceHydratedTargetIds.has(targetId) || + status?.phase === 'conflict' || + status?.hostObservationToken !== authority.hostObservationToken + ) { + return + } + if (error !== undefined) { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: 'error', + direction: 'push', + revision: status.revision ?? authority.revision, + updatedAt: status.updatedAt, + hostObservationToken: authority.hostObservationToken, + message: error + }) + return + } + if (result?.ok) { + // An edit is acknowledged once the host holds its exact layout. + store.acknowledgeDirectSshLayoutEdits( + Object.fromEntries( + Object.entries(store.pendingDirectSshLayoutEditsByTabId).filter(([tabId, edit]) => { + const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] + return ( + edit.targetId === targetId && + uploaded && + terminalLayoutNodeEqual(edit.root, uploaded.root) + ) + }) + ) + ) + } + applyRemoteWorkspacePushStatus(store, targetId, result ?? undefined, authority) +} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts index 020d7d43c923..4e485b172dee 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts @@ -158,7 +158,8 @@ describe('session writes deferred by a direct-SSH apply', () => { finalizeHydratedTerminals: () => { onApplied() return 0 - } + }, + importPeerTopology: async () => {} }) expect(result).toBe('applied') } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 9dbe57cf0bc8..cf51b3c71ab4 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -1,9 +1,18 @@ -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' +import type { + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport +} from '../../../shared/remote-workspace-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' import type { DirectSshAuthority } from '../../../shared/ssh-types' import { toSshExecutionHostId } from '../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { buildWorkspaceSessionPayload } from '../lib/workspace-session' +import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' +import { buildWorkspaceSessionHostPatches } from '../lib/workspace-session-host-persistence' import type { AppState } from '../store/types' import { admitDirectSshSnapshotApplyToken, @@ -79,6 +88,8 @@ type RemoteWorkspaceSnapshotApplyInput = { isPreparationTokenCurrent: (token: DirectSshPreparationToken) => boolean waitForWorkspaceSessionReady: (signal?: AbortSignal) => Promise finalizeHydratedTerminals: (authority: DirectSshAuthority) => number + /** Main commits the import and records it as agreed with the host, so it never exports back. */ + importPeerTopology: (pull: RemoteWorkspacePeerImport) => Promise /** * Host paths still carrying terminal tabs when this apply gave up placing them. `unverifiable`, * never proof the rows are not ours, so the caller owns getting back to a placed picture — this @@ -89,6 +100,41 @@ type RemoteWorkspaceSnapshotApplyInput = { export type RemoteWorkspaceSnapshotApplyResult = 'applied' | 'stale' | 'failed' +/** The fields the merge rewrote; the rest of its result is the window's own session. */ +function mergedFields( + merged: WorkspaceSessionState, + current: WorkspaceSessionState +): WorkspaceSessionPatch { + const before = new Map(Object.entries(current)) + return Object.fromEntries( + Object.entries(merged).filter(([field, value]) => value !== before.get(field)) + ) +} + +/** Whether the merge kept, on the target's worktrees, tabs or layout edits the host does not hold. */ +function mergeKeptLocalState( + merged: WorkspaceSessionState, + remote: WorkspaceSessionState, + worktreeIds: ReadonlySet, + localLayoutTabIds: ReadonlySet +): boolean { + const hostTabIds = new Set( + Object.values(remote.tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) + return ( + [...worktreeIds].some((worktreeId) => + (merged.tabsByWorktree[worktreeId] ?? []).some((tab) => !hostTabIds.has(tab.id)) + ) || + [...localLayoutTabIds].some( + (tabId) => + !terminalLayoutNodeEqual( + merged.terminalLayoutsByTabId[tabId]?.root, + remote.terminalLayoutsByTabId[tabId]?.root + ) + ) + ) +} + function currentRecoveryTabIds( state: AppState, authority: DirectSshAuthority, @@ -122,6 +168,7 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ isPreparationTokenCurrent, waitForWorkspaceSessionReady, finalizeHydratedTerminals, + importPeerTopology, onUnplacedTabWorktreePaths }: RemoteWorkspaceSnapshotApplyInput): Promise { const { authority } = token @@ -175,24 +222,25 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ onUnplacedTerminalTabs: (worktreePath) => unplacedTabWorktreePaths.push(worktreePath) }) } + const current = buildWorkspaceSessionPayload(state) + const localLayoutTabIds = new Set( + [...worktreeIds].flatMap((id) => + (state.tabsByWorktree[id] ?? []) + .filter( + (tab) => state.pendingDirectSshLayoutEditsByTabId[tab.id]?.targetId === authority.targetId + ) + .map((tab) => tab.id) + ) + ) const merged = mergeDirectSshRemoteWorkspaceSession( - buildWorkspaceSessionPayload(state), + current, remoteSession, worktreeIds, state.tabsByWorktree, currentRecoveryTabIds(state, authority, worktreeIds), toSshExecutionHostId(authority.targetId), state.closedTerminalTabTombstonesByTabId, - new Set( - [...worktreeIds].flatMap((id) => - (state.tabsByWorktree[id] ?? []) - .filter( - (tab) => - state.pendingDirectSshLayoutEditsByTabId[tab.id]?.targetId === authority.targetId - ) - .map((tab) => tab.id) - ) - ) + localLayoutTabIds ) if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { return 'stale' @@ -203,6 +251,22 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ try { const currentStore = store.getState() const replaceWorkspaceKeys = [...worktreeIds] + // Sent before the window's own apply; IPC order puts it ahead of any save that apply causes. + importPeerTopology({ + targetId: authority.targetId, + revision: snapshot.revision, + hostObservationToken: snapshot.hostObservationToken, + outcome: hasUnplacedTerminalTabs + ? 'conflict' + : mergeKeptLocalState(merged, remoteSession, worktreeIds, localLayoutTabIds) + ? 'kept-local' + : 'synced', + // Only the target's partition: the merge carries the window's copy of every other host's rows. + session: + buildWorkspaceSessionHostPatches(mergedFields(merged, current), currentStore).find( + ({ hostId }) => hostId === toSshExecutionHostId(authority.targetId) + )?.patch ?? {} + }).catch((error: unknown) => console.warn('[remote-workspace] import commit failed:', error)) currentStore.hydrateWorkspaceSession(merged, { directSshAuthority: authority, replaceWorkspaceKeys @@ -224,15 +288,16 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ // The host listed tabs on paths this client cannot place, so adopting zero of them is not // the host's picture. `conflict` is the phase that says exactly that, and it is load-bearing // twice over: - // - use-app-session-persistence.ts filters conflicted targets out of uploads, and an - // upload is a `replace-session` patch (remote-workspace-relay-sync.ts) that wholesale + // - main keeps conflicted targets out of exports (remote-workspace-export.ts), and an + // export is a `replace-session` patch (remote-workspace-relay-sync.ts) that wholesale // replaces the host snapshot - it would delete the very tabs we failed to adopt; // - workspace-terminal-host-authority.ts treats `offline`/`error` on an un-hydrated target // as `none`, its bounded floor, which authorises seeding AND sleeping-agent resume. // `conflict` is deliberately not in that set: the unplaced paths stay `unverifiable`, // and only the worktrees this apply did place read `none`. // Hydration is cleared, not merely withheld: the set is add-only, so a target that synced - // cleanly before would otherwise keep uploading from this incomplete picture (STA-3593). + // cleanly before would otherwise keep reading as hydrated from this incomplete picture + // (STA-3593). currentStore.clearRemoteWorkspaceHydrated(authority.targetId) currentStore.setRemoteWorkspaceSyncStatus(authority.targetId, { phase: 'conflict', diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts index b9e70bf86c2d..32331c619f5b 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts @@ -95,7 +95,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts index 58a5a02a0bfa..e46f37155f8c 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts @@ -94,7 +94,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts new file mode 100644 index 000000000000..fae38c13587c --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts @@ -0,0 +1,230 @@ +/** + * The window commits a host snapshot's import through main in one call, before applying it, so + * main records the agreement that keeps the import from exporting back. + */ +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport +} from '../../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' +import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' +import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const TARGET_ID = 'ssh-target-1' +const PATH = '/srv/proj/bug-cats' +const WORKTREE_ID = `repoA::${PATH}` + +const authority: DirectSshAuthority = { + targetId: TARGET_ID, + providerEpoch: 'provider-epoch-1' as SshProviderEpoch, + connectionGeneration: 1 +} + +function token(snapshotRevision: number): DirectSshSnapshotApplyToken { + return { + authority, + catalogRevision: 0, + repoFingerprint: 'fp', + authorityRequirement: 'required', + snapshotRevision, + outcome: 'complete' + } +} + +function snapshot(revision: number, tabIds: readonly string[]): RemoteWorkspaceObservedSnapshot { + return { + namespace: 'workspace', + revision, + updatedAt: revision, + schemaVersion: 1, + hostObservationToken: `observation-${revision}`, + session: { + activeWorktreePath: PATH, + activeTabId: tabIds[0] ?? null, + tabsByWorktreePath: { + [PATH]: tabIds.map((tabId, index) => ({ + id: tabId, + worktreePath: PATH, + ptyId: `pty-${tabId}`, + title: `Terminal ${index + 1}`, + customTitle: null, + color: null, + sortOrder: index, + createdAt: index + 1 + })) + }, + terminalLayoutsByTabId: {}, + activeWorktreePathsOnShutdown: [], + activeTabIdByWorktreePath: { [PATH]: tabIds[0] ?? null }, + remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])), + lastVisitedAtByWorktreePath: { [PATH]: revision }, + defaultTerminalTabsAppliedByWorktreePath: { [PATH]: true } + } + } satisfies RemoteWorkspaceObservedSnapshot +} + +type TestStore = ReturnType + +function seedCatalog(store: TestStore): void { + store.setState({ + worktreesByRepo: { + repoA: [ + makeWorktree({ + id: WORKTREE_ID, + repoId: 'repoA', + path: PATH, + hostId: `ssh:${TARGET_ID}` + } as never) + ] + }, + repos: [ + { + id: 'repoA', + path: '/srv/proj', + displayName: 'Proj', + badgeColor: '#000', + addedAt: 0, + connectionId: TARGET_ID + } as never + ], + // Why: only the IPC-backed reconnect is stubbed; hydration bookkeeping stays real because it is + // the very signal under test. + reconnectPersistedTerminals: (async () => {}) as never, + setRemoteWorkspaceSyncStatus: (() => {}) as never + }) +} + +async function importOf( + store: TestStore, + snap: RemoteWorkspaceObservedSnapshot +): Promise { + const importPeerTopology = vi.fn(async (_pull: RemoteWorkspacePeerImport) => {}) + await applyDirectSshRemoteWorkspaceSnapshot({ + store, + snapshot: snap, + token: token(snap.revision), + arrival: 1, + isArrivalCurrent: () => true, + isPreparationTokenCurrent: () => true, + waitForWorkspaceSessionReady: async () => true, + finalizeHydratedTerminals: () => 0, + importPeerTopology + }) + expect(importPeerTopology).toHaveBeenCalledOnce() + return importPeerTopology.mock.calls[0][0] +} + +describe('direct SSH snapshot import commit', () => { + it('sends the merge to the worktree partition with the pull it came from', async () => { + const store = createTestStore() + seedCatalog(store) + + const pull = await importOf(store, snapshot(6, ['host-tab'])) + + expect(pull).toMatchObject({ + targetId: TARGET_ID, + revision: 6, + hostObservationToken: 'observation-6', + outcome: 'synced' + }) + expect(pull.session.tabsByWorktree?.[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['host-tab']) + // Only what the merge rewrote; editor and browser state stay the window's own writes. + const mergedFields = new Set([ + 'activeRepoId', + 'activeWorktreeId', + 'activeWorkspaceKey', + 'activeTabId', + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'activeWorktreeIdsOnShutdown', + 'activeTabIdByWorktree', + 'remoteSessionIdsByTabId', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' + ]) + expect(Object.keys(pull.session).filter((field) => !mergedFields.has(field))).toEqual([]) + }) + + it("sends none of the window's rows for other partitions", async () => { + const store = createTestStore() + seedCatalog(store) + // The window's copy of a local worktree; main may already hold a newer one. + const localWorktreeId = 'repoL::/home/me/proj' + const localTab = { + id: 'local-tab', + ptyId: 'pty-local', + worktreeId: localWorktreeId, + title: 'local', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + store.setState({ + tabsByWorktree: { [localWorktreeId]: [localTab] }, + terminalLayoutsByTabId: { + [localTab.id]: { root: null, activeLeafId: null, expandedLeafId: null } + } + }) + + const pull = await importOf(store, snapshot(6, ['host-tab'])) + + expect(Object.keys(pull.session.tabsByWorktree ?? {})).toEqual([WORKTREE_ID]) + expect(pull.session.terminalLayoutsByTabId ?? {}).not.toHaveProperty(localTab.id) + }) + + it('reports kept-local when the merge keeps a tab the host has not seen', async () => { + const store = createTestStore() + seedCatalog(store) + await importOf(store, snapshot(1, ['agent'])) + // Created during the download, so the host's next snapshot still lists only `agent`. + store.setState({ + tabsByWorktree: { + [WORKTREE_ID]: [ + ...(store.getState().tabsByWorktree[WORKTREE_ID] ?? []), + { + id: 'setup', + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'setup', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + } + }) + + await expect(importOf(store, snapshot(2, ['agent']))).resolves.toMatchObject({ + outcome: 'kept-local' + }) + await expect(importOf(store, snapshot(3, ['agent', 'setup']))).resolves.toMatchObject({ + outcome: 'synced' + }) + }) + + it('reports a conflict when the window cannot place every host tab', async () => { + vi.useFakeTimers() + try { + const store = createTestStore() + seedCatalog(store) + store.setState({ worktreesByRepo: {} }) + const pending = importOf(store, snapshot(7, ['host-tab'])) + await vi.runAllTimersAsync() + + await expect(pending).resolves.toMatchObject({ revision: 7, outcome: 'conflict' }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts index 69660e1e6ea4..fcda90721f0c 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts @@ -98,7 +98,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts index 7d8fc937cc91..429b4b8409af 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts @@ -209,7 +209,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) // Exercise the real placement deadline without spending ten wall-clock seconds per snapshot. await vi.advanceTimersByTimeAsync(10_000) @@ -251,7 +252,8 @@ describe('a host snapshot whose terminal tabs cannot be placed locally', () => { isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) await Promise.resolve() await Promise.resolve() diff --git a/src/renderer/src/hooks/remote-workspace-target-sync-types.ts b/src/renderer/src/hooks/remote-workspace-target-sync-types.ts index 40d5b488c415..04f8247818a7 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync-types.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync-types.ts @@ -1,8 +1,7 @@ import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport } from '../../../shared/remote-workspace-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { DirectSshAuthority } from '../../../shared/ssh-types' import type { DirectSshPreparationInput, @@ -13,12 +12,7 @@ import type { RemoteWorkspaceSnapshotPlacementStore } from './remote-workspace-s export type RemoteWorkspaceApi = { get: (args: { targetId: string }) => Promise - setForConnectedTargets: (args: { - session?: WorkspaceSessionState - hydratedTargetIds?: string[] - expectedRevisionsByTargetId: Record - expectedHostObservationTokensByTargetId: Record - }) => Promise<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]> + importPeerTopology: (pull: RemoteWorkspacePeerImport) => Promise } export type RemoteWorkspaceTargetSyncDeps = { diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 65f7d2c02041..b89f086a3768 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -1,13 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { - RemoteWorkspaceObservedPatchResult, RemoteWorkspaceObservedSnapshot, RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types' import type { SshProviderEpoch } from '../../../shared/ssh-types' -import { i18n } from '@/i18n/i18n' -import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization' -import type { DirectSshPreparationInput } from './direct-ssh-reconnect-coordinator' import { appState, createHarness, @@ -21,84 +17,25 @@ import { } from './__tests__/remote-workspace-target-sync-test-harness' describe('createRemoteWorkspaceTargetSync', () => { - it('captures local tabs before get when deciding a revision-zero upload', async () => { + it('records a revision-zero pull with main, which seeds the empty host', async () => { const state = appState({ tabsByWorktree: { 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] } }) - const pendingGet = deferred() - const harness = createHarness(state, () => pendingGet.promise) - - const pending = harness.sync.syncAfterConnect(token()) - await flush() - state.tabsByWorktree = {} - pendingGet.resolve(snapshot(0)) - await pending - - expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() - expect(harness.setForConnectedTargets).toHaveBeenCalledWith( - expect.objectContaining({ - hydratedTargetIds: ['target-a'], - expectedRevisionsByTargetId: { 'target-a': 0 } - }) - ) - }) - - it('acknowledges the captured blank-host edit when a newer null-root edit arrives', async () => { - const firstEdit = { targetId: 'target-a', root: null } - const newerEdit = { targetId: 'target-a', root: null } - const acknowledgeDirectSshLayoutEdits = vi.fn() - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - }, - pendingDirectSshLayoutEditsByTabId: { 'tab-a': firstEdit }, - acknowledgeDirectSshLayoutEdits - }) const harness = createHarness(state, async () => snapshot(0)) - const uploaded = snapshot(1) - uploaded.session.terminalLayoutsByTabId = { - 'tab-a': { root: null, activeLeafId: null, expandedLeafId: null } - } - harness.setForConnectedTargets.mockImplementationOnce(async () => { - state.pendingDirectSshLayoutEditsByTabId = { 'tab-a': newerEdit } - return [{ targetId: owner.targetId, result: { ok: true, snapshot: uploaded } }] - }) await harness.sync.syncAfterConnect(token()) - const acknowledged = acknowledgeDirectSshLayoutEdits.mock.calls[0]?.[0]?.['tab-a'] - expect(acknowledged).toBe(firstEdit) - expect(state.pendingDirectSshLayoutEditsByTabId['tab-a']).toBe(newerEdit) - }) - - it.each([ - ['stale-revision', 'Workspace changed on another device'], - ['unavailable', 'Remote workspace sync unavailable'] - ] as const)('localizes the %s upload fallback', async (reason, message) => { - const previousLanguage = i18n.language - await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE) - try { - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - } - }) - const harness = createHarness(state, async () => snapshot(0), { - ok: false, - reason - }) - - await harness.sync.syncAfterConnect(token()) - - expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith( - 'target-a', - expect.objectContaining({ message: `[${message}]` }) - ) - } finally { - await i18n.changeLanguage(previousLanguage) - } + expect(state.markRemoteWorkspaceHydrated).toHaveBeenCalledWith('target-a') + expect(harness.importPeerTopology).toHaveBeenCalledOnce() + expect(harness.importPeerTopology).toHaveBeenCalledWith({ + targetId: 'target-a', + revision: 0, + hostObservationToken: snapshot(0).hostObservationToken, + outcome: 'kept-local', + session: {} + }) }) it('publishes nothing from a snapshot response after its authority turns stale', async () => { @@ -612,45 +549,6 @@ describe('createRemoteWorkspaceTargetSync', () => { } }) - it('does not publish a revision-zero push after a newer snapshot arrives', async () => { - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - } - }) - const pendingPush = - deferred<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]>() - const pendingCapture = deferred() - const harness = createHarness(state, async () => snapshot(0)) - harness.setForConnectedTargets.mockImplementationOnce(() => pendingPush.promise) - - const first = harness.sync.syncAfterConnect(token()) - await flush() - expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() - - harness.capturePreparationInput.mockImplementationOnce(() => pendingCapture.promise) - const second = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(85)) - await flush() - pendingPush.resolve([{ targetId: 'target-a', result: { ok: true, snapshot: snapshot(1) } }]) - await first - - expect(state.setRemoteWorkspaceSyncStatus).not.toHaveBeenCalledWith( - 'target-a', - expect.objectContaining({ direction: 'push' }) - ) - - harness.sync.stop() - pendingCapture.resolve({ - ...owner, - catalogRevision: 1, - repoRefs: [{ repoId: 'repo-a', executionHostId: 'ssh:target-a' }], - authorityRequirement: 'required', - reason: 'workspace-snapshot', - snapshotRevision: 85 - }) - await second - }) - it('stopping snapshot sync cancels the active placement waiter immediately', async () => { vi.useFakeTimers() const state = appState({ worktreesByRepo: {} }) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 05d027ece2e7..051e9041f684 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -1,7 +1,6 @@ import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' import type { DirectSshAuthority } from '../../../shared/ssh-types' import { translate } from '@/i18n/i18n' -import { buildWorkspaceSessionPayload } from '../lib/workspace-session' import type { DirectSshPreparationToken, DirectSshSnapshotApplyToken @@ -11,8 +10,6 @@ import { resolveExactDirectSshTargetWorktreeIds } from './remote-workspace-snaps import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' import { createRemoteWorkspaceSnapshotArrivalCoordinator } from './remote-workspace-snapshot-arrival-coordinator' import { createDeferredSnapshotPlacementRetries } from './remote-workspace-deferred-placement-retry' -import { applyRemoteWorkspacePushStatus } from './remote-workspace-push-status' -import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { waitForRemoteWorkspaceSessionReady } from './remote-workspace-session-readiness' import type { RemoteWorkspaceTargetSync, @@ -79,6 +76,7 @@ export function createRemoteWorkspaceTargetSync( waitForWorkspaceSessionReady: (signal) => waitForRemoteWorkspaceSessionReady(deps.store, signal), finalizeHydratedTerminals: deps.finalizeHydratedTerminals, + importPeerTopology: deps.remoteWorkspace.importPeerTopology, onUnplacedTabWorktreePaths: (worktreePaths) => { unplacedTabWorktreePaths = worktreePaths } @@ -178,49 +176,30 @@ export function createRemoteWorkspaceTargetSync( } return } - deps.store.getState().markRemoteWorkspaceHydrated(authority.targetId) - if (!hasLocalTabs) { - deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'idle', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - hostObservationToken: snapshot.hostObservationToken, - message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') - }) - return - } - if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { - return + const agreed = { + revision: snapshot.revision, + updatedAt: snapshot.updatedAt, + hostObservationToken: snapshot.hostObservationToken } - const pendingLayoutEdits = deps.store.getState().pendingDirectSshLayoutEditsByTabId - const results = await deps.remoteWorkspace.setForConnectedTargets({ - session: buildWorkspaceSessionPayload(deps.store.getState()), - hydratedTargetIds: [authority.targetId], - expectedRevisionsByTargetId: { [authority.targetId]: snapshot.revision }, - expectedHostObservationTokensByTargetId: { - [authority.targetId]: snapshot.hostObservationToken - } + deps.store.getState().markRemoteWorkspaceHydrated(authority.targetId) + deps.store.getState().setRemoteWorkspaceSyncStatus( + authority.targetId, + hasLocalTabs + ? { phase: 'pulling', direction: 'pull', ...agreed } + : { + phase: 'idle', + ...agreed, + message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') + } + ) + // An empty host has nothing to import; main seeds it from this desktop and reports the push. + await deps.remoteWorkspace.importPeerTopology({ + targetId: authority.targetId, + revision: snapshot.revision, + hostObservationToken: snapshot.hostObservationToken, + outcome: 'kept-local', + session: {} }) - if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { - return - } - const result = results.find((entry) => entry.targetId === authority.targetId)?.result - if (result?.ok) { - const currentState = deps.store.getState() - currentState.acknowledgeDirectSshLayoutEdits( - Object.fromEntries( - Object.entries(pendingLayoutEdits ?? {}).flatMap(([tabId, entry]) => { - const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] - return entry.targetId === authority.targetId && - uploaded && - terminalLayoutNodeEqual(entry.root, uploaded.root) - ? [[tabId, entry]] - : [] - }) - ) - ) - } - applyRemoteWorkspacePushStatus(deps.store.getState(), authority.targetId, result, snapshot) } const syncAfterConnect = (token: DirectSshPreparationToken): Promise => diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts index d8bfbd54d4c4..ae3057a646b2 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts @@ -113,7 +113,8 @@ describe('useIpcEvents agent status snapshot integration', () => { onChanged: (cb: typeof onChangedListenerRef.current) => { onChangedListenerRef.current = cb return () => {} - } + }, + onPushStatus: () => () => {} } }) ) diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 9c204026b7aa..af6bced327a9 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -24,6 +24,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'pty.onExit', 'rateLimits.onUpdate', 'remoteWorkspace.onChanged', + 'remoteWorkspace.onPushStatus', 'repos.onChanged', 'runtime.onBrowserDriverChanged', 'runtime.onBrowserRemoteViewersChanged', @@ -198,6 +199,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ssh.onStateChanged', 'ui.onSystemResumed', 'remoteWorkspace.onChanged', + 'remoteWorkspace.onPushStatus', 'ui.onTerminalZoom', 'agentStatus.onSet', 'agentStatus.onClear', diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index e6dbc3f352e7..13e645f02f4e 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -215,7 +215,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { [folderKey]: 'runtime:env-1' } } - ).written + ) expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} })) expect(patch).toHaveBeenCalledWith( @@ -259,7 +259,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { [folderKey]: 'runtime:stale-env' } } - ).written + ) expect(patch).toHaveBeenCalledTimes(1) expect(patch.mock.calls[0][0]).toEqual( @@ -306,7 +306,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }] } } - ).written + ) expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} })) expect(patch).toHaveBeenCalledWith( @@ -374,7 +374,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { ] } } - ).written + ) expect(patch).toHaveBeenCalledWith( expect.objectContaining({ @@ -484,7 +484,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { }) describe('patchWorkspaceSessionByHost write outcome', () => { - it('rejects `written` when a host partition fails, while the local write still resolves', async () => { + it('rejects when a host partition fails, after the local write resolves', async () => { const folderKey = folderWorkspaceKey('folder-1') const patch = vi.fn(async (_slice: unknown, hostId?: string) => { if (hostId) { @@ -500,8 +500,8 @@ describe('patchWorkspaceSessionByHost write outcome', () => { restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' } } ) - await expect(result.localWrite).resolves.toBeUndefined() - await expect(result.written).rejects.toThrow('runtime down') + await expect(result).rejects.toThrow('runtime down') + expect(patch).toHaveBeenCalledWith(expect.anything()) expect(patch).toHaveBeenCalledWith(expect.anything(), 'runtime:env-1') }) }) @@ -546,7 +546,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { { get: vi.fn(), patch, setSync: vi.fn() }, { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } - ).written + ) expect(patch).toHaveBeenCalledWith( { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, @@ -562,7 +562,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { { get: vi.fn(), patch, setSync: vi.fn() }, { remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } }, { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } - ).written + ) expect(patch).toHaveBeenCalledWith( { remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } }, @@ -583,7 +583,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { [remoteWorktreeId]: [{ id: 'remote-tab', worktreeId: remoteWorktreeId }] } } - ).written + ) expect(patch).toHaveBeenCalledWith( { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, @@ -603,7 +603,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } - ).written + ) expect(patch).toHaveBeenCalledTimes(1) expect(patch).toHaveBeenCalledWith( diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 909acdd4d69f..540e3173a0d0 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -66,6 +66,11 @@ type DurableSessionApi = SessionApi & { flush: () => Promise } +export type WorkspaceSessionHostPatch = { + patch: WorkspaceSessionPatch + hostId?: ExecutionHostId +} + export type WorkspaceSessionHostSnapshot = { state: WorkspaceSessionState hostId?: ExecutionHostId @@ -231,33 +236,40 @@ function splitWorkspaceSessionForWrite( return slices } -/** Patch path of the debounced session writer: split the partial patch by owner host and patch - * each partition. `localWrite` orders the SSH remote-workspace upload; `written` settles once - * every partition did and rejects if any failed, so the writer re-queues those fields. */ +/** A partial patch split by owner host, local first. */ +export function buildWorkspaceSessionHostPatches( + patch: WorkspaceSessionPatch, + state: HostPersistenceState +): WorkspaceSessionHostPatch[] { + const slices = splitWorkspaceSessionForWrite(patch as WorkspaceSessionState, state, 'patch') + return [ + { patch: slices[LOCAL_EXECUTION_HOST_ID] ?? patch }, + ...nonLocalHostSessionEntries(slices).map(([hostId, slice]) => ({ hostId, patch: slice })) + ] +} + +/** Patch path of the debounced session writer: patch each owner partition. Settles once every + * partition did and rejects if any failed, so the writer re-queues those fields. */ export function patchWorkspaceSessionByHost( api: SessionApi, patch: WorkspaceSessionPatch, state: HostPersistenceState -): { localWrite: Promise; written: Promise } { - const slices = splitWorkspaceSessionForWrite(patch as WorkspaceSessionState, state, 'patch') - const local = (slices[LOCAL_EXECUTION_HOST_ID] ?? patch) as WorkspaceSessionPatch - const localWrite = api.patch(local) - const hostWrites = nonLocalHostSessionEntries(slices).map(([hostId, slice]) => - api.patch(slice, hostId).catch((err: unknown) => { - console.warn(`[session] host partition patch failed for ${hostId}:`, err) - throw err - }) +): Promise { + const writes = buildWorkspaceSessionHostPatches(patch, state).map(({ hostId, patch: slice }) => + hostId + ? api.patch(slice, hostId).catch((err: unknown) => { + console.warn(`[session] host partition patch failed for ${hostId}:`, err) + throw err + }) + : api.patch(slice) ) // Why allSettled: every partition finishes before a retry rewrites the same fields. - const written = Promise.allSettled([localWrite, ...hostWrites]).then((results) => { + return Promise.allSettled(writes).then((results) => { const failure = results.find((result) => result.status === 'rejected') if (failure) { throw failure.reason } }) - // Why: a host failure must not surface as an unhandled rejection on the upload chain's promise. - localWrite.catch(() => {}) - return { localWrite, written } } /** Persist a fresh full snapshot to every owning host partition, then force the diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts b/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts index 26a3545f2295..cafb2bffe9b6 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts @@ -95,7 +95,7 @@ describe('ssh folder workspace partition ownership', () => { }, { tabsByWorktree: { [FOLDER_KEY]: [tab('tab-desktop', FOLDER_KEY)] } }, state - ).written + ) // Main applies a patch field-wise ({ ...current, ...patch }), so a `tabsByWorktree` written to // `ssh:` WITHOUT the folder row replaces the row main put there. @@ -313,7 +313,7 @@ describe('a bare workspace id two partitions both hold', () => { contestedHostWorkspaceSessions: read.contestedHostWorkspaceSessions, contestedPrimaryHostBySessionKey: read.contestedPrimaryHostBySessionKey } - ).written + ) // Main applies a patch field-wise, so an `ssh:` write carrying only the sibling would // erase the declined row from the one partition that still holds it. Declining to show a row diff --git a/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts b/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts index 651e30bb9776..aeface581b6a 100644 --- a/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts +++ b/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts @@ -89,7 +89,7 @@ async function saveDebouncedPatch(api: ReturnType) terminalLayoutsByTabId: payload.terminalLayoutsByTabId }, state - ).written + ) } async function saveQuitSnapshot(api: ReturnType) { diff --git a/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts b/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts index d0befefa537a..1e9dbc93b28f 100644 --- a/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts +++ b/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts @@ -74,14 +74,14 @@ describe('purge stops routing session writes to the removed runtime partition', } const patch = { activeWorktreeIdsOnShutdown: [WT_A] } as unknown as WorkspaceSessionPatch - await patchWorkspaceSessionByHost(api, patch, store.getState()).written + await patchWorkspaceSessionByHost(api, patch, store.getState()) const runtimeWritesBefore = api.patch.mock.calls.filter(([, hostId]) => hostId === RUNTIME_A) expect(runtimeWritesBefore).toHaveLength(1) store.getState().purgeStaleRuntimeHostState(['env-a']) api.patch.mockClear() - await patchWorkspaceSessionByHost(api, patch, store.getState()).written + await patchWorkspaceSessionByHost(api, patch, store.getState()) const runtimeWritesAfter = api.patch.mock.calls.filter(([, hostId]) => hostId === RUNTIME_A) expect(runtimeWritesAfter).toHaveLength(0) // The local write still happens. diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index 9544b709f9ca..fa1718407790 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -1,4 +1,5 @@ import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' +import type { WorkspaceSessionPatch } from './workspace-session-state-types' // Transient client-local fields are omitted, not merely unset: `recovery` is // this client's in-flight heal, stamped with this machine's clock, so the type @@ -65,6 +66,34 @@ export type RemoteWorkspaceObservedPatchResult = message?: string } +/** The CAS inputs an export to a host runs under: the host revision and observation this desktop last agreed on. */ +export type RemoteWorkspaceExportAuthority = { + revision: number + hostObservationToken: string +} + +/** A window's pull from one host: the session writes it imports and whether the import is whole. */ +export type RemoteWorkspacePeerImport = RemoteWorkspaceExportAuthority & { + targetId: string + /** + * `kept-local`: this desktop holds state the host lacks (tabs it never saw, unsent layout edits, + * or anything at all for a host never written), which must export. `conflict`: the window could + * not place every host tab, so this desktop must not export. + */ + outcome: 'synced' | 'kept-local' | 'conflict' + /** The import's patch to the target's own `ssh:` partition; empty when the host had nothing. */ + session: WorkspaceSessionPatch +} + +/** Main's report of one export it ran, for the window's sync status. */ +export type RemoteWorkspacePushStatusEvent = { + targetId: string + authority: RemoteWorkspaceExportAuthority + result: RemoteWorkspaceObservedPatchResult | null + /** Set instead of a result when the export threw. */ + error?: string +} + export const REMOTE_WORKSPACE_CHANGED_NOTIFICATION = 'workspace.changed' /** diff --git a/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts b/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts index 7ac0ec897a7f..748f3b1ebd7f 100644 --- a/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts +++ b/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts @@ -154,7 +154,8 @@ async function applySnapshot(snapshot: RemoteWorkspaceObservedSnapshot): Promise isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => useAppStore.getState().retryDirectSshTargetPanes(authority) + finalizeHydratedTerminals: () => useAppStore.getState().retryDirectSshTargetPanes(authority), + importPeerTopology: async () => {} }) expect(result).toBe('applied') }