fix(ssh): a host converted to its managed server keeps its workspaces' visit recency (#30015)

Converting an SSH host drops its relay-era rows, but recency stayed under
ssh:<target>|<id> keys, which the next startup prune discards because no row
matches them any more. Re-key that host's visits to runtime:<env>|<id> in the
same update, keeping the newer visit on a collision.
This commit is contained in:
Neil
2026-10-17 00:42:34 -07:00
committed by GitHub
parent 0f9c12db17
commit 824ca9545f
4 changed files with 85 additions and 1 deletions
@@ -143,6 +143,20 @@ describe('a host that just moved to its managed server', () => {
expect(state.folderWorkspaces.map((entry) => entry.executionHostId)).toEqual(['runtime:env-1'])
})
it('carries the host’s visit recency to the server instead of leaving it for the next prune', async () => {
store.setState({
lastVisitedAtByWorktreeId: { [`ssh:ssh-1|${worktreeId}`]: 500, [`local|${worktreeId}`]: 7 }
})
applySshManagedServerTransition('ssh-1', undefined, { kind: 'managed', environmentId: 'env-7' })
await vi.waitFor(() =>
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({
[`runtime:env-7|${worktreeId}`]: 500,
[`local|${worktreeId}`]: 7
})
)
})
it('moves the open workspace onto the server so its panes stop dialing the stopped relay', async () => {
store.setState({
worktreesByRepo: {
@@ -9,6 +9,7 @@ import {
import { translate } from '@/i18n/i18n'
import { useAppStore } from '../../store'
import { withoutConvertedSshHostRows } from '../../store/repos/converted-ssh-host-rows'
import { rekeyWorktreeVisitHost } from '@/lib/worktree-visit-recency'
import { preserveConvertedSshBrowserPages } from '../../store/repos/converted-ssh-browser-pages'
type ManagedServerStatus = SshConnectionState['managedServer']
@@ -154,7 +155,20 @@ async function loadManagedServerCatalogs(targetId: string, load: CatalogLoad): P
if (!isCurrent()) {
return
}
useAppStore.setState((state) => withoutConvertedSshHostRows(state, targetId))
useAppStore.setState((state) => {
// Why: the host's workspaces are the server's now; unmoved, the next prune drops their recency.
const lastVisitedAtByWorktreeId = rekeyWorktreeVisitHost(
state.lastVisitedAtByWorktreeId,
toSshExecutionHostId(targetId),
toRuntimeExecutionHostId(environmentId)
)
return {
...withoutConvertedSshHostRows(state, targetId),
...(lastVisitedAtByWorktreeId === state.lastVisitedAtByWorktreeId
? {}
: { lastVisitedAtByWorktreeId })
}
})
rehomeActiveWorkspace(targetId, environmentId)
if (!useAppStore.getState().startupWorktreeRefreshCompleted) {
rehomeAfterStartupScan(targetId, environmentId, isCurrent)
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
getWorktreeVisitKey,
getWorktreeVisitTimestamp,
rekeyWorktreeVisitHost,
removeWorktreeVisitEntries
} from './worktree-visit-recency'
@@ -42,3 +43,34 @@ describe('worktree visit recency identities', () => {
})
})
})
describe('rekeyWorktreeVisitHost', () => {
const id = 'repo::/srv/app'
it('moves one host’s visits to the host that now runs its workspaces, newer visit winning', () => {
const moved = rekeyWorktreeVisitHost(
{
[getWorktreeVisitKey(id, 'ssh:box')]: 300,
[getWorktreeVisitKey(id, 'runtime:env')]: 100,
[getWorktreeVisitKey('repo::/srv/other', 'ssh:box')]: 50,
[getWorktreeVisitKey(id, 'ssh:box-2')]: 70,
[getWorktreeVisitKey(id, 'local')]: 20,
[id]: 10
},
'ssh:box',
'runtime:env'
)
expect(moved).toEqual({
[getWorktreeVisitKey(id, 'runtime:env')]: 300,
[getWorktreeVisitKey('repo::/srv/other', 'runtime:env')]: 50,
[getWorktreeVisitKey(id, 'ssh:box-2')]: 70,
[getWorktreeVisitKey(id, 'local')]: 20,
[id]: 10
})
})
it('returns the same map when the host has no visits', () => {
const timestamps = { [getWorktreeVisitKey(id, 'local')]: 1 }
expect(rekeyWorktreeVisitHost(timestamps, 'ssh:box', 'runtime:env')).toBe(timestamps)
})
})
@@ -97,3 +97,27 @@ export function removeWorktreeVisitEntriesForTargets(
}
return changed ? next : (timestamps as Record<string, number>)
}
/**
* Move one host's recency to another host when the same workspaces now live there (an SSH host
* converted to its managed server). Bare legacy keys and other hosts are untouched; a collision
* keeps the newer visit.
*/
export function rekeyWorktreeVisitHost(
timestamps: Readonly<Record<string, number>>,
fromHostId: ExecutionHostId,
toHostId: ExecutionHostId
): Readonly<Record<string, number>> {
const prefix = `${fromHostId}|`
if (!Object.keys(timestamps).some((key) => key.startsWith(prefix))) {
return timestamps
}
const next: Record<string, number> = {}
for (const [key, timestamp] of Object.entries(timestamps)) {
const target = key.startsWith(prefix)
? composeWorktreeHostIdentity(toHostId, getWorktreeIdFromHostIdentity(key))
: key
next[target] = Math.max(next[target] ?? timestamp, timestamp)
}
return next
}