refactor(ssh): keep SSH terminal bindings on disconnect, reconnect and wake (one rule, main's) (#29036)

* refactor(ssh): stop the window clearing SSH terminal bindings on disconnect, connect and wake

The window dropped every SSH tab's terminal binding when an SSH host disconnected,
reconnected or the computer woke. Main's rule is the opposite: only a host-confirmed
stop withdraws a binding, because losing contact is not evidence the remote shell
exited. Startup already moves restored SSH bindings out of the tab, so the clear had
nothing left to act on. Delete it, its scope resolver and the wake listener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ssh): reduce the direct-SSH scope to the worktree ids its one caller reads

Removing the binding clear left terminalWorkspaceKeys and the folder evidence path
with no reader; gitRepos, lineageWorkspaceKeys and the owner counts had none
already. SSH target cleanup only reads the worktree ids, so return just those.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-15 03:26:08 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 62c6e99144
commit 2885bc3e92
24 changed files with 122 additions and 1495 deletions
+11 -29
View File
@@ -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",
@@ -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
@@ -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)
})
})
@@ -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', {
@@ -20,36 +20,3 @@ export function directSshAuthorityFromConnectionState(
connectionGeneration: state.connectionGeneration
}
}
export function registerDirectSshWakeRouting(deps: {
getConnectionStates: () => Iterable<readonly [string, SshConnectionState]>
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?.()
}
}
@@ -8,7 +8,6 @@ export function buildSshAuthorityReconciliationHarness(args: {
partialAuthority: { providerEpoch?: string; connectionGeneration?: number }
latestAuthority: { providerEpoch: string; connectionGeneration: number }
}): {
clearBindings: ReturnType<typeof vi.fn>
emitPartialState: () => void
getState: ReturnType<typeof vi.fn>
setSshConnectionState: ReturnType<typeof vi.fn>
@@ -27,7 +26,6 @@ export function buildSshAuthorityReconciliationHarness(args: {
const sshConnectionStates = new Map<string, Record<string, unknown>>()
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<string, unknown>) => {
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')
@@ -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<void> {
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)
})
})
@@ -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) {
@@ -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
@@ -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',
@@ -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()
@@ -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<string>
terminalWorkspaceKeys: Set<string>
lineageWorkspaceKeys: Set<WorkspaceKey>
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<Record<string, readonly DirectSshWorktreeOwner[]>>
detectedWorktreesByRepo?: Readonly<
Record<string, { worktrees: readonly DirectSshWorktreeOwner[] }>
>
folderWorkspaces?: readonly DirectSshFolderOwner[]
projectGroups?: readonly DirectSshGroupOwner[]
restoredRuntimeHostIdByWorkspaceSessionKey?: Readonly<Record<string, string | null | undefined>>
}
@@ -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)
})
})
+8 -165
View File
@@ -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<ExecutionHostId>
@@ -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<string>()
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<string> {
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<string>()
const terminalWorkspaceKeys = new Set<string>()
const lineageWorkspaceKeys = new Set<ReturnType<typeof worktreeWorkspaceKey>>()
const worktreeIds = new Set<string>()
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
}
@@ -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<string, string>
} {
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 })
})
})
@@ -1,84 +0,0 @@
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { CodexRestartNotice } from '../terminals/terminal-contracts'
export type DirectSshTerminalBindingState = {
tabsByWorktree: Record<string, TerminalTab[]>
ptyIdsByTabId: Record<string, string[]>
pendingCodexPaneRestartIds: Record<string, true>
codexRestartNoticeByPtyId: Record<string, CodexRestartNotice>
}
function clearPtyBinding(
state: DirectSshTerminalBindingState,
tab: TerminalTab,
next: {
ptyIdsByTabId: Record<string, string[]>
pendingCodexPaneRestartIds: Record<string, true>
codexRestartNoticeByPtyId: Record<string, CodexRestartNotice>
}
): 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<string>
): { clearedCount: number; patch: Partial<DirectSshTerminalBindingState> | 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
}
}
}
@@ -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())
})
})
@@ -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<Record<string, readonly TerminalTab[]>>,
lastKnownRelayPtyIdByTabId: Readonly<Record<string, string>> = {}
): Set<string> {
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
}
@@ -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<string> {
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<string>()
for (const worktrees of Object.values(state.worktreesByRepo)) {
for (const worktree of worktrees) {
@@ -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<AppState, [], [], TerminalSlice>
...createTerminalUnverifiedPtyLossActions(set),
...createTerminalDisownedPtySourceActions(set),
...createTerminalPaneHibernationActions(set, get),
...createDirectSshTerminalBindingActions(set, get),
...createTerminalShutdownActions(set, get),
...createTerminalRestartActions(set, get),
...createTerminalLayoutActions(set, get),
@@ -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<TerminalSlice, 'clearDirectSshTargetPtyBindings'> {
return {
clearDirectSshTargetPtyBindings: (targetId) => {
let clearedCount = 0
set((s) => {
const result = clearDirectSshTerminalBindings(s, resolveDirectSshTerminalKeys(s, targetId))
clearedCount = result.clearedCount
return result.patch ?? s
})
return clearedCount
}
}
}
@@ -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([])
@@ -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,
@@ -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<string> {
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) {