From 864594ffdd14bd4866c0ff732bae958559af53eb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:36:19 -0700 Subject: [PATCH] Fix SSH state for paired remote clients + complete target re-adoption sweep (STA-1468) (#7767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Sweep all persisted carriers of a removed SSH target id on re-adoption reassignSshTargetId re-pointed repos and worktree metas but left the old target id embedded in persisted session pty ids (ssh:@@pty-N in tabs, layouts, remoteSessionIdsByTabId), the startup reconnect list (activeConnectionIdsAtShutdown, replayed via ssh.connect at boot — the exact 'SSH target not found' in STA-1468), sleeping-agent resume records, provisioned project host setups, sidebar host-scope arrays, and relay pty leases. Any survivor resurfaces later as a failing connect or reattach. New ssh-target-id-migration module re-points every carrier in one pass, wired into reassignSshTargetId with per-carrier unit and store-level round-trip tests. Co-authored-by: Orca * Bridge SSH connection state to paired remote clients The SSH surface was desktop-only: ssh:state-changed went to the host's own BrowserWindow and the web client's ssh API was a no-op stub, so a paired client's reconnect overlay never learned the host connected and its target labels stayed empty (STA-1468 — overlay stuck on 'please connect' over a live terminal). - New sshStateChanged runtime client event, emitted from broadcastSshState through OrcaRuntimeService onto the existing clientEvents stream. - New ssh.listTargets / ssh.listRemovedTargetLabels RPC methods next to the previously unused ssh.getState / ssh.connect. - Web preload now routes listTargets / listRemovedTargetLabels / getState / connect to the paired host's runtime RPC instead of stubbing them. - useIpcEvents applies sshStateChanged on paired web clients through the same guarded path as desktop ssh.onStateChanged; desktop clients ignore the event since a foreign runtime's targets would pollute their local SSH store. Co-authored-by: Orca * Harden the SSH reconnect overlay against stale or unknown target state - Only present the destructive 'SSH host removed' state on positive evidence (a removal tombstone label, or a hydrated non-empty target list lacking the id). A client whose SSH state never hydrated has an empty labels map for every id and must not offer workspace removal. - After a failed Connect, resync target metadata so a stale overlay converges to the ghost/re-adopted state instead of offering the same failing Connect forever (the repeated 'SSH target not found' toast loop in STA-1468). Co-authored-by: Orca * Address CodeRabbit review on #7767 - Re-key workspaceSessionsByHostId partitions stored under a removed SSH host id during re-adoption (no writer keys partitions by ssh host today, but the schema tolerates it — re-key instead of stranding; live partition wins when both keys exist). - Track SSH target-list hydration explicitly (sshTargetsHydrated) instead of inferring it from a non-empty label map, so a legitimately empty target list still counts as removal evidence and a never-hydrated client still never offers destructive removal. - Apply the refreshed target list before the best-effort removed-labels fetch in the overlay resync, so a labels failure can't discard it. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/ssh.test.ts | 33 +++++ src/main/ipc/ssh.ts | 19 ++- src/main/persistence.test.ts | 134 +++++++++++++++++ src/main/persistence.ts | 72 ++++++++- src/main/runtime/orca-runtime.ts | 7 + src/main/runtime/rpc/methods/ssh.test.ts | 37 ++++- src/main/runtime/rpc/methods/ssh.ts | 17 ++- src/main/runtime/runtime-rpc.ts | 2 + src/main/ssh/ssh-target-id-migration.test.ts | 139 ++++++++++++++++++ src/main/ssh/ssh-target-id-migration.ts | 116 +++++++++++++++ .../components/terminal-pane/TerminalPane.tsx | 9 +- .../TerminalSshReconnectOverlay.test.tsx | 60 +++++++- .../TerminalSshReconnectOverlay.tsx | 11 ++ src/renderer/src/hooks/useIpcEvents.ts | 96 +++++++----- .../src/runtime/runtime-client-events.ts | 1 + src/renderer/src/store/slices/ssh.test.ts | 14 +- src/renderer/src/store/slices/ssh.ts | 13 +- src/renderer/src/web/web-preload-api.ts | 41 +++++- src/shared/runtime-client-events.ts | 5 + 19 files changed, 763 insertions(+), 63 deletions(-) create mode 100644 src/main/ssh/ssh-target-id-migration.test.ts create mode 100644 src/main/ssh/ssh-target-id-migration.ts diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index a1db12efa..cde245ee4 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -778,6 +778,39 @@ describe('SSH IPC handlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) }) + it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => { + const runtime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + notifySshStateChanged: vi.fn() + } + registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + // Why: paired remote clients only learn SSH state through this hook — + // without it their reconnect overlays never clear (STA-1468). + expect(runtime.notifySshStateChanged).toHaveBeenCalledWith( + 'ssh-1', + expect.objectContaining({ targetId: 'ssh-1', status: 'connected' }) + ) + }) + it('preserves active port forwards and live connections across handler re-registration', async () => { const target: SshTarget = { id: 'ssh-1', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 1fa15be1d..58391c4c0 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -90,6 +90,16 @@ export function getRegisteredSshState(targetId: string): SshConnectionState | un return registeredGetSshState?.(targetId) } +/** Public targets for runtime RPC clients — same list the desktop renderer gets. */ +export function listRegisteredSshTargets(): SshTarget[] { + return sshStore?.listTargets() ?? [] +} + +/** Removed-target id → last known label, for ghost-host display on paired clients. */ +export function listRegisteredRemovedSshTargetLabels(): Record { + return sshStore?.listRemovedTargetLabels() ?? {} +} + export async function disconnectRegisteredSshTarget(targetId: string): Promise { if (!connectionManager) { return @@ -235,13 +245,14 @@ function broadcastSshState( if (isRuntimeOwnedSshTargetId(targetId)) { return } + const enrichedState = withSshRemotePlatform(targetId, state) const win = getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('ssh:state-changed', { - targetId, - state: withSshRemotePlatform(targetId, state) - }) + win.webContents.send('ssh:state-changed', { targetId, state: enrichedState }) } + // Why: paired remote clients have no ssh:state-changed IPC; without this + // their terminals keep a stale reconnect overlay after the host connects. + currentRuntime?.notifySshStateChanged?.(targetId, enrichedState) } function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 956711371..52f826935 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3198,6 +3198,140 @@ describe('Store', () => { expect(reloaded.getWorktreeMeta('r1::/remote/wt')?.hostId).toBe('ssh:ssh-new') }) + it('reassignSshTargetId migrates session pty ids, reconnect list, leases, and host scope', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'r1::/wt', + activeTabId: 'tab1', + tabsByWorktree: { + 'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-2' })] + }, + terminalLayoutsByTabId: {}, + remoteSessionIdsByTabId: { tab1: 'ssh:ssh-old@@pty-2' }, + activeConnectionIdsAtShutdown: ['ssh-old'] + }) + store.upsertSshRemotePtyLease({ targetId: 'ssh-old', ptyId: 'pty-2', state: 'detached' }) + store.updateUI({ + workspaceHostScope: 'ssh:ssh-old', + visibleWorkspaceHostIds: ['local', 'ssh:ssh-old'], + workspaceHostOrder: ['ssh:ssh-old', 'local'] + }) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + store.flush() + + const reloaded = await createStore() + const session = reloaded.getWorkspaceSession() + expect(session.tabsByWorktree['r1::/wt'][0].ptyId).toBe('ssh:ssh-new@@pty-2') + expect(session.remoteSessionIdsByTabId).toEqual({ tab1: 'ssh:ssh-new@@pty-2' }) + expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-new']) + expect(reloaded.getSshRemotePtyLeases('ssh-new')).toHaveLength(1) + expect(reloaded.getSshRemotePtyLeases('ssh-old')).toHaveLength(0) + const ui = reloaded.getUI() + expect(ui.workspaceHostScope).toBe('ssh:ssh-new') + expect(ui.visibleWorkspaceHostIds).toEqual(['local', 'ssh:ssh-new']) + expect(ui.workspaceHostOrder).toEqual(['ssh:ssh-new', 'local']) + }) + + it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => { + const store = await createStore() + store.setWorkspaceSession( + { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: { + 'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-9' })] + }, + terminalLayoutsByTabId: {} + }, + 'ssh:ssh-old' + ) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + store.flush() + + const reloaded = await createStore() + // Old-key partition is gone; the re-keyed one carries migrated pty ids. + expect(reloaded.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({}) + expect(reloaded.getWorkspaceSession('ssh:ssh-new').tabsByWorktree['r1::/wt'][0].ptyId).toBe( + 'ssh:ssh-new@@pty-9' + ) + }) + + it('reassignSshTargetId keeps the live partition when both host keys exist', async () => { + const store = await createStore() + const baseSession = { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + terminalLayoutsByTabId: {} + } + store.setWorkspaceSession( + { ...baseSession, tabsByWorktree: { 'r1::/dead': [] } }, + 'ssh:ssh-old' + ) + store.setWorkspaceSession( + { ...baseSession, tabsByWorktree: { 'r1::/live': [] } }, + 'ssh:ssh-new' + ) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(store.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-new').tabsByWorktree).toEqual({ 'r1::/live': [] }) + }) + + it('reassignSshTargetId re-points an independent provisioned host setup', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'ssh:ssh-old', + setupId: 'cloud-project::ssh-old', + setupMethod: 'provisioned' + }) + + // Meta-only re-adoption (no repo pinned to the old id) must still migrate + // the provisioned setup, or new worktrees would be born on a dead host id. + store.reassignSshTargetId('ssh-old', 'ssh-new') + + const setups = store.getProjectHostSetups() + const provisioned = setups.find((entry) => entry.id === 'cloud-project::ssh-old') + expect(provisioned?.hostId).toBe('ssh:ssh-new') + }) + + it('reassignSshTargetId drops a stale setup when the new host already has one', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'ssh:ssh-old', + setupId: 'setup-old', + setupMethod: 'provisioned' + }) + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'ssh:ssh-new', + setupId: 'setup-new', + setupMethod: 'provisioned' + }) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + + const setups = store.getProjectHostSetups() + expect(setups.find((entry) => entry.id === 'setup-old')).toBeUndefined() + expect(setups.find((entry) => entry.id === 'setup-new')?.hostId).toBe('ssh:ssh-new') + }) + // ── 7. updateRepo ────────────────────────────────────────────────── it('updateRepo modifies the repo in place', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2caa3de28..b10fa4fe9 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -113,6 +113,10 @@ import { type ExecutionHostId } from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' +import { + migrateUiHostScopeSshTargetId, + migrateWorkspaceSessionSshTargetId +} from './ssh/ssh-target-id-migration' import { isWslUncPath } from '../shared/wsl-paths' import { isTerminalLeafId, @@ -6131,13 +6135,71 @@ export class Store { metaChanged = true } } - // Why: repo-row rewrites can affect host-setup compatibility, but meta-only - // rewrites cannot — keep that sync under the repo gate. Persist whenever - // either repos OR metas changed, so meta-only re-points aren't lost on quit. - if (repoCount > 0) { + // Why: the old id also survives in session pty ids, the startup reconnect + // list, sleeping-agent records, host setups, host-scope UI, and pty leases; + // any un-migrated carrier later throws `SSH target not found` (STA-1468). + let carrierChanged = migrateWorkspaceSessionSshTargetId( + this.state.workspaceSession, + oldTargetId, + newTargetId + ) + for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { + if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) { + carrierChanged = true + } + } + // Why: partitions are read by host id, so one stored under the removed id + // would be orphaned. No writer keys partitions by ssh host today, but the + // schema tolerates it — re-key rather than strand it. If the new key + // already has a partition, that one is live; drop the dead old one. + const partitions = this.state.workspaceSessionsByHostId + const oldPartition = partitions?.[oldHostId] + if (partitions && oldPartition) { + delete partitions[oldHostId] + partitions[newHostId] ??= oldPartition + carrierChanged = true + } + if (migrateUiHostScopeSshTargetId(this.state.ui, oldTargetId, newTargetId)) { + carrierChanged = true + } + for (const lease of this.state.sshRemotePtyLeases ?? []) { + if (lease.targetId === oldTargetId) { + lease.targetId = newTargetId + carrierChanged = true + } + } + let setupsChanged = false + const keptSetups: ProjectHostSetup[] = [] + for (const setup of this.state.projectHostSetups) { + if (setup.hostId !== oldHostId) { + keptSetups.push(setup) + continue + } + const duplicate = this.state.projectHostSetups.some( + (entry) => + entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId + ) + // Why: a setup already exists for the re-added host — the old row is a + // stale ghost that would violate the (projectId, hostId) uniqueness. + if (duplicate) { + setupsChanged = true + continue + } + setup.hostId = newHostId + setup.updatedAt = Date.now() + keptSetups.push(setup) + setupsChanged = true + } + if (setupsChanged) { + this.state.projectHostSetups = keptSetups + } + // Why: repo-row and host-setup rewrites can affect host-setup compatibility, + // but meta-only rewrites cannot — keep that sync under this gate. Persist + // whenever anything changed, so partial re-points aren't lost on quit. + if (repoCount > 0 || setupsChanged) { this.syncProjectHostSetupCompatibilityState() } - if (repoCount > 0 || metaChanged) { + if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) { this.scheduleSave() } return repoCount diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1296ef45d..bb09d201a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -130,6 +130,7 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../shared/executi import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' +import type { SshConnectionState } from '../../shared/ssh-types' import type { LinearCurrentIssueContextHints, LinearAttachResult, @@ -2806,6 +2807,12 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'reposChanged' }) } + // Why: SSH state changes originate in main's ssh handlers, not in runtime + // methods, so they need a public entry point onto the client-event stream. + notifySshStateChanged(targetId: string, state: SshConnectionState): void { + this.emitClientEvent({ type: 'sshStateChanged', targetId, state }) + } + private notifyActivateWorktree( repoId: string, worktreeId: string, diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index 94791a7fd..8efab5083 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -4,14 +4,23 @@ import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { SSH_METHODS } from './ssh' -const { connectRegisteredSshTargetMock, getRegisteredSshStateMock } = vi.hoisted(() => ({ +const { + connectRegisteredSshTargetMock, + getRegisteredSshStateMock, + listRegisteredSshTargetsMock, + listRegisteredRemovedSshTargetLabelsMock +} = vi.hoisted(() => ({ connectRegisteredSshTargetMock: vi.fn(), - getRegisteredSshStateMock: vi.fn() + getRegisteredSshStateMock: vi.fn(), + listRegisteredSshTargetsMock: vi.fn(), + listRegisteredRemovedSshTargetLabelsMock: vi.fn() })) vi.mock('../../../ipc/ssh', () => ({ connectRegisteredSshTarget: connectRegisteredSshTargetMock, - getRegisteredSshState: getRegisteredSshStateMock + getRegisteredSshState: getRegisteredSshStateMock, + listRegisteredSshTargets: listRegisteredSshTargetsMock, + listRegisteredRemovedSshTargetLabels: listRegisteredRemovedSshTargetLabelsMock })) function makeRequest(method: string, params?: unknown): RpcRequest { @@ -62,4 +71,26 @@ describe('ssh RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { state: null } }) }) + + it('lists the registered SSH targets for paired clients', async () => { + const targets = [{ id: 'ssh-1', label: 'Dev box', host: 'dev', port: 22, username: 'me' }] + listRegisteredSshTargetsMock.mockReturnValueOnce(targets) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listTargets')) + + expect(response).toMatchObject({ ok: true, result: { targets } }) + }) + + it('lists removed-target labels for ghost-host display on paired clients', async () => { + const labels = { 'ssh-old': 'Dev box' } + listRegisteredRemovedSshTargetLabelsMock.mockReturnValueOnce(labels) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listRemovedTargetLabels')) + + expect(response).toMatchObject({ ok: true, result: { labels } }) + }) }) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index e4d836ced..6de8634a8 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { connectRegisteredSshTarget, getRegisteredSshState } from '../../../ipc/ssh' +import { + connectRegisteredSshTarget, + getRegisteredSshState, + listRegisteredRemovedSshTargetLabels, + listRegisteredSshTargets +} from '../../../ipc/ssh' import { defineMethod, type RpcMethod } from '../core' const SshTarget = z.object({ @@ -16,5 +21,15 @@ export const SSH_METHODS: RpcMethod[] = [ name: 'ssh.connect', params: SshTarget, handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) }) + }), + defineMethod({ + name: 'ssh.listTargets', + params: null, + handler: () => ({ targets: listRegisteredSshTargets() }) + }), + defineMethod({ + name: 'ssh.listRemovedTargetLabels', + params: null, + handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() }) }) ] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index a5418500c..c2f949c27 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -308,6 +308,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'settings.update', 'ssh.connect', 'ssh.getState', + 'ssh.listRemovedTargetLabels', + 'ssh.listTargets', 'speech.dictation.cancel', 'speech.dictation.chunk', 'speech.dictation.finish', diff --git a/src/main/ssh/ssh-target-id-migration.test.ts b/src/main/ssh/ssh-target-id-migration.test.ts new file mode 100644 index 000000000..fa6507d62 --- /dev/null +++ b/src/main/ssh/ssh-target-id-migration.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest' +import type { PersistedUIState, TerminalTab, WorkspaceSessionState } from '../../shared/types' +import { + migrateUiHostScopeSshTargetId, + migrateWorkspaceSessionSshTargetId +} from './ssh-target-id-migration' + +const OLD_ID = 'ssh-1783337351840-ohabf0' +const NEW_ID = 'ssh-1783400000000-fresh1' + +const makeTab = (overrides: Partial = {}): TerminalTab => ({ + id: 'tab1', + ptyId: null, + worktreeId: 'r1::/wt', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides +}) + +const makeSession = (overrides: Partial = {}): WorkspaceSessionState => ({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides +}) + +describe('migrateWorkspaceSessionSshTargetId', () => { + it('re-encodes SSH pty ids embedded in tabs, layouts, and remote session ids', () => { + const session = makeSession({ + tabsByWorktree: { + 'r1::/wt': [ + makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@pty-3` }), + makeTab({ id: 'tab2', ptyId: 'local-pty-7' }), + makeTab({ id: 'tab3', ptyId: `ssh:other-target@@pty-1` }) + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@pty-3` } + } + }, + remoteSessionIdsByTabId: { + tab1: `ssh:${OLD_ID}@@pty-3`, + tab3: `ssh:other-target@@pty-1` + } + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) + + const tabs = session.tabsByWorktree['r1::/wt'] + expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@pty-3`) + // Local and other-target pty ids must be untouched. + expect(tabs[1].ptyId).toBe('local-pty-7') + expect(tabs[2].ptyId).toBe(`ssh:other-target@@pty-1`) + expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ + 'leaf-1': `ssh:${NEW_ID}@@pty-3` + }) + expect(session.remoteSessionIdsByTabId).toEqual({ + tab1: `ssh:${NEW_ID}@@pty-3`, + tab3: `ssh:other-target@@pty-1` + }) + }) + + it('replaces the old id in activeConnectionIdsAtShutdown and dedupes', () => { + const session = makeSession({ + activeConnectionIdsAtShutdown: [OLD_ID, NEW_ID, 'ssh-unrelated'] + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) + expect(session.activeConnectionIdsAtShutdown).toEqual([NEW_ID, 'ssh-unrelated']) + }) + + it('re-points sleeping agent records pinned to the old connection', () => { + const session = makeSession({ + sleepingAgentSessionsByPaneKey: { + 'tab1:leaf-1': { + paneKey: 'tab1:leaf-1', + worktreeId: 'r1::/wt', + agent: 'claude', + providerSession: { key: 'session_id', id: 's-1' }, + prompt: 'p', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId: OLD_ID + } + } + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) + expect(session.sleepingAgentSessionsByPaneKey?.['tab1:leaf-1'].connectionId).toBe(NEW_ID) + }) + + it('returns false when nothing references the old id', () => { + const session = makeSession({ + tabsByWorktree: { 'r1::/wt': [makeTab({ ptyId: 'local-pty' })] }, + activeConnectionIdsAtShutdown: ['ssh-unrelated'] + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(false) + expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-unrelated']) + }) +}) + +describe('migrateUiHostScopeSshTargetId', () => { + const makeUi = (overrides: Partial): PersistedUIState => + ({ ...overrides }) as PersistedUIState + + it('re-points scope, visible hosts, and host order, deduping collisions', () => { + const ui = makeUi({ + workspaceHostScope: `ssh:${OLD_ID}`, + visibleWorkspaceHostIds: ['local', `ssh:${OLD_ID}`, `ssh:${NEW_ID}`], + workspaceHostOrder: [`ssh:${OLD_ID}`, 'local'] + }) + + expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true) + expect(ui.workspaceHostScope).toBe(`ssh:${NEW_ID}`) + expect(ui.visibleWorkspaceHostIds).toEqual(['local', `ssh:${NEW_ID}`]) + expect(ui.workspaceHostOrder).toEqual([`ssh:${NEW_ID}`, 'local']) + }) + + it('returns false when the old host id appears nowhere', () => { + const ui = makeUi({ + workspaceHostScope: 'all', + visibleWorkspaceHostIds: ['local'], + workspaceHostOrder: ['local'] + }) + + expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts new file mode 100644 index 000000000..a917c4393 --- /dev/null +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -0,0 +1,116 @@ +import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types' +import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id' +import { toSshExecutionHostId } from '../../shared/execution-host' + +/** + * Carrier sweep for SSH target re-adoption (see ssh-target-readoption.ts). + * + * reassignSshTargetId re-points repos/worktree metas, but the removed target's + * id is also embedded in other persisted state: app-scoped SSH pty ids + * ("ssh:@@pty-N") inside the workspace session, the startup + * reconnect list, sleeping-agent resume records, and the sidebar host-scope + * arrays. Any survivor resurfaces later as `SSH target "" not found` at + * connect/reattach time (STA-1468), so every carrier must migrate together. + * + * All helpers mutate in place (matching how the Store edits this.state) and + * return whether anything changed so callers can gate scheduleSave. + */ + +function rewriteSshPtyId(ptyId: string, oldTargetId: string, newTargetId: string): string | null { + const parsed = parseAppSshPtyId(ptyId) + if (!parsed || parsed.connectionId !== oldTargetId) { + return null + } + return toAppSshPtyId(newTargetId, parsed.relayPtyId) +} + +function rewriteSshPtyIdRecordValues( + record: Record | undefined, + oldTargetId: string, + newTargetId: string +): boolean { + if (!record) { + return false + } + let changed = false + for (const [key, ptyId] of Object.entries(record)) { + const next = rewriteSshPtyId(ptyId, oldTargetId, newTargetId) + if (next) { + record[key] = next + changed = true + } + } + return changed +} + +/** Re-point every old-target-id carrier inside one workspace session partition. */ +export function migrateWorkspaceSessionSshTargetId( + session: WorkspaceSessionState, + oldTargetId: string, + newTargetId: string +): boolean { + let changed = false + for (const tabs of Object.values(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (!tab.ptyId) { + continue + } + const next = rewriteSshPtyId(tab.ptyId, oldTargetId, newTargetId) + if (next) { + tab.ptyId = next + changed = true + } + } + } + for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) { + if (rewriteSshPtyIdRecordValues(layout.ptyIdsByLeafId, oldTargetId, newTargetId)) { + changed = true + } + } + if (rewriteSshPtyIdRecordValues(session.remoteSessionIdsByTabId, oldTargetId, newTargetId)) { + changed = true + } + if (session.activeConnectionIdsAtShutdown?.includes(oldTargetId)) { + session.activeConnectionIdsAtShutdown = [ + ...new Set( + session.activeConnectionIdsAtShutdown.map((id) => (id === oldTargetId ? newTargetId : id)) + ) + ] + changed = true + } + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + if (record.connectionId === oldTargetId) { + record.connectionId = newTargetId + changed = true + } + } + return changed +} + +/** Re-point the sidebar host-scope arrays pinned to the old SSH host id. */ +export function migrateUiHostScopeSshTargetId( + ui: PersistedUIState, + oldTargetId: string, + newTargetId: string +): boolean { + const oldHostId = toSshExecutionHostId(oldTargetId) + const newHostId = toSshExecutionHostId(newTargetId) + let changed = false + if (ui.workspaceHostScope === oldHostId) { + ui.workspaceHostScope = newHostId + changed = true + } + if (ui.visibleWorkspaceHostIds?.includes(oldHostId)) { + ui.visibleWorkspaceHostIds = [ + ...new Set(ui.visibleWorkspaceHostIds.map((id) => (id === oldHostId ? newHostId : id))) + ] + changed = true + } + if (ui.workspaceHostOrder?.includes(oldHostId)) { + ui.workspaceHostOrder = [ + ...new Set(ui.workspaceHostOrder.map((id) => (id === oldHostId ? newHostId : id))) + ] + changed = true + } + return changed +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index e73d19646..90839885a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -323,8 +323,15 @@ export default function TerminalPane({ // Why: the target was removed entirely (a ghost) when it's no longer a known // SSH target. Reconnecting to it can only fail ("SSH target not found"), so // the overlay must offer to remove the workspace instead of Connect. + // Removal needs positive evidence — a removal tombstone label, or a + // successfully hydrated target list (even an empty one) that lacks the id. + // A client whose SSH state never hydrated (paired client on an older host) + // must not offer workspace removal off that ignorance. const sshReconnectTargetRemoved = useAppStore((store) => - sshReconnectTargetId ? !store.sshTargetLabels.has(sshReconnectTargetId) : false + sshReconnectTargetId + ? store.removedSshTargetLabels.has(sshReconnectTargetId) || + (store.sshTargetsHydrated && !store.sshTargetLabels.has(sshReconnectTargetId)) + : false ) useVisibleTerminalTabClaim({ isVisible, tabId }) diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx index 4653b88e4..3b891b342 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx @@ -32,12 +32,18 @@ vi.mock('@/i18n/i18n', () => ({ fallback.replace('{{value0}}', values?.value0 ?? '') })) -function installSshConnect(connect: ReturnType): void { +function installSshConnect( + connect: ReturnType, + overrides: Record> = {} +): void { Object.defineProperty(window, 'api', { configurable: true, value: { ssh: { - connect + connect, + listTargets: vi.fn().mockResolvedValue([]), + listRemovedTargetLabels: vi.fn().mockResolvedValue({}), + ...overrides } } }) @@ -110,6 +116,56 @@ describe('TerminalSshReconnectOverlay', () => { expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled() }) + it('resyncs target metadata after a failed connect so a stale overlay converges', async () => { + const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found')) + const listTargets = vi + .fn() + .mockResolvedValue([ + { id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' } + ]) + const listRemovedTargetLabels = vi.fn().mockResolvedValue({ 'ssh-dead': 'devbox (removed)' }) + installSshConnect(connect, { listTargets, listRemovedTargetLabels }) + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: 'Connect' })) + + // Why: the metadata refresh is what flips TerminalPane's targetRemoved + // derivation, replacing the failing Connect loop with the ghost-host UI. + await waitFor(() => { + expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox') + expect(useAppStore.getState().removedSshTargetLabels.get('ssh-dead')).toBe('devbox (removed)') + }) + }) + + it('still applies the target list when the removed-labels refresh fails', async () => { + const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found')) + const listTargets = vi + .fn() + .mockResolvedValue([ + { id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' } + ]) + const listRemovedTargetLabels = vi.fn().mockRejectedValue(new Error('unavailable')) + installSshConnect(connect, { listTargets, listRemovedTargetLabels }) + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: 'Connect' })) + + // Why: a removed-labels failure must not discard the refreshed target + // list — it alone is enough evidence for targetRemoved to converge. + await waitFor(() => { + expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox') + expect(useAppStore.getState().sshTargetsHydrated).toBe(true) + }) + }) + it('offers to remove the workspace (not Connect) when the SSH target was removed', async () => { const connect = vi.fn().mockResolvedValue(undefined) installSshConnect(connect) diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx index b2be21b66..8f3071de4 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx @@ -100,6 +100,17 @@ export function TerminalSshReconnectOverlay({ 'SSH connection failed' ) ) + // Why: a failed connect usually means the renderer's target metadata is + // stale (target removed, or re-added under a new id). Resync it so the + // overlay converges to the ghost/re-adopted state instead of offering + // the same failing Connect forever (STA-1468). Apply the target list + // first — a removed-labels failure must not discard it. + void (async () => { + const targets = await window.api.ssh.listTargets() + useAppStore.getState().setSshTargetsMetadata(targets) + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + })().catch(() => {}) } finally { if (mountedRef.current) { setConnecting(false) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 3b327b924..ac867b3c3 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -83,6 +83,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler' import { createRuntimeClientEventsSync } from './runtime-client-events-sync' import { detectLanguage } from '@/lib/language-detect' @@ -963,11 +964,26 @@ export function useIpcEvents(): void { } }) + // Assigned later in this effect, next to the ssh.onStateChanged wiring; + // events can't fire before that because subscriptions attach asynchronously. + let handleSshStateChangedEvent: ((data: { targetId: string; state: unknown }) => void) | null = + null + const handleRuntimeClientEvent = (environmentId: string, event: RuntimeClientEvent): void => { if (event.type === 'reposChanged') { runtimeProjectRefreshScheduler.request(environmentId) return } + if (event.type === 'sshStateChanged') { + // Why: only a paired web client routes its ssh.* API to the host that + // emitted this event, so only there can the store mirror host SSH state + // (STA-1468). Desktop clients own a local SSH surface a foreign + // runtime's targets would pollute, so they ignore it. + if (isPairedWebClientWindow()) { + handleSshStateChangedEvent?.({ targetId: event.targetId, state: event.state }) + } + return + } if (event.type === 'worktreesChanged') { void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => worktreeChangeRefreshQueue.enqueue({ repoId: event.repoId }) @@ -2667,49 +2683,49 @@ export function useIpcEvents(): void { let sshTargetStateEventId = 0 const latestSshTargetStateEventByTargetId = new Map() - unsubs.push( - window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => { - const store = useAppStore.getState() - const state = data.state as SshConnectionState - const stateEventId = ++sshTargetStateEventId - latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) - if (!store.sshTargetLabels.has(data.targetId)) { - // Why: targets added after boot aren't in the labels map, while - // removed targets can still race a final disconnect event. Confirm - // with main before mutating renderer state for an unknown target id. - window.api.ssh - .listTargets() - // Why: this refresh is now a deletion guard, not just a label fetch. - // Retry once so a transient IPC failure does not drop a real added-target event. - .catch(() => window.api.ssh.listTargets()) - .then((targets) => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { - return - } + handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => { + const store = useAppStore.getState() + const state = data.state as SshConnectionState + const stateEventId = ++sshTargetStateEventId + latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) + if (!store.sshTargetLabels.has(data.targetId)) { + // Why: targets added after boot aren't in the labels map, while + // removed targets can still race a final disconnect event. Confirm + // with main before mutating renderer state for an unknown target id. + window.api.ssh + .listTargets() + // Why: this refresh is now a deletion guard, not just a label fetch. + // Retry once so a transient IPC failure does not drop a real added-target event. + .catch(() => window.api.ssh.listTargets()) + .then((targets) => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { + return + } + latestSshTargetStateEventByTargetId.delete(data.targetId) + const latestStore = useAppStore.getState() + if (!targets.some((target) => target.id === data.targetId)) { + // Why: disconnect/state events can race after target removal. + // Treat absence from main's target list as deletion, not a new target. + latestStore.clearRemovedSshTargetState(data.targetId) + return + } + latestStore.setSshTargetsMetadata(targets) + applySshConnectionStateChange(data.targetId, state) + }) + .catch(() => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) { latestSshTargetStateEventByTargetId.delete(data.targetId) - const latestStore = useAppStore.getState() - if (!targets.some((target) => target.id === data.targetId)) { - // Why: disconnect/state events can race after target removal. - // Treat absence from main's target list as deletion, not a new target. - latestStore.clearRemovedSshTargetState(data.targetId) - return - } - latestStore.setSshTargetsMetadata(targets) applySshConnectionStateChange(data.targetId, state) - }) - .catch(() => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) { - latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state) - } - }) - return - } + } + }) + return + } - latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state) - }) - ) + latestSshTargetStateEventByTargetId.delete(data.targetId) + applySshConnectionStateChange(data.targetId, state) + } + + unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent)) let remoteWorkspaceClientId: string | null = null let remoteWorkspaceClientIdPromise: Promise | null = null diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 51827546a..0d44ddcbf 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -53,6 +53,7 @@ function isRuntimeClientEvent( return ( message.type === 'reposChanged' || message.type === 'worktreesChanged' || + message.type === 'sshStateChanged' || message.type === 'linearLinkedIssueUpdated' || message.type === 'activateWorktree' ) diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts index af8f17fd3..ef5ebf677 100644 --- a/src/renderer/src/store/slices/ssh.test.ts +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -146,7 +146,7 @@ describe('createSshSlice', () => { it('keeps SSH target label references stable when refreshed metadata is unchanged', () => { const store = createTestStore() const labels = new Map([['ssh-1', 'Remote']]) - store.setState({ sshTargetLabels: labels }) + store.setState({ sshTargetLabels: labels, sshTargetsHydrated: true }) const previousState = store.getState() store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote' }]) @@ -155,6 +155,18 @@ describe('createSshSlice', () => { expect(store.getState().sshTargetLabels).toBe(labels) }) + it('marks targets hydrated on the first load, even when the list is empty', () => { + const store = createTestStore() + expect(store.getState().sshTargetsHydrated).toBe(false) + + store.getState().setSshTargetsMetadata([]) + + // Why: an empty target set is still positive knowledge — the overlay's + // targetRemoved derivation may only trust absence after a real load. + expect(store.getState().sshTargetsHydrated).toBe(true) + expect(store.getState().sshTargetLabels.size).toBe(0) + }) + it('keeps SSH connection state references stable when duplicate state arrives', () => { const store = createTestStore() const sshConnectionStates = new Map([ diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 8b98b77b6..6b80e0543 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -37,6 +37,11 @@ export type SshSlice = { * tombstones). Lets ghost-host UI show a friendly name instead of the raw id * for a workspace still pinned to a deleted target. */ removedSshTargetLabels: Map + /** True once a target list actually loaded (even an empty one). Distinguishes + * "this client knows the target set" from "never hydrated" (e.g. a paired + * client on a host without the ssh RPC), so absence from sshTargetLabels + * only counts as removal evidence when this is set. */ + sshTargetsHydrated: boolean remoteWorkspaceHydratedTargetIds: Set remoteWorkspaceSyncStatusByTargetId: Record sshCredentialQueue: SshCredentialRequest[] @@ -71,6 +76,7 @@ export const createSshSlice: StateCreator = (set) => sshConnectionStates: new Map(), sshTargetLabels: new Map(), removedSshTargetLabels: new Map(), + sshTargetsHydrated: false, remoteWorkspaceHydratedTargetIds: new Set(), remoteWorkspaceSyncStatusByTargetId: {}, sshCredentialQueue: [], @@ -101,10 +107,13 @@ export const createSshSlice: StateCreator = (set) => setSshTargetsMetadata: (targets) => set((s) => { if (sshTargetLabelsEqual(s.sshTargetLabels, targets)) { - return s + // Why: an unchanged (even empty) list is still a successful load — the + // hydration flag must flip on the first fetch of an empty target set. + return s.sshTargetsHydrated ? s : { sshTargetsHydrated: true } } return { - sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])) + sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])), + sshTargetsHydrated: true } }), clearRemovedSshTargetState: (targetId) => diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 7696f454f..af231d806 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -35,6 +35,7 @@ import type { WorkspaceSessionState } from '../../../shared/types' import type { SkillDiscoveryResult } from '../../../shared/skills' +import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' import { getDefaultOnboardingState, getDefaultSettings, @@ -2659,19 +2660,51 @@ function createPtyApi(): NonNullable['pty']> { function createSshApi(): NonNullable['ssh']> { return { - listTargets: () => Promise.resolve([]), - listRemovedTargetLabels: () => Promise.resolve({}), + // Why: SSH connections are owned by the paired host. Read/connect route to + // its runtime RPC so remote worktrees can show real connection state and + // reconnect (STA-1468); target management stays desktop-only. + listTargets: async () => { + if (!requireActiveEnvironmentOrNull()) { + return [] + } + const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>('ssh.listTargets') + return targets + }, + listRemovedTargetLabels: async () => { + if (!requireActiveEnvironmentOrNull()) { + return {} + } + const { labels } = await callRuntimeResult<{ labels: Record }>( + 'ssh.listRemovedTargetLabels' + ) + return labels + }, addTarget: () => Promise.reject(new Error('SSH target management is unavailable in the web client.')), updateTarget: () => Promise.reject(new Error('SSH target management is unavailable in the web client.')), removeTarget: () => Promise.resolve(), importConfig: () => Promise.resolve([]), - connect: () => Promise.resolve(null), + connect: async (args) => { + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.connect', + { targetId: args.targetId } + ) + return state + }, disconnect: () => Promise.resolve(), terminateSessions: () => Promise.resolve(), resetRelay: () => Promise.resolve(), - getState: () => Promise.resolve(null), + getState: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return null + } + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.getState', + { targetId: args.targetId } + ) + return state + }, needsPassphrasePrompt: () => Promise.resolve(false), testConnection: () => Promise.resolve({ diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index f73c4010f..2ebc69005 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -4,10 +4,15 @@ import type { WorktreeSetupLaunch, WorktreeStartupLaunch } from './types' +import type { SshConnectionState } from './ssh-types' export type RuntimeClientEvent = | { type: 'reposChanged' } | { type: 'worktreesChanged'; repoId: string } + // Why: SSH connections live on the runtime host; paired clients have no IPC + // channel for ssh:state-changed, so without this event their reconnect + // overlays never learn the host connected (STA-1468). + | { type: 'sshStateChanged'; targetId: string; state: SshConnectionState } | { type: 'linearLinkedIssueUpdated' worktreeId: string