Fix SSH state for paired remote clients + complete target re-adoption sweep (STA-1468) (#7767)
* 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:<id>@@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 <help@stably.ai> * 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 <help@stably.ai> * 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 <help@stably.ai> * 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 <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
38e82b4fff
commit
864594ffdd
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, string> {
|
||||
return sshStore?.listRemovedTargetLabels() ?? {}
|
||||
}
|
||||
|
||||
export async function disconnectRegisteredSshTarget(targetId: string): Promise<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
})
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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> = {}): TerminalTab => ({
|
||||
id: 'tab1',
|
||||
ptyId: null,
|
||||
worktreeId: 'r1::/wt',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const makeSession = (overrides: Partial<WorkspaceSessionState> = {}): 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>): 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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:<targetId>@@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 "<old>" 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<string, string> | 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
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -32,12 +32,18 @@ vi.mock('@/i18n/i18n', () => ({
|
|||
fallback.replace('{{value0}}', values?.value0 ?? '')
|
||||
}))
|
||||
|
||||
function installSshConnect(connect: ReturnType<typeof vi.fn>): void {
|
||||
function installSshConnect(
|
||||
connect: ReturnType<typeof vi.fn>,
|
||||
overrides: Record<string, ReturnType<typeof vi.fn>> = {}
|
||||
): 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(
|
||||
<TerminalSshReconnectOverlay targetId="ssh-dead" targetLabel="devbox" status="disconnected" />
|
||||
)
|
||||
|
||||
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(
|
||||
<TerminalSshReconnectOverlay targetId="ssh-dead" targetLabel="devbox" status="disconnected" />
|
||||
)
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, number>()
|
||||
|
||||
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<string | null> | null = null
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ function isRuntimeClientEvent(
|
|||
return (
|
||||
message.type === 'reposChanged' ||
|
||||
message.type === 'worktreesChanged' ||
|
||||
message.type === 'sshStateChanged' ||
|
||||
message.type === 'linearLinkedIssueUpdated' ||
|
||||
message.type === 'activateWorktree'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
/** 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<string>
|
||||
remoteWorkspaceSyncStatusByTargetId: Record<string, RemoteWorkspaceSyncStatus>
|
||||
sshCredentialQueue: SshCredentialRequest[]
|
||||
|
|
@ -71,6 +76,7 @@ export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (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<AppState, [], [], SshSlice> = (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) =>
|
||||
|
|
|
|||
|
|
@ -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<Partial<PreloadApi>['pty']> {
|
|||
|
||||
function createSshApi(): NonNullable<Partial<PreloadApi>['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<string, string> }>(
|
||||
'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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue