diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f5f37d12825..c0d3c2cea85 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -13008,7 +13008,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. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes keep their PTY bindings through disconnect and reconnect, 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. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-3107", "https://github.com/stablyai/orca/pull/12664", @@ -13022,15 +13022,15 @@ "https://github.com/stablyai/orca/issues/12685", "https://github.com/stablyai/orca/issues/12902" ], - "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target PTY bindings and preserve relay identity, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. Successful current-transport recovery must clear every stale error surfaced for that pane without clearing or displaying a sibling pane's error; a later genuine failure must remain visible. Each transport retains at most eight suppressed messages, each pane retains at most eight distinct messages, and the rendered surface retains at most 24 lines and 4,000 characters. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle. Reconnect must alternate exact activation with authoritative inventory so neither a stale activation response nor an activation failure can strand or retire a pane, and activating a parked surface whose persisted binding was already retired must respawn it rather than report a changed owner after signalling its exit.", - "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, and another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect. 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. A focused recovery oracle surfaces two distinct failures through one transport, completes authoritative replay, and requires both matching clear callbacks; pane-state tests require only the active pane's error to render, matching-pane recovery to preserve sibling and unrelated errors, dismissal to re-admit later failures, and distinct storms to retain only the newest eight messages within 24 lines and 4,000 characters.", + "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH disconnect, reconnect, and wake must keep PTY bindings and relay identity, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. Successful current-transport recovery must clear every stale error surfaced for that pane without clearing or displaying a sibling pane's error; a later genuine failure must remain visible. Each transport retains at most eight suppressed messages, each pane retains at most eight distinct messages, and the rendered surface retains at most 24 lines and 4,000 characters. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle. Reconnect must alternate exact activation with authoritative inventory so neither a stale activation response nor an activation failure can strand or retire a pane, and activating a parked surface whose persisted binding was already retired must respawn it rather than report a changed owner after signalling its exit.", + "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 direct SSH binding retention. They assert SSH state changes leave PTY bindings in place, relay identity survives, and another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect. 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. A focused recovery oracle surfaces two distinct failures through one transport, completes authoritative replay, and requires both matching clear callbacks; pane-state tests require only the active pane's error to render, matching-pane recovery to preserve sibling and unrelated errors, dismissal to re-admit later failures, and distinct storms to retain only the newest eight messages within 24 lines and 4,000 characters.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/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-pending-spawn.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "pnpm run build:web-from-renderer", @@ -13069,8 +13069,7 @@ "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts", "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", "tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts", - "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts", - "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts", + "src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts", "src/renderer/src/store/slices/terminals-hydration.test.ts", "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts", "src/renderer/src/hooks/direct-ssh-state-routing.test.ts", @@ -13222,17 +13221,9 @@ ] }, { - "file": "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts", + "file": "src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts", "assertions": [ - "one atomic patch clears exact-target live PTY indexes while preserving relay identity and null-PTY activation", - "another SSH target, local, WSL, floating, and runtime-owned terminal state remains unchanged" - ] - }, - { - "file": "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts", - "assertions": [ - "Git and folder workspaces resolve only from consistent exact-target provenance", - "ambiguous, contradictory, mixed, and runtime-owned folders fail closed" + "connected pushes, disconnects, and errors keep an SSH tab's PTY binding; loss of contact is not exit" ] }, { @@ -13362,13 +13353,13 @@ "summary": "Two focused files and 26 tests passed, including pane-scoped rendering and recovery, sibling-error retention, split-close pane-id churn cleanup, current-transport multi-error clearing, dismissal re-admission, multi-line deduplication, and 8-message/24-line/4,000-character bounds." }, { - "date": "2026-08-29", + "date": "2026-10-15", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 10.7, - "summary": "Fourteen direct-SSH files and 155 tests passed, including 32-arrival placement and readiness bursts with one listener/timer maximum, immediate supersession and stop cleanup, latest-only hydration, stale-push fencing, pending-capture and in-flight-write upload exclusion, and existing retry, authority, hydration, split-pane, and detach contracts." + "durationSeconds": 3.3, + "summary": "Six direct-SSH files and 57 tests passed, including SSH tab bindings kept through connected pushes, disconnects and errors, partial-authority reconciliation, hydration, split-pane and detach contracts." }, { "date": "2026-08-23", @@ -13397,15 +13388,6 @@ "durationSeconds": 5, "summary": "Eleven fault-injection and recovery-UI files and 127 tests passed, covering suspended heartbeat clocks, stale socket, PTY, and create generations, canonical pre-ready close recovery with one replacement subscription, cold and repeated PTY reattachment, authoritative health, bounded PTY and terminal-create recovery, post-probe timeout clipping, manually retryable create cutoff, accurate capability-probe failures, same-PTY manual reconnect, pane-state cleanup, fatal error deduplication, stable create identity, cross-process PTY adoption, and fail-closed legacy inventory." }, - { - "date": "2026-07-28", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", - "result": "passed", - "durationSeconds": 15.8, - "summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning." - }, { "date": "2026-07-28", "runner": "local", diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index b4bdb27367c..0c5b207acf4 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -57,7 +57,6 @@ 6 src/renderer/src/store/slices/browser/browser-tab-actions.ts 4 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts 2 src/renderer/src/store/slices/browser/create-browser-slice.ts -1 src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts 9 src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts 7 src/renderer/src/store/slices/editor/actions/close-file-action.ts 1 src/renderer/src/store/slices/editor/actions/git-status-actions.ts diff --git a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx deleted file mode 100644 index 4da89ed8686..00000000000 --- a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx +++ /dev/null @@ -1,178 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { renderHook } from '@testing-library/react' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { createTestStore, makeTab, makeWorktree } from '@/store/slices/store-test-helpers' -import { useRemoteRuntimeRecoveryTriggers } from '@/runtime/use-remote-runtime-recovery-triggers' -import { registerDirectSshWakeRouting } from './direct-ssh-state-routing' - -const { retryAllRemoteRuntimePtyRecoveriesNowMock } = vi.hoisted(() => ({ - retryAllRemoteRuntimePtyRecoveriesNowMock: vi.fn() -})) - -vi.mock('@/components/terminal-pane/remote-runtime-pty-recovery-state', () => ({ - retryAllRemoteRuntimePtyRecoveriesNow: retryAllRemoteRuntimePtyRecoveriesNowMock -})) - -function authority(): DirectSshAuthority { - return { - targetId: 'direct-target', - providerEpoch: 'direct-epoch' as SshProviderEpoch, - connectionGeneration: 7 - } -} - -describe('runtime and direct SSH wake isolation', () => { - const retryConnectionsNow = vi.fn(() => Promise.resolve()) - const resumeCallbacks = new Set<() => void>() - const onSystemResumed = vi.fn((callback: () => void) => { - resumeCallbacks.add(callback) - return () => resumeCallbacks.delete(callback) - }) - - beforeEach(() => { - retryConnectionsNow.mockClear() - retryAllRemoteRuntimePtyRecoveriesNowMock.mockClear() - onSystemResumed.mockClear() - resumeCallbacks.clear() - ;(window as unknown as { api: unknown }).api = { - runtimeEnvironments: { retryConnectionsNow }, - ui: { onSystemResumed } - } - }) - - afterEach(() => { - delete (window as unknown as { api?: unknown }).api - }) - - it('advances both runtime backoffs and clears only the exact direct target on wake', () => { - const currentAuthority = authority() - const directWorktreeId = 'repo-direct::/work/direct' - const runtimeWorktreeId = 'repo-runtime::/work/runtime' - const directPtyId = 'ssh:direct-target@@pty-live' - const store = createTestStore() - store.setState({ - repos: [ - { - id: 'repo-direct', - path: '/work/direct', - displayName: 'direct', - badgeColor: '#000', - addedAt: 1, - connectionId: 'direct-target', - executionHostId: 'ssh:direct-target' - }, - { - id: 'repo-runtime', - path: '/work/runtime', - displayName: 'runtime', - badgeColor: '#000', - addedAt: 1, - executionHostId: 'runtime:env-1' - } - ], - worktreesByRepo: { - 'repo-direct': [ - makeWorktree({ - id: directWorktreeId, - repoId: 'repo-direct', - path: '/work/direct', - hostId: 'ssh:direct-target' - }) - ], - 'repo-runtime': [ - makeWorktree({ - id: runtimeWorktreeId, - repoId: 'repo-runtime', - path: '/work/runtime', - hostId: 'ssh:runtime-owned-target', - runtimeOwnerEnvironmentId: 'env-1' - }) - ] - }, - tabsByWorktree: { - [directWorktreeId]: [ - makeTab({ - id: 'tab-direct', - worktreeId: directWorktreeId, - ptyId: directPtyId, - generation: 0 - }) - ], - [runtimeWorktreeId]: [ - makeTab({ id: 'tab-runtime', worktreeId: runtimeWorktreeId, ptyId: null }) - ] - }, - ptyIdsByTabId: { 'tab-direct': [directPtyId], 'tab-runtime': [] }, - sshConnectionStates: new Map([ - [ - 'direct-target', - { - targetId: 'direct-target', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: currentAuthority.providerEpoch, - connectionGeneration: currentAuthority.connectionGeneration - } - ] - ]), - sshStateByEnvironment: new Map([ - [ - 'env-1', - { - connectionStates: new Map([ - [ - 'runtime-owned-target', - { - targetId: 'runtime-owned-target', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: 'runtime-epoch' as SshProviderEpoch, - connectionGeneration: 3 - } - ] - ]), - targetGenerations: new Map(), - targetLabels: new Map([['runtime-owned-target', 'runtime target']]), - removedTargetLabels: new Map(), - targetsHydrated: true - } - ] - ]) - }) - const wakePreparation = vi.fn() - const correctedCounts: number[] = [] - const unregisterDirectWake = registerDirectSshWakeRouting({ - getConnectionStates: () => store.getState().sshConnectionStates, - wakeAuthority: (nextAuthority) => { - correctedCounts.push( - store.getState().clearDirectSshTargetPtyBindings(nextAuthority.targetId) - ) - wakePreparation(nextAuthority) - }, - onSystemResumed - }) - const { unmount } = renderHook(() => useRemoteRuntimeRecoveryTriggers()) - - window.dispatchEvent(new Event('online')) - - expect(retryConnectionsNow).toHaveBeenCalledTimes(1) - expect(retryAllRemoteRuntimePtyRecoveriesNowMock).toHaveBeenCalledTimes(1) - expect(wakePreparation).toHaveBeenCalledOnce() - expect(wakePreparation).toHaveBeenCalledWith(currentAuthority) - expect(correctedCounts).toEqual([1]) - expect(store.getState().tabsByWorktree[directWorktreeId][0]).toMatchObject({ - generation: 0, - ptyId: null - }) - expect(store.getState().tabsByWorktree[runtimeWorktreeId][0].generation ?? 0).toBe(0) - - unregisterDirectWake() - unmount() - window.dispatchEvent(new Event('online')) - expect(retryConnectionsNow).toHaveBeenCalledTimes(1) - expect(wakePreparation).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.test.ts b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts index 673fd0e2d6c..5b324773fca 100644 --- a/src/renderer/src/hooks/direct-ssh-state-routing.test.ts +++ b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts @@ -1,10 +1,7 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { - directSshAuthorityFromConnectionState, - registerDirectSshWakeRouting -} from './direct-ssh-state-routing' +import { directSshAuthorityFromConnectionState } from './direct-ssh-state-routing' function authority(epoch = 'epoch-a'): DirectSshAuthority { return { @@ -14,66 +11,7 @@ function authority(epoch = 'epoch-a'): DirectSshAuthority { } } -describe('registerDirectSshWakeRouting', () => { - it('wakes only complete connected direct authorities and removes both listeners', () => { - const wakeAuthority = vi.fn() - let resume: (() => void) | undefined - const unsubscribeSystemResumed = vi.fn() - const stop = registerDirectSshWakeRouting({ - getConnectionStates: () => - new Map([ - [ - 'target-a', - { - ...authority(), - status: 'connected' as const, - error: null, - reconnectAttempt: 0 - } - ], - [ - 'target-partial', - { - targetId: 'target-partial', - status: 'connected' as const, - error: null, - reconnectAttempt: 0, - providerEpoch: 'partial' as SshProviderEpoch - } - ], - [ - 'target-disconnected', - { - ...authority('disconnected'), - targetId: 'target-disconnected', - status: 'disconnected' as const, - error: null, - reconnectAttempt: 0 - } - ] - ]), - wakeAuthority, - onSystemResumed: (callback) => { - resume = callback - return unsubscribeSystemResumed - } - }) - - window.dispatchEvent(new Event('online')) - resume?.() - - expect(wakeAuthority).toHaveBeenCalledTimes(2) - expect(wakeAuthority).toHaveBeenNthCalledWith(1, authority()) - expect(wakeAuthority).toHaveBeenNthCalledWith(2, authority()) - - stop() - stop() - window.dispatchEvent(new Event('online')) - resume?.() - expect(unsubscribeSystemResumed).toHaveBeenCalledOnce() - expect(wakeAuthority).toHaveBeenCalledTimes(2) - }) - +describe('directSshAuthorityFromConnectionState', () => { it('rejects mismatched keys and malformed partial authority', () => { expect( directSshAuthorityFromConnectionState('other-target', { diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.ts b/src/renderer/src/hooks/direct-ssh-state-routing.ts index 1cdb1b27c08..e34e77716da 100644 --- a/src/renderer/src/hooks/direct-ssh-state-routing.ts +++ b/src/renderer/src/hooks/direct-ssh-state-routing.ts @@ -20,36 +20,3 @@ export function directSshAuthorityFromConnectionState( connectionGeneration: state.connectionGeneration } } - -export function registerDirectSshWakeRouting(deps: { - getConnectionStates: () => Iterable - wakeAuthority: (authority: DirectSshAuthority) => void - onSystemResumed?: (callback: () => void) => () => void -}): () => void { - let stopped = false - const wake = (): void => { - if (stopped) { - return - } - for (const [targetId, state] of deps.getConnectionStates()) { - const authority = directSshAuthorityFromConnectionState(targetId, state) - if (authority) { - deps.wakeAuthority(authority) - } - } - } - if (typeof window.addEventListener === 'function') { - window.addEventListener('online', wake) - } - const unsubscribeSystemResumed = deps.onSystemResumed?.(wake) - return () => { - if (stopped) { - return - } - stopped = true - if (typeof window.removeEventListener === 'function') { - window.removeEventListener('online', wake) - } - unsubscribeSystemResumed?.() - } -} diff --git a/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts index bb78b94aff2..b70917963db 100644 --- a/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts @@ -8,7 +8,6 @@ export function buildSshAuthorityReconciliationHarness(args: { partialAuthority: { providerEpoch?: string; connectionGeneration?: number } latestAuthority: { providerEpoch: string; connectionGeneration: number } }): { - clearBindings: ReturnType emitPartialState: () => void getState: ReturnType setSshConnectionState: ReturnType @@ -27,7 +26,6 @@ export function buildSshAuthorityReconciliationHarness(args: { const sshConnectionStates = new Map>() let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined const getState = vi.fn(() => Promise.resolve(latestState)) - const clearBindings = vi.fn() const setSshConnectionState = vi.fn((nextTargetId: string, state: Record) => { sshConnectionStates.set(nextTargetId, state) }) @@ -38,7 +36,6 @@ export function buildSshAuthorityReconciliationHarness(args: { setSshTargetsMetadata: vi.fn(), setRemovedSshTargetLabels: vi.fn(), clearRemoteDetectedAgents: vi.fn(), - clearDirectSshTargetPtyBindings: clearBindings, clearRemovedSshTargetState: vi.fn() }) vi.stubGlobal( @@ -56,7 +53,6 @@ export function buildSshAuthorityReconciliationHarness(args: { ) return { - clearBindings, emitPartialState: () => { if (!sshStateListener) { throw new Error('Expected SSH state listener') diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts index 6213422710d..a2be9692f78 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts @@ -5,6 +5,7 @@ import type { SshConnectionState, SshProviderEpoch } from '../../../../shared/ssh-types' +import { makeTab, makeWorktree } from '@/store/slices/store-test-helpers' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' function connectedState( @@ -39,6 +40,46 @@ async function settle(): Promise { const originalStore = useAppStore.getState() const cleanups: (() => void)[] = [] +const WORKTREE_ID = 'repo-ssh::/work/demo' +const TAB_ID = 'tab-ssh' +const PTY_ID = 'ssh:target-a@@pty-1' + +function seedSshTab(): void { + useAppStore.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/work/demo', + displayName: 'demo', + badgeColor: '#000', + addedAt: 1, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + 'repo-ssh': [ + makeWorktree({ + id: WORKTREE_ID, + repoId: 'repo-ssh', + path: '/work/demo', + hostId: 'ssh:target-a' + }) + ] + }, + tabsByWorktree: { + [WORKTREE_ID]: [makeTab({ id: TAB_ID, worktreeId: WORKTREE_ID, ptyId: PTY_ID })] + }, + ptyIdsByTabId: { [TAB_ID]: [PTY_ID] } + }) +} + +function expectSshTabBound(): void { + const state = useAppStore.getState() + expect(state.tabsByWorktree[WORKTREE_ID][0].ptyId).toBe(PTY_ID) + expect(state.ptyIdsByTabId[TAB_ID]).toEqual([PTY_ID]) +} + async function createHarness( initialStates: readonly SshConnectionState[] = [], reconciledStates: readonly SshConnectionState[] = [] @@ -79,8 +120,6 @@ async function createHarness( } } }) - const store = useAppStore.getState() - const clearBindings = vi.spyOn(store, 'clearDirectSshTargetPtyBindings').mockReturnValue(1) const unsubs: (() => void)[] = [] const stop = registerDirectSshStateIpcBridge(unsubs) cleanups.push(() => { @@ -92,7 +131,6 @@ async function createHarness( await settle() return { getState, - clearBindings, emit: (state: SshConnectionState) => { if (!stateListener) { throw new Error('SSH state listener was not registered') @@ -121,8 +159,9 @@ afterEach(async () => { describe('direct SSH connected-state routing', () => { it.each(['reply-first', 'push-first'] as const)( - 'clears the target bindings once per connected push when ordering is %s', + 'keeps the target bindings on connected pushes when ordering is %s', async (ordering) => { + seedSshTab() const harness = await createHarness() const state = connectedState() if (ordering === 'reply-first') { @@ -132,59 +171,38 @@ describe('direct SSH connected-state routing', () => { if (ordering === 'push-first') { harness.applyConnectReply(state) } - - expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) await settle() - - harness.clearBindings.mockClear() harness.emit({ ...state }) - expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) + + expectSshTabBound() } ) - it('leaves bindings alone on initial hydration and clears on the next push', async () => { - const state = connectedState() - const harness = await createHarness([state]) - - expect(harness.clearBindings).not.toHaveBeenCalled() - - harness.emit({ ...state }) - expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) - - const next = connectedState(state.targetId, 2) - harness.applyConnectReply(next) - harness.clearBindings.mockClear() - harness.emit(next) - expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(next.targetId) - }) - it.each(['disconnected', 'error'] as const)( - 'clears bindings on %s and again on the next connected push', + 'keeps bindings on %s and on the next connected push', async (status) => { + seedSshTab() const state = connectedState() const harness = await createHarness([state]) harness.emit({ ...state, status }) + // Loss of contact is not evidence the remote PTY exited. + expectSshTabBound() - expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) harness.applyConnectReply(state) harness.emit(state) - expect(harness.clearBindings).toHaveBeenCalledTimes(2) + expectSshTabBound() } ) it.each(['providerEpoch', 'connectionGeneration'] as const)( - 'reconciles a partial %s on initial hydration without clearing', + 'reconciles a partial %s on initial hydration', async (missingField) => { const state = connectedState() const partial: SshConnectionState = { ...state } delete partial[missingField] - const harness = await createHarness([partial], [state]) + await createHarness([partial], [state]) expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toEqual(state) - expect(harness.clearBindings).not.toHaveBeenCalled() - - harness.emit(state) - expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) } ) @@ -204,31 +222,14 @@ describe('direct SSH connected-state routing', () => { const partial: SshConnectionState = { ...state } delete partial[missingField] harness.emit(partial) + expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toEqual(partial) - expect(harness.clearBindings).not.toHaveBeenCalled() resolveState(state) await settle() - expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toMatchObject( authority(state) ) } ) - - it('keeps disconnect cleanup separate for each target', async () => { - const harness = await createHarness() - const first = connectedState('target-a') - const second = connectedState('target-b') - for (const state of [first, second]) { - harness.applyConnectReply(state) - harness.emit(state) - } - await settle() - expect(harness.clearBindings.mock.calls).toEqual([[first.targetId], [second.targetId]]) - - harness.clearBindings.mockClear() - harness.emit({ ...first, status: 'disconnected' }) - expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(first.targetId) - }) }) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts index 88908fbf106..1c5b83d36ce 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -4,7 +4,6 @@ import { useAppStore } from '../../store' import { directSshAuthoritiesEqual } from '../../store/slices/worktrees/listing/direct-ssh-authority' import { directSshAuthorityFromConnectionState, - registerDirectSshWakeRouting, type DirectSshConnectedStateOrigin } from '../direct-ssh-state-routing' import { hydrateDirectSshInitialState } from './direct-ssh-initial-state-hydration' @@ -161,8 +160,6 @@ export function registerDirectSshStateIpcBridge(unsubs: (() => void)[]): () => v store.clearPortForwards(targetId) store.setDetectedPorts(targetId, []) - - store.clearDirectSshTargetPtyBindings(targetId) return } @@ -174,10 +171,6 @@ export function registerDirectSshStateIpcBridge(unsubs: (() => void)[]): () => v reconcileSshAuthority(targetId, state, origin, sshStateWatermarkByTargetId.get(targetId) ?? 0) return } - // Why: no pane binds an `ssh:` PTY, so every binding a newly pushed connection finds is stale. - if (origin === 'push') { - store.clearDirectSshTargetPtyBindings(targetId) - } if (origin === 'initial-hydration') { hydrateSshPorts(targetId, authority) } @@ -232,17 +225,6 @@ export function registerDirectSshStateIpcBridge(unsubs: (() => void)[]): () => v } unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent)) - unsubs.push( - registerDirectSshWakeRouting({ - getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], - wakeAuthority: (authority) => { - useAppStore.getState().clearDirectSshTargetPtyBindings(authority.targetId) - }, - ...(typeof window.api.ui.onSystemResumed === 'function' - ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } - : {}) - }) - ) return () => { stopped = true for (const deadline of deadlines) { diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts index e8a8d7c3054..c6ba4bb61aa 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts @@ -73,37 +73,33 @@ describe('useIpcEvents agent status snapshot integration', () => { it.each([ { partialAuthority: { providerEpoch: 'epoch-current' } }, { partialAuthority: { connectionGeneration: 7 } } - ])( - 'fills a same-watermark partial SSH authority and clears its bindings once', - async ({ partialAuthority }) => { - const harness = buildSshAuthorityReconciliationHarness({ - partialAuthority, - latestAuthority: { - providerEpoch: 'epoch-current', - connectionGeneration: 7 - } - }) - stubDirectSshModules({ storeState: harness.storeState }) - const { useIpcEvents } = await import('./useIpcEvents') - useIpcEvents() - - harness.emitPartialState() - - await vi.waitFor(() => { - expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith('target-reconciliation') - }) - expect(harness.getState).toHaveBeenCalledOnce() - expect(harness.setSshConnectionState).toHaveBeenCalledTimes(2) - expect(harness.storedState()).toEqual({ - targetId: 'target-reconciliation', - status: 'connected', - error: null, - reconnectAttempt: 0, + ])('fills a same-watermark partial SSH authority', async ({ partialAuthority }) => { + const harness = buildSshAuthorityReconciliationHarness({ + partialAuthority, + latestAuthority: { providerEpoch: 'epoch-current', connectionGeneration: 7 - }) - } - ) + } + }) + stubDirectSshModules({ storeState: harness.storeState }) + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + harness.emitPartialState() + + await vi.waitFor(() => { + expect(harness.setSshConnectionState).toHaveBeenCalledTimes(2) + }) + expect(harness.getState).toHaveBeenCalledOnce() + expect(harness.storedState()).toEqual({ + targetId: 'target-reconciliation', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'epoch-current', + connectionGeneration: 7 + }) + }) it.each([ { partialAuthority: { providerEpoch: 'epoch-conflict' } }, @@ -128,7 +124,6 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(harness.getState).toHaveBeenCalledOnce() }) await Promise.resolve() - expect(harness.clearBindings).not.toHaveBeenCalled() expect(harness.setSshConnectionState).toHaveBeenCalledOnce() expect(harness.storedState()).toEqual( expect.objectContaining({ @@ -291,7 +286,6 @@ describe('useIpcEvents agent status snapshot integration', () => { fetchRuntimeEnvironmentRepos: vi.fn(async () => []), fetchWorktreeLineage: vi.fn(async () => undefined), clearRemoteDetectedAgents: vi.fn(), - clearDirectSshTargetPtyBindings: vi.fn(), clearRemovedSshTargetState: vi.fn() }) let partialTargetStateCalls = 0 diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 0482f12460e..307422762ef 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -85,7 +85,6 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'ui.onSwitchTab', 'ui.onSwitchTabAcrossAllTypes', 'ui.onSwitchTerminalTab', - 'ui.onSystemResumed', 'ui.onTerminalShortcutCaptured', 'ui.onTerminalTabCloseRequest', 'ui.onTerminalZoom', @@ -201,7 +200,6 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ssh.onPortForwardsChanged', 'ssh.onDetectedPortsChanged', 'ssh.onStateChanged', - 'ui.onSystemResumed', 'ui.onTerminalZoom', 'agentStatus.onSet', 'agentStatus.onClear', diff --git a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts index 3a2f64e7ced..e6c6cd15ef5 100644 --- a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts @@ -7,8 +7,7 @@ describe('useIpcEvents updater integration', () => { vi.unstubAllGlobals() }) - it('clears stale remote PTYs when an SSH connection fully disconnects', async () => { - const clearDirectSshTargetPtyBindings = vi.fn(() => 1) + it('clears remote agent and port state when an SSH connection fully disconnects', async () => { const setSshConnectionState = vi.fn() const setSshTargetsMetadata = vi.fn() const clearRemovedSshTargetState = vi.fn() @@ -60,7 +59,6 @@ describe('useIpcEvents updater integration', () => { removeSshCredentialRequest: vi.fn(), clearRemoteDetectedAgents: vi.fn(), clearRemovedSshTargetState, - clearDirectSshTargetPtyBindings, sshConnectionStates: new Map(), repos: [{ id: 'repo-1', connectionId: 'conn-1' }], worktreesByRepo: { @@ -253,8 +251,6 @@ describe('useIpcEvents updater integration', () => { 'conn-1', expect.objectContaining({ status: 'disconnected' }) ) - expect(clearDirectSshTargetPtyBindings).toHaveBeenCalledOnce() - expect(clearDirectSshTargetPtyBindings).toHaveBeenCalledWith('conn-1') expect(storeState.clearRemoteDetectedAgents).toHaveBeenCalledWith('conn-1') setSshConnectionState.mockClear() diff --git a/src/renderer/src/lib/direct-ssh-target-scope-types.ts b/src/renderer/src/lib/direct-ssh-target-scope-types.ts index 068e723bb9d..421f6753665 100644 --- a/src/renderer/src/lib/direct-ssh-target-scope-types.ts +++ b/src/renderer/src/lib/direct-ssh-target-scope-types.ts @@ -1,23 +1,6 @@ -import type { FolderWorkspace, WorkspaceKey } from '../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../shared/project-group-types' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' -export type DirectSshGitRepoRef = { - repoId: string - executionHostId: `ssh:${string}` -} - -export type DirectSshTargetScope = { - catalogRevision: number - gitRepos: DirectSshGitRepoRef[] - gitWorktreeIds: Set - terminalWorkspaceKeys: Set - lineageWorkspaceKeys: Set - ambiguousOwnerCount: number - contradictoryOwnerCount: number -} - export type DirectSshRepoOwner = Pick< Repo, 'id' | 'path' | 'projectGroupId' | 'connectionId' | 'executionHostId' @@ -26,24 +9,13 @@ export type DirectSshWorktreeOwner = Pick< Worktree, 'id' | 'repoId' | 'hostId' | 'runtimeOwnerEnvironmentId' > -export type DirectSshFolderOwner = Pick< - FolderWorkspace, - 'id' | 'projectGroupId' | 'folderPath' | 'connectionId' | 'executionHostId' -> -export type DirectSshGroupOwner = Pick< - ProjectGroup, - 'id' | 'parentGroupId' | 'connectionId' | 'executionHostId' -> export type DirectSshTargetScopeInput = { targetId: string - catalogRevision: number repos: readonly DirectSshRepoOwner[] worktreesByRepo?: Readonly> detectedWorktreesByRepo?: Readonly< Record > - folderWorkspaces?: readonly DirectSshFolderOwner[] - projectGroups?: readonly DirectSshGroupOwner[] restoredRuntimeHostIdByWorkspaceSessionKey?: Readonly> } diff --git a/src/renderer/src/lib/direct-ssh-target-scope.test.ts b/src/renderer/src/lib/direct-ssh-target-scope.test.ts index a010fab8984..892722d8253 100644 --- a/src/renderer/src/lib/direct-ssh-target-scope.test.ts +++ b/src/renderer/src/lib/direct-ssh-target-scope.test.ts @@ -1,19 +1,17 @@ import { describe, expect, it } from 'vitest' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { - resolveDirectSshTargetScope, + resolveDirectSshTargetWorktreeIds, type DirectSshTargetScopeInput } from './direct-ssh-target-scope' const baseInput: DirectSshTargetScopeInput = { targetId: 'target-a', - catalogRevision: 17, repos: [] } -describe('resolveDirectSshTargetScope', () => { +describe('resolveDirectSshTargetWorktreeIds', () => { it('resolves duplicate repo IDs by exact SSH host instead of first wins', () => { - const scope = resolveDirectSshTargetScope({ + const worktreeIds = resolveDirectSshTargetWorktreeIds({ ...baseInput, repos: [ { @@ -32,23 +30,11 @@ describe('resolveDirectSshTargetScope', () => { } ], worktreesByRepo: { - shared: [ - { - id: 'shared::remote', - repoId: 'shared', - hostId: 'ssh:target-a' - } - ] + shared: [{ id: 'shared::remote', repoId: 'shared', hostId: 'ssh:target-a' }] } }) - expect(scope.catalogRevision).toBe(17) - expect(scope.gitRepos).toEqual([{ repoId: 'shared', executionHostId: 'ssh:target-a' }]) - expect(scope.gitWorktreeIds).toEqual(new Set(['shared::remote'])) - expect(scope.terminalWorkspaceKeys).toEqual(new Set(['shared::remote'])) - expect(scope.lineageWorkspaceKeys).toEqual(new Set([worktreeWorkspaceKey('shared::remote')])) - expect(scope.ambiguousOwnerCount).toBe(0) - expect(scope.contradictoryOwnerCount).toBe(0) + expect(worktreeIds).toEqual(new Set(['shared::remote'])) }) it('keeps duplicate same-host repo owners ambiguous', () => { @@ -59,76 +45,19 @@ describe('resolveDirectSshTargetScope', () => { connectionId: 'target-a', executionHostId: 'ssh:target-a' as const } - const scope = resolveDirectSshTargetScope({ + const worktreeIds = resolveDirectSshTargetWorktreeIds({ ...baseInput, repos: [duplicate, { ...duplicate }], worktreesByRepo: { - duplicate: [ - { - id: 'duplicate::worktree', - repoId: 'duplicate', - hostId: 'ssh:target-a' - } - ] + duplicate: [{ id: 'duplicate::worktree', repoId: 'duplicate', hostId: 'ssh:target-a' }] } }) - expect(scope.gitRepos).toEqual([]) - expect(scope.gitWorktreeIds.size).toBe(0) - expect(scope.terminalWorkspaceKeys.size).toBe(0) - expect(scope.ambiguousOwnerCount).toBe(2) - }) - - it('gives folder workspaces the same exact target isolation as Git worktrees', () => { - const scope = resolveDirectSshTargetScope({ - ...baseInput, - repos: [ - { - id: 'remote-repo', - path: '/srv/project/repo', - projectGroupId: 'remote-group', - connectionId: 'target-a', - executionHostId: 'ssh:target-a' - } - ], - worktreesByRepo: { - 'remote-repo': [ - { - id: 'remote-repo::worktree', - repoId: 'remote-repo', - hostId: 'ssh:target-a' - } - ] - }, - projectGroups: [ - { - id: 'remote-group', - parentGroupId: null, - connectionId: 'target-a', - executionHostId: 'ssh:target-a' - } - ], - folderWorkspaces: [ - { - id: 'remote-folder', - projectGroupId: 'remote-group', - folderPath: '/srv/project', - connectionId: 'target-a' - } - ] - }) - - expect(scope.gitWorktreeIds).toEqual(new Set(['remote-repo::worktree'])) - expect(scope.terminalWorkspaceKeys).toEqual( - new Set(['remote-repo::worktree', 'folder:remote-folder']) - ) - expect(scope.lineageWorkspaceKeys).toEqual( - new Set([worktreeWorkspaceKey('remote-repo::worktree'), folderWorkspaceKey('remote-folder')]) - ) + expect(worktreeIds.size).toBe(0) }) it('keeps runtime-owned SSH work isolated from the direct target', () => { - const scope = resolveDirectSshTargetScope({ + const worktreeIds = resolveDirectSshTargetWorktreeIds({ ...baseInput, repos: [ { @@ -151,13 +80,11 @@ describe('resolveDirectSshTargetScope', () => { } }) - expect(scope.gitWorktreeIds.size).toBe(0) - expect(scope.terminalWorkspaceKeys.size).toBe(0) - expect(scope.contradictoryOwnerCount).toBe(1) + expect(worktreeIds.size).toBe(0) }) it('rejects explicit worktree and exact repo ownership contradictions', () => { - const scope = resolveDirectSshTargetScope({ + const worktreeIds = resolveDirectSshTargetWorktreeIds({ ...baseInput, repos: [ { @@ -173,115 +100,20 @@ describe('resolveDirectSshTargetScope', () => { } }) - expect(scope.gitRepos).toEqual([{ repoId: 'repo', executionHostId: 'ssh:target-a' }]) - expect(scope.gitWorktreeIds.size).toBe(0) - expect(scope.contradictoryOwnerCount).toBe(1) + expect(worktreeIds.size).toBe(0) }) - it('excludes ambiguous legacy and duplicate same-host folder owners', () => { - const scope = resolveDirectSshTargetScope({ + it('excludes legacy rows with no owner and unresolved runtime owners', () => { + const worktreeIds = resolveDirectSshTargetWorktreeIds({ ...baseInput, - repos: [], worktreesByRepo: { legacy: [{ id: 'legacy::worktree', repoId: 'legacy' }], unresolved: [ - { - id: 'unresolved::worktree', - repoId: 'unresolved', - hostId: 'runtime:unresolved-owner' - } + { id: 'unresolved::worktree', repoId: 'unresolved', hostId: 'runtime:unresolved-owner' } ] - }, - projectGroups: [ - { - id: 'group', - parentGroupId: null, - connectionId: 'target-a', - executionHostId: 'ssh:target-a' - } - ], - folderWorkspaces: [ - { - id: 'duplicate-folder', - projectGroupId: 'group', - folderPath: '/srv/project', - connectionId: 'target-a' - }, - { - id: 'duplicate-folder', - projectGroupId: 'group', - folderPath: '/srv/project', - connectionId: 'target-a' - } - ] - }) - - expect(scope.gitWorktreeIds.size).toBe(0) - expect(scope.terminalWorkspaceKeys.size).toBe(0) - expect(scope.ambiguousOwnerCount).toBe(3) - expect(scope.contradictoryOwnerCount).toBe(0) - }) - - it('rejects conflicting folder workspace, group, repo, and restored provenance', () => { - const scope = resolveDirectSshTargetScope({ - ...baseInput, - repos: [ - { - id: 'repo-b', - path: '/srv/project/repo', - projectGroupId: 'group', - connectionId: 'target-b', - executionHostId: 'ssh:target-b' - } - ], - projectGroups: [ - { - id: 'group', - parentGroupId: null, - connectionId: 'target-a', - executionHostId: 'ssh:target-a' - } - ], - folderWorkspaces: [ - { - id: 'mixed-folder', - projectGroupId: 'group', - folderPath: '/srv/project', - connectionId: 'target-a' - } - ], - restoredRuntimeHostIdByWorkspaceSessionKey: { - 'folder:mixed-folder': 'runtime:hub-a' } }) - expect(scope.terminalWorkspaceKeys.size).toBe(0) - expect(scope.contradictoryOwnerCount).toBe(1) - }) - - it('does not infer an effective folder connection from an SSH host stamp alone', () => { - const scope = resolveDirectSshTargetScope({ - ...baseInput, - repos: [], - projectGroups: [ - { - id: 'legacy-group', - parentGroupId: null, - connectionId: null, - executionHostId: 'ssh:target-a' - } - ], - folderWorkspaces: [ - { - id: 'legacy-folder', - projectGroupId: 'legacy-group', - folderPath: '/srv/legacy', - connectionId: null - } - ] - }) - - expect(scope.terminalWorkspaceKeys.size).toBe(0) - expect(scope.ambiguousOwnerCount).toBe(1) + expect(worktreeIds.size).toBe(0) }) }) diff --git a/src/renderer/src/lib/direct-ssh-target-scope.ts b/src/renderer/src/lib/direct-ssh-target-scope.ts index 66e0e173ad6..54d4cf7ef3b 100644 --- a/src/renderer/src/lib/direct-ssh-target-scope.ts +++ b/src/renderer/src/lib/direct-ssh-target-scope.ts @@ -1,29 +1,18 @@ import { getRepoExecutionHostId, - parseExecutionHostId, parseRoutableExecutionHostId, toRuntimeExecutionHostId, toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' -import { getProjectGroupSubtreeIds } from '../../../shared/project-groups' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' import type { - DirectSshFolderOwner as FolderOwner, - DirectSshGitRepoRef, - DirectSshGroupOwner as GroupOwner, DirectSshRepoOwner as RepoOwner, - DirectSshTargetScope, DirectSshTargetScopeInput, DirectSshWorktreeOwner as WorktreeOwner } from './direct-ssh-target-scope-types' import { indexDirectSshOwnerRows } from './direct-ssh-target-owner-index' -export type { - DirectSshGitRepoRef, - DirectSshTargetScope, - DirectSshTargetScopeInput -} from './direct-ssh-target-scope-types' +export type { DirectSshTargetScopeInput } from './direct-ssh-target-scope-types' type HostEvidence = { hosts: Set @@ -144,162 +133,16 @@ function resolveWorktreeEvidence( return evidence } -function getFolderCandidateRepos( - folder: FolderOwner, - groups: readonly GroupOwner[], - repos: readonly RepoOwner[], - scopeConnectionId: string | null -): RepoOwner[] { - const groupIds = getProjectGroupSubtreeIds(groups, folder.projectGroupId) - const groupRepos = repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const pathRepos = repos.filter( - (repo) => - !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && - isPathInsideOrEqual(folder.folderPath, repo.path) - ) - if (scopeConnectionId) { - return [ - ...groupRepos, - ...pathRepos.filter((repo) => (repo.connectionId ?? null) === scopeConnectionId) - ] - } - if (groupRepos.length === 0) { - return pathRepos - } - const groupConnections = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) - return [ - ...groupRepos, - ...pathRepos.filter((repo) => groupConnections.has(repo.connectionId ?? null)) - ] -} - -function resolveFolderEvidence( - input: DirectSshTargetScopeInput, - folder: FolderOwner, - group: GroupOwner | undefined -): HostEvidence { - const evidence = newHostEvidence() - const folderConnection = folder.connectionId?.trim() || null - const groupConnection = group?.connectionId?.trim() || null - if (folderConnection) { - evidence.hosts.add(toSshExecutionHostId(folderConnection)) - } - if (groupConnection) { - evidence.hosts.add(toSshExecutionHostId(groupConnection)) - } - addHostEvidence(evidence, group?.executionHostId) - addHostEvidence( - evidence, - input.restoredRuntimeHostIdByWorkspaceSessionKey?.[folderWorkspaceKey(folder.id)] - ) - - const scopeConnection = folderConnection ?? groupConnection - const candidateRepos = getFolderCandidateRepos( - folder, - input.projectGroups ?? [], - input.repos, - scopeConnection - ) - const repoOwnerKeys = new Set() - for (const repo of candidateRepos) { - const repoEvidence = resolveRepoEvidence(repo) - evidence.ambiguous ||= repoEvidence.ambiguous - evidence.contradictory ||= repoEvidence.contradictory - for (const host of repoEvidence.hosts) { - const ownerKey = JSON.stringify([repo.id, host]) - evidence.ambiguous ||= repoOwnerKeys.has(ownerKey) - repoOwnerKeys.add(ownerKey) - evidence.hosts.add(host) - } - } - const hasSshOwner = [...evidence.hosts].some( - (hostId) => parseExecutionHostId(hostId)?.kind === 'ssh' - ) - const hasConnectionOwner = - Boolean(scopeConnection) || candidateRepos.some((repo) => Boolean(repo.connectionId?.trim())) - evidence.ambiguous ||= !group || (hasSshOwner && !hasConnectionOwner) - evidence.contradictory ||= evidence.hosts.size > 1 - return evidence -} - -export function resolveDirectSshTargetScope( - input: DirectSshTargetScopeInput -): DirectSshTargetScope { +/** Git worktrees whose owner evidence names exactly this SSH target; ambiguous or contradictory rows are left out. */ +export function resolveDirectSshTargetWorktreeIds(input: DirectSshTargetScopeInput): Set { const expectedHost = toSshExecutionHostId(input.targetId) const repoRowsById = indexDirectSshOwnerRows(input.repos) - const gitRepos: DirectSshGitRepoRef[] = [] - let ambiguousOwnerCount = 0 - let contradictoryOwnerCount = 0 - - for (const [repoId, rows] of repoRowsById) { - const matchingRows = rows.filter((repo) => { - const evidence = resolveRepoEvidence(repo) - if (evidence.contradictory) { - contradictoryOwnerCount++ - return false - } - if (evidence.ambiguous) { - ambiguousOwnerCount++ - return false - } - return evidence.hosts.has(expectedHost) - }) - if (matchingRows.length === 1) { - gitRepos.push({ repoId, executionHostId: expectedHost }) - } else if (matchingRows.length > 1) { - ambiguousOwnerCount++ - } - } - - const gitWorktreeIds = new Set() - const terminalWorkspaceKeys = new Set() - const lineageWorkspaceKeys = new Set>() + const worktreeIds = new Set() for (const [worktreeId, rows] of collectWorktreeRows(input)) { const evidence = resolveWorktreeEvidence(input, rows, repoRowsById) - if (evidence.contradictory) { - contradictoryOwnerCount++ - } else if (evidence.ambiguous || evidence.hosts.size === 0) { - ambiguousOwnerCount++ - } else if (evidence.hosts.has(expectedHost)) { - gitWorktreeIds.add(worktreeId) - terminalWorkspaceKeys.add(worktreeId) - lineageWorkspaceKeys.add(worktreeWorkspaceKey(worktreeId)) + if (!evidence.contradictory && !evidence.ambiguous && evidence.hosts.has(expectedHost)) { + worktreeIds.add(worktreeId) } } - - const folderRowsById = indexDirectSshOwnerRows(input.folderWorkspaces ?? []) - const groupRowsById = indexDirectSshOwnerRows(input.projectGroups ?? []) - for (const [folderId, rows] of folderRowsById) { - if (rows.length !== 1) { - ambiguousOwnerCount++ - continue - } - const groupRows = groupRowsById.get(rows[0].projectGroupId) ?? [] - if (groupRows.length > 1) { - ambiguousOwnerCount++ - continue - } - const evidence = resolveFolderEvidence(input, rows[0], groupRows[0]) - if (evidence.contradictory) { - contradictoryOwnerCount++ - } else if (evidence.ambiguous || evidence.hosts.size === 0) { - ambiguousOwnerCount++ - } else if (evidence.hosts.has(expectedHost)) { - const workspaceKey = folderWorkspaceKey(folderId) - terminalWorkspaceKeys.add(workspaceKey) - lineageWorkspaceKeys.add(workspaceKey) - } - } - - return { - catalogRevision: input.catalogRevision, - gitRepos, - gitWorktreeIds, - terminalWorkspaceKeys, - lineageWorkspaceKeys, - ambiguousOwnerCount, - contradictoryOwnerCount - } + return worktreeIds } diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts deleted file mode 100644 index df2ae5f524c..00000000000 --- a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { - clearDirectSshTerminalBindings, - type DirectSshTerminalBindingState -} from './direct-ssh-terminal-recovery' -import { createTestStore, makeWorktree } from './store-test-helpers' - -function makeTab(id: string, ptyId: string | null, pendingActivationSpawn?: true): TerminalTab { - return { - id, - ptyId, - worktreeId: 'repo::/work', - title: id, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ...(pendingActivationSpawn ? { pendingActivationSpawn } : {}) - } -} - -function makeState(): DirectSshTerminalBindingState & { - lastKnownRelayPtyIdByTabId: Record -} { - return { - tabsByWorktree: { - 'repo::/ssh-work': [ - makeTab('ssh-live', 'ssh-target@@pty-1', true), - makeTab('ssh-unbound', null, true) - ], - 'folder::ssh-work': [makeTab('folder-live', 'ssh-target@@pty-2')], - 'repo::/other-host': [makeTab('other-live', 'ssh-other@@pty-3', true)] - }, - ptyIdsByTabId: { - 'ssh-live': ['ssh-target@@pty-1', 'ssh-target@@pty-split'], - 'ssh-unbound': [], - 'folder-live': ['ssh-target@@pty-2'], - 'other-live': ['ssh-other@@pty-3'] - }, - lastKnownRelayPtyIdByTabId: { - 'ssh-live': 'ssh-target@@pty-1', - 'folder-live': 'ssh-target@@pty-2', - 'other-live': 'ssh-other@@pty-3' - }, - pendingCodexPaneRestartIds: { - 'ssh-target@@pty-1': true, - 'ssh-target@@pty-split': true, - 'ssh-other@@pty-3': true - }, - codexRestartNoticeByPtyId: { - 'ssh-target@@pty-1': { - previousAccountLabel: 'old', - nextAccountLabel: 'new' - }, - 'ssh-other@@pty-3': { - previousAccountLabel: 'old', - nextAccountLabel: 'new' - } - } - } -} - -describe('clearDirectSshTerminalBindings', () => { - it('clears exact Git and folder workspace bindings in one projection', () => { - const state = makeState() - const result = clearDirectSshTerminalBindings( - state, - new Set(['repo::/ssh-work', 'folder::ssh-work']) - ) - - expect(result.clearedCount).toBe(2) - expect(result.patch?.tabsByWorktree?.['repo::/ssh-work']).toEqual([ - expect.objectContaining({ id: 'ssh-live', ptyId: null }), - state.tabsByWorktree['repo::/ssh-work'][1] - ]) - expect(result.patch?.tabsByWorktree?.['repo::/ssh-work'][0]).not.toHaveProperty( - 'pendingActivationSpawn' - ) - expect(result.patch?.tabsByWorktree?.['repo::/ssh-work'][1]).toBe( - state.tabsByWorktree['repo::/ssh-work'][1] - ) - expect(result.patch?.tabsByWorktree?.['folder::ssh-work'][0].ptyId).toBeNull() - expect(result.patch?.ptyIdsByTabId).toMatchObject({ - 'ssh-live': [], - 'folder-live': [], - 'other-live': ['ssh-other@@pty-3'] - }) - }) - - it('preserves relay reattach ids and every other host', () => { - const state = makeState() - const result = clearDirectSshTerminalBindings(state, new Set(['repo::/ssh-work'])) - - expect(result.patch).not.toHaveProperty('lastKnownRelayPtyIdByTabId') - expect(state.lastKnownRelayPtyIdByTabId['ssh-live']).toBe('ssh-target@@pty-1') - expect(result.patch?.tabsByWorktree?.['repo::/other-host']).toBe( - state.tabsByWorktree['repo::/other-host'] - ) - expect(result.patch?.pendingCodexPaneRestartIds).toEqual({ - 'ssh-other@@pty-3': true - }) - expect(result.patch?.codexRestartNoticeByPtyId).toEqual({ - 'ssh-other@@pty-3': { - previousAccountLabel: 'old', - nextAccountLabel: 'new' - } - }) - }) - - it('clears an exact target through one Zustand publication without activity changes', () => { - const store = createTestStore() - const worktreeId = 'repo-ssh::/work/demo' - const tab = { ...makeTab('ssh-live', 'ssh-target@@pty-1', true), worktreeId } - store.setState({ - repos: [ - { - id: 'repo-ssh', - path: '/work/demo', - displayName: 'demo', - badgeColor: '#000', - addedAt: 1, - connectionId: 'target' - } - ], - worktreesByRepo: { - 'repo-ssh': [ - makeWorktree({ - id: worktreeId, - repoId: 'repo-ssh', - path: '/work/demo', - hostId: 'ssh:target' - }) - ] - }, - tabsByWorktree: { [worktreeId]: [tab] }, - ptyIdsByTabId: { [tab.id]: ['ssh-target@@pty-1'] }, - lastKnownRelayPtyIdByTabId: { [tab.id]: 'ssh-target@@pty-1' }, - sortEpoch: 9 - }) - let publications = 0 - const unsubscribe = store.subscribe(() => { - publications += 1 - }) - - expect(store.getState().clearDirectSshTargetPtyBindings('target')).toBe(1) - unsubscribe() - - const state = store.getState() - expect(publications).toBe(1) - expect(state.tabsByWorktree[worktreeId][0]).toMatchObject({ ptyId: null }) - expect(state.ptyIdsByTabId[tab.id]).toEqual([]) - expect(state.lastKnownRelayPtyIdByTabId[tab.id]).toBe('ssh-target@@pty-1') - expect(state.sortEpoch).toBe(9) - }) - - it('leaves unbound tabs byte-identical and repeats as a no-op', () => { - const state = makeState() - const first = clearDirectSshTerminalBindings(state, new Set(['repo::/ssh-work'])) - const afterFirst = { ...state, ...first.patch } - const second = clearDirectSshTerminalBindings(afterFirst, new Set(['repo::/ssh-work'])) - - expect(first.patch?.tabsByWorktree?.['repo::/ssh-work'][1]).toBe( - state.tabsByWorktree['repo::/ssh-work'][1] - ) - expect(second).toEqual({ clearedCount: 0, patch: null }) - }) -}) diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts deleted file mode 100644 index 637d4ac6400..00000000000 --- a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { CodexRestartNotice } from '../terminals/terminal-contracts' - -export type DirectSshTerminalBindingState = { - tabsByWorktree: Record - ptyIdsByTabId: Record - pendingCodexPaneRestartIds: Record - codexRestartNoticeByPtyId: Record -} - -function clearPtyBinding( - state: DirectSshTerminalBindingState, - tab: TerminalTab, - next: { - ptyIdsByTabId: Record - pendingCodexPaneRestartIds: Record - codexRestartNoticeByPtyId: Record - } -): void { - const clearedPtyIds = new Set([tab.ptyId, ...(state.ptyIdsByTabId[tab.id] ?? [])]) - next.ptyIdsByTabId[tab.id] = [] - for (const ptyId of clearedPtyIds) { - if (!ptyId) { - continue - } - delete next.pendingCodexPaneRestartIds[ptyId] - delete next.codexRestartNoticeByPtyId[ptyId] - } -} - -export function clearDirectSshTerminalBindings( - state: DirectSshTerminalBindingState, - terminalWorkspaceKeys: ReadonlySet -): { clearedCount: number; patch: Partial | null } { - let tabsByWorktree = state.tabsByWorktree - const ptyIdsByTabId = { ...state.ptyIdsByTabId } - const pendingCodexPaneRestartIds = { ...state.pendingCodexPaneRestartIds } - const codexRestartNoticeByPtyId = { ...state.codexRestartNoticeByPtyId } - let clearedCount = 0 - - for (const workspaceKey of terminalWorkspaceKeys) { - const tabs = state.tabsByWorktree[workspaceKey] - if (!tabs) { - continue - } - let nextTabs = tabs - for (const [index, tab] of tabs.entries()) { - if (tab.ptyId == null) { - continue - } - const { pendingActivationSpawn: _pendingActivationSpawn, ...tabWithoutActivationSpawn } = tab - void _pendingActivationSpawn - if (nextTabs === tabs) { - nextTabs = [...tabs] - } - nextTabs[index] = { ...tabWithoutActivationSpawn, ptyId: null } - clearPtyBinding(state, tab, { - ptyIdsByTabId, - pendingCodexPaneRestartIds, - codexRestartNoticeByPtyId - }) - clearedCount += 1 - } - if (nextTabs !== tabs) { - if (tabsByWorktree === state.tabsByWorktree) { - tabsByWorktree = { ...state.tabsByWorktree } - } - tabsByWorktree[workspaceKey] = nextTabs - } - } - - if (clearedCount === 0) { - return { clearedCount, patch: null } - } - return { - clearedCount, - patch: { - tabsByWorktree, - ptyIdsByTabId, - pendingCodexPaneRestartIds, - codexRestartNoticeByPtyId - } - } -} diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts deleted file mode 100644 index 5f9974966be..00000000000 --- a/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { resolveDirectSshTerminalWorkspaceKeys } from './direct-ssh-terminal-workspace-scope' - -function tab(worktreeId: string, ptyId: string): TerminalTab { - return { - id: `tab-${worktreeId}`, - worktreeId, - ptyId, - title: worktreeId, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } -} - -describe('resolveDirectSshTerminalWorkspaceKeys', () => { - it('uses a parsed live target PTY when the catalog row is stale', () => { - expect( - resolveDirectSshTerminalWorkspaceKeys( - { targetId: 'target-a', catalogRevision: 1, repos: [] }, - { 'stale-worktree': [tab('stale-worktree', 'ssh:target-a@@pty-1')] } - ) - ).toEqual(new Set(['stale-worktree'])) - }) - - it('uses a retained target PTY after disconnect clears the live binding', () => { - expect( - resolveDirectSshTerminalWorkspaceKeys( - { targetId: 'target-a', catalogRevision: 1, repos: [] }, - { 'stale-worktree': [tab('stale-worktree', '')] }, - { 'tab-stale-worktree': 'ssh:target-a@@pty-1' } - ) - ).toEqual(new Set(['stale-worktree'])) - }) - - it('refuses retained PTY fallback when explicit ownership names another host', () => { - expect( - resolveDirectSshTerminalWorkspaceKeys( - { - targetId: 'target-a', - catalogRevision: 1, - repos: [], - worktreesByRepo: { - repo: [ - { - id: 'contradictory-worktree', - repoId: 'repo', - hostId: 'ssh:target-b' - } - ] - } - }, - { 'contradictory-worktree': [tab('contradictory-worktree', '')] }, - { 'tab-contradictory-worktree': 'ssh:target-a@@pty-1' } - ) - ).toEqual(new Set()) - }) - - it('refuses retained PTY fallback when a repo id is owned by two SSH hosts', () => { - expect( - resolveDirectSshTerminalWorkspaceKeys( - { - targetId: 'target-a', - catalogRevision: 1, - repos: [ - { - id: 'shared', - path: '/srv/a/shared', - projectGroupId: null, - connectionId: 'target-a', - executionHostId: 'ssh:target-a' - }, - { - id: 'shared', - path: '/srv/b/shared', - projectGroupId: null, - connectionId: 'target-b', - executionHostId: 'ssh:target-b' - } - ], - worktreesByRepo: { - shared: [{ id: 'shared::/work', repoId: 'shared' }] - } - }, - { 'shared::/work': [tab('shared::/work', '')] }, - { 'tab-shared::/work': 'ssh:target-a@@pty-retained' } - ) - ).toEqual(new Set()) - }) - - it('refuses a retained target when the current PTY names another SSH host', () => { - expect( - resolveDirectSshTerminalWorkspaceKeys( - { targetId: 'target-a', catalogRevision: 1, repos: [] }, - { - 'stale-worktree': [ - tab('stale-worktree', 'ssh:target-b@@pty-live'), - tab('stale-worktree', '') - ] - }, - { 'tab-stale-worktree': 'ssh:target-a@@pty-retained' } - ) - ).toEqual(new Set()) - }) - - it('refuses parsed PTY fallback when explicit worktree ownership names another host', () => { - expect( - resolveDirectSshTerminalWorkspaceKeys( - { - targetId: 'target-a', - catalogRevision: 1, - repos: [], - worktreesByRepo: { - repo: [ - { - id: 'contradictory-worktree', - repoId: 'repo', - hostId: 'ssh:target-b' - } - ] - } - }, - { - 'contradictory-worktree': [tab('contradictory-worktree', 'ssh:target-a@@pty-1')] - } - ) - ).toEqual(new Set()) - }) - - it('refuses parsed PTY fallback when explicit host provenance is malformed', () => { - expect( - resolveDirectSshTerminalWorkspaceKeys( - { - targetId: 'target-a', - catalogRevision: 1, - repos: [], - worktreesByRepo: { - repo: [ - { - id: 'malformed-worktree', - repoId: 'repo', - hostId: 'ssh:%' - } - ] - } - }, - { - 'malformed-worktree': [tab('malformed-worktree', 'ssh:target-a@@pty-1')] - } - ) - ).toEqual(new Set()) - }) - - it('refuses folder PTY fallback when project host provenance is malformed', () => { - expect( - resolveDirectSshTerminalWorkspaceKeys( - { - targetId: 'target-a', - catalogRevision: 1, - repos: [], - folderWorkspaces: [ - { - id: 'folder-a', - projectGroupId: 'group-a', - folderPath: '/srv/project', - connectionId: 'target-a' - } - ], - projectGroups: [ - { - id: 'group-a', - parentGroupId: null, - connectionId: 'target-a', - executionHostId: 'ssh:%' - } - ] - }, - { - 'folder:folder-a': [tab('folder:folder-a', 'ssh:target-a@@pty-1')] - } - ) - ).toEqual(new Set()) - }) - - it('refuses fallback when explicit worktree and repo-derived ownership disagree', () => { - expect( - resolveDirectSshTerminalWorkspaceKeys( - { - targetId: 'target-a', - catalogRevision: 1, - repos: [ - { - id: 'repo', - path: '/srv/repo', - projectGroupId: null, - connectionId: 'target-b', - executionHostId: 'ssh:target-b' - } - ], - worktreesByRepo: { - repo: [ - { - id: 'contradictory-worktree', - repoId: 'repo', - hostId: 'ssh:target-a' - } - ] - } - }, - { - 'contradictory-worktree': [tab('contradictory-worktree', 'ssh:target-a@@pty-1')] - } - ) - ).toEqual(new Set()) - }) - - it('refuses runtime-owned and contradictory folder provenance', () => { - const keys = resolveDirectSshTerminalWorkspaceKeys( - { - targetId: 'target-a', - catalogRevision: 1, - repos: [], - worktreesByRepo: { - repo: [ - { - id: 'runtime-worktree', - repoId: 'repo', - hostId: 'ssh:target-a', - runtimeOwnerEnvironmentId: 'runtime-1' - } - ] - }, - folderWorkspaces: [ - { - id: 'folder-b', - projectGroupId: 'group-b', - folderPath: '/srv/project', - connectionId: 'target-b' - } - ], - projectGroups: [ - { - id: 'group-b', - parentGroupId: null, - connectionId: 'target-b', - executionHostId: 'ssh:target-b' - } - ] - }, - { - 'runtime-worktree': [tab('runtime-worktree', 'ssh:target-a@@pty-1')], - 'folder:folder-b': [tab('folder:folder-b', 'ssh:target-a@@pty-2')] - } - ) - - expect(keys).toEqual(new Set()) - }) -}) diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.ts b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.ts deleted file mode 100644 index 428069a60ea..00000000000 --- a/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - getRepoExecutionHostId, - parseExecutionHostId, - toSshExecutionHostId -} from '../../../../shared/execution-host' -import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' -import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' -import { - resolveDirectSshTargetScope, - type DirectSshTargetScopeInput -} from '../../lib/direct-ssh-target-scope' - -function isExplicitContradictoryHost( - rawHostId: string | null | undefined, - expectedHostId: string -): boolean { - if (!rawHostId?.trim()) { - return false - } - const parsed = parseExecutionHostId(rawHostId) - return parsed == null || parsed.id !== expectedHostId -} - -function worktreeHasContradictoryOwner( - input: DirectSshTargetScopeInput, - worktreeId: string -): boolean { - const rows = [ - ...Object.values(input.worktreesByRepo ?? {}).flat(), - ...Object.values(input.detectedWorktreesByRepo ?? {}).flatMap((entry) => entry.worktrees) - ].filter((worktree) => worktree.id === worktreeId) - const expectedHostId = toSshExecutionHostId(input.targetId) - for (const row of rows) { - if ( - row.runtimeOwnerEnvironmentId?.trim() || - isExplicitContradictoryHost(row.hostId, expectedHostId) - ) { - return true - } - const repos = input.repos.filter((repo) => repo.id === row.repoId) - if (repos.some((repo) => getRepoExecutionHostId(repo) !== expectedHostId)) { - return true - } - } - const restored = input.restoredRuntimeHostIdByWorkspaceSessionKey - return ( - isExplicitContradictoryHost(restored?.[worktreeId], expectedHostId) || - isExplicitContradictoryHost(restored?.[worktreeWorkspaceKey(worktreeId)], expectedHostId) - ) -} - -function folderHasContradictoryOwner( - input: DirectSshTargetScopeInput, - folderWorkspaceId: string -): boolean { - const expectedHostId = toSshExecutionHostId(input.targetId) - const folders = (input.folderWorkspaces ?? []).filter((folder) => folder.id === folderWorkspaceId) - for (const folder of folders) { - if (isExplicitContradictoryHost(folder.executionHostId, expectedHostId)) { - return true - } - if (folder.connectionId?.trim() && folder.connectionId.trim() !== input.targetId) { - return true - } - const groups = (input.projectGroups ?? []).filter((group) => group.id === folder.projectGroupId) - if ( - groups.some( - (group) => - (group.connectionId?.trim() && group.connectionId.trim() !== input.targetId) || - isExplicitContradictoryHost(group.executionHostId, expectedHostId) - ) - ) { - return true - } - const groupIds = getProjectGroupSubtreeIds(input.projectGroups ?? [], folder.projectGroupId) - const candidateRepos = input.repos.filter( - (repo) => - (repo.projectGroupId != null && groupIds.has(repo.projectGroupId)) || - isPathInsideOrEqual(folder.folderPath, repo.path) - ) - if (candidateRepos.some((repo) => getRepoExecutionHostId(repo) !== expectedHostId)) { - return true - } - } - return isExplicitContradictoryHost( - input.restoredRuntimeHostIdByWorkspaceSessionKey?.[folderWorkspaceKey(folderWorkspaceId)], - expectedHostId - ) -} - -function workspaceHasContradictoryOwner( - input: DirectSshTargetScopeInput, - workspaceKey: string -): boolean { - if (workspaceKey.startsWith('folder:')) { - return folderHasContradictoryOwner(input, workspaceKey.slice('folder:'.length)) - } - return worktreeHasContradictoryOwner(input, workspaceKey) -} - -export function resolveDirectSshTerminalWorkspaceKeys( - input: DirectSshTargetScopeInput, - tabsByWorktree: Readonly>, - lastKnownRelayPtyIdByTabId: Readonly> = {} -): Set { - const keys = new Set(resolveDirectSshTargetScope(input).terminalWorkspaceKeys) - for (const [workspaceKey, tabs] of Object.entries(tabsByWorktree)) { - const liveSshTargets = new Set( - tabs - .map((tab) => parseAppSshPtyId(tab.ptyId ?? '')?.connectionId) - .filter((targetId): targetId is string => Boolean(targetId)) - ) - const retainedSshTargets = new Set( - tabs - .map((tab) => parseAppSshPtyId(lastKnownRelayPtyIdByTabId[tab.id] ?? '')?.connectionId) - .filter((targetId): targetId is string => Boolean(targetId)) - ) - const ptyTargets = liveSshTargets.size > 0 ? liveSshTargets : retainedSshTargets - if ( - keys.has(workspaceKey) || - workspaceHasContradictoryOwner(input, workspaceKey) || - ptyTargets.size !== 1 || - !ptyTargets.has(input.targetId) - ) { - continue - } - keys.add(workspaceKey) - } - return keys -} diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 57823f6c6f1..012cb469400 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -2,7 +2,7 @@ import type { AppState } from '../types' import type { SshTarget, SshTargetSummary } from '../../../../shared/ssh-types' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation' -import { resolveDirectSshTargetScope } from '../../lib/direct-ssh-target-scope' +import { resolveDirectSshTargetWorktreeIds } from '../../lib/direct-ssh-target-scope' import { unbindTerminalTabPtys } from './tabs/worktree-layout-lifecycle' export function sshTargetLabelsEqual( @@ -44,14 +44,13 @@ export function sshTargetGenerationsEqual( } function collectSshTargetTerminalTabIds(state: AppState, targetId: string): Set { - const targetWorktreeIds = resolveDirectSshTargetScope({ + const targetWorktreeIds = resolveDirectSshTargetWorktreeIds({ targetId, - catalogRevision: 0, repos: state.repos, worktreesByRepo: state.worktreesByRepo, detectedWorktreesByRepo: state.detectedWorktreesByRepo, restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey - }).gitWorktreeIds + }) const tabIds = new Set() for (const worktrees of Object.values(state.worktreesByRepo)) { for (const worktree of worktrees) { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 929f12d5dc5..f954184a6d5 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -16,7 +16,6 @@ import { createTerminalPtyReleaseActions } from '../terminals/terminal-pty-relea import { createTerminalUnverifiedPtyLossActions } from '../terminals/terminal-unverified-pty-loss' import { createTerminalDisownedPtySourceActions } from '../terminals/terminal-disowned-pty-sources' import { createTerminalPaneHibernationActions } from '../terminals/terminal-pane-hibernation' -import { createDirectSshTerminalBindingActions } from '../terminals/direct-ssh-terminal-bindings' import { createTerminalShutdownActions } from '../terminals/terminal-shutdown' import { createTerminalRestartActions } from '../terminals/terminal-restart-state' import { createTerminalLayoutActions } from '../terminals/terminal-layout-state' @@ -83,7 +82,6 @@ export const createTerminalSlice: StateCreator ...createTerminalUnverifiedPtyLossActions(set), ...createTerminalDisownedPtySourceActions(set), ...createTerminalPaneHibernationActions(set, get), - ...createDirectSshTerminalBindingActions(set, get), ...createTerminalShutdownActions(set, get), ...createTerminalRestartActions(set, get), ...createTerminalLayoutActions(set, get), diff --git a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts deleted file mode 100644 index 6a5ea2afda9..00000000000 --- a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { clearDirectSshTerminalBindings } from '../slices/direct-ssh-terminal-recovery' -import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -import { resolveDirectSshTerminalKeys } from './terminal-pty-identities' - -export function createDirectSshTerminalBindingActions( - set: TerminalStoreSet, - _get: TerminalStoreGet -): Pick { - return { - clearDirectSshTargetPtyBindings: (targetId) => { - let clearedCount = 0 - set((s) => { - const result = clearDirectSshTerminalBindings(s, resolveDirectSshTerminalKeys(s, targetId)) - clearedCount = result.clearedCount - return result.patch ?? s - }) - return clearedCount - } - } -} diff --git a/src/renderer/src/store/terminals/restored-relay-session-identity.test.ts b/src/renderer/src/store/terminals/restored-relay-session-identity.test.ts index e7e2ed15296..a174965629e 100644 --- a/src/renderer/src/store/terminals/restored-relay-session-identity.test.ts +++ b/src/renderer/src/store/terminals/restored-relay-session-identity.test.ts @@ -95,19 +95,13 @@ describe('restored relay session identity (#17743)', () => { }) }) - it('keeps a deferred relay session id after a disconnect clears the row binding', async () => { + it('keeps a deferred relay session id while the host is disconnected', async () => { const store = seedStore('disconnected') store.getState().hydrateWorkspaceSession(persistedSession()) await store.getState().reconnectPersistedTerminals() expect(store.getState().deferredSshSessionIdsByTabId[TAB_ID]).toBe(RELAY_PTY_ID) - - // A relay drop clears the row binding. Loss of contact is not evidence the remote PTY exited, - // so the handle must survive into the next write. - store.getState().clearDirectSshTargetPtyBindings(TARGET_ID) - - expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBeNull() - expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([]) + // Loss of contact is not evidence the remote PTY exited, so the handle survives into the next write. expect(buildWorkspaceSessionPayload(store.getState()).remoteSessionIdsByTabId).toEqual({ [TAB_ID]: RELAY_PTY_ID }) @@ -117,7 +111,6 @@ describe('restored relay session identity (#17743)', () => { const store = seedStore('disconnected') store.getState().hydrateWorkspaceSession(persistedSession()) await store.getState().reconnectPersistedTerminals() - store.getState().clearDirectSshTargetPtyBindings(TARGET_ID) // No live PTY, no row binding, host unreachable — `unverifiable`, never `exited`. expect([...getOrphanTerminalIds(store.getState(), WORKTREE_ID)]).toEqual([]) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 5ede2ac2a5a..8f059e368b7 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -139,7 +139,6 @@ export type TerminalActions = { markUnverifiedPtyLoss: (tabId: string) => void /** Records the relay's own answer that a PTY id is gone; the one `exited` a respawn may act on. */ markPtySourceDisowned: (ptyId: string) => void - clearDirectSshTargetPtyBindings: (targetId: string) => number /** Awaited verified shutdown; unlike closeTab, physical teardown gates the state commit. */ shutdownWorktreeTerminals: ( worktreeId: string, diff --git a/src/renderer/src/store/terminals/terminal-pty-identities.ts b/src/renderer/src/store/terminals/terminal-pty-identities.ts index a227560e12d..1d477869402 100644 --- a/src/renderer/src/store/terminals/terminal-pty-identities.ts +++ b/src/renderer/src/store/terminals/terminal-pty-identities.ts @@ -1,24 +1,5 @@ -import type { AppState } from '../types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' -import { resolveDirectSshTerminalWorkspaceKeys } from '../slices/direct-ssh-terminal-workspace-scope' - -export function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set { - return resolveDirectSshTerminalWorkspaceKeys( - { - targetId, - catalogRevision: 0, - repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, - folderWorkspaces: state.folderWorkspaces, - projectGroups: state.projectGroups, - restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey - }, - state.tabsByWorktree, - state.lastKnownRelayPtyIdByTabId - ) -} export function getPendingActivationSpawnCount(value: boolean | number | undefined): number { if (value === true) {