diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index efd27c8ca95f..133a52e97575 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -7731,7 +7731,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, and 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 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 merge and explicit export now carry exact target/provider incarnation authority, select local tabs by worktree plus tab, and reject cross-target layouts, mounted leaves, reconnect hints, remote sessions, and ambiguous duplicate ownership before hydration or publication. Durable adoption also rejects git or folder workspace keys that share one bare tab identity, and queued explicit export revalidates target, provider epoch, and connection generation before using a replacement multiplexer. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and 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 macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, eager six-terminal remount after renderer reload, and two full restarts after a live session is stranded in its SSH partition. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, and WSL remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", @@ -7744,6 +7744,8 @@ "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "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/main/ipc/remote-workspace-patch-authority.test.ts src/main/ipc/remote-workspace-cache.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-session-partition-adoption.test.ts src/main/persistence-ssh-partition-adoption.test.ts src/main/ipc/session.test.ts src/renderer/src/lib/workspace-session-host-persistence.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.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/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", @@ -7754,10 +7756,21 @@ "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_SSH_TARGET_HOST= SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-session-partition-adoption.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", + "src/shared/workspace-session-partition-adoption.test.ts", + "src/main/persistence-ssh-partition-adoption.test.ts", + "src/main/ipc/session.test.ts", + "src/renderer/src/lib/workspace-session-host-persistence.test.ts", + "src/main/ipc/remote-workspace.test.ts", + "src/main/ipc/remote-workspace-patch-queue.test.ts", + "src/main/ipc/remote-workspace-patch-authority.test.ts", + "src/main/ipc/remote-workspace-cache.test.ts", + "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", + "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.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", @@ -7813,9 +7826,67 @@ "src/shared/ssh-types.test.ts", "tests/e2e/terminal-cold-activation-deferral.spec.ts", "tests/e2e/ssh-cold-activation-restore.spec.ts", + "tests/e2e/ssh-session-partition-adoption.spec.ts", "tests/e2e/ssh-docker-relay-perf.spec.ts" ], "assertionRefs": [ + { + "file": "src/shared/workspace-session-partition-adoption.test.ts", + "assertions": [ + "topology revisions select terminal membership authority while each authoritative pane moves its PTY, incarnation, layout fields, and provider session as one bundle", + "equal-revision pane conflicts remain unmerged instead of pairing stale PTY identity with newer provider metadata", + "folder-only workspaces retain tab groups, layouts, files, and provider-session identity", + "git and folder workspace keys sharing one tab identity preserve both partitions without merging layout, incarnation, or remote-session ownership", + "equivalent 1000- and 2000-workspace partitions each enumerate global layouts exactly three times", + "old tombstones cannot retire a newer SSH incarnation and reconciliation is idempotent" + ] + }, + { + "file": "src/main/persistence-ssh-partition-adoption.test.ts", + "assertions": [ + "one transaction enumerates every persisted SSH partition and preserves non-SSH partitions", + "equal- or unequal-revision duplicate repository keys preserve non-empty local state and every ambiguous SSH source while reconciled sources prune independently", + "the durable owner flush precedes complete source pruning and both failure phases remain retryable", + "qualified adoption scopes to one SSH partition and frozen persistence rejects before reporting durable success", + "a sync-flushed SSH binding and incarnation survive the crash window before renderer adoption" + ] + }, + { + "file": "src/renderer/src/lib/workspace-session-host-persistence.test.ts", + "assertions": [ + "startup delegates persisted SSH enumeration to main even with no loaded repository", + "freshly adopted local and SSH state merges back with every runtime-host slice", + "durability failure rejects startup adoption instead of being folded into an empty inventory" + ] + }, + { + "file": "src/main/ipc/remote-workspace.test.ts", + "assertions": [ + "duplicate repository ids resolve through the exact SSH target host and export only that target's adopted state", + "conflicting local and target partitions skip replacement exports, and explicit sessions require matching target provenance and durable PTY identity", + "late workspace notifications from a replaced provider cannot enter the current provider's snapshot cache" + ] + }, + { + "file": "src/main/ipc/remote-workspace-patch-authority.test.ts", + "assertions": [ + "an explicit export queued behind a same-target patch revalidates target, provider epoch, and connection generation before replacement multiplexer use", + "stale rejection leaves the per-target queue retryable for a current-authority export", + "a late old-provider snapshot response cannot poison the replacement provider's retry revision" + ] + }, + { + "file": "src/main/ipc/remote-workspace-cache.test.ts", + "assertions": [ + "snapshot cache entries are visible only to the exact target, provider epoch, and connection generation that produced them" + ] + }, + { + "file": "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", + "assertions": [ + "the connected-target push carries the session target id and authority alongside the hydrated target set" + ] + }, { "file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts", "assertions": [ @@ -8142,6 +8213,14 @@ "repo and worktree setup fails closed unless the exact direct SSH host and complete authority are returned", "a reconnected terminal accepts input and writes a proof file visible inside the Linux SSH target" ] + }, + { + "file": "tests/e2e/ssh-session-partition-adoption.spec.ts", + "assertions": [ + "a live Git terminal stranded in an SSH partition retains its exact tab and PTY identity across two full app restarts", + "the atomic boot adoption prunes the source partition and remote export remains non-empty", + "the restored shell retains its environment and writes proof through a non-loopback Linux SSH target" + ] } ], "evidenceRuns": [ @@ -8216,6 +8295,15 @@ "result": "passed", "durationSeconds": 12.5, "summary": "One Electron journey passed on the final implementation after exact-authority hydration; six restored SSH terminal managers remounted after renderer reload and remote input reached the Linux target." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_SSH_TARGET_HOST= SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-session-partition-adoption.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 19.4, + "summary": "A headed macOS client created a live terminal in a throwaway Docker Linux Git repository over non-loopback SSH, moved its persisted tab into the SSH host partition, and completed two full restarts with the same tab and PTY identities, a pruned source partition, a non-empty remote snapshot, and a shell-environment proof file." } ], "runtimeBudget": { @@ -8232,7 +8320,7 @@ }, "performanceBudget": { "required": true, - "evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed." + "evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. Startup adoption enumerates the in-memory persisted partition map once, reconciles each SSH partition in stable host order, and performs exactly two durable flushes total: owner first, then source pruning. Remote export adds one in-memory partition overlay per target only when no explicit session is supplied; neither path adds polling, subprocesses, listeners, or provider calls. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed." }, "promotionCriteria": [ "Use deterministic fake providers for failure and unknown-liveness cases.", diff --git a/src/main/ipc/remote-workspace-cache.test.ts b/src/main/ipc/remote-workspace-cache.test.ts index 33f3222b5ace..cbabc7677bef 100644 --- a/src/main/ipc/remote-workspace-cache.test.ts +++ b/src/main/ipc/remote-workspace-cache.test.ts @@ -3,6 +3,7 @@ import type { RemoteWorkspaceSession, RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch } from '../../shared/ssh-types' import { _getRemoteWorkspaceCacheSizesForTests, _resetRemoteWorkspaceCachesForTests @@ -32,6 +33,14 @@ function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspac } } +function authority(targetId: string): DirectSshAuthority { + return { + targetId, + providerEpoch: `epoch-${targetId}` as SshProviderEpoch, + connectionGeneration: 1 + } +} + describe('remote workspace snapshot cache', () => { beforeEach(() => { _resetRemoteWorkspaceCachesForTests() @@ -40,7 +49,7 @@ describe('remote workspace snapshot cache', () => { it('LRU-evicts old target snapshots', () => { for (let i = 0; i <= REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) { _rememberRemoteWorkspaceSnapshotForTests( - `target-${i}`, + authority(`target-${i}`), snapshot({ activeWorktreePath: `/repo-${i}`, activeTabId: null, @@ -53,24 +62,39 @@ describe('remote workspace snapshot cache', () => { expect(_getRemoteWorkspaceCacheSizesForTests().snapshots).toBe( REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES ) - expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeUndefined() - expect(_getRemoteWorkspaceSnapshotForTests('target-1')?.session.activeWorktreePath).toBe( - '/repo-1' - ) + expect(_getRemoteWorkspaceSnapshotForTests(authority('target-0'))).toBeUndefined() + expect( + _getRemoteWorkspaceSnapshotForTests(authority('target-1'))?.session.activeWorktreePath + ).toBe('/repo-1') }) it('refreshes snapshot recency on cache reads', () => { for (let i = 0; i < REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) { _rememberRemoteWorkspaceSnapshotForTests( - `target-${i}`, + authority(`target-${i}`), snapshot(emptyRemoteWorkspaceSession()) ) } - expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined() - _rememberRemoteWorkspaceSnapshotForTests('target-new', snapshot(emptyRemoteWorkspaceSession())) + expect(_getRemoteWorkspaceSnapshotForTests(authority('target-0'))).toBeDefined() + _rememberRemoteWorkspaceSnapshotForTests( + authority('target-new'), + snapshot(emptyRemoteWorkspaceSession()) + ) + + expect(_getRemoteWorkspaceSnapshotForTests(authority('target-0'))).toBeDefined() + expect(_getRemoteWorkspaceSnapshotForTests(authority('target-1'))).toBeUndefined() + }) + + it('does not return a snapshot across provider authority rotation', () => { + const original = authority('target-1') + const replacement = { + ...original, + providerEpoch: 'epoch-replacement' as SshProviderEpoch, + connectionGeneration: 2 + } + _rememberRemoteWorkspaceSnapshotForTests(original, snapshot(emptyRemoteWorkspaceSession(), 99)) - expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined() - expect(_getRemoteWorkspaceSnapshotForTests('target-1')).toBeUndefined() + expect(_getRemoteWorkspaceSnapshotForTests(replacement)).toBeUndefined() }) }) diff --git a/src/main/ipc/remote-workspace-events.ts b/src/main/ipc/remote-workspace-events.ts index cd7ae2b7c459..659808c0e615 100644 --- a/src/main/ipc/remote-workspace-events.ts +++ b/src/main/ipc/remote-workspace-events.ts @@ -1,7 +1,10 @@ +import type { DirectSshAuthority } from '../../shared/ssh-types' + type RemoteWorkspaceNotificationHandler = ( targetId: string, method: string, - params: Record + params: Record, + authority: DirectSshAuthority ) => void const handlers = new Set() @@ -16,9 +19,10 @@ export function registerRemoteWorkspaceNotificationHandler( export function notifyRemoteWorkspaceHandlers( targetId: string, method: string, - params: Record + params: Record, + authority: DirectSshAuthority ): void { for (const handler of handlers) { - handler(targetId, method, params) + handler(targetId, method, params, authority) } } diff --git a/src/main/ipc/remote-workspace-explicit-session-authority.ts b/src/main/ipc/remote-workspace-explicit-session-authority.ts new file mode 100644 index 000000000000..b5377588c572 --- /dev/null +++ b/src/main/ipc/remote-workspace-explicit-session-authority.ts @@ -0,0 +1,103 @@ +import { isDeepStrictEqual } from 'node:util' +import type { Store } from '../persistence' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' +import type { RemoteWorkspaceSession } from '../../shared/remote-workspace-types' +import { parseAppSshPtyId } from '../../shared/ssh-pty-id' +import type { DirectSshAuthority } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' + +type SessionAuthorityStore = Pick + +function targetForWorktree( + store: SessionAuthorityStore, + worktreeId: string, + targetId: string +): string | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return store.getRepo(repoId, toSshExecutionHostId(targetId))?.connectionId ?? null +} + +export function exportSessionForTarget( + store: SessionAuthorityStore, + authority: DirectSshAuthority, + session: WorkspaceSessionState +): RemoteWorkspaceSession | null { + const isTargetWorktree = (worktreeId: string): boolean => + targetForWorktree(store, worktreeId, authority.targetId) === authority.targetId + const ownerByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) { + for (const tab of tabs) { + const owner = ownerByTabId.get(tab.id) + if ( + owner && + owner !== worktreeId && + (isTargetWorktree(owner) || isTargetWorktree(worktreeId)) + ) { + return null + } + ownerByTabId.set(tab.id, worktreeId) + } + } + const ownsPty = (ptyId: string): boolean => + parseAppSshPtyId(ptyId)?.connectionId === authority.targetId + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) { + if (!isTargetWorktree(worktreeId)) { + continue + } + for (const tab of tabs) { + if (tab.worktreeId !== worktreeId || (tab.ptyId && !ownsPty(tab.ptyId))) { + return null + } + const layout = session.terminalLayoutsByTabId[tab.id] + if (Object.values(layout?.ptyIdsByLeafId ?? {}).some((ptyId) => !ownsPty(ptyId))) { + return null + } + const remoteSessionId = session.remoteSessionIdsByTabId?.[tab.id] + if (remoteSessionId && !ownsPty(remoteSessionId)) { + return null + } + } + } + return exportRemoteWorkspaceSession(session, { isTargetWorktree }) +} + +function hasWorkspaceTabs(session: WorkspaceSessionState): boolean { + return Object.values(session.tabsByWorktree).some((tabs) => tabs.length > 0) +} + +function hasRemoteWorkspaceTabs(session: RemoteWorkspaceSession): boolean { + return Object.values(session.tabsByWorktreePath).some((tabs) => tabs.length > 0) +} + +export function exportExplicitSessionForTarget( + store: SessionAuthorityStore, + authority: DirectSshAuthority, + session: WorkspaceSessionState +): RemoteWorkspaceSession | null { + const projected = exportSessionForTarget(store, authority, session) + if (!projected) { + return null + } + if (hasWorkspaceTabs(session) && !hasRemoteWorkspaceTabs(projected)) { + return null + } + const durableSessions = [ + store.getWorkspaceSession(), + store.getWorkspaceSession(toSshExecutionHostId(authority.targetId)) + ] + for (const durableSession of durableSessions) { + const durableProjection = exportSessionForTarget(store, authority, durableSession) + if (!durableProjection) { + return null + } + if ( + hasRemoteWorkspaceTabs(durableProjection) && + !isDeepStrictEqual(durableProjection, projected) + ) { + return null + } + } + return projected +} diff --git a/src/main/ipc/remote-workspace-patch-authority.test.ts b/src/main/ipc/remote-workspace-patch-authority.test.ts new file mode 100644 index 000000000000..6cb47d10a57b --- /dev/null +++ b/src/main/ipc/remote-workspace-patch-authority.test.ts @@ -0,0 +1,244 @@ +import { ipcMain } from 'electron' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { + RemoteWorkspaceSession, + RemoteWorkspaceSnapshot +} from '../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch, SshTarget } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +const { + getActiveMultiplexerMock, + getSshConnectionStoreMock, + getSshProviderAuthorityMock, + isCurrentSshProviderAuthorityMock, + registerRemoteWorkspaceNotificationHandlerMock +} = vi.hoisted(() => ({ + getActiveMultiplexerMock: vi.fn(), + getSshConnectionStoreMock: vi.fn(), + getSshProviderAuthorityMock: vi.fn(), + isCurrentSshProviderAuthorityMock: vi.fn(), + registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()) +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() } +})) + +vi.mock('./ssh', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock, + getSshConnectionStore: getSshConnectionStoreMock +})) + +vi.mock('./remote-workspace-events', () => ({ + registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock +})) + +vi.mock('../ssh/ssh-provider-authority', () => ({ + getSshProviderAuthority: getSshProviderAuthorityMock, + isCurrentSshProviderAuthority: isCurrentSshProviderAuthorityMock +})) + +import { + _resetRemoteWorkspaceCachesForTests, + registerRemoteWorkspaceHandlers +} from './remote-workspace' + +const TARGET: SshTarget = { + id: 'target-1', + label: 'Target 1', + host: 'one.example.com', + port: 22, + username: 'alice' +} + +function authority(providerEpoch: string, connectionGeneration: number): DirectSshAuthority { + return { + targetId: TARGET.id, + providerEpoch: providerEpoch as SshProviderEpoch, + connectionGeneration + } +} + +function sessionWithTab(worktreePath: string, tabId: string): WorkspaceSessionState { + const worktreeId = `repo-target-1::${worktreePath}` + return { + activeRepoId: 'repo-target-1', + activeWorktreeId: worktreeId, + activeTabId: tabId, + tabsByWorktree: { + [worktreeId]: [{ id: tabId, type: 'terminal', title: tabId, worktreeId } as never] + }, + terminalLayoutsByTabId: {} + } +} + +function snapshot(session: RemoteWorkspaceSession, revision: number): RemoteWorkspaceSnapshot { + return { + namespace: 'target', + revision, + updatedAt: 123, + schemaVersion: 1, + session + } +} + +function emptyRemoteSession(activeWorktreePath: string): RemoteWorkspaceSession { + return { + activeWorktreePath, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + } +} + +function patchedSession(params: Record): RemoteWorkspaceSession { + return (params.patch as { session: RemoteWorkspaceSession }).session +} + +describe('remote workspace queued patch authority', () => { + const handlers = new Map unknown>() + let currentAuthority = authority('epoch-1', 1) + let currentSession = sessionWithTab('/first', 'first-tab') + let currentMux: { request: ReturnType } | undefined + + beforeEach(() => { + _resetRemoteWorkspaceCachesForTests() + handlers.clear() + currentAuthority = authority('epoch-1', 1) + currentSession = sessionWithTab('/first', 'first-tab') + currentMux = undefined + 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() + getActiveMultiplexerMock.mockReset() + getActiveMultiplexerMock.mockImplementation(() => currentMux) + getSshConnectionStoreMock.mockReset() + getSshConnectionStoreMock.mockReturnValue({ listTargets: () => [TARGET] }) + getSshProviderAuthorityMock.mockReset() + getSshProviderAuthorityMock.mockImplementation(() => currentAuthority) + isCurrentSshProviderAuthorityMock.mockReset() + isCurrentSshProviderAuthorityMock.mockImplementation( + (candidate: DirectSshAuthority) => + candidate.targetId === currentAuthority.targetId && + candidate.providerEpoch === currentAuthority.providerEpoch && + candidate.connectionGeneration === currentAuthority.connectionGeneration + ) + const store = { + getRepo: vi.fn(() => ({ + id: 'repo-target-1', + path: '/repo', + connectionId: TARGET.id, + executionHostId: `ssh:${TARGET.id}` + })), + getWorkspaceSession: vi.fn(() => currentSession) + } as unknown as Store + registerRemoteWorkspaceHandlers(store, () => null) + }) + + function exportExplicit( + session: WorkspaceSessionState, + sessionAuthority: DirectSshAuthority + ): Promise { + const handler = handlers.get('remoteWorkspace:setForConnectedTargets') + if (!handler) { + throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') + } + currentSession = session + return handler(null, { + session, + sessionTargetId: TARGET.id, + sessionAuthority, + hydratedTargetIds: [TARGET.id] + }) as Promise + } + + it('rejects stale queued authority before replacement mux use and permits retry', async () => { + const originalAuthority = currentAuthority + const replacementAuthority = authority('epoch-2', 2) + let releaseFirstPatch!: () => void + const firstPatchCanFinish = new Promise((resolve) => { + releaseFirstPatch = resolve + }) + const originalRequest = vi.fn(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return snapshot(emptyRemoteSession('/previous'), 7) + } + await firstPatchCanFinish + return { ok: true, snapshot: snapshot(patchedSession(params), 8) } + }) + const replacementRequest = vi.fn(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return snapshot(emptyRemoteSession('/replacement'), 9) + } + return { ok: true, snapshot: snapshot(patchedSession(params), 10) } + }) + currentMux = { request: originalRequest } + + const first = exportExplicit(sessionWithTab('/first', 'first-tab'), originalAuthority) + await vi.waitFor(() => + expect(originalRequest.mock.calls.some(([method]) => method === 'workspace.patch')).toBe(true) + ) + const stale = exportExplicit(sessionWithTab('/stale', 'stale-tab'), originalAuthority) + await new Promise((resolve) => setTimeout(resolve, 0)) + + currentAuthority = replacementAuthority + currentMux = { request: replacementRequest } + releaseFirstPatch() + + await expect(first).resolves.toMatchObject([{ targetId: TARGET.id, result: { ok: true } }]) + await expect(stale).resolves.toEqual([]) + expect(replacementRequest).not.toHaveBeenCalled() + + await expect( + exportExplicit(sessionWithTab('/current', 'current-tab'), replacementAuthority) + ).resolves.toMatchObject([{ targetId: TARGET.id, result: { ok: true } }]) + expect(replacementRequest).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ patch: expect.objectContaining({ kind: 'replace-session' }) }) + ) + }) + + it('rejects a late snapshot from a replaced provider without poisoning its retry cache', async () => { + const originalAuthority = currentAuthority + const replacementAuthority = authority('epoch-2', 2) + let releaseOriginalSnapshot!: () => void + const originalSnapshotCanFinish = new Promise((resolve) => { + releaseOriginalSnapshot = resolve + }) + const originalRequest = vi.fn(async (method: string) => { + if (method === 'workspace.get') { + await originalSnapshotCanFinish + return snapshot(emptyRemoteSession('/stale-provider'), 99) + } + return { ok: false, reason: 'unavailable', message: 'provider replaced' } + }) + const replacementPatchRevisions: number[] = [] + const replacementRequest = vi.fn(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return snapshot(emptyRemoteSession('/replacement'), 4) + } + replacementPatchRevisions.push(params.baseRevision as number) + return { ok: true, snapshot: snapshot(patchedSession(params), 5) } + }) + currentMux = { request: originalRequest } + + const stale = exportExplicit(sessionWithTab('/stale', 'stale-tab'), originalAuthority) + await vi.waitFor(() => + expect(originalRequest).toHaveBeenCalledWith('workspace.get', expect.anything()) + ) + currentAuthority = replacementAuthority + currentMux = { request: replacementRequest } + releaseOriginalSnapshot() + + await expect(stale).resolves.toEqual([]) + await expect( + exportExplicit(sessionWithTab('/current', 'current-tab'), replacementAuthority) + ).resolves.toMatchObject([{ targetId: TARGET.id, result: { ok: true } }]) + expect(replacementRequest).toHaveBeenCalledWith('workspace.get', expect.anything()) + expect(replacementPatchRevisions).toEqual([4]) + }) +}) diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index 5ce4ac6b0b12..1ca1955ba3f9 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -69,8 +69,11 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { const handlers = new Map unknown>() const muxByTargetId = new Map }>() const getRepoMock = vi.fn() + const getWorkspaceSessionMock = vi.fn() + let currentSession = sessionWithTab('unused::/unused', 'unused') const store = { - getRepo: getRepoMock + getRepo: getRepoMock, + getWorkspaceSession: getWorkspaceSessionMock } as unknown as Store const target: SshTarget = { @@ -95,6 +98,18 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { listTargets: () => [target] }) getRepoMock.mockReset() + getWorkspaceSessionMock.mockReset() + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => + hostId + ? { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + } + : currentSession + ) getRepoMock.mockImplementation((repoId: string) => repoId === 'repo-target-1' ? ({ @@ -122,7 +137,8 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { if (!handler) { throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') } - return handler(null, args) + currentSession = args.session + return handler(null, { hydratedTargetIds: args.hydratedTargetIds }) } it('serializes overlapping writes for the same target so they use fresh base revisions', async () => { diff --git a/src/main/ipc/remote-workspace-relay-sync.ts b/src/main/ipc/remote-workspace-relay-sync.ts index d18984b2a7e4..8631ff48261c 100644 --- a/src/main/ipc/remote-workspace-relay-sync.ts +++ b/src/main/ipc/remote-workspace-relay-sync.ts @@ -3,10 +3,11 @@ import type { RemoteWorkspaceSession, RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' -import type { SshTarget } from '../../shared/ssh-types' +import type { DirectSshAuthority, SshTarget } from '../../shared/ssh-types' import { getActiveMultiplexer } from './ssh' import { CLIENT_ID } from './remote-workspace-client-identity' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' +import { isCurrentSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getCachedRemoteWorkspaceSnapshot, rememberRemoteWorkspaceSnapshot @@ -17,19 +18,26 @@ import { } from './remote-workspace-snapshot-normalization' export async function getRemoteSnapshot( - target: SshTarget + target: SshTarget, + authority: DirectSshAuthority ): Promise { const mux = getActiveMultiplexer(target.id) - if (!mux) { + if (!mux || authority.targetId !== target.id || !isCurrentSshProviderAuthority(authority)) { return null } const namespace = getRemoteWorkspaceNamespace(target) try { const raw = await mux.request('workspace.get', { namespace }) + if (!isCurrentSshProviderAuthority(authority)) { + return null + } const snapshot = normalizeSnapshot(raw, namespace) - rememberRemoteWorkspaceSnapshot(target.id, snapshot) + rememberRemoteWorkspaceSnapshot(authority, snapshot) return snapshot } catch (err) { + if (!isCurrentSshProviderAuthority(authority)) { + return null + } if ((err as { code?: unknown })?.code === -32601) { return null } @@ -39,15 +47,24 @@ export async function getRemoteSnapshot( export async function patchRemoteWorkspaceSession( target: SshTarget, - session: RemoteWorkspaceSession + session: RemoteWorkspaceSession, + authority: DirectSshAuthority ): Promise { + if (authority.targetId !== target.id || !isCurrentSshProviderAuthority(authority)) { + return null + } const mux = getActiveMultiplexer(target.id) if (!mux) { return null } const namespace = getRemoteWorkspaceNamespace(target) const current = - getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) ?? undefined + getCachedRemoteWorkspaceSnapshot(authority) ?? + (await getRemoteSnapshot(target, authority)) ?? + undefined + if (!isCurrentSshProviderAuthority(authority)) { + return null + } if (current && remoteWorkspaceSessionMatchesSnapshot(current, session)) { // Why: a pulled workspace snapshot rehydrates local state and can trigger // session persistence. Identical target sessions must stay a local no-op or @@ -57,7 +74,10 @@ export async function patchRemoteWorkspaceSession( const requestPatch = async ( baseRevision: number | undefined - ): Promise => { + ): Promise => { + if (!isCurrentSshProviderAuthority(authority)) { + return null + } try { return (await mux.request('workspace.patch', { namespace, @@ -66,6 +86,9 @@ export async function patchRemoteWorkspaceSession( patch: { kind: 'replace-session', session } })) as RemoteWorkspacePatchResult } catch (err) { + if (!isCurrentSshProviderAuthority(authority)) { + return null + } return (err as { code?: unknown })?.code === -32601 ? { ok: false, @@ -81,12 +104,15 @@ export async function patchRemoteWorkspaceSession( } const result = await requestPatch(current?.revision) + if (!result) { + return null + } if (result.ok) { - rememberRemoteWorkspaceSnapshot(target.id, result.snapshot) + rememberRemoteWorkspaceSnapshot(authority, result.snapshot) return result } if (result.snapshot) { - rememberRemoteWorkspaceSnapshot(target.id, result.snapshot) + rememberRemoteWorkspaceSnapshot(authority, result.snapshot) } if ( @@ -103,10 +129,13 @@ export async function patchRemoteWorkspaceSession( // only for backwards revisions restores the blank-slate target without // overwriting a newer snapshot from another device. const retry = await requestPatch(result.snapshot.revision) + if (!retry) { + return null + } if (retry.ok) { - rememberRemoteWorkspaceSnapshot(target.id, retry.snapshot) + rememberRemoteWorkspaceSnapshot(authority, retry.snapshot) } else if (retry.snapshot) { - rememberRemoteWorkspaceSnapshot(target.id, retry.snapshot) + rememberRemoteWorkspaceSnapshot(authority, retry.snapshot) } return retry } diff --git a/src/main/ipc/remote-workspace-snapshot-cache.ts b/src/main/ipc/remote-workspace-snapshot-cache.ts index b5eb5ff4daa7..3c62ef95ab1f 100644 --- a/src/main/ipc/remote-workspace-snapshot-cache.ts +++ b/src/main/ipc/remote-workspace-snapshot-cache.ts @@ -1,17 +1,36 @@ import type { RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' +import type { DirectSshAuthority } from '../../shared/ssh-types' +import { isCurrentSshProviderAuthority } from '../ssh/ssh-provider-authority' export const REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES = 64 -const latestSnapshotByTargetId = new Map() +type RemoteWorkspaceSnapshotCacheEntry = { + authority: DirectSshAuthority + snapshot: RemoteWorkspaceSnapshot +} + +const latestSnapshotByTargetId = new Map() + +function authoritiesEqual(left: DirectSshAuthority, right: DirectSshAuthority): boolean { + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} export function rememberRemoteWorkspaceSnapshot( - targetId: string, + authority: DirectSshAuthority, snapshot: RemoteWorkspaceSnapshot ): void { + if (!isCurrentSshProviderAuthority(authority)) { + return + } + const targetId = authority.targetId if (latestSnapshotByTargetId.has(targetId)) { latestSnapshotByTargetId.delete(targetId) } - latestSnapshotByTargetId.set(targetId, snapshot) + latestSnapshotByTargetId.set(targetId, { authority: { ...authority }, snapshot }) while (latestSnapshotByTargetId.size > REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES) { const oldest = latestSnapshotByTargetId.keys().next() if (oldest.done) { @@ -22,16 +41,21 @@ export function rememberRemoteWorkspaceSnapshot( } export function getCachedRemoteWorkspaceSnapshot( - targetId: string + authority: DirectSshAuthority ): RemoteWorkspaceSnapshot | undefined { - const snapshot = latestSnapshotByTargetId.get(targetId) - if (!snapshot) { + const entry = latestSnapshotByTargetId.get(authority.targetId) + if (!entry) { + return undefined + } + if (!authoritiesEqual(entry.authority, authority) || !isCurrentSshProviderAuthority(authority)) { + latestSnapshotByTargetId.delete(authority.targetId) return undefined } // Why: remote workspace snapshots can contain the whole tab/layout session // for a target. Touch cache hits so deleted or rarely used targets age out. - rememberRemoteWorkspaceSnapshot(targetId, snapshot) - return snapshot + latestSnapshotByTargetId.delete(authority.targetId) + latestSnapshotByTargetId.set(authority.targetId, entry) + return entry.snapshot } export function clearRemoteWorkspaceSnapshotCache(): void { @@ -44,15 +68,30 @@ export function getRemoteWorkspaceSnapshotCacheSize(): number { /** @internal - exposed for cache-bound tests only. */ export function _rememberRemoteWorkspaceSnapshotForTests( - targetId: string, + authority: DirectSshAuthority, snapshot: RemoteWorkspaceSnapshot ): void { - rememberRemoteWorkspaceSnapshot(targetId, snapshot) + const targetId = authority.targetId + latestSnapshotByTargetId.delete(targetId) + latestSnapshotByTargetId.set(targetId, { authority: { ...authority }, snapshot }) + while (latestSnapshotByTargetId.size > REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES) { + const oldest = latestSnapshotByTargetId.keys().next() + if (oldest.done) { + break + } + latestSnapshotByTargetId.delete(oldest.value) + } } /** @internal - exposed for cache-bound tests only. */ export function _getRemoteWorkspaceSnapshotForTests( - targetId: string + authority: DirectSshAuthority ): RemoteWorkspaceSnapshot | undefined { - return getCachedRemoteWorkspaceSnapshot(targetId) + const entry = latestSnapshotByTargetId.get(authority.targetId) + if (!entry || !authoritiesEqual(entry.authority, authority)) { + return undefined + } + latestSnapshotByTargetId.delete(authority.targetId) + latestSnapshotByTargetId.set(authority.targetId, entry) + return entry.snapshot } diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index b0061f71ef2f..5680bff3273b 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -5,17 +5,34 @@ import type { RemoteWorkspaceSession, RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' -import type { SshTarget } from '../../shared/ssh-types' +import type { DirectSshAuthority, SshProviderEpoch, SshTarget } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' const { getActiveMultiplexerMock, getSshConnectionStoreMock, + getSshProviderAuthorityMock, + isCurrentSshProviderAuthorityMock, registerRemoteWorkspaceNotificationHandlerMock } = vi.hoisted(() => ({ getActiveMultiplexerMock: vi.fn(), getSshConnectionStoreMock: vi.fn(), - registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()) + getSshProviderAuthorityMock: vi.fn((targetId: string) => ({ + targetId, + providerEpoch: `epoch-${targetId}`, + connectionGeneration: 1 + })), + isCurrentSshProviderAuthorityMock: vi.fn((_candidate: DirectSshAuthority) => true), + registerRemoteWorkspaceNotificationHandlerMock: vi.fn( + ( + _handler: ( + targetId: string, + method: string, + params: Record, + authority: DirectSshAuthority + ) => void + ) => vi.fn() + ) })) vi.mock('electron', () => ({ @@ -34,10 +51,16 @@ vi.mock('./remote-workspace-events', () => ({ registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock })) +vi.mock('../ssh/ssh-provider-authority', () => ({ + getSshProviderAuthority: getSshProviderAuthorityMock, + isCurrentSshProviderAuthority: isCurrentSshProviderAuthorityMock +})) + import { _resetRemoteWorkspaceCachesForTests, registerRemoteWorkspaceHandlers } from './remote-workspace' +import { _getRemoteWorkspaceSnapshotForTests } from './remote-workspace-snapshot-cache' import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { @@ -50,6 +73,15 @@ function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspac } } +function emptyRemoteSession(): RemoteWorkspaceSession { + return { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + } +} + const baseSession = { activeRepoId: null, activeWorktreeId: null, @@ -75,6 +107,14 @@ const targets: SshTarget[] = [ } ] +function authority(targetId: string): DirectSshAuthority { + return { + targetId, + providerEpoch: `epoch-${targetId}` as SshProviderEpoch, + connectionGeneration: 1 + } +} + describe('remoteWorkspaceSessionMatchesSnapshot', () => { it('matches normalized equivalent sessions', () => { expect( @@ -152,9 +192,11 @@ describe('remoteWorkspace:setForConnectedTargets', () => { const muxByTargetId = new Map }>() const getRepoMock = vi.fn() const getWorkspaceSessionMock = vi.fn() + const patchWorkspaceSessionMock = vi.fn() const store = { getRepo: getRepoMock, - getWorkspaceSession: getWorkspaceSessionMock + getWorkspaceSession: getWorkspaceSessionMock, + patchWorkspaceSession: patchWorkspaceSessionMock } as unknown as Store beforeEach(() => { @@ -172,8 +214,12 @@ describe('remoteWorkspace:setForConnectedTargets', () => { listTargets: () => targets }) getRepoMock.mockReset() + getSshProviderAuthorityMock.mockClear() + isCurrentSshProviderAuthorityMock.mockReset() + isCurrentSshProviderAuthorityMock.mockReturnValue(true) getWorkspaceSessionMock.mockReset() getWorkspaceSessionMock.mockReturnValue(baseSession) + patchWorkspaceSessionMock.mockReset() getRepoMock.mockImplementation((repoId: string) => repoId === 'repo-target-1' ? ({ @@ -224,6 +270,8 @@ describe('remoteWorkspace:setForConnectedTargets', () => { async function callSetForConnectedTargets(args: { session?: WorkspaceSessionState + sessionTargetId?: string + sessionAuthority?: DirectSshAuthority hydratedTargetIds?: unknown }): Promise { const handler = handlers.get('remoteWorkspace:setForConnectedTargets') @@ -246,9 +294,86 @@ describe('remoteWorkspace:setForConnectedTargets', () => { expect(getActiveMultiplexerMock).not.toHaveBeenCalled() }) + it('rejects a late workspace notification from replaced provider authority', () => { + const currentAuthority = authority('target-1') + const staleAuthority = { + ...currentAuthority, + providerEpoch: 'stale-epoch' as SshProviderEpoch, + connectionGeneration: 0 + } + getSshConnectionStoreMock.mockReturnValue({ + listTargets: () => targets, + getTarget: (targetId: string) => targets.find((target) => target.id === targetId) + }) + isCurrentSshProviderAuthorityMock.mockImplementation( + (candidate: DirectSshAuthority) => + candidate.providerEpoch === currentAuthority.providerEpoch && + candidate.connectionGeneration === currentAuthority.connectionGeneration + ) + const notificationHandler = + registerRemoteWorkspaceNotificationHandlerMock.mock.calls.at(-1)?.[0] + expect(notificationHandler).toBeTypeOf('function') + + notificationHandler?.( + 'target-1', + 'workspace.changed', + { snapshot: snapshot({ ...emptyRemoteSession(), activeWorktreePath: '/stale' }, 99) }, + staleAuthority + ) + expect(_getRemoteWorkspaceSnapshotForTests(currentAuthority)).toBeUndefined() + + notificationHandler?.( + 'target-1', + 'workspace.changed', + { snapshot: snapshot({ ...emptyRemoteSession(), activeWorktreePath: '/current' }, 4) }, + currentAuthority + ) + expect(_getRemoteWorkspaceSnapshotForTests(currentAuthority)?.revision).toBe(4) + }) + + it('does not export an explicit session without target provenance', async () => { + await expect( + callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1'] }) + ).resolves.toEqual([]) + + expect(getSshConnectionStoreMock).not.toHaveBeenCalled() + expect(getActiveMultiplexerMock).not.toHaveBeenCalled() + }) + + it('rejects missing, conflicting, and stale explicit provider authority', async () => { + await expect( + callSetForConnectedTargets({ + session: baseSession, + sessionTargetId: 'target-1', + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + await expect( + callSetForConnectedTargets({ + session: baseSession, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-2'), + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + isCurrentSshProviderAuthorityMock.mockReturnValue(false) + await expect( + callSetForConnectedTargets({ + session: baseSession, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + + expect(getSshConnectionStoreMock).not.toHaveBeenCalled() + }) + it('writes only to explicitly hydrated connected targets', async () => { const result = await callSetForConnectedTargets({ session: baseSession, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), hydratedTargetIds: ['target-1', 'missing-target'] }) @@ -274,7 +399,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { { id: 'tab-store', title: 'Store shell', - ptyId: 'pty-store', + ptyId: 'ssh:target-1@@pty-store', worktreeId: 'repo-target-1::/repo' } as never ] @@ -296,4 +421,287 @@ describe('remoteWorkspace:setForConnectedTargets', () => { }) ) }) + + it('exports tabs stranded in the target ssh partition when the local partition is empty', async () => { + const worktreeId = 'repo-target-1::/repo' + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => { + if (hostId === 'ssh:target-1') { + return { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [ + { + id: 'stranded-tab', + title: 'Stranded shell', + ptyId: null, + worktreeId + } as never + ] + } + } + } + return { ...baseSession, tabsByWorktree: { [worktreeId]: [] } } + }) + + await callSetForConnectedTargets({ hydratedTargetIds: ['target-1'] }) + + expect(getWorkspaceSessionMock).toHaveBeenCalledWith('ssh:target-1') + expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + tabsByWorktreePath: { + '/repo': [expect.objectContaining({ id: 'stranded-tab' })] + } + }) + }) + }) + ) + // Adoption on this path is read-only: the renderer owns the local + // partition, so a store-side move here would be undone by its next write. + expect(patchWorkspaceSessionMock).not.toHaveBeenCalled() + }) + + it('qualifies duplicate repository ids by SSH target during export', async () => { + const worktreeId = 'duplicate::/repo' + getRepoMock.mockImplementation((repoId: string, hostId?: string) => { + if (repoId !== 'duplicate' || !hostId?.startsWith('ssh:')) { + return undefined + } + return { + id: repoId, + path: '/repo', + displayName: 'Duplicate', + badgeColor: 'blue', + addedAt: 1, + connectionId: hostId.slice(4), + executionHostId: hostId + } as never + }) + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => ({ + ...baseSession, + tabsByWorktree: + hostId === 'ssh:target-1' || hostId === 'ssh:target-2' + ? { + [worktreeId]: [ + { + id: `${hostId}-tab`, + title: hostId, + ptyId: null, + worktreeId + } as never + ] + } + : { [worktreeId]: [] } + })) + + await callSetForConnectedTargets({ hydratedTargetIds: ['target-1', 'target-2'] }) + + for (const targetId of ['target-1', 'target-2']) { + expect(getRepoMock).toHaveBeenCalledWith('duplicate', `ssh:${targetId}`) + expect(requestByTargetId.get(targetId)).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + tabsByWorktreePath: { + '/repo': [expect.objectContaining({ id: `ssh:${targetId}-tab` })] + } + }) + }) + }) + ) + } + }) + + it('fails closed when local and target partitions conflict on one workspace key', async () => { + const worktreeId = 'duplicate::/repo' + getRepoMock.mockImplementation((repoId: string, hostId?: string) => + repoId === 'duplicate' && hostId === 'ssh:target-2' + ? ({ id: repoId, connectionId: 'target-2', executionHostId: hostId } as never) + : undefined + ) + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => ({ + ...baseSession, + tabsByWorktree: { + [worktreeId]: [ + { + id: hostId === 'ssh:target-2' ? 'target-b-tab' : 'target-a-tab', + title: hostId ?? 'local', + ptyId: null, + worktreeId + } as never + ] + }, + terminalTopologyRevisionByRepoId: { duplicate: 4 } + })) + + await expect(callSetForConnectedTargets({ hydratedTargetIds: ['target-2'] })).resolves.toEqual( + [] + ) + + expect(getWorkspaceSessionMock).toHaveBeenCalledWith('ssh:target-2') + expect(requestByTargetId.get('target-2')).not.toHaveBeenCalledWith( + 'workspace.patch', + expect.anything() + ) + }) + + it('fences a transient explicit PTY loss while allowing matching durable target state', async () => { + const worktreeId = 'repo-target-1::/repo' + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => { + if (hostId !== undefined) { + return baseSession + } + return { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [ + { + id: 'tab-explicit', + title: 'Shell', + ptyId: 'ssh:target-1@@pty-durable', + worktreeId + } as never + ] + }, + terminalLayoutsByTabId: { + 'tab-explicit': { + root: { type: 'leaf', id: 'leaf-1' }, + ptyIdsByLeafId: { 'leaf-1': 'ssh:target-1@@pty-durable' } + } as never + } + } + }) + + await expect( + callSetForConnectedTargets({ + session: { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [{ id: 'tab-explicit', title: 'Shell', ptyId: null, worktreeId } as never] + }, + terminalLayoutsByTabId: { + 'tab-explicit': { + root: { type: 'leaf', id: 'leaf-1' }, + ptyIdsByLeafId: {} + } as never + } + }, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + + expect(getWorkspaceSessionMock).toHaveBeenCalledWith() + expect(requestByTargetId.get('target-1')).not.toHaveBeenCalledWith( + 'workspace.patch', + expect.anything() + ) + + const session = { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [ + { + id: 'tab-explicit', + title: 'Shell', + ptyId: 'ssh:target-1@@pty-durable', + worktreeId + } as never + ] + } + } + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => + hostId === undefined ? session : baseSession + ) + + await callSetForConnectedTargets({ + session, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), + hydratedTargetIds: ['target-1'] + }) + + expect(getWorkspaceSessionMock).toHaveBeenCalledWith() + expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + tabsByWorktreePath: { + '/repo': [expect.objectContaining({ id: 'tab-explicit' })] + } + }) + }) + }) + ) + }) + + it('does not export a target-qualified explicit session through a conflicting target', async () => { + const worktreeId = 'duplicate::/repo' + await expect( + callSetForConnectedTargets({ + session: { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [ + { id: 'target-a-tab', title: 'Target A', ptyId: null, worktreeId } as never + ] + } + }, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), + hydratedTargetIds: ['target-2'] + }) + ).resolves.toEqual([]) + + expect(getRepoMock).not.toHaveBeenCalled() + expect(requestByTargetId.get('target-2')).toBeUndefined() + }) + + it('rejects cross-target PTYs and duplicate tab ownership before explicit export', async () => { + const worktreeId = 'repo-target-1::/repo' + const localWorktreeId = 'local::/local' + const targetTab = { + id: 'shared-tab', + title: 'Target A', + ptyId: 'ssh:target-2@@foreign', + worktreeId + } + await expect( + callSetForConnectedTargets({ + session: { + ...baseSession, + tabsByWorktree: { [worktreeId]: [targetTab as never] }, + remoteSessionIdsByTabId: { 'shared-tab': 'ssh:target-2@@foreign' } + }, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + + await expect( + callSetForConnectedTargets({ + session: { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [{ ...targetTab, ptyId: 'ssh:target-1@@owned', worktreeId } as never], + [localWorktreeId]: [{ ...targetTab, ptyId: null, worktreeId: localWorktreeId } as never] + } + }, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + + expect(requestByTargetId.get('target-1')).not.toHaveBeenCalledWith( + 'workspace.patch', + expect.anything() + ) + }) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 527a001bb678..e0eedca2c88e 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -1,16 +1,27 @@ import { ipcMain, type BrowserWindow } from 'electron' import type { Store } from '../persistence' import { getActiveMultiplexer, getSshConnectionStore } from './ssh' -import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' import type { RemoteWorkspaceChangedEvent, RemoteWorkspacePatchResult, RemoteWorkspaceSession } from '../../shared/remote-workspace-types' +import type { DirectSshAuthority } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' +import { adoptOrphanedWorkspaceSessionPartition } from '../../shared/workspace-session-partition-adoption' +import { findAmbiguousWorkspaceSessionKeys } from '../../shared/workspace-session-partition-authority' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' +import { + exportExplicitSessionForTarget, + exportSessionForTarget +} from './remote-workspace-explicit-session-authority' +import { + getSshProviderAuthority, + isCurrentSshProviderAuthority +} from '../ssh/ssh-provider-authority' import { CLIENT_ID } from './remote-workspace-client-identity' import { listRemoteWorkspaceConnectedClients } from './remote-workspace-connected-clients' import { @@ -55,27 +66,17 @@ function getExplicitHydratedTargetIds(value: unknown): Set | null { return new Set(value) } -function targetForWorktree(store: Store, worktreeId: string): string | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return store.getRepo(repoId)?.connectionId ?? null -} - -function exportSessionForTarget( - store: Store, - targetId: string, - session: WorkspaceSessionState -): RemoteWorkspaceSession { - return exportRemoteWorkspaceSession(session, { - isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId) === targetId - }) -} - export function handleRemoteWorkspaceNotification( targetId: string, method: string, - params: Record + params: Record, + authority: DirectSshAuthority ): void { - if (method !== 'workspace.changed') { + if ( + method !== 'workspace.changed' || + authority.targetId !== targetId || + !isCurrentSshProviderAuthority(authority) + ) { return } const target = getSshConnectionStore()?.getTarget(targetId) @@ -84,7 +85,7 @@ export function handleRemoteWorkspaceNotification( } const namespace = getRemoteWorkspaceNamespace(target) const snapshot = normalizeSnapshot(params.snapshot, namespace) - rememberRemoteWorkspaceSnapshot(targetId, snapshot) + rememberRemoteWorkspaceSnapshot(authority, snapshot) const event: RemoteWorkspaceChangedEvent = { targetId, snapshot, @@ -116,33 +117,89 @@ export function registerRemoteWorkspaceHandlers( if (!target) { return null } - return getRemoteSnapshot(target) + return getRemoteSnapshot(target, getSshProviderAuthority(target.id)) }) ipcMain.handle( 'remoteWorkspace:setForConnectedTargets', - async (_event, args: { session?: WorkspaceSessionState; hydratedTargetIds?: unknown }) => { + async ( + _event, + args: { + session?: WorkspaceSessionState + sessionTargetId?: unknown + sessionAuthority?: unknown + hydratedTargetIds?: 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 sessionTargetId = + typeof args.sessionTargetId === 'string' && args.sessionTargetId.length > 0 + ? args.sessionTargetId + : null + const sessionAuthority = isAdmissibleDirectSshAuthority(args.sessionAuthority) + ? ({ ...args.sessionAuthority } as DirectSshAuthority) + : null + if ( + args.session && + (!sessionTargetId || + !sessionAuthority || + sessionAuthority.targetId !== sessionTargetId || + !hydratedTargetIds.has(sessionTargetId) || + !isCurrentSshProviderAuthority(sessionAuthority)) + ) { + return [] + } const targets = getSshConnectionStore() ?.listTargets() .filter( - (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) + (target) => + hydratedTargetIds.has(target.id) && + (!args.session || target.id === sessionTargetId) && + getActiveMultiplexer(target.id) ) ?? [] - const workspaceSession = args.session ?? store.getWorkspaceSession() + const fallbackSession = args.session ?? store.getWorkspaceSession() const results = await Promise.all( targets.map(async (target) => { + // Boot owns persistence; export only overlays stranded SSH state. + let session: RemoteWorkspaceSession | null + const authority = args.session ? sessionAuthority : getSshProviderAuthority(target.id) + if (!authority || !isCurrentSshProviderAuthority(authority)) { + return null + } + if (args.session) { + session = exportExplicitSessionForTarget(store, authority, args.session) + } else { + const targetPartition = store.getWorkspaceSession(toSshExecutionHostId(target.id)) + const ambiguousKeys = findAmbiguousWorkspaceSessionKeys([ + fallbackSession, + targetPartition + ]) + const hasPopulatedLocalConflict = [...ambiguousKeys].some( + (key) => (fallbackSession.tabsByWorktree[key]?.length ?? 0) > 0 + ) + if (hasPopulatedLocalConflict) { + return null + } + session = exportSessionForTarget( + store, + authority, + adoptOrphanedWorkspaceSessionPartition(fallbackSession, targetPartition).session + ) + } + if (!session) { + return null + } // 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(store, target.id, workspaceSession) const result = await queueRemoteWorkspacePatch(target.id, () => - patchRemoteWorkspaceSession(target, session) + patchRemoteWorkspaceSession(target, session, authority) ) return result ? { targetId: target.id, result } : null }) diff --git a/src/main/ipc/session.test.ts b/src/main/ipc/session.test.ts new file mode 100644 index 000000000000..35d7e60dcd23 --- /dev/null +++ b/src/main/ipc/session.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, onMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock, on: onMock } +})) + +import { registerSessionHandlers } from './session' + +describe('registerSessionHandlers', () => { + beforeEach(() => { + handleMock.mockClear() + onMock.mockClear() + }) + + it('routes authoritative SSH partition enumeration through the store', () => { + const expected = { tabsByWorktree: {} } + const store = { + adoptSshWorkspaceSessionPartition: vi.fn(() => expected) + } + registerSessionHandlers(store as never) + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'session:adopt-ssh-partition' + )?.[1] as (event: unknown, hostId?: string) => unknown + + expect(handler({})).toBe(expected) + expect(handler({}, 'ssh:target-1')).toBe(expected) + expect(store.adoptSshWorkspaceSessionPartition.mock.calls).toEqual([ + [undefined], + ['ssh:target-1'] + ]) + }) +}) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 7c70acb4f454..eca3a0e817c3 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -21,6 +21,10 @@ export function registerSessionHandlers(store: Store): void { store.patchWorkspaceSession(args, hostId) }) + ipcMain.handle('session:adopt-ssh-partition', (_event, hostId?: string) => { + return store.adoptSshWorkspaceSessionPartition(hostId) + }) + ipcMain.handle('session:flush', () => { // Why: durable lifecycle RPCs must propagate disk failures instead of // returning success through Store.flush(), which intentionally only logs. diff --git a/src/main/persistence-ssh-partition-adoption.test.ts b/src/main/persistence-ssh-partition-adoption.test.ts new file mode 100644 index 000000000000..046b04b5fe5c --- /dev/null +++ b/src/main/persistence-ssh-partition-adoption.test.ts @@ -0,0 +1,540 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { PersistedState } from '../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { + testState, + createStore, + writeDataFile, + readDataFile, + makeTerminalTab +} from './persistence-test-harness' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import { TEST_LEAF_1 } from './persistence-session-fixtures' + +// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. +const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ + loadUserSshConfigMock: vi.fn(), + sshConfigHostsToTargetsMock: vi.fn() +})) + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: loadUserSshConfigMock, + sshConfigHostsToTargets: sshConfigHostsToTargetsMock +})) +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('encrypted:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('encrypted:'.length) + } + } +})) + +vi.mock('./telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock +})) + +describe('Store SSH workspace-session partition adoption', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + const makeHostSession = (activeRepoId: string): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId + }) + + const makeBoundHostSession = (ptyId: string | null): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-1', + activeWorktreeId: 'repo-1::/worktree', + activeTabId: 'tab-1', + tabsByWorktree: { + 'repo-1::/worktree': [ + { + id: 'tab-1', + worktreeId: 'repo-1::/worktree', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId + } + ] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: ptyId ? { [TEST_LEAF_1]: ptyId } : {} + } + } + }) + + it('atomically moves stranded ssh tabs into the local partition', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'repo-1::/worktree': [] } + }) + store.setWorkspaceSession(source, 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + source.tabsByWorktree['repo-1::/worktree'] + ) + expect(adopted.terminalLayoutsByTabId['tab-1']).toEqual(source.terminalLayoutsByTabId['tab-1']) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + + it('finishes cleanup when an earlier move left the same tab in both partitions', async () => { + const store = await createStore() + const session = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(session) + store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + session.tabsByWorktree['repo-1::/worktree'] + ) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + + it('keeps populated local state and preserves an equal-revision SSH fork', async () => { + const store = await createStore() + const local = makeBoundHostSession('ssh:ssh-1@@local-pty') + const source = makeBoundHostSession('ssh:ssh-1@@stale-pty') + source.tabsByWorktree['repo-1::/worktree'][0].id = 'stale-tab' + store.setWorkspaceSession(local) + store.setWorkspaceSession(source, 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + local.tabsByWorktree['repo-1::/worktree'] + ) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + }) + + it('ignores non-ssh adoption requests without mutating their partition', async () => { + const store = await createStore() + const local = makeBoundHostSession('local-pty') + const runtime = makeBoundHostSession('runtime:env-1@@runtime-pty') + store.setWorkspaceSession(local) + store.setWorkspaceSession(runtime, 'runtime:env-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('runtime:env-1') + + expect(adopted).toEqual(local) + expect(store.getWorkspaceSession('runtime:env-1')).toEqual(runtime) + }) + + it('prunes tombstoned ssh tabs without resurrecting them locally', async () => { + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + writeDataFile({ + schemaVersion: 1, + workspaceSession: { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'repo-1::/worktree': [] }, + terminalSurfaceTombstonesByPaneKey: { + [`tab-1:${TEST_LEAF_1}`]: { + worktreeId: 'repo-1::/worktree', + parentTabId: 'tab-1', + leafId: TEST_LEAF_1, + ptyId: 'ssh:ssh-1@@remote-pty', + incarnationId: 'inc-1', + retiredAt: 1 + } + } + }, + workspaceSessionsByHostId: { 'ssh:ssh-1': source } + }) + const store = await createStore() + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual([]) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + + it('enumerates every persisted SSH partition including folder-only projects', async () => { + const store = await createStore() + const folderKey = folderWorkspaceKey('ssh-folder') + const boundFolderSession = makeBoundHostSession('ssh:ssh-folder@@folder-pty') + const folderSession = getDefaultWorkspaceSession() + folderSession.tabsByWorktree = { + [folderKey]: [ + { + ...boundFolderSession.tabsByWorktree['repo-1::/worktree'][0], + id: 'folder-tab', + worktreeId: folderKey + } + ] + } + folderSession.terminalLayoutsByTabId = { + 'folder-tab': boundFolderSession.terminalLayoutsByTabId['tab-1'] + } + folderSession.openFilesByWorktree = { + [folderKey]: [ + { + filePath: '/srv/folder/a.ts', + relativePath: 'a.ts', + worktreeId: folderKey, + language: 'typescript' + } + ] + } + store.setWorkspaceSession(folderSession, 'ssh:ssh-folder') + store.setWorkspaceSession(makeBoundHostSession('ssh:ssh-repo@@repo-pty'), 'ssh:ssh-repo') + store.setWorkspaceSession(makeHostSession('runtime-repo'), 'runtime:env-a') + const flush = vi.spyOn(store, 'flushOrThrow') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(flush).toHaveBeenCalledTimes(2) + expect(adopted.tabsByWorktree[folderKey]).toHaveLength(1) + expect(adopted.openFilesByWorktree?.[folderKey]?.[0]?.relativePath).toBe('a.ts') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'runtime:env-a']) + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo') + }) + + it('preserves equal-revision repo conflicts in both SSH source partitions', async () => { + const store = await createStore() + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + left.terminalTopologyRevisionByRepoId = { 'repo-1': 4 } + right.terminalTopologyRevisionByRepoId = { 'repo-1': 4 } + right.tabsByWorktree['repo-1::/worktree'][0].id = 'right-tab' + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toBeUndefined() + expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) + expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) + }) + + it('preserves SSH sources whose distinct workspaces reuse one tab identity', async () => { + const store = await createStore() + const left = getDefaultWorkspaceSession() + const right = getDefaultWorkspaceSession() + left.tabsByWorktree = { + 'repo-1::/worktree': [makeTerminalTab({ id: 'shared-tab', worktreeId: 'repo-1::/worktree' })] + } + right.tabsByWorktree = { + 'repo-2::/worktree': [makeTerminalTab({ id: 'shared-tab', worktreeId: 'repo-2::/worktree' })] + } + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) + expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) + expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) + }) + + it('keeps non-empty local state and unequal-revision duplicate SSH sources partitioned', async () => { + const store = await createStore() + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + left.terminalTopologyRevisionByRepoId = { 'repo-1': 2 } + right.terminalTopologyRevisionByRepoId = { 'repo-1': 7 } + right.tabsByWorktree['repo-1::/worktree'][0].id = 'right-tab' + store.setWorkspaceSession(structuredClone(left)) + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + left.tabsByWorktree['repo-1::/worktree'] + ) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) + expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) + expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) + }) + + it('does not choose a folder tab between conflicting SSH sources', async () => { + const store = await createStore() + const folderKey = folderWorkspaceKey('shared-folder') + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + left.tabsByWorktree = { + [folderKey]: [{ ...left.tabsByWorktree['repo-1::/worktree'][0], worktreeId: folderKey }] + } + right.tabsByWorktree = { + [folderKey]: [ + { + ...right.tabsByWorktree['repo-1::/worktree'][0], + id: 'right-tab', + worktreeId: folderKey + } + ] + } + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree[folderKey]).toBeUndefined() + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) + }) + + it('prunes only the source whose state reconciled without ambiguity', async () => { + const store = await createStore() + const folderKey = folderWorkspaceKey('ambiguous-folder') + const local = getDefaultWorkspaceSession() + local.tabsByWorktree[folderKey] = [ + makeTerminalTab({ id: 'local-folder-tab', worktreeId: folderKey }) + ] + const accepted = makeBoundHostSession('ssh:accepted@@accepted-pty') + const retained = getDefaultWorkspaceSession() + retained.tabsByWorktree = { + [folderKey]: [makeTerminalTab({ id: 'remote-folder-tab', worktreeId: folderKey })] + } + store.setWorkspaceSession(local) + store.setWorkspaceSession(accepted, 'ssh:accepted') + store.setWorkspaceSession(retained, 'ssh:retained') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(adopted.tabsByWorktree[folderKey]?.[0]?.id).toBe('local-folder-tab') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:retained']) + }) + + it('scopes a qualified adoption request to exactly one SSH partition', async () => { + const store = await createStore() + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + right.tabsByWorktree = { + 'repo-2::/worktree': [makeTerminalTab({ id: 'right-tab', worktreeId: 'repo-2::/worktree' })] + } + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:left') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(adopted.tabsByWorktree['repo-2::/worktree']).toBeUndefined() + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:right']) + }) + + it('rejects adoption before mutation when persistence writes are frozen', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(source, 'ssh:ssh-1') + store.flushOrThrow() + store.freezeWrites() + + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('writes are frozen') + expect(store.getWorkspaceSession().tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + }) + + it('rolls back owner and sources when the durable owner flush fails', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(getDefaultWorkspaceSession()) + store.setWorkspaceSession(source, 'ssh:ssh-1') + const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('owner flush failed') + }) + + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('owner flush failed') + flush.mockRestore() + + expect(store.getWorkspaceSession().tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:ssh-1']) + }) + + it('keeps the durable owner and retryable sources when source pruning fails', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(getDefaultWorkspaceSession()) + store.setWorkspaceSession(source, 'ssh:ssh-1') + const durableFlush = store.flushOrThrow.bind(store) + const flush = vi + .spyOn(store, 'flushOrThrow') + .mockImplementationOnce(durableFlush) + .mockImplementationOnce(() => { + throw new Error('source prune failed') + }) + + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('source prune failed') + flush.mockRestore() + + expect(store.getWorkspaceSession().tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSession.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(persisted.workspaceSessionsByHostId?.['ssh:ssh-1']?.tabsByWorktree).toEqual( + source.tabsByWorktree + ) + }) + + it('converges idempotently when retrying after a source-prune failure', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(getDefaultWorkspaceSession()) + store.setWorkspaceSession(source, 'ssh:ssh-1') + const durableFlush = store.flushOrThrow.bind(store) + const flush = vi + .spyOn(store, 'flushOrThrow') + .mockImplementationOnce(durableFlush) + .mockImplementationOnce(() => { + throw new Error('source prune failed') + }) + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('source prune failed') + flush.mockRestore() + const beforeRetry = structuredClone(store.getWorkspaceSession()) + + const afterRetry = store.adoptSshWorkspaceSessionPartition() + + expect(afterRetry).toEqual(beforeRetry) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().tabsByWorktree).toEqual(beforeRetry.tabsByWorktree) + expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId).toEqual( + beforeRetry.terminalLayoutsByTabId + ) + expect(reloaded.getWorkspaceSessionHostIds()).toEqual(['local']) + }) + + it('reconciles a newer SSH binding, incarnation, and sleeping provider identity', async () => { + const store = await createStore() + const local = makeBoundHostSession('ssh:ssh-1@@old-pty') + local.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-old' } + local.sleepingAgentSessionsByPaneKey = { + [`tab-1:${TEST_LEAF_1}`]: { + paneKey: `tab-1:${TEST_LEAF_1}`, + tabId: 'tab-1', + worktreeId: 'repo-1::/worktree', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-old' }, + prompt: 'old', + state: 'working', + capturedAt: 9, + updatedAt: 9 + } + } + const source = makeBoundHostSession('ssh:ssh-1@@new-pty') + source.terminalTopologyRevisionByRepoId = { 'repo-1': 1 } + source.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-new' } + source.sleepingAgentSessionsByPaneKey = { + [`tab-1:${TEST_LEAF_1}`]: { + ...local.sleepingAgentSessionsByPaneKey[`tab-1:${TEST_LEAF_1}`], + providerSession: { key: 'session_id', id: 'provider-new' }, + prompt: 'new', + capturedAt: 1, + updatedAt: 1 + } + } + store.setWorkspaceSession(local) + store.setWorkspaceSession(source, 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']?.[0]?.ptyId).toBe('ssh:ssh-1@@new-pty') + expect(adopted.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[TEST_LEAF_1]).toBe( + 'ssh:ssh-1@@new-pty' + ) + expect(adopted.terminalPtyIncarnationsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]).toBe('inc-new') + expect( + adopted.sleepingAgentSessionsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]?.providerSession.id + ).toBe('provider-new') + }) + + it('preserves a sync-flushed SSH incarnation across the adoption crash window', async () => { + const store = await createStore() + const local = makeBoundHostSession('ssh:ssh-1@@old-pty') + local.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-old' } + const source = structuredClone(local) + store.setWorkspaceSession(local) + store.setWorkspaceSession(source, 'ssh:ssh-1') + expect( + store.persistPtyBinding( + { + worktreeId: 'repo-1::/worktree', + tabId: 'tab-1', + leafId: TEST_LEAF_1, + ptyId: 'ssh:ssh-1@@new-pty', + incarnationId: 'inc-new', + expectedBinding: { ptyId: 'ssh:ssh-1@@old-pty', incarnationId: 'inc-old' } + }, + 'ssh:ssh-1' + ) + ).toBe(true) + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[TEST_LEAF_1]).toBe( + 'ssh:ssh-1@@new-pty' + ) + expect(adopted.terminalPtyIncarnationsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]).toBe('inc-new') + expect(adopted.terminalTopologyRevisionByRepoId?.['repo-1']).toBe(1) + }) + + it('removes all SSH source metadata only after the owner is durable', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + source.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-1' } + source.terminalTopologyRevisionByRepoId = { 'repo-1': 3 } + source.tabGroups = { + 'repo-1::/worktree': [ + { + id: 'group-1', + worktreeId: 'repo-1::/worktree', + activeTabId: 'tab-1', + tabOrder: ['tab-1'] + } + ] + } + store.setWorkspaceSession(source, 'ssh:ssh-1') + + store.adoptSshWorkspaceSessionPartition() + + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSessionsByHostId?.['ssh:ssh-1']).toBeUndefined() + expect(persisted.workspaceSession.terminalPtyIncarnationsByPaneKey).toEqual({ + [`tab-1:${TEST_LEAF_1}`]: 'inc-1' + }) + expect(persisted.workspaceSession.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 3 }) + expect(persisted.workspaceSession.tabGroups?.['repo-1::/worktree']?.[0]?.id).toBe('group-1') + }) +}) diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index ad89783a5a38..012faa56f3e0 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -91,6 +91,11 @@ import { normalizeWorktreeCardProperties } from '../../../shared/constants' import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' +import { adoptOrphanedWorkspaceSessionPartition } from '../../../shared/workspace-session-partition-adoption' +import { + findCrossHostWorkspaceSessionKeyCollisions, + findWorkspaceTabIdOwnerCollisions +} from '../../../shared/workspace-session-partition-authority' import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' @@ -1979,6 +1984,9 @@ export class Store { } flushOrThrow(): void { + if (this.writesFrozen) { + throw new Error('Cannot synchronously flush while persistence writes are frozen') + } if (this.quitFlushStarted) { throw new Error('Cannot synchronously flush after final persistence has started') } @@ -2090,8 +2098,8 @@ export class Store { return this.getProjectHostOperations().getRepoCount() } - getRepo(id: string): Repo | undefined { - return this.getProjectHostOperations().getRepo(id) + getRepo(id: string, hostId?: ExecutionHostId): Repo | undefined { + return this.getProjectHostOperations().getRepo(id, hostId) } setResolvedRepoGitUsername( @@ -2780,6 +2788,69 @@ export class Store { return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() } + adoptSshWorkspaceSessionPartition(hostId?: string): WorkspaceSessionState { + if (hostId && parseExecutionHostId(hostId)?.kind !== 'ssh') { + return this.getWorkspaceSession() + } + const originalOwner = this.getWorkspaceSession() + const originalPartitions = this.state.workspaceSessionsByHostId + const sshPartitions = Object.entries(originalPartitions ?? {}) + .filter(([partitionHostId, session]) => + Boolean( + session && + parseExecutionHostId(partitionHostId)?.kind === 'ssh' && + (!hostId || partitionHostId === hostId) + ) + ) + .sort(([left], [right]) => left.localeCompare(right)) as [string, WorkspaceSessionState][] + if (sshPartitions.length === 0) { + return originalOwner + } + + const sources = sshPartitions.map(([, source]) => source) + const preservedWorkspaceKeys = new Set([ + ...findCrossHostWorkspaceSessionKeyCollisions(sources), + ...findWorkspaceTabIdOwnerCollisions([originalOwner, ...sources]) + ]) + const prunableHostIds = new Set() + let owner = originalOwner + for (const [partitionHostId, source] of sshPartitions) { + const adoption = adoptOrphanedWorkspaceSessionPartition(owner, source, { + preserveWorkspaceKeys: preservedWorkspaceKeys + }) + owner = adoption.session + if (adoption.ambiguousWorktreeIds.length === 0) { + prunableHostIds.add(partitionHostId) + } + } + if (prunableHostIds.size === 0) { + return originalOwner + } + this.state.workspaceSession = owner + try { + this.flushOrThrow() + } catch (error) { + this.state.workspaceSession = originalOwner + this.state.workspaceSessionsByHostId = originalPartitions + throw error + } + + this.state.workspaceSessionsByHostId = Object.fromEntries( + Object.entries(originalPartitions ?? {}).filter( + ([partitionHostId]) => !prunableHostIds.has(partitionHostId) + ) + ) + try { + this.flushOrThrow() + } catch (error) { + // Why: phase one is already durable; keep its owner plus retryable sources. + this.state.workspaceSession = owner + this.state.workspaceSessionsByHostId = originalPartitions + throw error + } + return owner + } + /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { return ( diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts index baaae4777f68..349e6d0cfcc2 100644 --- a/src/main/persistence/tracking-repos/project-host-operations.ts +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -11,6 +11,7 @@ import type { } from '../../../shared/project-types' import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' +import type { ExecutionHostId } from '../../../shared/execution-host' import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../shared/execution-host' import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime' import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' @@ -201,8 +202,11 @@ export class ProjectHostPersistenceOperations { return this.state.repos.length } - getRepo(id: string): Repo | undefined { - const repo = this.state.repos.find((r) => r.id === id) + getRepo(id: string, hostId?: ExecutionHostId): Repo | undefined { + const repo = this.state.repos.find( + (candidate) => + candidate.id === id && (!hostId || getRepoExecutionHostId(candidate) === hostId) + ) return repo ? this.hydrateRepo(repo) : undefined } diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index eb59d0a56fad..f7a93f20dab6 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -62,6 +62,7 @@ import { } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events' +import { getSshProviderAuthority } from './ssh-provider-authority' import { PortScanner } from './ssh-port-scanner' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { SshPortForwardManager } from './ssh-port-forward' @@ -1471,8 +1472,9 @@ export class SshRelaySession { } private wireUpRemoteWorkspaceEvents(mux: SshChannelMultiplexer): void { + const authority = getSshProviderAuthority(this.targetId) mux.onNotification((method, params) => { - notifyRemoteWorkspaceHandlers(this.targetId, method, params) + notifyRemoteWorkspaceHandlers(this.targetId, method, params, authority) }) } diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 1d2a87bfeb1c..88af68c0982e 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -4,6 +4,7 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { ExecutionHostId } from '../../shared/execution-host' +import type { DirectSshAuthority } from '../../shared/ssh-types' import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, @@ -17,6 +18,7 @@ export type WorkspaceSessionApi = { get: (hostId?: ExecutionHostId) => Promise set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + adoptSshPartition: (hostId?: `ssh:${string}`) => Promise flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void @@ -37,6 +39,8 @@ export type WorkspaceSessionApi = { get: (args: { targetId: string }) => Promise setForConnectedTargets: (args: { session?: WorkspaceSessionState + sessionTargetId?: string + sessionAuthority?: DirectSshAuthority hydratedTargetIds?: string[] }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> listEnabledConnectedTargets: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 0c057883f59c..382a72d68425 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3205,6 +3205,7 @@ const api = { get: (hostId) => ipcRenderer.invoke('session:get', hostId), set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + adoptSshPartition: (hostId) => ipcRenderer.invoke('session:adopt-ssh-partition', hostId), flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), 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 985f54e535a9..8a1e907bc078 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -212,7 +212,10 @@ describe('createRemoteWorkspaceTargetSync', () => { expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() expect(harness.setForConnectedTargets).toHaveBeenCalledWith( - expect.objectContaining({ hydratedTargetIds: ['target-a'] }) + expect.objectContaining({ + sessionTargetId: 'target-a', + hydratedTargetIds: ['target-a'] + }) ) }) @@ -272,7 +275,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreeId: 'repo-a::/remote/work', - ptyId: 'local-pty', + ptyId: 'ssh:target-a@@local-pty', generation: 7, pendingActivationSpawn: { requestedAt: 10 } } @@ -282,7 +285,7 @@ describe('createRemoteWorkspaceTargetSync', () => { calls.push('hydrate') expect(session.tabsByWorktree['repo-a::/remote/work'][0]).toMatchObject({ id: 'stable-tab', - ptyId: 'local-pty', + ptyId: 'ssh:target-a@@local-pty', generation: 7, pendingActivationSpawn: { requestedAt: 10 } }) @@ -301,7 +304,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreePath: '/remote/work', - ptyId: 'remote-pty', + ptyId: 'ssh:target-a@@remote-pty', generation: 99 } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] ] @@ -368,7 +371,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreeId: 'repo-a::/remote/work', - ptyId: 'local-pty', + ptyId: 'ssh:target-a@@local-pty', generation: 1 } ] @@ -381,7 +384,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreePath: '/remote/work', - ptyId: 'new-remote-pty', + ptyId: 'ssh:target-a@@new-remote-pty', generation: 8 } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] ] @@ -391,7 +394,7 @@ describe('createRemoteWorkspaceTargetSync', () => { expect( hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] - ).toMatchObject({ generation: 8, ptyId: 'new-remote-pty' }) + ).toMatchObject({ generation: 8, ptyId: 'ssh:target-a@@new-remote-pty' }) }) it('does not preserve recovery evidence from another authority', async () => { @@ -402,7 +405,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreeId: 'repo-a::/remote/work', - ptyId: 'local-pty', + ptyId: 'ssh:target-a@@local-pty', generation: 1 } ] @@ -415,7 +418,7 @@ describe('createRemoteWorkspaceTargetSync', () => { connectionGeneration: 2 }, tabGeneration: 1, - ptyId: 'local-pty' + ptyId: 'ssh:target-a@@local-pty' } }, hydrateTabsSession @@ -426,7 +429,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreePath: '/remote/work', - ptyId: 'new-remote-pty', + ptyId: 'ssh:target-a@@new-remote-pty', generation: 8 } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] ] @@ -436,7 +439,7 @@ describe('createRemoteWorkspaceTargetSync', () => { expect( hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] - ).toMatchObject({ generation: 8, ptyId: 'new-remote-pty' }) + ).toMatchObject({ generation: 8, ptyId: 'ssh:target-a@@new-remote-pty' }) }) it('does not finalize an older snapshot superseded during terminal reattach', async () => { diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 836fd763fc6f..fb6982a4fc7a 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -24,6 +24,8 @@ type RemoteWorkspaceApi = { get: (args: { targetId: string }) => Promise setForConnectedTargets: (args: { session?: WorkspaceSessionState + sessionTargetId?: string + sessionAuthority?: DirectSshAuthority hydratedTargetIds?: string[] }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> } @@ -200,6 +202,8 @@ export function createRemoteWorkspaceTargetSync( } const results = await deps.remoteWorkspace.setForConnectedTargets({ session: buildWorkspaceSessionPayload(deps.store.getState()), + sessionTargetId: authority.targetId, + sessionAuthority: authority, hydratedTargetIds: [authority.targetId] }) if (!deps.isPreparationTokenCurrent(token)) { 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 29d166d0816f..711a54c7658c 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { @@ -13,6 +14,19 @@ import { type HostPersistenceState } from './workspace-session-host-persistence' +function sessionTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + describe('fetchWorkspaceSessionFromHosts', () => { it('reads saved runtime host partitions before runtime repos are loaded', async () => { const worktreeId = 'remote-repo::/srv/remote-wt' @@ -175,6 +189,184 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' }) }) + it('adopts tabs stranded in an ssh host partition and moves them to the local partition', async () => { + const worktreeId = 'ssh-repo::/home/user/wt' + const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } + const strandedTab = { + id: 'stranded-tab', + ptyId: null, + worktreeId, + title: 'Stranded', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const local = { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeId]: [] } } + const adopted = { + ...local, + tabsByWorktree: { [worktreeId]: [strandedTab] }, + terminalLayoutsByTabId: { 'stranded-tab': { root: null } as never } + } + const get = vi.fn().mockResolvedValue(local) + const adoptSshPartition = vi.fn().mockResolvedValue(adopted) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, adoptSshPartition }, [ + sshRepo + ]) + + expect(adoptSshPartition).toHaveBeenCalledWith() + expect(read.session.tabsByWorktree[worktreeId]).toEqual([strandedTab]) + expect(read.session.terminalLayoutsByTabId['stranded-tab']).toBeDefined() + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({}) + }) + + it('merges freshly adopted SSH state back with runtime host slices', async () => { + const sshWorktreeId = 'ssh-repo::/home/user/ssh-wt' + const runtimeWorktreeId = 'runtime-repo::/srv/runtime-wt' + const sshTab = sessionTab('ssh-tab', sshWorktreeId) + const runtimeTab = sessionTab('runtime-tab', runtimeWorktreeId) + const local = getDefaultWorkspaceSession() + const get = vi.fn(async (hostId?: string): Promise => { + return hostId === 'runtime:env-1' + ? { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [runtimeWorktreeId]: [runtimeTab] } + } + : local + }) + const adoptSshPartition = vi.fn().mockResolvedValue({ + ...local, + tabsByWorktree: { [sshWorktreeId]: [sshTab] } + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners( + { get, adoptSshPartition }, + [{ connectionId: 'ssh-1', executionHostId: null }], + ['runtime:env-1'] + ) + + expect(read.session.tabsByWorktree[sshWorktreeId]).toEqual([sshTab]) + expect(read.session.tabsByWorktree[runtimeWorktreeId]).toEqual([runtimeTab]) + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ + [runtimeWorktreeId]: 'runtime:env-1' + }) + }) + + it('keeps locally populated worktrees over a stale ssh partition copy', async () => { + const worktreeId = 'ssh-repo::/home/user/wt' + const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } + const liveTab = { + id: 'live-tab', + ptyId: null, + worktreeId, + title: 'Live', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 2 + } + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'ssh:conn-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [{ ...liveTab, id: 'stale-tab', title: 'Stale' }] + } + } + } + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [liveTab] } + } + }) + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [sshRepo]) + + expect(read.session.tabsByWorktree[worktreeId]).toEqual([liveTab]) + }) + + it('keeps legacy SSH partitions isolated when distinct workspaces reuse a tab identity', async () => { + const leftKey = 'left::/home/user/left' + const rightKey = folderWorkspaceKey('right-folder') + const sharedTabId = 'shared-tab' + const repos = [ + { id: 'left', connectionId: 'conn-1', executionHostId: null }, + { id: 'right', connectionId: 'conn-2', executionHostId: null } + ] + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'ssh:conn-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [leftKey]: [sessionTab(sharedTabId, leftKey)] } + } + } + if (hostId === 'ssh:conn-2') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [rightKey]: [sessionTab(sharedTabId, rightKey)] } + } + } + return getDefaultWorkspaceSession() + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, repos) + + expect(read.session.tabsByWorktree).toEqual({}) + }) + + it('delegates persisted SSH partition enumeration to one main transaction', async () => { + const worktreeA = 'repo-a::/home/user/wt-a' + const worktreeB = 'repo-b::/home/user/wt-b' + const repos = [ + { id: 'repo-a', connectionId: 'conn-1', executionHostId: null }, + { id: 'repo-b', connectionId: 'conn-2', executionHostId: null } + ] + const tabA = sessionTab('tab-a', worktreeA) + const tabB = sessionTab('tab-b', worktreeB) + const get = vi.fn().mockResolvedValue(getDefaultWorkspaceSession()) + const adoptSshPartition = vi.fn( + async (hostId?: `ssh:${string}`): Promise => { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: + hostId === 'ssh:conn-1' + ? { [worktreeA]: [tabA] } + : { [worktreeA]: [tabA], [worktreeB]: [tabB] } + } + } + ) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, adoptSshPartition }, repos) + + expect(read.session.tabsByWorktree[worktreeA]).toEqual([tabA]) + expect(read.session.tabsByWorktree[worktreeB]).toEqual([tabB]) + expect(adoptSshPartition.mock.calls).toEqual([[]]) + }) + + it('runs main partition enumeration when no SSH repository is loaded', async () => { + const folderKey = folderWorkspaceKey('ssh-folder') + const folderTab = sessionTab('folder-tab', folderKey) + const get = vi.fn().mockResolvedValue(getDefaultWorkspaceSession()) + const adoptSshPartition = vi.fn().mockResolvedValue({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [folderKey]: [folderTab] } + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, adoptSshPartition }, []) + + expect(adoptSshPartition).toHaveBeenCalledWith() + expect(read.session.tabsByWorktree[folderKey]).toEqual([folderTab]) + }) + + it('fails closed when the durable main adoption transaction rejects', async () => { + const get = vi.fn().mockResolvedValue(getDefaultWorkspaceSession()) + const adoptSshPartition = vi.fn().mockRejectedValue(new Error('disk unavailable')) + + await expect( + fetchWorkspaceSessionWithRuntimeHostOwners({ get, adoptSshPartition }, []) + ).rejects.toThrow('disk unavailable') + }) + it('routes restored runtime folder workspace patches back to the runtime host', async () => { const folderKey = folderWorkspaceKey('folder-1') const patch = vi.fn().mockResolvedValue(undefined) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index b84b50909f7b..ac7701b00dc9 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -21,6 +21,7 @@ import { indexWorkspaceRuntimeHostOwnership, type WorkspaceRuntimeOwnerProjection } from './workspace-runtime-host-ownership' +import { adoptStrandedSshHostPartitions } from './workspace-session-ssh-partition-hydration' export type HostPersistenceState = { repos: readonly Pick[] @@ -37,6 +38,7 @@ export type HostPersistenceState = { type SessionApi = { get: (hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + adoptSshPartition?: (hostId?: `ssh:${string}`) => Promise setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } @@ -313,7 +315,7 @@ export async function fetchWorkspaceSessionFromHosts( } export async function fetchWorkspaceSessionWithRuntimeHostOwners( - api: Pick, + api: Pick, repos: readonly Pick[], additionalRuntimeHostIds: readonly ExecutionHostId[] = [] ): Promise { @@ -335,8 +337,19 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners( } }) ) + // Why: ssh partitions stay out of `slices` — SSH worktrees are local-owned + // here, so an ssh slice must never feed the runtime-owner map or win a blind + // last-wins merge; it is only a source for orphan adoption. + if (api.adoptSshPartition) { + // Main enumerates persisted partitions, including folder-only SSH projects. + slices[LOCAL_EXECUTION_HOST_ID] = await api.adoptSshPartition() + } + const merged = mergeWorkspaceSessionsFromHosts(slices) + const session = api.adoptSshPartition + ? merged + : await adoptStrandedSshHostPartitions(api, repos, merged) return { - session: mergeWorkspaceSessionsFromHosts(slices), + session, runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(slices) } } diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts new file mode 100644 index 000000000000..202f4d7adbba --- /dev/null +++ b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts @@ -0,0 +1,57 @@ +import type { Repo } from '../../../shared/repo-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { adoptOrphanedWorkspaceSessionPartition } from '../../../shared/workspace-session-partition-adoption' +import { + findCrossHostWorkspaceSessionKeyCollisions, + findWorkspaceTabIdOwnerCollisions +} from '../../../shared/workspace-session-partition-authority' + +type SshPartitionHydrationApi = { + get: (hostId?: ExecutionHostId) => Promise +} + +/** Collect the distinct SSH hosts owning any persisted repo. */ +export function listKnownSshHostIds( + repos: readonly Pick[] +): ExecutionHostId[] { + const hostIds = new Set() + for (const repo of repos) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'ssh') { + hostIds.add(parsed.id) + } + } + return [...hostIds] +} + +/** Recover SSH tabs through the main process's atomic partition move. */ +export async function adoptStrandedSshHostPartitions( + api: SshPartitionHydrationApi, + repos: readonly Pick[], + merged: WorkspaceSessionState +): Promise { + const sources: WorkspaceSessionState[] = [] + for (const hostId of listKnownSshHostIds(repos)) { + try { + sources.push(await api.get(hostId)) + } catch (err) { + console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) + } + } + const preserveWorkspaceKeys = new Set([ + ...findCrossHostWorkspaceSessionKeyCollisions(sources), + ...findWorkspaceTabIdOwnerCollisions([merged, ...sources]) + ]) + let session = merged + for (const source of sources) { + session = adoptOrphanedWorkspaceSessionPartition(session, source, { + preserveWorkspaceKeys + }).session + } + return session +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 57f5efacef3b..694a57272d78 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -833,6 +833,7 @@ function createWebPreloadApi(): Partial { }) ) }, + adoptSshPartition: async () => getStoredWorkspaceSession(), // localStorage writes synchronously, so there is no deferred web flush. flush: async () => {}, readTerminalScrollback: () => null, diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts new file mode 100644 index 000000000000..2f79540decae --- /dev/null +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -0,0 +1,655 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from './constants' +import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' +import type { WorkspaceSessionState } from './workspace-session-state-types' +import { folderWorkspaceKey } from './workspace-scope' +import { adoptOrphanedWorkspaceSessionPartition } from './workspace-session-partition-adoption' + +const WORKTREE_ID = 'repo-1::/srv/wt' +const FOLDER_KEY = folderWorkspaceKey('folder-1') +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `tab-1:${LEAF_ID}` + +function tab(id: string, worktreeId = WORKTREE_ID, ptyId: string | null = null): TerminalTab { + return { + id, + ptyId, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session(overrides: Partial): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), ...overrides } +} + +function layout(ptyId: string | null): TerminalLayoutSnapshot { + return { + root: { type: 'leaf' as const, leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: ptyId ? { [LEAF_ID]: ptyId } : undefined + } +} + +describe('adoptOrphanedWorkspaceSessionPartition', () => { + it.each([1000, 2000])( + 'indexes %i equivalent workspaces without per-workspace global layout scans', + (workspaceCount) => { + let globalLayoutScans = 0 + const makeEquivalentSession = (): WorkspaceSessionState => { + const tabsByWorktree: WorkspaceSessionState['tabsByWorktree'] = {} + const terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] = {} + for (let index = 0; index < workspaceCount; index += 1) { + const workspaceKey = `repo-${index}::/srv/worktree-${index}` + const tabId = `tab-${index}` + tabsByWorktree[workspaceKey] = [tab(tabId, workspaceKey, null)] + terminalLayoutsByTabId[tabId] = layout(null) + } + return session({ + tabsByWorktree, + terminalLayoutsByTabId: new Proxy(terminalLayoutsByTabId, { + ownKeys(target) { + globalLayoutScans += 1 + return Reflect.ownKeys(target) + } + }) + }) + } + + const base = makeEquivalentSession() + const source = makeEquivalentSession() + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.ambiguousWorktreeIds).toEqual([]) + expect(adoption.reconciledWorktreeIds).toHaveLength(workspaceCount) + expect(globalLayoutScans).toBe(3) + }, + 20_000 + ) + + it.each([ + ['git worktrees', 'repo-a::/srv/a', 'repo-b::/srv/b'], + ['folder workspaces', folderWorkspaceKey('folder-a'), folderWorkspaceKey('folder-b')] + ])('fails closed when %s reuse one tab identity', (_kind, baseKey, sourceKey) => { + const tabId = 'duplicate-tab' + const paneKey = `${tabId}:${LEAF_ID}` + const base = session({ + tabsByWorktree: { [baseKey]: [tab(tabId, baseKey, 'local-pty')] }, + terminalLayoutsByTabId: { [tabId]: layout('local-pty') }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'local-incarnation' }, + remoteSessionIdsByTabId: { [tabId]: 'local-session' } + }) + const source = session({ + tabsByWorktree: { [sourceKey]: [tab(tabId, sourceKey, 'ssh:one@@remote-pty')] }, + terminalLayoutsByTabId: { [tabId]: layout('ssh:one@@remote-pty') }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'remote-incarnation' }, + remoteSessionIdsByTabId: { [tabId]: 'ssh:one@@remote-session' }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId, + worktreeId: sourceKey, + agent: 'codex', + providerSession: { key: 'session_id', id: 'remote-provider-session' }, + prompt: 'remote', + state: 'working', + capturedAt: 2, + updatedAt: 2 + } + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(new Set(adoption.ambiguousWorktreeIds)).toEqual(new Set([baseKey, sourceKey])) + expect(adoption.reconciledWorktreeIds).toEqual([]) + expect(adoption.sourceAuthoritativePaneKeys).toEqual([]) + expect(adoption.session.tabsByWorktree[baseKey]?.[0]?.ptyId).toBe('local-pty') + expect(adoption.session.tabsByWorktree[sourceKey]).toBeUndefined() + expect(adoption.session.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[LEAF_ID]).toBe( + 'local-pty' + ) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe('local-incarnation') + expect(adoption.session.remoteSessionIdsByTabId?.[tabId]).toBe('local-session') + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[paneKey]).toBeUndefined() + }) + + it('adopts complete folder-only state without a repository entry', () => { + const source = session({ + activeWorkspaceKey: FOLDER_KEY, + activeWorktreeId: FOLDER_KEY, + activeTabId: 'tab-1', + tabsByWorktree: { [FOLDER_KEY]: [tab('tab-1', FOLDER_KEY, 'ssh:one@@pty-1')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-1') }, + openFilesByWorktree: { + [FOLDER_KEY]: [ + { + filePath: '/srv/folder/a.ts', + relativePath: 'a.ts', + worktreeId: FOLDER_KEY, + language: 'typescript' + } + ] + }, + activeFileIdByWorktree: { [FOLDER_KEY]: '/srv/folder/a.ts' }, + tabGroups: { + [FOLDER_KEY]: [ + { id: 'group-1', worktreeId: FOLDER_KEY, activeTabId: 'tab-1', tabOrder: ['tab-1'] } + ] + }, + tabGroupLayouts: { [FOLDER_KEY]: { type: 'leaf', groupId: 'group-1' } }, + activeGroupIdByWorktree: { [FOLDER_KEY]: 'group-1' }, + activeTabIdByWorktree: { [FOLDER_KEY]: 'tab-1' }, + lastVisitedAtByWorktreeId: { [FOLDER_KEY]: 42 }, + defaultTerminalTabsAppliedByWorktreeId: { [FOLDER_KEY]: true } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(session({}), source) + + expect(adoption.reconciledWorktreeIds).toEqual([FOLDER_KEY]) + expect(adoption.session.tabsByWorktree[FOLDER_KEY]?.[0]?.ptyId).toBe('ssh:one@@pty-1') + expect(adoption.session.activeWorkspaceKey).toBe(FOLDER_KEY) + expect(adoption.session.activeWorktreeId).toBe(FOLDER_KEY) + expect(adoption.session.openFilesByWorktree?.[FOLDER_KEY]?.[0]?.relativePath).toBe('a.ts') + expect(adoption.session.tabGroups?.[FOLDER_KEY]?.[0]?.id).toBe('group-1') + expect(adoption.session.tabGroupLayouts?.[FOLDER_KEY]).toEqual({ + type: 'leaf', + groupId: 'group-1' + }) + expect(adoption.session.lastVisitedAtByWorktreeId?.[FOLDER_KEY]).toBe(42) + }) + + it('uses a newer SSH topology revision as terminal membership authority', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('local-tab', WORKTREE_ID, 'local-pty')] }, + terminalLayoutsByTabId: { 'local-tab': layout('local-pty') }, + terminalTopologyRevisionByRepoId: { 'repo-1': 3 }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'local-group', + worktreeId: WORKTREE_ID, + activeTabId: 'local-tab', + tabOrder: ['local-tab'] + } + ] + } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-1')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-1') }, + terminalTopologyRevisionByRepoId: { 'repo-1': 4 }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'ssh-group', + worktreeId: WORKTREE_ID, + activeTabId: 'tab-1', + tabOrder: ['tab-1'] + } + ] + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.sourceAuthoritativeWorktreeIds).toEqual([WORKTREE_ID]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'tab-1' + ]) + expect(adoption.session.terminalLayoutsByTabId['local-tab']).toBeUndefined() + expect(adoption.session.tabGroups?.[WORKTREE_ID]?.[0]?.id).toBe('ssh-group') + expect(adoption.session.terminalTopologyRevisionByRepoId?.['repo-1']).toBe(4) + }) + + it('keeps newer local membership when the SSH topology revision is stale', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('local-tab')] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 8 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('stale-tab')] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 7 } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.sourceAuthoritativeWorktreeIds).toEqual([]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'local-tab' + ]) + }) + + it('uses SSH incarnation provenance for a newer binding and provider session', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@old')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@old') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-old' }, + remoteSessionIdsByTabId: { 'tab-1': 'ssh:one@@old' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 1 }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-old' }, + prompt: 'old', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@new')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@new') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-new' }, + remoteSessionIdsByTabId: { 'tab-1': 'ssh:one@@new' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 2 }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-new' }, + prompt: 'new', + state: 'working', + capturedAt: 2, + updatedAt: 2 + } + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.sourceAuthoritativePaneKeys).toEqual([PANE_KEY]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe('ssh:one@@new') + expect(adoption.session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe( + 'ssh:one@@new' + ) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-new') + expect(adoption.session.remoteSessionIdsByTabId?.['tab-1']).toBe('ssh:one@@new') + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.providerSession.id).toBe( + 'provider-new' + ) + }) + + it('moves an older provider record with its authoritative SSH incarnation', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@old')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@old') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-old' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 4 }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-wrong' }, + prompt: 'wrong', + state: 'working', + capturedAt: 9, + updatedAt: 9 + } + } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@new')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@new') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-new' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 5 }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + ...base.sleepingAgentSessionsByPaneKey![PANE_KEY], + providerSession: { key: 'session_id', id: 'provider-new' }, + prompt: 'new', + capturedAt: 1, + updatedAt: 1 + } + } + }) + + const adopted = adoptOrphanedWorkspaceSessionPartition(base, source).session + + expect(adopted.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-new') + expect(adopted.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.providerSession.id).toBe( + 'provider-new' + ) + }) + + it('preserves equal-revision pane bundles instead of combining their provenance', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@base')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@base') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-base' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 3 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:two@@source')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:two@@source') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-source' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 3 }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-source' }, + prompt: 'source', + state: 'working', + capturedAt: 10, + updatedAt: 10 + } + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.ambiguousWorktreeIds).toEqual([WORKTREE_ID]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe('ssh:one@@base') + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-base') + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeUndefined() + }) + + it('keeps a local incarnation when the SSH binding revision is older', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@local')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@local') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-local' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 5 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@stale')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@stale') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-stale' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 4 } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.sourceAuthoritativePaneKeys).toEqual([]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe('ssh:one@@local') + expect(adoption.session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe( + 'ssh:one@@local' + ) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-local') + }) + + it('does not let an old tombstone retire a newer SSH incarnation', () => { + const base = session({ + terminalSurfaceTombstonesByPaneKey: { + [PANE_KEY]: { + worktreeId: WORKTREE_ID, + parentTabId: 'tab-1', + leafId: LEAF_ID, + ptyId: 'ssh:one@@old', + incarnationId: 'inc-old', + retiredAt: 1 + } + } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@new')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@new') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-new' } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(adoption.session.terminalSurfaceTombstonesByPaneKey?.[PANE_KEY]).toBeUndefined() + }) + + it('is idempotent after provenance reconciliation', () => { + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@new')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@new') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-new' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 2 } + }) + const first = adoptOrphanedWorkspaceSessionPartition(session({}), source).session + const second = adoptOrphanedWorkspaceSessionPartition(first, source).session + + expect(second).toEqual(first) + }) + + it('adopts pty-bound ssh panes over a revision-only base full of dormant tab rows', () => { + const ghostLeaf = '22222222-2222-4222-8222-222222222222' + const base = session({ + tabsByWorktree: { + [WORKTREE_ID]: [tab('ghost-1'), tab('ghost-2')] + }, + terminalLayoutsByTabId: { + 'ghost-1': { ...layout(null), root: { type: 'leaf', leafId: ghostLeaf } }, + 'ghost-2': { ...layout(null), root: { type: 'leaf', leafId: ghostLeaf } } + }, + terminalPtyIncarnationsByPaneKey: { + [`ghost-1:${ghostLeaf}`]: 'inc-ghost-1', + [`ghost-2:${ghostLeaf}`]: 'inc-ghost-2' + }, + terminalTopologyRevisionByRepoId: { 'repo-1': 162 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-34')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-34') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-live' }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-live' }, + prompt: 'live', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.sourceAuthoritativeWorktreeIds).toEqual([WORKTREE_ID]) + const adoptedIds = adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id) + expect(adoptedIds).toContain('tab-1') + expect(adoptedIds).toContain('ghost-1') + expect(adoptedIds).toContain('ghost-2') + const adoptedLive = adoption.session.tabsByWorktree[WORKTREE_ID]?.find( + (entry) => entry.id === 'tab-1' + ) + expect(adoptedLive?.ptyId).toBe('ssh:one@@pty-34') + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.providerSession?.id).toBe( + 'session-live' + ) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-live') + }) + + it('keeps the winner tab list intact when the veto grafts a stray live pane', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('kept-dormant')] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 162 } + }) + const source = session({ + tabsByWorktree: { + [WORKTREE_ID]: [ + tab('kept-dormant'), + tab('stray-dormant'), + tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-40') + ] + }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-40') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-live' } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + // The graft is additive: the winner's own row survives, only the source's + // pty-bound stray joins it, and dormant strays stay out of the adopted view. + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'kept-dormant', + 'tab-1' + ]) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-live') + }) + + it('does not resurrect a tombstoned pane through the pty-bound tiebreak', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1')] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 30 }, + terminalSurfaceTombstonesByPaneKey: { + [PANE_KEY]: { + worktreeId: WORKTREE_ID, + parentTabId: 'tab-1', + leafId: LEAF_ID, + ptyId: 'ssh:one@@pty-5', + incarnationId: 'inc-closed', + retiredAt: 20 + } + } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-5')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-5') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-closed' } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect( + adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id) ?? [] + ).not.toContain('tab-1') + }) + + it('merges a grafted key per pane: base row and layout survive, source bindings adopt', () => { + const keptLeaf = '33333333-3333-4333-8333-333333333333' + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('kept-dormant')] }, + terminalLayoutsByTabId: { + 'kept-dormant': { + root: { type: 'leaf', leafId: keptLeaf }, + activeLeafId: keptLeaf, + expandedLeafId: null, + titlesByLeafId: { [keptLeaf]: 'base-title' } + } + }, + terminalTopologyRevisionByRepoId: { 'repo-1': 162 } + }) + const source = session({ + tabsByWorktree: { + [WORKTREE_ID]: [ + // Why the shared-id shape: the renderer wipes its persisted bindings on every + // disconnect while the runtime partition keeps the live ones, so a base row + // without a binding whose source copy is bound is the routine live topology. + // The pane-level merge adopts the binding onto the kept row; the row itself + // and leaf state the source does not bind stay base's. + tab('kept-dormant', WORKTREE_ID, 'ssh:one@@pty-50'), + tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-51') + ] + }, + terminalLayoutsByTabId: { + 'kept-dormant': { + root: { type: 'leaf', leafId: keptLeaf }, + activeLeafId: keptLeaf, + expandedLeafId: keptLeaf, + ptyIdsByLeafId: { [keptLeaf]: 'ssh:one@@pty-50' } + }, + 'tab-1': layout('ssh:one@@pty-51') + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'kept-dormant', + 'tab-1' + ]) + const keptLayout = adoption.session.terminalLayoutsByTabId['kept-dormant'] + expect(keptLayout?.ptyIdsByLeafId?.[keptLeaf]).toBe('ssh:one@@pty-50') + // Base's top-level layout fields win the per-pane merge; a wholesale replace + // would take the source copy's expandedLeafId instead. + expect(keptLayout?.expandedLeafId).toBeNull() + expect( + adoption.session.tabsByWorktree[WORKTREE_ID]?.find((entry) => entry.id === 'kept-dormant') + ?.ptyId + ).toBe('ssh:one@@pty-50') + }) + + it('keeps a base-kept tab leaf untouched when the source copy does not bind it', () => { + const keptLeaf = '44444444-4444-4444-8444-444444444444' + const baseLayout = { + root: { type: 'leaf' as const, leafId: keptLeaf }, + activeLeafId: keptLeaf, + expandedLeafId: null, + titlesByLeafId: { [keptLeaf]: 'base-title' } + } + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('kept-dormant')] }, + terminalLayoutsByTabId: { 'kept-dormant': baseLayout }, + remoteSessionIdsByTabId: { 'kept-dormant': 'base-session' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 162 } + }) + const source = session({ + tabsByWorktree: { + [WORKTREE_ID]: [tab('kept-dormant'), tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-52')] + }, + terminalLayoutsByTabId: { + 'kept-dormant': { + root: { type: 'leaf', leafId: keptLeaf }, + activeLeafId: keptLeaf, + expandedLeafId: null, + titlesByLeafId: { [keptLeaf]: 'source-title-should-not-win' } + }, + 'tab-1': layout('ssh:one@@pty-52') + }, + remoteSessionIdsByTabId: { 'kept-dormant': 'source-session-should-not-win' } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect( + adoption.session.terminalLayoutsByTabId['kept-dormant']?.titlesByLeafId?.[keptLeaf] + ).toBe('base-title') + expect(adoption.session.remoteSessionIdsByTabId?.['kept-dormant']).toBe('base-session') + }) + + it('keeps a close-emptied worktree closed against a stale pty-bound partition copy', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 163 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-dead')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-dead') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-stale' }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-stale' }, + prompt: 'stale', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.sourceAuthoritativeWorktreeIds).toEqual([]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeUndefined() + }) +}) diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts new file mode 100644 index 000000000000..cc3b1254400a --- /dev/null +++ b/src/shared/workspace-session-partition-adoption.ts @@ -0,0 +1,274 @@ +import type { WorkspaceSessionState } from './workspace-session-state-types' +import { + createWorkspaceSessionAuthorityIndex, + findWorkspaceTabIdOwnerCollisions, + workspaceTerminalAuthority +} from './workspace-session-partition-authority' +import { mergeAdoptedWorkspaceSessionState } from './workspace-session-partition-state-merge' +import { + collectTerminalProvenance, + collectWorkspaceKeys, + layoutLeafCount, + mergeTerminalLayout, + mergeWorkspaceRecord, + paneLeafId, + paneTabId, + tabIsPtyBound +} from './workspace-session-partition-provenance' + +export type WorkspaceSessionPartitionAdoption = { + session: WorkspaceSessionState + reconciledWorktreeIds: string[] + sourceAuthoritativeWorktreeIds: string[] + sourceAuthoritativePaneKeys: string[] + ambiguousWorktreeIds: string[] +} + +type WorkspaceSessionPartitionAdoptionOptions = { + preserveWorkspaceKeys?: ReadonlySet +} + +export function adoptOrphanedWorkspaceSessionPartition( + base: WorkspaceSessionState, + source: WorkspaceSessionState | null | undefined, + options: WorkspaceSessionPartitionAdoptionOptions = {} +): WorkspaceSessionPartitionAdoption { + if (!source) { + return { + session: base, + reconciledWorktreeIds: [], + sourceAuthoritativeWorktreeIds: [], + sourceAuthoritativePaneKeys: [], + ambiguousWorktreeIds: [] + } + } + const sourceKeys = collectWorkspaceKeys(source) + const baseKeys = collectWorkspaceKeys(base) + const baseAuthorityIndex = createWorkspaceSessionAuthorityIndex(base) + const sourceAuthorityIndex = createWorkspaceSessionAuthorityIndex(source) + const decisionByWorkspaceKey = new Map( + [...sourceKeys].map((key) => [ + key, + workspaceTerminalAuthority( + base, + source, + key, + { + base: baseKeys.has(key), + source: true + }, + { + base: baseAuthorityIndex, + source: sourceAuthorityIndex + } + ) + ]) + ) + const ambiguousWorktreeIds = new Set([ + ...findWorkspaceTabIdOwnerCollisions([base, source]), + ...[...sourceKeys].filter( + (key) => + options.preserveWorkspaceKeys?.has(key) || + decisionByWorkspaceKey.get(key)?.authority === 'ambiguous' + ) + ]) + const sourceAuthority = new Set( + [...sourceKeys].filter( + (key) => + !ambiguousWorktreeIds.has(key) && decisionByWorkspaceKey.get(key)?.authority === 'source' + ) + ) + // Keys where the pty-bound veto overturned base's revision win. Base's own tab rows + // must survive for these: base is the side that runs the topology-authority code + // path, so its list reflects deliberate closes the source never observed. The + // source's pty-bound strays are grafted in additively below instead of replacing + // the list, and every other per-key field keeps base priority. + const grafted = new Set( + [...sourceAuthority].filter( + (key) => decisionByWorkspaceKey.get(key)?.ptyBoundVetoFlip && base.tabsByWorktree[key] + ) + ) + const sourceFieldAuthority = new Set([...sourceAuthority].filter((key) => !grafted.has(key))) + const reconciledKeys = new Set([...sourceKeys].filter((key) => !ambiguousWorktreeIds.has(key))) + const tabsByWorktree = mergeWorkspaceRecord( + base.tabsByWorktree, + source.tabsByWorktree, + reconciledKeys, + sourceFieldAuthority + ) ?? { ...base.tabsByWorktree } + for (const key of grafted) { + const baseTabs = base.tabsByWorktree[key] ?? [] + const baseTabIds = new Set(baseTabs.map((tab) => tab.id)) + const strays = (source.tabsByWorktree[key] ?? []).filter( + (tab) => !baseTabIds.has(tab.id) && tabIsPtyBound(source, tab) + ) + if (strays.length > 0) { + tabsByWorktree[key] = [...baseTabs, ...strays] + } + } + const { liveTabIds, touchedTabIds, sourceWorkspaceKeyByTabId, sourcePaneAuthority } = + collectTerminalProvenance(base, source, tabsByWorktree, reconciledKeys, sourceAuthority) + const sourcePaneAuthoritativeTabIds = new Set( + [...sourcePaneAuthority].map((paneKey) => paneTabId(paneKey)) + ) + for (const key of reconciledKeys) { + if (sourceFieldAuthority.has(key)) { + continue + } + const sourceTabsById = new Map( + (source.tabsByWorktree[key] ?? []).map((tab) => [tab.id, tab] as const) + ) + tabsByWorktree[key] = (tabsByWorktree[key] ?? []).map((tab) => { + const sourceTab = sourceTabsById.get(tab.id) + return sourceTab?.ptyId && sourcePaneAuthoritativeTabIds.has(tab.id) + ? { ...tab, ptyId: sourceTab.ptyId } + : tab + }) + } + + const terminalLayoutsByTabId = { ...base.terminalLayoutsByTabId } + const remoteSessionIdsByTabId = { ...base.remoteSessionIdsByTabId } + for (const tabId of touchedTabIds) { + if (!liveTabIds.has(tabId)) { + delete terminalLayoutsByTabId[tabId] + delete remoteSessionIdsByTabId[tabId] + continue + } + const sourceLayout = source.terminalLayoutsByTabId[tabId] + const sourceWorkspaceKey = sourceWorkspaceKeyByTabId.get(tabId) + // Why sourceFieldAuthority: a grafted key keeps base's own rows, so their layout + // and remote-session entries merge per pane instead of being replaced wholesale; + // grafted strays have no base-side entry and still take source's copy through + // the merge fallback. + terminalLayoutsByTabId[tabId] = + sourceWorkspaceKey && sourceFieldAuthority.has(sourceWorkspaceKey) + ? (sourceLayout ?? terminalLayoutsByTabId[tabId]) + : (mergeTerminalLayout( + terminalLayoutsByTabId[tabId], + sourceLayout, + tabId, + sourcePaneAuthority + ) ?? terminalLayoutsByTabId[tabId]) + if ( + source.remoteSessionIdsByTabId?.[tabId] !== undefined && + ((sourceWorkspaceKey && sourceFieldAuthority.has(sourceWorkspaceKey)) || + remoteSessionIdsByTabId[tabId] === undefined || + sourcePaneAuthoritativeTabIds.has(tabId)) + ) { + remoteSessionIdsByTabId[tabId] = source.remoteSessionIdsByTabId[tabId] + } + } + + const terminalPtyIncarnationsByPaneKey = { ...base.terminalPtyIncarnationsByPaneKey } + for (const paneKey of sourcePaneAuthority) { + const incarnationId = source.terminalPtyIncarnationsByPaneKey?.[paneKey] + if (incarnationId === undefined) { + delete terminalPtyIncarnationsByPaneKey[paneKey] + } else { + terminalPtyIncarnationsByPaneKey[paneKey] = incarnationId + } + } + for (const paneKey of Object.keys(terminalPtyIncarnationsByPaneKey)) { + if (touchedTabIds.has(paneTabId(paneKey)) && !liveTabIds.has(paneTabId(paneKey))) { + delete terminalPtyIncarnationsByPaneKey[paneKey] + } + } + + const terminalSurfaceTombstonesByPaneKey = { + ...base.terminalSurfaceTombstonesByPaneKey + } + for (const [paneKey, tombstone] of Object.entries( + source.terminalSurfaceTombstonesByPaneKey ?? {} + )) { + if (!sourceAuthority.has(tombstone.worktreeId)) { + continue + } + const liveIncarnation = terminalPtyIncarnationsByPaneKey[paneKey] + if (!liveIncarnation || liveIncarnation === tombstone.incarnationId) { + terminalSurfaceTombstonesByPaneKey[paneKey] = tombstone + } + } + for (const paneKey of sourcePaneAuthority) { + const tombstone = terminalSurfaceTombstonesByPaneKey[paneKey] + const sourceIncarnation = source.terminalPtyIncarnationsByPaneKey?.[paneKey] + const sourceWorkspaceKey = sourceWorkspaceKeyByTabId.get(paneTabId(paneKey)) + const sourcePtyId = + source.terminalLayoutsByTabId[paneTabId(paneKey)]?.ptyIdsByLeafId?.[paneLeafId(paneKey)] + if ( + tombstone && + ((sourceIncarnation && tombstone.incarnationId !== sourceIncarnation) || + (!sourceIncarnation && + sourceWorkspaceKey && + sourceAuthority.has(sourceWorkspaceKey) && + sourcePtyId && + sourcePtyId !== tombstone.ptyId)) + ) { + delete terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + + const retiredTabIds = new Set() + for (const [paneKey, tombstone] of Object.entries(terminalSurfaceTombstonesByPaneKey)) { + const incarnation = terminalPtyIncarnationsByPaneKey[paneKey] + const layout = terminalLayoutsByTabId[tombstone.parentTabId] + if ( + (!incarnation || incarnation === tombstone.incarnationId) && + layoutLeafCount(layout?.root ?? null) <= 1 + ) { + retiredTabIds.add(tombstone.parentTabId) + } + } + for (const [workspaceKey, tabs] of Object.entries(tabsByWorktree)) { + tabsByWorktree[workspaceKey] = tabs.filter((tab) => !retiredTabIds.has(tab.id)) + } + for (const tabId of retiredTabIds) { + liveTabIds.delete(tabId) + delete terminalLayoutsByTabId[tabId] + delete remoteSessionIdsByTabId[tabId] + for (const paneKey of Object.keys(terminalPtyIncarnationsByPaneKey)) { + if (paneTabId(paneKey) === tabId) { + delete terminalPtyIncarnationsByPaneKey[paneKey] + } + } + } + + const sleepingAgentSessionsByPaneKey = { ...base.sleepingAgentSessionsByPaneKey } + for (const paneKey of sourcePaneAuthority) { + const record = source.sleepingAgentSessionsByPaneKey?.[paneKey] + const tombstone = terminalSurfaceTombstonesByPaneKey[paneKey] + if ( + record && + (!tombstone || tombstone.incarnationId !== terminalPtyIncarnationsByPaneKey[paneKey]) + ) { + sleepingAgentSessionsByPaneKey[paneKey] = record + } else { + delete sleepingAgentSessionsByPaneKey[paneKey] + } + } + for (const [paneKey, record] of Object.entries(sleepingAgentSessionsByPaneKey)) { + if (reconciledKeys.has(record.worktreeId) && record.tabId && retiredTabIds.has(record.tabId)) { + delete sleepingAgentSessionsByPaneKey[paneKey] + } + } + + const session = mergeAdoptedWorkspaceSessionState({ + base, + source, + reconciledKeys, + sourceAuthority: sourceFieldAuthority, + ambiguousWorktreeIds, + tabsByWorktree, + terminalLayoutsByTabId, + remoteSessionIdsByTabId, + sleepingAgentSessionsByPaneKey, + terminalPtyIncarnationsByPaneKey, + terminalSurfaceTombstonesByPaneKey + }) + return { + session, + reconciledWorktreeIds: [...reconciledKeys], + sourceAuthoritativeWorktreeIds: [...sourceAuthority], + sourceAuthoritativePaneKeys: [...sourcePaneAuthority], + ambiguousWorktreeIds: [...ambiguousWorktreeIds] + } +} diff --git a/src/shared/workspace-session-partition-authority.test.ts b/src/shared/workspace-session-partition-authority.test.ts new file mode 100644 index 000000000000..fc4f7c328133 --- /dev/null +++ b/src/shared/workspace-session-partition-authority.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from './constants' +import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' +import type { WorkspaceSessionState } from './workspace-session-state-types' +import { workspaceTerminalAuthority } from './workspace-session-partition-authority' + +const WORKTREE_ID = 'repo-1::/srv/wt' +const REPO_ID = 'repo-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function tab(id: string, ptyId: string | null = null): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session(overrides: Partial): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), ...overrides } +} + +function layout(ptyId: string | null): TerminalLayoutSnapshot { + return { + root: { type: 'leaf' as const, leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: ptyId ? { [LEAF_ID]: ptyId } : undefined + } +} + +describe('workspaceTerminalAuthority', () => { + it('does not let a one-sided revision veto the partition holding pty-bound panes', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1'), tab('ghost-2')] }, + terminalLayoutsByTabId: { 'ghost-1': layout(null), 'ghost-2': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 162 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-34')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-34') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') + }) + + it('mirrors the pty-bound tiebreak toward a live base against a revisionless source', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-3')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-3') } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1')] }, + terminalLayoutsByTabId: { 'ghost-1': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 7 } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('base') + }) + + it('yields a higher two-sided revision to the side holding pty-bound panes', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1'), tab('ghost-2')] }, + terminalLayoutsByTabId: { 'ghost-1': layout(null), 'ghost-2': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 162 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-34')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-34') }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 5 } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') + }) + + it('keeps two-sided revision precedence when both sides hold pty-bound panes', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('a', 'ssh:target@@pty-1')] }, + terminalLayoutsByTabId: { a: layout('ssh:target@@pty-1') }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 9 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('b', 'ssh:target@@pty-2')] }, + terminalLayoutsByTabId: { b: layout('ssh:target@@pty-2') }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 4 } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('base') + }) + + it('keeps revision precedence when both partitions record a revision', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('stale-1')] }, + terminalLayoutsByTabId: { 'stale-1': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 3 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-1')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-1') }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 9 } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') + }) + + it('counts a pane bound through its layout when the tab record carries no ptyId', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1')] }, + terminalLayoutsByTabId: { 'ghost-1': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 12 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', null)] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-9') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') + }) + + it('stays ambiguous when neither side holds a pty-bound pane and bundles differ', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('a')] }, + terminalLayoutsByTabId: { a: layout(null) } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('b')] }, + terminalLayoutsByTabId: { b: layout(null) } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('ambiguous') + }) + + it('stays ambiguous when both sides hold pty-bound panes and bundles differ', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('a', 'ssh:target@@pty-1')] }, + terminalLayoutsByTabId: { a: layout('ssh:target@@pty-1') } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('b', 'ssh:target@@pty-2')] }, + terminalLayoutsByTabId: { b: layout('ssh:target@@pty-2') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('ambiguous') + }) + + it('keeps authority with a revision winner whose tab list is explicitly empty', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 163 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('stale-1', 'ssh:target@@pty-dead')] }, + terminalLayoutsByTabId: { 'stale-1': layout('ssh:target@@pty-dead') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('base') + }) + + it('vetoes a revision winner that has no record at all for the workspace key', () => { + const base = session({ + terminalTopologyRevisionByRepoId: { [REPO_ID]: 12 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-3')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-3') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') + }) + + it('still prefers the only populated side when the other has the key with no tabs', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('a')] }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 5 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [] } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('base') + }) +}) diff --git a/src/shared/workspace-session-partition-authority.ts b/src/shared/workspace-session-partition-authority.ts new file mode 100644 index 000000000000..c033dac9b4be --- /dev/null +++ b/src/shared/workspace-session-partition-authority.ts @@ -0,0 +1,330 @@ +import type { WorkspaceSessionState } from './workspace-session-state-types' +import { parseWorkspaceKey } from './workspace-scope' +import { getRepoIdFromWorktreeId } from './worktree/id' +import { + collectWorkspaceKeys, + hasPtyBoundPane, + paneTabId, + topologyRevisionAuthority, + WORKTREE_RECORD_FIELDS +} from './workspace-session-partition-provenance' + +export function repoIdForWorkspaceKey(key: string): string | null { + const scope = parseWorkspaceKey(key) + if (scope?.type === 'folder') { + return null + } + return getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : key) +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(value, (_key, entry: unknown) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return entry + } + return Object.fromEntries( + Object.entries(entry as Record).sort(([left], [right]) => + left.localeCompare(right) + ) + ) + }) +} + +const EMPTY_WORKSPACE_BUNDLE_SIGNATURE = canonicalJson({ + workspaceRecords: {}, + layouts: {}, + remoteSessions: {}, + incarnations: {}, + sleepingAgents: {}, + tombstones: {}, + browserPages: {} +}) + +type WorkspaceSessionBundle = { + workspaceRecords: Record + layouts: Record + remoteSessions: Record + incarnations: Record + sleepingAgents: Record + tombstones: Record + browserPages: Record +} + +export type WorkspaceSessionAuthorityIndex = { + workspaceKeys: ReadonlySet + bundleSignaturesByWorkspaceKey: ReadonlyMap +} + +function emptyWorkspaceBundle( + session: WorkspaceSessionState, + workspaceKey: string +): WorkspaceSessionBundle { + return { + workspaceRecords: Object.fromEntries( + WORKTREE_RECORD_FIELDS.map((field) => { + const value = session[field]?.[workspaceKey] + return [field, value === undefined ? undefined : canonicalJson(value)] + }) + ), + layouts: {}, + remoteSessions: {}, + incarnations: {}, + sleepingAgents: {}, + tombstones: {}, + browserPages: {} + } +} + +export function createWorkspaceSessionAuthorityIndex( + session: WorkspaceSessionState +): WorkspaceSessionAuthorityIndex { + const workspaceKeys = collectWorkspaceKeys(session) + const bundles = new Map( + [...workspaceKeys].map((workspaceKey) => [ + workspaceKey, + emptyWorkspaceBundle(session, workspaceKey) + ]) + ) + const ownersByTabId = new Map>() + for (const [workspaceKey, tabs] of Object.entries(session.tabsByWorktree)) { + for (const tab of tabs) { + const owners = ownersByTabId.get(tab.id) ?? new Set() + owners.add(workspaceKey) + ownersByTabId.set(tab.id, owners) + } + } + const addForTabOwners = ( + field: 'layouts' | 'remoteSessions' | 'incarnations', + recordKey: string, + tabId: string, + value: unknown + ): void => { + for (const workspaceKey of ownersByTabId.get(tabId) ?? []) { + const bundle = bundles.get(workspaceKey) + if (bundle) { + bundle[field][recordKey] = value + } + } + } + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { + addForTabOwners('layouts', tabId, tabId, canonicalJson(layout)) + } + for (const [tabId, remoteSessionId] of Object.entries(session.remoteSessionIdsByTabId ?? {})) { + addForTabOwners('remoteSessions', tabId, tabId, canonicalJson(remoteSessionId)) + } + for (const [paneKey, incarnationId] of Object.entries( + session.terminalPtyIncarnationsByPaneKey ?? {} + )) { + addForTabOwners('incarnations', paneKey, paneTabId(paneKey), canonicalJson(incarnationId)) + } + for (const [paneKey, record] of Object.entries(session.sleepingAgentSessionsByPaneKey ?? {})) { + const bundle = bundles.get(record.worktreeId) + if (bundle) { + bundle.sleepingAgents[paneKey] = canonicalJson(record) + } + } + for (const [paneKey, tombstone] of Object.entries( + session.terminalSurfaceTombstonesByPaneKey ?? {} + )) { + const bundle = bundles.get(tombstone.worktreeId) + if (bundle) { + bundle.tombstones[paneKey] = canonicalJson(tombstone) + } + } + for (const [browserWorkspaceKey, pages] of Object.entries( + session.browserPagesByWorkspace ?? {} + )) { + const pagesSignature = canonicalJson(pages) + for (const workspaceKey of new Set(pages.map((page) => page.worktreeId))) { + const bundle = bundles.get(workspaceKey) + if (bundle) { + bundle.browserPages[browserWorkspaceKey] = pagesSignature + } + } + } + return { + workspaceKeys, + bundleSignaturesByWorkspaceKey: new Map( + [...bundles].map(([workspaceKey, bundle]) => [workspaceKey, canonicalJson(bundle)]) + ) + } +} + +function indexedWorkspaceSessionBundlesEquivalent( + base: WorkspaceSessionAuthorityIndex, + source: WorkspaceSessionAuthorityIndex, + workspaceKey: string +): boolean { + return ( + (base.bundleSignaturesByWorkspaceKey.get(workspaceKey) ?? EMPTY_WORKSPACE_BUNDLE_SIGNATURE) === + (source.bundleSignaturesByWorkspaceKey.get(workspaceKey) ?? EMPTY_WORKSPACE_BUNDLE_SIGNATURE) + ) +} + +export function workspaceSessionBundlesEquivalent( + base: WorkspaceSessionState, + source: WorkspaceSessionState, + workspaceKey: string +): boolean { + return indexedWorkspaceSessionBundlesEquivalent( + createWorkspaceSessionAuthorityIndex(base), + createWorkspaceSessionAuthorityIndex(source), + workspaceKey + ) +} + +export type WorkspaceTerminalAuthority = 'base' | 'source' | 'equivalent' | 'ambiguous' + +function undisputed(authority: WorkspaceTerminalAuthority): WorkspaceTerminalAuthorityDecision { + return { authority, ptyBoundVetoFlip: false } +} + +export type WorkspaceTerminalAuthorityDecision = { + authority: WorkspaceTerminalAuthority + // Set when the pty-bound veto overturned a revision-based winner. Adoption uses it + // to graft the loser's pty-bound tabs into the winner's list instead of replacing + // the list wholesale, so the winner's own rows (including its closes of dormant + // tabs) survive the flip. + ptyBoundVetoFlip: boolean +} + +export function workspaceTerminalAuthority( + base: WorkspaceSessionState, + source: WorkspaceSessionState, + workspaceKey: string, + presence?: { base: boolean; source: boolean }, + indexes?: { + base: WorkspaceSessionAuthorityIndex + source: WorkspaceSessionAuthorityIndex + } +): WorkspaceTerminalAuthorityDecision { + const repoId = repoIdForWorkspaceKey(workspaceKey) + const baseRevision = repoId ? base.terminalTopologyRevisionByRepoId?.[repoId] : undefined + const sourceRevision = repoId ? source.terminalTopologyRevisionByRepoId?.[repoId] : undefined + const revisionAuthority = topologyRevisionAuthority(baseRevision, sourceRevision) + if (revisionAuthority) { + // Why the pty-bound veto: the counter is per repo and bumped only by the process + // running the topology-authority code path, so it can outrank the other side + // without describing this workspace's terminals at all. A winner holding only + // dormant tab rows (or no record for the key) yields to a loser holding a + // pty-bound pane — preferring the counter there duplicates the running terminals' + // agent sessions through cold restore. An explicitly empty winner list is a close + // fenced by the counter alone (tombstones are legacy): it keeps authority, so a + // stale pty-bound copy elsewhere cannot resurrect what the user closed. + const winner = revisionAuthority === 'base' ? base : source + const loser = revisionAuthority === 'base' ? source : base + const winnerTabs = winner.tabsByWorktree[workspaceKey] + const loserTabs = loser.tabsByWorktree[workspaceKey] ?? [] + if ( + (winnerTabs === undefined || winnerTabs.length > 0) && + !hasPtyBoundPane(winner, winnerTabs ?? []) && + hasPtyBoundPane(loser, loserTabs) + ) { + return { + authority: revisionAuthority === 'base' ? 'source' : 'base', + ptyBoundVetoFlip: true + } + } + return undisputed(revisionAuthority) + } + const baseIndex = indexes?.base ?? createWorkspaceSessionAuthorityIndex(base) + const sourceIndex = indexes?.source ?? createWorkspaceSessionAuthorityIndex(source) + const baseHasKey = presence?.base ?? baseIndex.workspaceKeys.has(workspaceKey) + const sourceHasKey = presence?.source ?? sourceIndex.workspaceKeys.has(workspaceKey) + if (sourceHasKey !== baseHasKey) { + return undisputed(sourceHasKey ? 'source' : 'base') + } + const baseTabs = base.tabsByWorktree[workspaceKey] ?? [] + const sourceTabs = source.tabsByWorktree[workspaceKey] ?? [] + if (sourceTabs.length > 0 !== baseTabs.length > 0) { + return undisputed(sourceTabs.length > 0 ? 'source' : 'base') + } + // Why pty-bound wins: a persisted pty binding is the partition's proof that it + // tracked a running terminal; a copy with none describes only dormant surfaces. + // Treating the sides as ambiguous here would keep the dormant copy and duplicate + // the running terminals' agent sessions through cold restore. + const basePtyBound = hasPtyBoundPane(base, baseTabs) + const sourcePtyBound = hasPtyBoundPane(source, sourceTabs) + if (sourcePtyBound !== basePtyBound) { + return undisputed(sourcePtyBound ? 'source' : 'base') + } + return undisputed( + indexedWorkspaceSessionBundlesEquivalent(baseIndex, sourceIndex, workspaceKey) + ? 'equivalent' + : 'ambiguous' + ) +} + +export function findAmbiguousWorkspaceSessionKeys( + sources: readonly WorkspaceSessionState[] +): Set { + const ambiguous = new Set() + const indexes = sources.map(createWorkspaceSessionAuthorityIndex) + const keysBySource = indexes.map((index) => index.workspaceKeys) + for (let left = 0; left < sources.length; left += 1) { + for (let right = left + 1; right < sources.length; right += 1) { + const keys = new Set([...keysBySource[left], ...keysBySource[right]]) + for (const key of keys) { + if ( + workspaceTerminalAuthority( + sources[left], + sources[right], + key, + { + base: keysBySource[left].has(key), + source: keysBySource[right].has(key) + }, + { + base: indexes[left], + source: indexes[right] + } + ).authority === 'ambiguous' + ) { + ambiguous.add(key) + } + } + } + } + return ambiguous +} + +export function findCrossHostWorkspaceSessionKeyCollisions( + sources: readonly WorkspaceSessionState[] +): Set { + const collisions = new Set() + const keysBySource = sources.map(collectWorkspaceKeys) + for (let left = 0; left < sources.length; left += 1) { + for (let right = left + 1; right < sources.length; right += 1) { + for (const key of keysBySource[left]) { + if (keysBySource[right].has(key)) { + collisions.add(key) + } + } + } + } + return collisions +} + +export function findWorkspaceTabIdOwnerCollisions( + sources: readonly WorkspaceSessionState[] +): Set { + const ownersByTabId = new Map>() + for (const source of sources) { + for (const [workspaceKey, tabs] of Object.entries(source.tabsByWorktree)) { + for (const tab of tabs) { + const owners = ownersByTabId.get(tab.id) ?? new Set() + owners.add(workspaceKey) + ownersByTabId.set(tab.id, owners) + } + } + } + const collisions = new Set() + for (const owners of ownersByTabId.values()) { + if (owners.size > 1) { + for (const owner of owners) { + collisions.add(owner) + } + } + } + return collisions +} diff --git a/src/shared/workspace-session-partition-provenance.ts b/src/shared/workspace-session-partition-provenance.ts new file mode 100644 index 000000000000..003bb8065420 --- /dev/null +++ b/src/shared/workspace-session-partition-provenance.ts @@ -0,0 +1,256 @@ +import type { BrowserHistoryEntry } from './browser-workspace-types' +import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' +import type { WorkspaceSessionState } from './workspace-session-state-types' + +export const WORKTREE_RECORD_FIELDS = [ + 'tabsByWorktree', + 'openFilesByWorktree', + 'activeFileIdByWorktree', + 'browserTabsByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabIdByWorktree', + 'unifiedTabs', + 'tabGroups', + 'tabGroupLayouts', + 'activeGroupIdByWorktree', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +export function paneTabId(paneKey: string): string { + const separator = paneKey.indexOf(':') + return separator === -1 ? paneKey : paneKey.slice(0, separator) +} + +export function paneLeafId(paneKey: string): string { + const separator = paneKey.indexOf(':') + return separator === -1 ? '' : paneKey.slice(separator + 1) +} + +export function collectWorkspaceKeys(session: WorkspaceSessionState): Set { + const keys = new Set() + for (const field of WORKTREE_RECORD_FIELDS) { + for (const key of Object.keys((session[field] as Record | undefined) ?? {})) { + keys.add(key) + } + } + for (const key of session.activeWorktreeIdsOnShutdown ?? []) { + keys.add(key) + } + if (session.activeWorktreeId) { + keys.add(session.activeWorktreeId) + } + for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) { + for (const page of pages) { + keys.add(page.worktreeId) + } + } + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + keys.add(record.worktreeId) + } + for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { + keys.add(tombstone.worktreeId) + } + return keys +} + +// Which side a recorded-revision comparison prefers: magnitude when both sides record +// a revision for the repo, the recorded side when only one does, null on a tie or when +// neither records one. +export function topologyRevisionAuthority( + baseRevision: number | undefined, + sourceRevision: number | undefined +): 'base' | 'source' | null { + if (baseRevision === undefined) { + return sourceRevision === undefined ? null : 'source' + } + if (sourceRevision === undefined || baseRevision === sourceRevision) { + return sourceRevision === undefined ? 'base' : null + } + return sourceRevision > baseRevision ? 'source' : 'base' +} + +export function hasPtyBoundPane( + state: WorkspaceSessionState, + tabs: readonly TerminalTab[] +): boolean { + return tabs.some((tab) => tabIsPtyBound(state, tab)) +} + +export function tabIsPtyBound(state: WorkspaceSessionState, tab: TerminalTab): boolean { + return ( + Boolean(tab.ptyId) || + Object.values(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).some(Boolean) + ) +} + +export function collectTerminalProvenance( + base: WorkspaceSessionState, + source: WorkspaceSessionState, + tabsByWorktree: WorkspaceSessionState['tabsByWorktree'], + sourceKeys: ReadonlySet, + sourceAuthority: ReadonlySet +): { + liveTabIds: Set + touchedTabIds: Set + sourceWorkspaceKeyByTabId: Map + sourcePaneAuthority: Set +} { + const liveTabIds = new Set( + Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) + const touchedTabIds = new Set( + [...sourceKeys].flatMap((key) => [ + ...(base.tabsByWorktree[key] ?? []).map((tab) => tab.id), + ...(source.tabsByWorktree[key] ?? []).map((tab) => tab.id) + ]) + ) + const sourceWorkspaceKeyByTabId = new Map() + for (const key of sourceKeys) { + for (const tab of source.tabsByWorktree[key] ?? []) { + sourceWorkspaceKeyByTabId.set(tab.id, key) + } + } + const sourcePaneAuthority = new Set() + for (const [paneKey, incarnationId] of Object.entries( + source.terminalPtyIncarnationsByPaneKey ?? {} + )) { + const baseTombstone = base.terminalSurfaceTombstonesByPaneKey?.[paneKey] + if ( + liveTabIds.has(paneTabId(paneKey)) && + sourceAuthority.has(sourceWorkspaceKeyByTabId.get(paneTabId(paneKey)) ?? '') && + (!baseTombstone || baseTombstone.incarnationId !== incarnationId) + ) { + sourcePaneAuthority.add(paneKey) + } + } + // Why the liveTabIds gates: a grafted workspace key carries only part of the + // source's tab list, so panes of source tabs that did not land in the adopted view + // must not adopt layouts, incarnations, or sleeping-agent records. + for (const key of sourceAuthority) { + for (const tab of source.tabsByWorktree[key] ?? []) { + if (!liveTabIds.has(tab.id)) { + continue + } + for (const leafId of Object.keys( + source.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} + )) { + sourcePaneAuthority.add(`${tab.id}:${leafId}`) + } + } + } + for (const [paneKey, record] of Object.entries(source.sleepingAgentSessionsByPaneKey ?? {})) { + if (sourceAuthority.has(record.worktreeId) && (!record.tabId || liveTabIds.has(record.tabId))) { + sourcePaneAuthority.add(paneKey) + } + } + return { liveTabIds, touchedTabIds, sourceWorkspaceKeyByTabId, sourcePaneAuthority } +} + +export function mergeWorkspaceRecord( + base: Record | undefined, + source: Record | undefined, + sourceKeys: ReadonlySet, + sourceAuthority: ReadonlySet +): Record | undefined { + if (!base && !source) { + return undefined + } + const merged = { ...base } + for (const key of sourceKeys) { + const value = source?.[key] + if (value !== undefined && (sourceAuthority.has(key) || merged[key] === undefined)) { + merged[key] = value + } + } + return merged +} + +function layoutContainsLeaf(root: TerminalLayoutSnapshot['root'], leafId: string): boolean { + if (!root) { + return false + } + return root.type === 'leaf' + ? root.leafId === leafId + : layoutContainsLeaf(root.first, leafId) || layoutContainsLeaf(root.second, leafId) +} + +export function layoutLeafCount(root: TerminalLayoutSnapshot['root']): number { + if (!root) { + return 0 + } + return root.type === 'leaf' ? 1 : layoutLeafCount(root.first) + layoutLeafCount(root.second) +} + +export function mergeTerminalLayout( + base: TerminalLayoutSnapshot | undefined, + source: TerminalLayoutSnapshot | undefined, + tabId: string, + sourcePaneAuthority: ReadonlySet +): TerminalLayoutSnapshot | undefined { + if (!base || !source) { + return base ?? source + } + const merged: TerminalLayoutSnapshot = { ...source, ...base } + const paneFields = [ + 'ptyIdsByLeafId', + 'buffersByLeafId', + 'scrollbackRefsByLeafId', + 'titlesByLeafId' + ] as const + for (const field of paneFields) { + const values = { ...base[field] } + const sourceValues = source[field] + for (const leafId of Object.keys(values)) { + if (sourcePaneAuthority.has(`${tabId}:${leafId}`) && sourceValues?.[leafId] === undefined) { + delete values[leafId] + } + } + for (const [leafId, value] of Object.entries(sourceValues ?? {})) { + const paneKey = `${tabId}:${leafId}` + if ( + (sourcePaneAuthority.has(paneKey) || values[leafId] === undefined) && + layoutContainsLeaf(base.root, leafId) + ) { + values[leafId] = value + } + } + if (Object.keys(values).length > 0 || base[field] || source[field]) { + merged[field] = values + } + } + return merged +} + +export function mergeTopologyRevisions( + base: Record | undefined, + source: Record | undefined +): Record { + const merged = { ...base } + for (const [repoId, revision] of Object.entries(source ?? {})) { + merged[repoId] = Math.max(merged[repoId] ?? 0, revision) + } + return merged +} + +export function mergeUnique( + values: readonly string[] | undefined, + incoming: readonly string[] | undefined +): string[] { + return [...new Set([...(values ?? []), ...(incoming ?? [])])] +} + +export function mergeBrowserHistory( + base: readonly BrowserHistoryEntry[] | undefined, + source: readonly BrowserHistoryEntry[] | undefined +): BrowserHistoryEntry[] { + const entries = new Map(source?.map((entry) => [entry.normalizedUrl, entry])) + for (const entry of base ?? []) { + const current = entries.get(entry.normalizedUrl) + if (!current || entry.lastVisitedAt >= current.lastVisitedAt) { + entries.set(entry.normalizedUrl, entry) + } + } + return [...entries.values()].sort((left, right) => right.lastVisitedAt - left.lastVisitedAt) +} diff --git a/src/shared/workspace-session-partition-state-merge.ts b/src/shared/workspace-session-partition-state-merge.ts new file mode 100644 index 000000000000..d6f42fb65a90 --- /dev/null +++ b/src/shared/workspace-session-partition-state-merge.ts @@ -0,0 +1,120 @@ +import type { WorkspaceSessionState } from './workspace-session-state-types' +import { repoIdForWorkspaceKey } from './workspace-session-partition-authority' +import { + mergeBrowserHistory, + mergeTopologyRevisions, + mergeUnique, + mergeWorkspaceRecord +} from './workspace-session-partition-provenance' + +type PartitionStateMerge = { + base: WorkspaceSessionState + source: WorkspaceSessionState + reconciledKeys: ReadonlySet + sourceAuthority: ReadonlySet + ambiguousWorktreeIds: ReadonlySet + tabsByWorktree: WorkspaceSessionState['tabsByWorktree'] + terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] + remoteSessionIdsByTabId: NonNullable + sleepingAgentSessionsByPaneKey: NonNullable< + WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] + > + terminalPtyIncarnationsByPaneKey: NonNullable< + WorkspaceSessionState['terminalPtyIncarnationsByPaneKey'] + > + terminalSurfaceTombstonesByPaneKey: NonNullable< + WorkspaceSessionState['terminalSurfaceTombstonesByPaneKey'] + > +} + +export function mergeAdoptedWorkspaceSessionState( + input: PartitionStateMerge +): WorkspaceSessionState { + const { base, source, reconciledKeys, sourceAuthority, ambiguousWorktreeIds } = input + const browserPagesByWorkspace = { ...base.browserPagesByWorkspace } + for (const [workspaceId, pages] of Object.entries(source.browserPagesByWorkspace ?? {})) { + if ( + (browserPagesByWorkspace[workspaceId] === undefined && + pages.every((page) => reconciledKeys.has(page.worktreeId))) || + pages.some((page) => sourceAuthority.has(page.worktreeId)) + ) { + browserPagesByWorkspace[workspaceId] = pages + } + } + const mergeRecord = ( + baseRecord: Record | undefined, + sourceRecord: Record | undefined + ) => mergeWorkspaceRecord(baseRecord, sourceRecord, reconciledKeys, sourceAuthority) + + return { + ...source, + ...base, + activeRepoId: + base.activeRepoId ?? (ambiguousWorktreeIds.size === 0 ? source.activeRepoId : null), + activeWorkspaceKey: + base.activeWorkspaceKey ?? + (ambiguousWorktreeIds.size === 0 ? source.activeWorkspaceKey : null), + activeWorkspaceExecutionHostId: + base.activeWorkspaceExecutionHostId ?? + (ambiguousWorktreeIds.size === 0 ? source.activeWorkspaceExecutionHostId : null), + activeWorktreeId: + base.activeWorktreeId ?? (ambiguousWorktreeIds.size === 0 ? source.activeWorktreeId : null), + activeTabId: base.activeTabId ?? (ambiguousWorktreeIds.size === 0 ? source.activeTabId : null), + tabsByWorktree: input.tabsByWorktree, + terminalLayoutsByTabId: input.terminalLayoutsByTabId, + openFilesByWorktree: mergeRecord(base.openFilesByWorktree, source.openFilesByWorktree), + activeFileIdByWorktree: mergeRecord(base.activeFileIdByWorktree, source.activeFileIdByWorktree), + browserTabsByWorktree: mergeRecord(base.browserTabsByWorktree, source.browserTabsByWorktree), + browserPagesByWorkspace, + browserUrlHistory: mergeBrowserHistory(base.browserUrlHistory, source.browserUrlHistory), + markdownFrontmatterVisible: { + ...(ambiguousWorktreeIds.size === 0 ? source.markdownFrontmatterVisible : {}), + ...base.markdownFrontmatterVisible + }, + activeBrowserTabIdByWorktree: mergeRecord( + base.activeBrowserTabIdByWorktree, + source.activeBrowserTabIdByWorktree + ), + activeTabTypeByWorktree: mergeRecord( + base.activeTabTypeByWorktree, + source.activeTabTypeByWorktree + ), + activeTabIdByWorktree: mergeRecord(base.activeTabIdByWorktree, source.activeTabIdByWorktree), + unifiedTabs: mergeRecord(base.unifiedTabs, source.unifiedTabs), + tabGroups: mergeRecord(base.tabGroups, source.tabGroups), + tabGroupLayouts: mergeRecord(base.tabGroupLayouts, source.tabGroupLayouts), + activeGroupIdByWorktree: mergeRecord( + base.activeGroupIdByWorktree, + source.activeGroupIdByWorktree + ), + lastVisitedAtByWorktreeId: mergeRecord( + base.lastVisitedAtByWorktreeId, + source.lastVisitedAtByWorktreeId + ), + defaultTerminalTabsAppliedByWorktreeId: mergeRecord( + base.defaultTerminalTabsAppliedByWorktreeId, + source.defaultTerminalTabsAppliedByWorktreeId + ), + activeWorktreeIdsOnShutdown: mergeUnique( + base.activeWorktreeIdsOnShutdown, + source.activeWorktreeIdsOnShutdown?.filter((key) => reconciledKeys.has(key)) + ), + activeConnectionIdsAtShutdown: mergeUnique( + base.activeConnectionIdsAtShutdown, + source.activeConnectionIdsAtShutdown + ), + remoteSessionIdsByTabId: input.remoteSessionIdsByTabId, + sleepingAgentSessionsByPaneKey: input.sleepingAgentSessionsByPaneKey, + terminalPtyIncarnationsByPaneKey: input.terminalPtyIncarnationsByPaneKey, + terminalTopologyRevisionByRepoId: mergeTopologyRevisions( + base.terminalTopologyRevisionByRepoId, + Object.fromEntries( + Object.entries(source.terminalTopologyRevisionByRepoId ?? {}).filter( + ([repoId]) => + ![...ambiguousWorktreeIds].some((key) => repoIdForWorkspaceKey(key) === repoId) + ) + ) + ), + terminalSurfaceTombstonesByPaneKey: input.terminalSurfaceTombstonesByPaneKey + } +} diff --git a/tests/e2e/ssh-session-partition-adoption.spec.ts b/tests/e2e/ssh-session-partition-adoption.spec.ts new file mode 100644 index 000000000000..849061c48b42 --- /dev/null +++ b/tests/e2e/ssh-session-partition-adoption.spec.ts @@ -0,0 +1,193 @@ +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { getDefaultWorkspaceSession } from '../../src/shared/constants' +import type { WorkspaceSessionState } from '../../src/shared/types' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + execDockerSshRelayTargetCommand, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { createRestartSession } from './helpers/orca-restart' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +type PersistedSessionData = { + workspaceSession: WorkspaceSessionState + workspaceSessionsByHostId?: Record +} + +function projectSessionForWorktree( + session: WorkspaceSessionState, + worktreeId: string +): WorkspaceSessionState { + const source = getDefaultWorkspaceSession() + const tabs = session.tabsByWorktree[worktreeId] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + source.tabsByWorktree = { [worktreeId]: tabs } + source.terminalLayoutsByTabId = Object.fromEntries( + Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => tabIds.has(tabId)) + ) + source.activeTabIdByWorktree = { + [worktreeId]: session.activeTabIdByWorktree?.[worktreeId] ?? null + } + source.lastVisitedAtByWorktreeId = { + [worktreeId]: session.lastVisitedAtByWorktreeId?.[worktreeId] ?? 0 + } + source.defaultTerminalTabsAppliedByWorktreeId = { + [worktreeId]: session.defaultTerminalTabsAppliedByWorktreeId?.[worktreeId] ?? false + } + if (session.remoteSessionIdsByTabId) { + source.remoteSessionIdsByTabId = Object.fromEntries( + Object.entries(session.remoteSessionIdsByTabId).filter(([tabId]) => tabIds.has(tabId)) + ) + } + return source +} + +function strandSessionInSshPartition( + userDataDir: string, + hostId: `ssh:${string}`, + worktreeId: string, + session: WorkspaceSessionState +): void { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedSessionData + const source = projectSessionForWorktree(session, worktreeId) + const tabIds = new Set(source.tabsByWorktree[worktreeId].map((tab) => tab.id)) + data.workspaceSession.tabsByWorktree[worktreeId] = [] + for (const tabId of tabIds) { + delete data.workspaceSession.terminalLayoutsByTabId[tabId] + delete data.workspaceSession.remoteSessionIdsByTabId?.[tabId] + } + delete data.workspaceSession.activeTabIdByWorktree?.[worktreeId] + delete data.workspaceSession.lastVisitedAtByWorktreeId?.[worktreeId] + delete data.workspaceSession.defaultTerminalTabsAppliedByWorktreeId?.[worktreeId] + data.workspaceSessionsByHostId = { + ...data.workspaceSessionsByHostId, + [hostId]: source + } + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) +} + +async function readTabIdentity( + page: Page, + worktreeId: string +): Promise<{ id: string; ptyId: string | null }> { + return page.evaluate((id) => { + const tab = window.__store?.getState().tabsByWorktree[id]?.[0] + if (!tab) { + throw new Error(`No tab restored for ${id}`) + } + return { id: tab.id, ptyId: tab.ptyId } + }, worktreeId) +} + +test.describe('SSH session partition adoption', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH adoption uses POSIX SSH tooling.') + + async function runSshSessionPartitionAdoption(testInfo: TestInfo): Promise { + test.setTimeout(360_000) + const restart = createRestartSession(testInfo) + let target: DockerSshRelayTarget | null = null + let app: ElectronApplication | null = null + try { + target = startDockerSshRelayTarget(testInfo) + const first = await restart.launch() + app = first.app + await waitForSessionReady(first.page) + const remote = await connectDockerSshRelayTarget(first.page, target) + await expect.poll(() => waitForActiveWorktree(first.page)).toBe(remote.worktreeId) + await waitForActiveTerminalManager(first.page, 60_000) + const ptyId = await waitForActivePanePtyId(first.page, 60_000) + const identity = await readTabIdentity(first.page, remote.worktreeId) + expect(identity.ptyId).toBe(ptyId) + + const token = `SSH_PARTITION_${Date.now()}` + const proofPath = `/tmp/orca-ssh-partition-${Date.now()}` + await focusActiveTerminalInput(first.page) + await first.page.keyboard.type( + `export ORCA_PARTITION_TOKEN=${token}; printf '%s' "$ORCA_PARTITION_TOKEN" > ${proofPath}` + ) + await first.page.keyboard.press('Enter') + await expect + .poll(() => execDockerSshRelayTargetCommand(target!, `cat ${proofPath}`)) + .toBe(token) + + const persisted = await first.page.evaluate(() => window.api.session.get()) + await restart.close(first.app) + app = null + const hostId = `ssh:${encodeURIComponent(remote.targetId)}` as const + strandSessionInSshPartition(restart.userDataDir, hostId, remote.worktreeId, persisted) + + const second = await restart.launch() + app = second.app + await waitForSessionReady(second.page, 60_000) + await expect + .poll(() => waitForActiveWorktree(second.page), { timeout: 60_000 }) + .toBe(remote.worktreeId) + await waitForActiveTerminalManager(second.page, 60_000) + expect(await readTabIdentity(second.page, remote.worktreeId)).toEqual(identity) + expect(await second.page.evaluate((id) => window.api.session.get(id), hostId)).toMatchObject({ + tabsByWorktree: {} + }) + expect( + await second.page.evaluate( + async ({ targetId, worktreePath }) => { + const snapshot = await window.api.remoteWorkspace.get({ targetId }) + return snapshot?.session.tabsByWorktreePath[worktreePath]?.map((tab) => tab.id) ?? [] + }, + { targetId: remote.targetId, worktreePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH } + ) + ).toContain(identity.id) + await waitForActivePanePtyId(second.page, 60_000) + await focusActiveTerminalInput(second.page) + await second.page.keyboard.type(`printf '%s' "$ORCA_PARTITION_TOKEN" > ${proofPath}.restored`) + await second.page.keyboard.press('Enter') + await expect + .poll(() => execDockerSshRelayTargetCommand(target!, `cat ${proofPath}.restored`)) + .toBe(token) + + await restart.close(second.app) + app = null + const third = await restart.launch() + app = third.app + await waitForSessionReady(third.page, 60_000) + await expect + .poll(() => waitForActiveWorktree(third.page), { timeout: 60_000 }) + .toBe(remote.worktreeId) + expect(await readTabIdentity(third.page, remote.worktreeId)).toEqual(identity) + expect(await third.page.evaluate((id) => window.api.session.get(id), hostId)).toMatchObject({ + tabsByWorktree: {} + }) + } finally { + if (app) { + await restart.close(app) + } + await restart.dispose() + cleanupDockerSshRelayTarget(target) + } + } + + test('round-trips stranded tabs without remote deletion', async (// oxlint-disable-next-line no-empty-pattern -- The test owns each Electron launch. + {}, testInfo) => runSshSessionPartitionAdoption(testInfo)) + test('round-trips stranded tabs without remote deletion @headful', async (// oxlint-disable-next-line no-empty-pattern -- The test owns each Electron launch. + {}, testInfo) => runSshSessionPartitionAdoption(testInfo)) +})