fix: reconcile SSH repo rows after host re-add (#8201)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-07-11 03:20:55 -07:00
committed by GitHub
co-authored by Orca
parent 6e3ebf534c
commit 25ecf2eea2
28 changed files with 1304 additions and 240 deletions
@@ -0,0 +1,131 @@
# SSH Repo Host Reconciliation Design
Date: 2026-07-10
## Problem
The proven flow is **Settings -> SSH -> remove host -> re-add the same host**. Main matches the
removal tombstone and `Store.reassignSshTargetId` moves the persisted repo from the old target ID to
the new target ID without changing `Repo.id`. A renderer catalog merge can then retain the cached
old-target row alongside the fetched new-target row because repo rows are keyed by execution host
and repo ID.
The stale row can route a terminal or destructive action to a removed SSH target. PR #7997 made
worktree deletion host-scoped and fail closed; this follow-up removes the superseded renderer row
without weakening that boundary.
## Why UUID Inference Is Unsafe
The same `Repo.id` can legitimately exist on multiple hosts. For example, a local checkout and a
checkout on an SSH server can share the repository UUID. Removing the SSH host without re-adding it
must keep the SSH ghost visible so the user can forget it. A local or runtime row with the same UUID
does not prove that the SSH row moved.
Main already has exact migration evidence. `readoptOrphanedWorkspacesForTarget` selects a removed
target tombstone, and `Store.reassignSshTargetId` knows which repo IDs it moved from that old target
to the new target. The renderer must consume this evidence instead of inferring migration from live
siblings or SSH target metadata.
## Ordering Gap
Main sends `repos:changed` before `ssh:addTarget` or `ssh:importConfig` returns. Either order is
possible in renderer state:
1. The catalog transaction can merge old and new rows before the add response supplies migration
evidence.
2. The add response can supply evidence while renderer state still contains only the old row.
Evidence must therefore remain pending until the corresponding new direct-SSH row arrives. At that
point the renderer removes only the mapped `(repoId, oldTargetId)` row and its old host setup, and
moves cached worktree ownership onto the new host.
## Non-Goals
- Do not re-key repo or worktree UUIDs or introduce compound serialized IDs.
- Do not infer host migration from labels, paths, target-list absence, or same-UUID siblings.
- Do not remove PR #7997's execution-host context from destructive operations.
- Do not change git-provider behavior or add provider-specific assumptions.
## Design
1. `Store.reassignSshTargetId` returns the exact repo IDs it moved instead of only a count.
2. Main records a list of `{ oldTargetId, newTargetId, repoIds }` for each add/import operation.
3. The SSH IPC add and import results return the changed targets plus this migration evidence. Main
still sends `repos:changed` when at least one repo moved.
4. Both desktop SSH management surfaces record the evidence in the repo slice immediately after the
invoke resolves. The web preload returns an empty evidence list because target management is
desktop-only.
5. The repo slice merges duplicate evidence and keeps unresolved repo IDs pending. Every local,
targeted-runtime, and all-host catalog transaction reconciles against the latest pending set.
6. Reconciliation requires the exact new direct-SSH owner to exist before pruning the exact old
direct-SSH owner. Local rows, runtime-owned rows, unrelated SSH hosts, and same-UUID siblings are
not considered evidence.
7. When a repo row is pruned, its matching `ProjectHostSetup` is removed. The desktop catalog owns
local and direct-SSH setups; `runtime:<environmentId>` setups remain authoritative on the remote
Orca server.
8. Cached visible and detected worktree rows for the migrated repo are moved from the exact old SSH
host to the new host. If a new-host worktree row already arrived, it wins and the stale duplicate
is discarded.
9. A host-scoped worktree response is ignored if its captured repo/host owner no longer exists.
Repo catalog transactions also reapply pending worktree migration, covering responses that land
after evidence arrives but before the new repo row.
```text
main: remove target -> tombstone
main: re-add matching identity -> reassign old target ID to new target ID
main: return exact (old target, new target, moved repo IDs)
renderer: retain evidence until catalog includes the exact new SSH owner
renderer: remove only the exact old SSH row/setup and migrate cached worktree ownership
```
## Safety Constraints
- A local or runtime sibling with the same UUID never supersedes an SSH ghost.
- A mapping is not consumed until the exact new direct-SSH row exists.
- Runtime-owned SSH rows and `runtime:<environmentId>` setup rows remain untouched.
- Catalog transactions reconcile inside the Zustand updater so overlapping responses use the latest
repos and pending evidence.
- Missing, offline, or unhydrated target metadata is not deletion or migration evidence.
## Test Plan
- Persistence returns exact migrated repo IDs and leaves other hosts untouched.
- Tombstone re-adoption returns exact old/new/repo mappings for manual add and multi-host import.
- SSH IPC returns the evidence, sends `repos:changed`, and clears operation-local evidence.
- Pure renderer tests prove exact pruning, pending evidence, unrelated-host preservation, runtime
ownership preservation, and evidence deduplication.
- Store tests cover both event orders: catalog first and evidence first.
- Remove-without-re-add keeps a forgettable SSH row and setup when a local repo shares its UUID.
- Old direct-SSH setups are removed only for proven migrations; runtime setups remain.
- Old/new cached worktree duplicates collapse to the authoritative new-host row, and an old-only
worktree row migrates to the exact new host.
- A deferred old-host worktree response cannot restore old visible or detected ownership after the
new repo catalog consumes the migration evidence.
- Targeted runtime and all-host catalog transactions reconcile against the latest state.
Run focused tests, typecheck, lint, `pnpm check:max-lines-ratchet`, and the production build required
by the repository.
## Electron Validation
Use an isolated profile and a throwaway Linux SSH target:
1. Add a repo through the throwaway SSH target.
2. Remove the SSH target and re-add the same identity without restarting the renderer.
3. Confirm exactly one SSH project row remains and its worktree opens.
4. Confirm Explorer lists the remote files without an ambiguous-host routing error.
5. Repeat the cycle and confirm the stale row does not return.
6. Keep an adjacent local project visible and confirm it remains unchanged.
There is no new control or copy. The behavioral evidence is the stable row, successful workspace
routing, and deterministic store assertions.
## Performance And Scope
Renderer reconciliation indexes direct SSH repo owners once, then processes each migrated repo ID
once. Setup cleanup is linear in current repos and setups. The change adds no polling, listeners,
subprocesses, or persistent renderer state. IPC payload growth is bounded by the repo IDs actually
migrated during the user-triggered add/import operation.
Windows and POSIX paths do not participate in reconciliation. SSH identity matching remains the
existing strict alias or host/user/port logic, and git-provider identity is not used.
+30 -3
View File
@@ -29,7 +29,12 @@ const {
addTarget: vi.fn(),
updateTarget: vi.fn(),
removeTarget: vi.fn(),
importFromSshConfig: vi.fn().mockReturnValue([])
importFromSshConfig: vi.fn().mockReturnValue([]),
lastRepoReadoptions: [] as {
oldTargetId: string
newTargetId: string
repoIds: string[]
}[]
},
mockConnectionManager: {
connect: vi.fn(),
@@ -293,6 +298,7 @@ describe('SSH IPC handlers', () => {
mockSshStore.updateTarget.mockReset()
mockSshStore.removeTarget.mockReset()
mockSshStore.importFromSshConfig.mockReset().mockReturnValue([])
mockSshStore.lastRepoReadoptions = []
mockWindow.webContents.send.mockReset()
mockStore.getSshRemotePtyLeases.mockReset().mockReturnValue([])
mockStore.markSshRemotePtyLease.mockReset()
@@ -379,7 +385,28 @@ describe('SSH IPC handlers', () => {
const result = await handlers.get('ssh:addTarget')!(null, { target: newTarget })
expect(mockSshStore.addTarget).toHaveBeenCalledWith(newTarget)
expect(result).toEqual(withId)
expect(result).toEqual({ target: withId, repoReadoptions: [] })
})
it('ssh:addTarget returns exact re-adoption evidence and refreshes repos', async () => {
const target = {
id: 'ssh-new',
label: 'Server',
host: 'server.example.com',
port: 22,
username: 'deploy'
}
const repoReadoptions = [
{ oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['repo-1'] }
]
mockSshStore.addTarget.mockReturnValue(target)
mockSshStore.lastRepoReadoptions = repoReadoptions
const result = await handlers.get('ssh:addTarget')!(null, { target })
expect(result).toEqual({ target, repoReadoptions })
expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed')
expect(mockSshStore.lastRepoReadoptions).toEqual([])
})
it('ssh:removeTarget calls store.removeTarget', async () => {
@@ -438,7 +465,7 @@ describe('SSH IPC handlers', () => {
mockSshStore.importFromSshConfig.mockReturnValue(imported)
const result = await handlers.get('ssh:importConfig')!(null, {})
expect(result).toEqual(imported)
expect(result).toEqual({ targets: imported, repoReadoptions: [] })
})
it('ssh:connect throws for unknown targetId', async () => {
+12 -9
View File
@@ -11,6 +11,7 @@ import type {
DetectedPort,
EnrichedDetectedPort,
SavedPortForward,
SshRepoReadoption,
SshTarget,
SshConnectionStatus,
SshConnectionState
@@ -744,15 +745,17 @@ export function registerSshHandlers(
// Why: SSH target add/import can re-adopt workspaces orphaned on a removed
// target id (see ssh-target-readoption). When that re-points repos, the
// renderer must refresh its repo list to surface the reattached workspaces.
function notifyReposChangedIfReadopted(): void {
if (!sshStore || sshStore.lastReadoptedRepoCount <= 0) {
return
function takeRepoReadoptions(): SshRepoReadoption[] {
if (!sshStore || sshStore.lastRepoReadoptions.length === 0) {
return []
}
sshStore.lastReadoptedRepoCount = 0
const repoReadoptions = sshStore.lastRepoReadoptions
sshStore.lastRepoReadoptions = []
const win = getCurrentMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('repos:changed')
}
return repoReadoptions
}
ipcMain.handle('ssh:listTargets', () => {
@@ -768,8 +771,8 @@ export function registerSshHandlers(
// Why: re-adding a removed host can re-adopt orphaned workspaces (re-point
// repos/worktrees off the dead id). Refresh the renderer's repo list so the
// reattached workspaces move from grey ghosts back onto the live host.
notifyReposChangedIfReadopted()
return target
const repoReadoptions = takeRepoReadoptions()
return { target, repoReadoptions }
})
ipcMain.handle(
@@ -784,9 +787,9 @@ export function registerSshHandlers(
})
ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => {
const result = sshStore!.importFromSshConfig(args)
notifyReposChangedIfReadopted()
return result
const targets = sshStore!.importFromSshConfig(args)
const repoReadoptions = takeRepoReadoptions()
return { targets, repoReadoptions }
})
// ── Connection lifecycle ───────────────────────────────────────────
+8 -8
View File
@@ -3317,9 +3317,9 @@ describe('Store', () => {
store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' }))
store.setWorktreeMeta('r1::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' })
const count = store.reassignSshTargetId('ssh-old', 'ssh-new')
const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new')
expect(count).toBe(1)
expect(repoIds).toEqual(['r1'])
const repo = store.getRepo('r1')!
expect(repo.connectionId).toBe('ssh-new')
expect(repo.executionHostId).toBe('ssh:ssh-new')
@@ -3338,9 +3338,9 @@ describe('Store', () => {
})
)
const count = store.reassignSshTargetId('ssh-old', 'ssh-new')
const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new')
expect(count).toBe(1)
expect(repoIds).toEqual(['ssh-repo'])
expect(store.getRepo('local-repo')!.connectionId).toBeUndefined()
expect(store.getRepo('ssh-repo')!.connectionId).toBe('ssh-new')
})
@@ -3350,9 +3350,9 @@ describe('Store', () => {
// SSH repos created via addRemoteRepoFromPath leave executionHostId unset.
store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old' }))
const count = store.reassignSshTargetId('ssh-old', 'ssh-new')
const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new')
expect(count).toBe(1)
expect(repoIds).toEqual(['r1'])
const repo = store.getRepo('r1')!
expect(repo.connectionId).toBe('ssh-new')
// Must not stamp an executionHostId where there wasn't one.
@@ -3365,8 +3365,8 @@ describe('Store', () => {
// must still be saved, not left in memory only.
store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' })
const count = store.reassignSshTargetId('ssh-old', 'ssh-new')
expect(count).toBe(0) // no repo matched
const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new')
expect(repoIds).toEqual([]) // no repo matched
store.flush()
const reloaded = await createStore()
+9 -9
View File
@@ -6141,16 +6141,16 @@ export class Store {
/**
* Re-point every repo and worktree meta pinned to a removed SSH target id
* onto a re-added target's id, so orphaned workspaces reattach to the live
* host instead of remaining un-removable ghosts. Returns the number of repos
* re-pointed (0 when nothing referenced the old id).
* host instead of remaining un-removable ghosts. Returns the ids of repos
* re-pointed (empty when nothing referenced the old id).
*/
reassignSshTargetId(oldTargetId: string, newTargetId: string): number {
reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] {
if (oldTargetId === newTargetId) {
return 0
return []
}
const oldHostId = toSshExecutionHostId(oldTargetId)
const newHostId = toSshExecutionHostId(newTargetId)
let repoCount = 0
const repoIds = new Set<string>()
for (const repo of this.state.repos) {
const matchesConnection = repo.connectionId === oldTargetId
const matchesHost = repo.executionHostId === oldHostId
@@ -6167,7 +6167,7 @@ export class Store {
if (matchesHost) {
repo.executionHostId = newHostId
}
repoCount++
repoIds.add(repo.id)
}
// Re-point worktree metas whose hostId pointed at the old SSH host.
let metaChanged = false
@@ -6238,13 +6238,13 @@ export class Store {
// 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) {
if (repoIds.size > 0 || setupsChanged) {
this.syncProjectHostSetupCompatibilityState()
}
if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) {
if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) {
this.scheduleSave()
}
return repoCount
return [...repoIds]
}
// ── SSH Remote PTY Leases ──────────────────────────────────────────
+41 -5
View File
@@ -64,7 +64,7 @@ function createMockStore() {
reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => {
reassignments.push({ oldTargetId, newTargetId })
// Pretend one repo referenced the old id.
return 1
return ['repo-1']
})
}
}
@@ -458,6 +458,39 @@ describe('SshConnectionStore', () => {
)
expect(result).toHaveLength(1)
})
it('reports every exact repo migration from a multi-host re-import', () => {
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old-a',
configHost: 'host-a',
host: 'host-a.example.com',
port: 22,
username: '',
label: 'host-a',
removedAt: 1
})
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old-b',
configHost: 'host-b',
host: 'host-b.example.com',
port: 22,
username: '',
label: 'host-b',
removedAt: 1
})
loadUserSshConfigMock.mockReturnValue([{ host: 'host-a' }, { host: 'host-b' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'host-a' }),
candidate({ configHost: 'host-b' })
])
sshStore.importFromSshConfig({ reAdopt: true })
expect(sshStore.lastRepoReadoptions).toEqual([
{ oldTargetId: 'ssh-old-a', newTargetId: 'tmp-host-a', repoIds: ['repo-1'] },
{ oldTargetId: 'ssh-old-b', newTargetId: 'tmp-host-b', repoIds: ['repo-1'] }
])
})
})
describe('re-adoption of orphaned workspaces', () => {
@@ -520,8 +553,9 @@ describe('SshConnectionStore', () => {
const [oldId, newId] = mockStore.reassignSshTargetId.mock.calls[0]
expect(oldId).toBe('ssh-old')
expect(newId).toMatch(/^ssh-/)
// Re-adoption count surfaces so the IPC layer can refresh the repo list.
expect(sshStore.lastReadoptedRepoCount).toBe(1)
expect(sshStore.lastRepoReadoptions).toEqual([
{ oldTargetId: 'ssh-old', newTargetId: newId, repoIds: ['repo-1'] }
])
})
// Why: drive the real remove→re-add path so the tombstone carries the
@@ -549,7 +583,9 @@ describe('SshConnectionStore', () => {
})
expect(mockStore.reassignSshTargetId).toHaveBeenCalledWith(added.id, readded.id)
expect(sshStore.lastReadoptedRepoCount).toBe(1)
expect(sshStore.lastRepoReadoptions).toEqual([
{ oldTargetId: added.id, newTargetId: readded.id, repoIds: ['repo-1'] }
])
})
// A different account on the SAME host must NOT re-adopt, even though both
@@ -592,7 +628,7 @@ describe('SshConnectionStore', () => {
})
expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled()
expect(sshStore.lastReadoptedRepoCount).toBe(0)
expect(sshStore.lastRepoReadoptions).toEqual([])
})
})
})
+8 -9
View File
@@ -1,5 +1,5 @@
import type { Store } from '../persistence'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshRepoReadoption, SshTarget } from '../../shared/ssh-types'
import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host'
import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser'
import {
@@ -44,14 +44,13 @@ export class SshConnectionStore {
this.store.addSshTarget(full)
// Why: re-adopt workspaces that were orphaned when the same host was removed
// (repos/worktrees still point at the old, now-dead target id). Track the
// count so the IPC layer knows whether to refresh the repo list.
this.lastReadoptedRepoCount = readoptOrphanedWorkspacesForTarget(this.store, full)
// exact migrations so IPC can refresh and renderer can prune only proven stale rows.
this.lastRepoReadoptions = readoptOrphanedWorkspacesForTarget(this.store, full)
return full
}
/** Repos re-adopted by the most recent addTarget/importFromSshConfig call.
* Lets the IPC layer broadcast repos:changed only when workspaces reattached. */
lastReadoptedRepoCount = 0
/** Exact migrations from the most recent add/import operation. */
lastRepoReadoptions: SshRepoReadoption[] = []
upsertRuntimeOwnedTarget(
runtimeId: string,
@@ -120,7 +119,7 @@ export class SshConnectionStore {
* manual targets. Returns the inserted and updated targets.
*/
importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] {
let readoptedThisImport = 0
const readoptions: SshRepoReadoption[] = []
// Why: the explicit Import action re-adopts every config host, so it clears
// all tombstones first. The passive on-open sync passes no flag and keeps
// deleted hosts suppressed.
@@ -212,12 +211,12 @@ export class SshConnectionStore {
// Why: a freshly-inserted config host may be one the user removed and is
// now re-importing — re-adopt its orphaned workspaces. Updated-in-place
// targets keep their id, so their repos were never orphaned.
readoptedThisImport += readoptOrphanedWorkspacesForTarget(this.store, inserted)
readoptions.push(...readoptOrphanedWorkspacesForTarget(this.store, inserted))
changed.push(inserted)
}
}
this.lastReadoptedRepoCount = readoptedThisImport
this.lastRepoReadoptions = readoptions
return changed
}
}
+24 -20
View File
@@ -28,7 +28,7 @@ function makeFakeStore(tombstones: RemovedSshTargetTombstone[]) {
},
reassignSshTargetId: (oldTargetId: string, newTargetId: string) => {
reassigned.push({ oldId: oldTargetId, newId: newTargetId })
return 1
return ['repo-1']
}
} as unknown as Store
return { store, reassigned, remaining: () => current }
@@ -49,40 +49,44 @@ const tombstone = (
describe('readoptOrphanedWorkspacesForTarget', () => {
it('re-adopts on matching configHost alias', () => {
const fake = makeFakeStore([tombstone({ configHost: 'devbox', host: 'changed.example.com' })])
const count = readoptOrphanedWorkspacesForTarget(
const readoptions = readoptOrphanedWorkspacesForTarget(
fake.store,
makeTarget({ configHost: 'devbox', host: 'now.example.com' })
)
expect(count).toBe(1)
expect(readoptions).toEqual([
{ oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['repo-1'] }
])
expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }])
expect(fake.remaining()).toHaveLength(0) // tombstone consumed
})
it('re-adopts on matching host+user+port when no alias', () => {
const fake = makeFakeStore([tombstone()])
const count = readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())
expect(count).toBe(1)
const readoptions = readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())
expect(readoptions).toEqual([
{ oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['repo-1'] }
])
expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }])
})
it('does not re-adopt when identity differs', () => {
const fake = makeFakeStore([tombstone({ host: 'other.example.com', username: 'root' })])
const count = readoptOrphanedWorkspacesForTarget(
const readoptions = readoptOrphanedWorkspacesForTarget(
fake.store,
makeTarget({ host: 'dev.example.com', username: 'tim' })
)
expect(count).toBe(0)
expect(readoptions).toEqual([])
expect(fake.reassigned).toEqual([])
expect(fake.remaining()).toHaveLength(1) // tombstone left for a future match
})
it('matches host/user/port case-insensitively', () => {
const fake = makeFakeStore([tombstone({ host: 'Dev.Example.COM', username: 'Tim' })])
const count = readoptOrphanedWorkspacesForTarget(
const readoptions = readoptOrphanedWorkspacesForTarget(
fake.store,
makeTarget({ host: 'dev.example.com', username: 'tim' })
)
expect(count).toBe(1)
expect(readoptions).toHaveLength(1)
})
it('does not match alias against a different host tuple', () => {
@@ -90,16 +94,16 @@ describe('readoptOrphanedWorkspacesForTarget', () => {
const fake = makeFakeStore([
tombstone({ configHost: 'prod', host: 'prod.example.com', username: 'root' })
])
const count = readoptOrphanedWorkspacesForTarget(
const readoptions = readoptOrphanedWorkspacesForTarget(
fake.store,
makeTarget({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' })
)
expect(count).toBe(0)
expect(readoptions).toEqual([])
})
it('is a no-op when there are no tombstones', () => {
const fake = makeFakeStore([])
expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toBe(0)
expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toEqual([])
})
it('does NOT re-adopt across two different aliases that share host+user+port', () => {
@@ -108,11 +112,11 @@ describe('readoptOrphanedWorkspacesForTarget', () => {
const fake = makeFakeStore([
tombstone({ configHost: 'prod-deploy', host: 'prod.example.com', username: 'deploy' })
])
const count = readoptOrphanedWorkspacesForTarget(
const readoptions = readoptOrphanedWorkspacesForTarget(
fake.store,
makeTarget({ configHost: 'prod-admin', host: 'prod.example.com', username: 'deploy' })
)
expect(count).toBe(0)
expect(readoptions).toEqual([])
expect(fake.reassigned).toEqual([])
expect(fake.remaining()).toHaveLength(1) // tombstone preserved
})
@@ -129,7 +133,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => {
username: 'alice'
})
])
const count = readoptOrphanedWorkspacesForTarget(
const readoptions = readoptOrphanedWorkspacesForTarget(
fake.store,
makeTarget({
configHost: 'dev.example.com',
@@ -138,7 +142,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => {
username: 'bob'
})
)
expect(count).toBe(0)
expect(readoptions).toEqual([])
expect(fake.reassigned).toEqual([])
})
@@ -151,7 +155,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => {
username: 'alice'
})
])
const count = readoptOrphanedWorkspacesForTarget(
const readoptions = readoptOrphanedWorkspacesForTarget(
fake.store,
makeTarget({
configHost: 'dev.example.com',
@@ -160,7 +164,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => {
username: 'alice'
})
)
expect(count).toBe(1)
expect(readoptions).toHaveLength(1)
})
it('still re-adopts via tuple when the re-added target has no alias', () => {
@@ -169,11 +173,11 @@ describe('readoptOrphanedWorkspacesForTarget', () => {
const fake = makeFakeStore([
tombstone({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' })
])
const count = readoptOrphanedWorkspacesForTarget(
const readoptions = readoptOrphanedWorkspacesForTarget(
fake.store,
makeTarget({ configHost: undefined, host: 'dev.example.com', username: 'tim' })
)
expect(count).toBe(1)
expect(readoptions).toHaveLength(1)
})
})
+18 -7
View File
@@ -1,5 +1,9 @@
import type { Store } from '../persistence'
import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types'
import type {
RemovedSshTargetTombstone,
SshRepoReadoption,
SshTarget
} from '../../shared/ssh-types'
/**
* Re-adoption of workspaces orphaned when an SSH target was removed.
@@ -56,14 +60,18 @@ function tombstoneMatches(tombstone: RemovedSshTargetTombstone, target: Identity
/**
* Re-point orphaned repos/worktrees onto `newTarget` if a removed target with
* the same host identity is tombstoned. Consumes the matching tombstone(s).
* Returns the number of repos re-adopted (0 when there was nothing to adopt).
* Returns exact repo/target migrations so the renderer can discard only rows
* proven to be stale after its per-host catalog merge.
*/
export function readoptOrphanedWorkspacesForTarget(store: Store, newTarget: SshTarget): number {
export function readoptOrphanedWorkspacesForTarget(
store: Store,
newTarget: SshTarget
): SshRepoReadoption[] {
const tombstones = store.getRemovedSshTargetTombstones()
if (tombstones.length === 0) {
return 0
return []
}
let readopted = 0
const readoptions: SshRepoReadoption[] = []
for (const tombstone of tombstones) {
// Why: a re-added target can't share the id of one that still exists, but
// guard anyway so we never re-point a live target onto itself.
@@ -74,12 +82,15 @@ export function readoptOrphanedWorkspacesForTarget(store: Store, newTarget: SshT
if (!tombstoneMatches(tombstone, newTarget)) {
continue
}
readopted += store.reassignSshTargetId(tombstone.oldTargetId, newTarget.id)
const repoIds = store.reassignSshTargetId(tombstone.oldTargetId, newTarget.id)
if (repoIds.length > 0) {
readoptions.push({ oldTargetId: tombstone.oldTargetId, newTargetId: newTarget.id, repoIds })
}
// Consume the tombstone whether or not it re-pointed anything: the host has
// returned, so the record has served its purpose.
store.removeRemovedSshTargetTombstone(tombstone.oldTargetId)
}
return readopted
return readoptions
}
/** Build a tombstone from a target about to be removed. */
+4 -2
View File
@@ -383,6 +383,8 @@ import type {
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type {
SshConnectionState,
SshConfigImportResult,
SshTargetAddResult,
SshTarget,
PortForwardEntry,
EnrichedDetectedPort
@@ -3001,13 +3003,13 @@ export type PreloadApi = {
// Removed-target id → last known label, for showing a friendly host name on
// workspaces still pinned to a target that no longer exists.
listRemovedTargetLabels: () => Promise<Record<string, string>>
addTarget: (args: { target: Omit<SshTarget, 'id'> }) => Promise<SshTarget>
addTarget: (args: { target: Omit<SshTarget, 'id'> }) => Promise<SshTargetAddResult>
updateTarget: (args: {
id: string
updates: Partial<Omit<SshTarget, 'id'>>
}) => Promise<SshTarget>
removeTarget: (args: { id: string }) => Promise<void>
importConfig: (args?: { reAdopt?: boolean }) => Promise<SshTarget[]>
importConfig: (args?: { reAdopt?: boolean }) => Promise<SshConfigImportResult>
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
disconnect: (args: { targetId: string }) => Promise<void>
terminateSessions: (args: { targetId: string }) => Promise<void>
+4 -2
View File
@@ -130,6 +130,8 @@ import {
} from '../shared/rich-markdown-context-menu'
import type {
SshConnectionState,
SshConfigImportResult,
SshTargetAddResult,
SshTarget,
PortForwardEntry,
EnrichedDetectedPort
@@ -4036,7 +4038,7 @@ const api = {
listRemovedTargetLabels: (): Promise<Record<string, string>> =>
ipcRenderer.invoke('ssh:listRemovedTargetLabels'),
addTarget: (args: { target: Omit<SshTarget, 'id'> }): Promise<SshTarget> =>
addTarget: (args: { target: Omit<SshTarget, 'id'> }): Promise<SshTargetAddResult> =>
ipcRenderer.invoke('ssh:addTarget', args),
updateTarget: (args: {
@@ -4047,7 +4049,7 @@ const api = {
removeTarget: (args: { id: string }): Promise<void> =>
ipcRenderer.invoke('ssh:removeTarget', args),
importConfig: (args?: { reAdopt?: boolean }): Promise<SshTarget[]> =>
importConfig: (args?: { reAdopt?: boolean }): Promise<SshConfigImportResult> =>
ipcRenderer.invoke('ssh:importConfig', args),
connect: (args: { targetId: string }): Promise<SshConnectionState | null> =>
@@ -19,9 +19,7 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
export { getSshPaneSearchEntries } from './ssh-search'
type SshPaneProps = Record<string, never>
export function SshPane(_props: SshPaneProps): React.JSX.Element {
export function SshPane(): React.JSX.Element {
const [targets, setTargets] = useState<SshTarget[]>([])
// Why: connection states are already hydrated and kept up-to-date by the
// global store (via useIpcEvents.ts). Reading from the store avoids
@@ -72,7 +70,8 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
// a sync failure must not block listing the already-known targets.
void (async () => {
try {
await window.api.ssh.importConfig()
const result = await window.api.ssh.importConfig()
useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions)
} catch {
// Surfaced on demand via the explicit Import button; ignore here.
}
@@ -92,9 +91,12 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
}
try {
await (editingId
? window.api.ssh.updateTarget({ id: editingId, updates: savePayload.payload.updates })
: window.api.ssh.addTarget({ target: savePayload.payload.target }))
if (editingId) {
await window.api.ssh.updateTarget({ id: editingId, updates: savePayload.payload.updates })
} else {
const result = await window.api.ssh.addTarget({ target: savePayload.payload.target })
useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions)
}
recordFeatureInteraction('ssh')
if (!mountedRef.current) {
return
@@ -288,17 +290,18 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
// Why: the explicit Import action re-adopts every ~/.ssh/config host,
// including ones the user previously deleted — clear tombstones so a
// deliberate re-import can bring them back.
const synced = (await window.api.ssh.importConfig({ reAdopt: true })) as SshTarget[]
const result = await window.api.ssh.importConfig({ reAdopt: true })
useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions)
recordFeatureInteraction('ssh')
if (mountedRef.current) {
if (synced.length === 0) {
if (result.targets.length === 0) {
toast('~/.ssh/config already in sync')
} else {
toast.success(
translate(
'auto.components.settings.SshPane.f8050f6307',
'Synced {{value0}} server{{value1}}',
{ value0: synced.length, value1: synced.length > 1 ? 's' : '' }
{ value0: result.targets.length, value1: result.targets.length > 1 ? 's' : '' }
)
)
}
@@ -39,6 +39,7 @@ export function AddRemoteHostDialog({
const [isSaving, setIsSaving] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata)
const recordSshRepoReadoptions = useAppStore((s) => s.recordSshRepoReadoptions)
const setRuntimeEnvironments = useAppStore((s) => s.setRuntimeEnvironments)
const refreshRuntimeEnvironmentStatus = useAppStore((s) => s.refreshRuntimeEnvironmentStatus)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
@@ -106,7 +107,8 @@ export function AddRemoteHostDialog({
setIsSaving(true)
try {
await window.api.ssh.addTarget({ target })
const result = await window.api.ssh.addTarget({ target })
recordSshRepoReadoptions(result.repoReadoptions)
await refreshSshTargetMetadata()
recordFeatureInteraction('ssh')
toast.success(
@@ -131,7 +133,9 @@ export function AddRemoteHostDialog({
const importSshConfig = async () => {
setIsImporting(true)
try {
const synced = (await window.api.ssh.importConfig()) as SshTarget[]
const result = await window.api.ssh.importConfig()
const synced = result.targets
recordSshRepoReadoptions(result.repoReadoptions)
await refreshSshTargetMetadata()
recordFeatureInteraction('ssh')
if (synced.length === 0) {
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { reconcileReadoptedSshWorktreesByRepo } from './readopted-ssh-worktree-rows'
const readoption = {
oldTargetId: 'ssh-old',
newTargetId: 'ssh-new',
repoIds: ['repo-1']
}
describe('reconcileReadoptedSshWorktreesByRepo', () => {
it('moves an old-host row onto the exact re-adopted host', () => {
const result = reconcileReadoptedSshWorktreesByRepo(
{ 'repo-1': [{ id: 'worktree-1', hostId: 'ssh:ssh-old' }] },
[readoption]
)
expect(result['repo-1']).toEqual([{ id: 'worktree-1', hostId: 'ssh:ssh-new' }])
})
it('keeps the already-fetched new-host row when both hosts are present', () => {
const result = reconcileReadoptedSshWorktreesByRepo(
{
'repo-1': [
{ id: 'worktree-1', hostId: 'ssh:ssh-old', label: 'stale' },
{ id: 'worktree-1', hostId: 'ssh:ssh-new', label: 'authoritative' }
]
},
[readoption]
)
expect(result['repo-1']).toEqual([
{ id: 'worktree-1', hostId: 'ssh:ssh-new', label: 'authoritative' }
])
})
it('leaves unrelated repo and host rows untouched', () => {
const rows: Record<string, { id: string; hostId: ExecutionHostId }[]> = {
'repo-1': [{ id: 'other-host', hostId: 'ssh:ssh-other' }],
'repo-2': [{ id: 'old-host', hostId: 'ssh:ssh-old' }]
}
expect(reconcileReadoptedSshWorktreesByRepo(rows, [readoption])).toBe(rows)
})
})
@@ -0,0 +1,52 @@
import type { SshRepoReadoption } from '../../../../shared/ssh-types'
import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
type HostedWorktree = {
id: string
hostId?: ExecutionHostId
}
function reconcileRows<T extends HostedWorktree>(
rows: readonly T[],
oldHostId: ExecutionHostId,
newHostId: ExecutionHostId
): T[] {
const owners = new Set(
rows.filter((row) => row.hostId !== oldHostId).map((row) => `${row.id}\0${row.hostId ?? ''}`)
)
const result: T[] = []
for (const row of rows) {
if (row.hostId !== oldHostId) {
result.push(row)
continue
}
const key = `${row.id}\0${newHostId}`
if (!owners.has(key)) {
result.push({ ...row, hostId: newHostId })
owners.add(key)
}
}
return result
}
export function reconcileReadoptedSshWorktreesByRepo<T extends HostedWorktree>(
rowsByRepo: Readonly<Record<string, readonly T[]>>,
readoptions: readonly SshRepoReadoption[]
): Record<string, T[]> {
let result = rowsByRepo as Record<string, T[]>
for (const readoption of readoptions) {
const oldHostId = toSshExecutionHostId(readoption.oldTargetId)
const newHostId = toSshExecutionHostId(readoption.newTargetId)
for (const repoId of readoption.repoIds) {
const rows = result[repoId]
if (!rows?.some((row) => row.hostId === oldHostId)) {
continue
}
if (result === rowsByRepo) {
result = { ...result }
}
result[repoId] = reconcileRows(rows, oldHostId, newHostId)
}
}
return result
}
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { createTestStore } from './store-test-helpers'
const localRepo: Repo = {
id: 'local-repo',
path: '/local',
displayName: 'Local',
badgeColor: '#000',
addedAt: 1
}
const remoteRepo: Repo = {
id: 'remote-repo',
path: '/remote',
displayName: 'Remote',
badgeColor: '#000',
addedAt: 1
}
const runtimeEnvironmentCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
runtimeEnvironmentCall.mockReset()
vi.stubGlobal('window', {
api: {
repos: { list: vi.fn().mockResolvedValue([localRepo]) },
projects: {
list: vi.fn().mockResolvedValue([]),
listHostSetups: vi.fn().mockResolvedValue([])
},
runtimeEnvironments: {
list: vi.fn().mockResolvedValue([{ id: 'env-1', name: 'Remote' }]),
call: (args: RuntimeEnvironmentCallRequest) =>
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
}
},
dispatchEvent: vi.fn()
})
})
describe('fetchReposForAllHosts generation', () => {
it('does not validate repo UI from a superseded refresh', async () => {
let resolveOlderRemote!: (value: unknown) => void
let resolveNewerRemote!: (value: unknown) => void
let markOlderRemoteStarted!: () => void
let markNewerRemoteStarted!: () => void
const olderRemote = new Promise((resolve) => {
resolveOlderRemote = resolve
})
const newerRemote = new Promise((resolve) => {
resolveNewerRemote = resolve
})
const olderRemoteStarted = new Promise<void>((resolve) => {
markOlderRemoteStarted = resolve
})
const newerRemoteStarted = new Promise<void>((resolve) => {
markNewerRemoteStarted = resolve
})
let repoListCalls = 0
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method !== 'repo.list') {
return {
id: 'rpc-other',
ok: true,
result: { projects: [], setups: [] },
_meta: { runtimeId: 'runtime-remote' }
}
}
repoListCalls++
if (repoListCalls === 1) {
markOlderRemoteStarted()
return olderRemote
}
markNewerRemoteStarted()
return newerRemote
})
const store = createTestStore()
store.setState({
activeRepoId: 'remote-repo',
filterRepoIds: ['remote-repo'],
trustedOrcaHooks: { 'remote-repo': { all: { approvedAt: 1 } } }
})
const response = {
id: 'rpc-repo-list',
ok: true,
result: { repos: [remoteRepo] },
_meta: { runtimeId: 'runtime-remote' }
}
const olderFetch = store.getState().fetchReposForAllHosts()
await olderRemoteStarted
const newerFetch = store.getState().fetchReposForAllHosts()
await newerRemoteStarted
resolveOlderRemote(response)
await olderFetch
expect(store.getState().activeRepoId).toBe('remote-repo')
expect(store.getState().filterRepoIds).toEqual(['remote-repo'])
expect(store.getState().trustedOrcaHooks).toEqual({
'remote-repo': { all: { approvedAt: 1 } }
})
resolveNewerRemote(response)
await newerFetch
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo'])
})
})
@@ -286,7 +286,87 @@ function expectSharedProjectMetadata(projects: readonly Project[], sharedProject
expect(sharedProject?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' })
}
function directSshRepo(targetId: string): Repo {
return {
...localRepo,
id: 're-adopted-repo',
connectionId: targetId,
executionHostId: `ssh:${targetId}`
}
}
const repoReadoption = {
oldTargetId: 'ssh-old',
newTargetId: 'ssh-new',
repoIds: ['re-adopted-repo']
}
describe('fetchReposForAllHosts', () => {
it('prunes a superseded direct SSH row during a local catalog transaction', async () => {
const staleRepo = directSshRepo('ssh-old')
const liveRepo = directSshRepo('ssh-new')
reposList.mockResolvedValue([liveRepo])
const store = createTestStore()
store.setState({
repos: [staleRepo],
pendingSshRepoReadoptions: [repoReadoption]
})
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
expect(store.getState().repos).toEqual([liveRepo])
})
it('preserves an old direct SSH row when no re-adoption evidence exists', async () => {
const staleRepo = directSshRepo('ssh-old')
const liveRepo = directSshRepo('ssh-new')
reposList.mockResolvedValue([liveRepo])
const store = createTestStore()
store.setState({ repos: [staleRepo] })
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
expect(store.getState().repos).toEqual([staleRepo, liveRepo])
})
it('reconciles a targeted runtime response against the latest repo state', async () => {
const staleRepo = directSshRepo('ssh-old')
const liveRepo = directSshRepo('ssh-new')
let resolveRepoList!: (response: unknown) => void
const repoListResponse = new Promise((resolve) => (resolveRepoList = resolve))
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method === 'repo.list') {
return repoListResponse
}
return {
id: 'rpc-other',
ok: true,
result: { projects: [], setups: [] },
_meta: { runtimeId: 'runtime-remote' }
}
})
const store = createTestStore()
store.setState({
repos: [staleRepo],
pendingSshRepoReadoptions: [repoReadoption]
})
const load = store.getState().fetchRuntimeEnvironmentRepos('env-1')
store.setState({ repos: [staleRepo, liveRepo] })
resolveRepoList({
id: 'rpc-repo-list',
ok: true,
result: { repos: [remoteRepo] },
_meta: { runtimeId: 'runtime-remote' }
})
await load
expect(store.getState().repos).toEqual([
liveRepo,
{ ...remoteRepo, executionHostId: 'runtime:env-1' }
])
})
it('loads local + all configured runtime environments even when a remote env is active', async () => {
// Why: a cold start that restored a remote workspace leaves the remote
// environment active. The active-host-only fetchRepos would drop local
@@ -0,0 +1,291 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
import { createTestStore } from './store-test-helpers'
const repoId = 're-adopted-repo'
const projectId = `repo:${repoId}`
function directSshRepo(targetId: string): Repo {
return {
id: repoId,
path: '/srv/repo',
displayName: 'Re-adopted repo',
badgeColor: '#000',
addedAt: 1,
connectionId: targetId,
executionHostId: `ssh:${targetId}`
}
}
function directSshSetup(targetId: string): ProjectHostSetup {
return {
id: `setup-${targetId}`,
projectId,
hostId: `ssh:${targetId}`,
repoId,
path: '/srv/repo',
displayName: 'Re-adopted repo',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1,
connectionId: targetId,
executionHostId: `ssh:${targetId}`
}
}
function directSshWorktree(targetId: string, displayName = 'main'): Worktree {
return {
id: `${repoId}::/srv/repo`,
repoId,
hostId: `ssh:${targetId}`,
path: '/srv/repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true,
displayName,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1
}
}
const project: Project = {
id: projectId,
displayName: 'Re-adopted repo',
badgeColor: '#000',
sourceRepoIds: [repoId],
createdAt: 1,
updatedAt: 1
}
const reposList = vi.fn()
const projectsList = vi.fn()
const setupsList = vi.fn()
const worktreesListDetected = vi.fn()
beforeEach(() => {
reposList.mockReset()
projectsList.mockReset()
setupsList.mockReset()
worktreesListDetected.mockReset()
vi.stubGlobal('window', {
api: {
repos: { list: reposList },
projects: { list: projectsList, listHostSetups: setupsList },
worktrees: { listDetected: worktreesListDetected }
},
dispatchEvent: vi.fn()
})
})
describe('SSH repo host reconciliation', () => {
const readoption = { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repoId] }
it('reconciles when repos:changed finishes before add-target evidence arrives', async () => {
const staleRepo = directSshRepo('ssh-old')
const liveRepo = directSshRepo('ssh-new')
reposList.mockResolvedValue([liveRepo])
projectsList.mockResolvedValue([project])
setupsList.mockResolvedValue([directSshSetup('ssh-new')])
const store = createTestStore()
store.setState({
repos: [staleRepo],
projectHostSetups: [directSshSetup('ssh-old')],
worktreesByRepo: {
[repoId]: [
directSshWorktree('ssh-old', 'stale'),
directSshWorktree('ssh-new', 'authoritative')
]
}
})
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
expect(store.getState().repos).toEqual([staleRepo, liveRepo])
store.getState().recordSshRepoReadoptions([readoption])
expect(store.getState().repos).toEqual([liveRepo])
expect(store.getState().projectHostSetups).toEqual([directSshSetup('ssh-new')])
expect(store.getState().worktreesByRepo[repoId]).toEqual([
directSshWorktree('ssh-new', 'authoritative')
])
})
it('keeps evidence pending until repos:changed delivers the new-host row', async () => {
const liveRepo = directSshRepo('ssh-new')
const runtimeSetup: ProjectHostSetup = {
...directSshSetup('ssh-runtime'),
id: 'runtime-setup',
hostId: 'runtime:env-1',
executionHostId: 'runtime:env-1'
}
reposList.mockResolvedValue([liveRepo])
projectsList.mockResolvedValue([project])
setupsList.mockResolvedValue([directSshSetup('ssh-new')])
const store = createTestStore()
store.setState({
repos: [directSshRepo('ssh-old')],
projectHostSetups: [directSshSetup('ssh-old'), runtimeSetup]
})
store.getState().recordSshRepoReadoptions([readoption])
expect(store.getState().repos).toEqual([directSshRepo('ssh-old')])
expect(store.getState().pendingSshRepoReadoptions).toEqual([readoption])
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
expect(store.getState().repos).toEqual([liveRepo])
expect(store.getState().projectHostSetups).toEqual(
expect.arrayContaining([directSshSetup('ssh-new'), runtimeSetup])
)
expect(store.getState().projectHostSetups).toHaveLength(2)
expect(store.getState().pendingSshRepoReadoptions).toEqual([])
})
it('drops an older all-host catalog that resolves after evidence is consumed', async () => {
const staleRepo = directSshRepo('ssh-old')
const liveRepo = directSshRepo('ssh-new')
let resolveOldSetups!: (setups: ProjectHostSetup[]) => void
let markOldSetupStarted!: () => void
const oldSetups = new Promise<ProjectHostSetup[]>((resolve) => {
resolveOldSetups = resolve
})
const oldSetupStarted = new Promise<void>((resolve) => {
markOldSetupStarted = resolve
})
reposList.mockResolvedValueOnce([staleRepo]).mockResolvedValueOnce([liveRepo])
projectsList.mockResolvedValue([project])
setupsList
.mockImplementationOnce(() => {
markOldSetupStarted()
return oldSetups
})
.mockResolvedValueOnce([directSshSetup('ssh-new')])
const store = createTestStore()
store.setState({ repos: [staleRepo], projectHostSetups: [directSshSetup('ssh-old')] })
store.getState().recordSshRepoReadoptions([readoption])
const olderFetch = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
await oldSetupStarted
const newerFetch = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
await newerFetch
resolveOldSetups([directSshSetup('ssh-old')])
await olderFetch
expect(store.getState().repos).toEqual([liveRepo])
expect(store.getState().projectHostSetups).toEqual([directSshSetup('ssh-new')])
})
it('rejects an old-host worktree response that resolves after re-adoption', async () => {
const staleWorktree = directSshWorktree('ssh-old', 'stale')
let resolveOldWorktrees!: (value: unknown) => void
const oldWorktrees = new Promise((resolve) => {
resolveOldWorktrees = resolve
})
worktreesListDetected.mockReturnValueOnce(oldWorktrees)
reposList.mockResolvedValue([directSshRepo('ssh-new')])
projectsList.mockResolvedValue([project])
setupsList.mockResolvedValue([directSshSetup('ssh-new')])
const store = createTestStore()
store.setState({
repos: [directSshRepo('ssh-old')],
worktreesByRepo: { [repoId]: [staleWorktree] },
detectedWorktreesByRepo: {
[repoId]: {
repoId,
authoritative: true,
source: 'git',
worktrees: [
{ ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true }
]
}
}
})
const staleFetch = store.getState().fetchWorktrees(repoId)
await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalled())
store.getState().recordSshRepoReadoptions([readoption])
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
resolveOldWorktrees({
repoId,
authoritative: true,
source: 'git',
worktrees: [
{ ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true }
]
})
await staleFetch
expect(store.getState().worktreesByRepo[repoId]).toEqual([
directSshWorktree('ssh-new', 'stale')
])
expect(store.getState().detectedWorktreesByRepo[repoId].worktrees).toEqual([
{
...directSshWorktree('ssh-new', 'stale'),
ownership: 'orca-managed',
selectedCheckout: false,
visible: true
}
])
})
it('rejects a worktree response after its final repo owner is removed', async () => {
const staleWorktree = directSshWorktree('ssh-old', 'stale')
let resolveOldWorktrees!: (value: unknown) => void
const oldWorktrees = new Promise((resolve) => {
resolveOldWorktrees = resolve
})
worktreesListDetected.mockReturnValueOnce(oldWorktrees)
const store = createTestStore()
store.setState({ repos: [directSshRepo('ssh-old')] })
const staleFetch = store.getState().fetchWorktrees(repoId)
await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalled())
store.setState({ repos: [], worktreesByRepo: {}, detectedWorktreesByRepo: {} })
resolveOldWorktrees({
repoId,
authoritative: true,
source: 'git',
worktrees: [
{ ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true }
]
})
await staleFetch
expect(store.getState().worktreesByRepo[repoId]).toBeUndefined()
expect(store.getState().detectedWorktreesByRepo[repoId]).toBeUndefined()
})
it('preserves a forgettable SSH ghost when a local repo shares its UUID', async () => {
const localRepo: Repo = {
...directSshRepo('ssh-old'),
path: '/local/repo',
connectionId: undefined,
executionHostId: undefined
}
const oldRepo = directSshRepo('ssh-old')
reposList.mockResolvedValue([localRepo, oldRepo])
projectsList.mockResolvedValue([project])
setupsList.mockResolvedValue([directSshSetup('ssh-old')])
const store = createTestStore()
store.setState({ repos: [localRepo, oldRepo], projectHostSetups: [directSshSetup('ssh-old')] })
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }, oldRepo])
expect(store.getState().projectHostSetups).toEqual(
expect.arrayContaining([directSshSetup('ssh-old')])
)
expect(store.getState().projectHostSetups).toHaveLength(2)
})
})
+193 -55
View File
@@ -5,6 +5,7 @@ auditing and preserving. */
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import type { AppState } from '../types'
import type { SshRepoReadoption } from '../../../../shared/ssh-types'
import type {
GlobalSettings,
Project,
@@ -48,7 +49,12 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
import { reconcileFetchedRepos } from './repo-identity-reconcile'
import { pruneSupersededSshRepoRows } from './superseded-ssh-repo-rows'
import {
mergeSshRepoReadoptions,
reconcileReadoptedSshRepoRows,
type SshRepoReconciliation
} from './superseded-ssh-repo-rows'
import { reconcileReadoptedSshWorktreesByRepo } from './readopted-ssh-worktree-rows'
import { splitRepoReorderByHost } from './repo-reorder-host-split'
import { omitSparsePresetsForRepos } from './sparse-presets'
import {
@@ -700,8 +706,19 @@ function mergeFetchedProjectCompatibilityForHost({
repos: readonly Repo[]
hostId: string
}): Pick<RepoSlice, 'projects' | 'projectHostSetups'> {
const fetchedSetupsForHost = fetched.projectHostSetups.filter((setup) => setup.hostId === hostId)
const preservedSetups = previous.projectHostSetups.filter((setup) => setup.hostId !== hostId)
const setupBelongsToFetchedCatalog = (setup: ProjectHostSetup): boolean => {
if (hostId !== LOCAL_EXECUTION_HOST_ID) {
return setup.hostId === hostId
}
const owner = parseExecutionHostId(setup.hostId)
// Why: desktop persistence owns local and direct-SSH setups; runtime setups
// remain authoritative on their remote Orca server.
return setup.hostId === LOCAL_EXECUTION_HOST_ID || owner?.kind === 'ssh'
}
const fetchedSetupsForHost = fetched.projectHostSetups.filter(setupBelongsToFetchedCatalog)
const preservedSetups = previous.projectHostSetups.filter(
(setup) => !setupBelongsToFetchedCatalog(setup)
)
const projectHostSetups = mergeProjectHostSetupsByOwner(preservedSetups, fetchedSetupsForHost)
const previousProjectById = new Map(previous.projects.map((project) => [project.id, project]))
const reposById = getReposById(repos)
@@ -923,15 +940,78 @@ function mergeFetchedRepoCatalog(
currentRepos: readonly Repo[]
): {
repos: Repo[]
projectCompatibility: Pick<RepoSlice, 'projects' | 'projectHostSetups'>
projectHostSetupCompatibility: ProjectHostSetupProjection
hostId: ReturnType<typeof getRuntimeTargetHostId>
} {
const repos = mergeFetchedReposForHost(currentRepos, catalog.repos, catalog.hostId)
const projectCompatibility = mergeProjectHostSetupCompatibility(
projectCompatibilityFromRepos(repos),
catalog.projectHostSetupCompatibility
return {
repos,
projectHostSetupCompatibility: catalog.projectHostSetupCompatibility,
hostId: catalog.hostId
}
}
function reconcileSupersededSshRepos(
repos: readonly Repo[],
state: Pick<AppState, 'pendingSshRepoReadoptions'>
): SshRepoReconciliation {
return reconcileReadoptedSshRepoRows(repos, state.pendingSshRepoReadoptions)
}
function filterSetupsForPrunedRepoRows(
setups: readonly ProjectHostSetup[],
mergedRepos: readonly Repo[],
reconciledRepos: readonly Repo[]
): ProjectHostSetup[] {
const survivingOwners = new Set(
reconciledRepos.map((repo) => `${getRepoExecutionHostId(repo)}:${repo.id}`)
)
return { repos, projectCompatibility, hostId: catalog.hostId }
const prunedOwners = new Set(
mergedRepos
.filter((repo) => !survivingOwners.has(`${getRepoExecutionHostId(repo)}:${repo.id}`))
.map((repo) => `${getRepoExecutionHostId(repo)}:${repo.id}`)
)
if (prunedOwners.size === 0) {
return [...setups]
}
return setups.filter(
(setup) => !setup.repoId || !prunedOwners.has(`${setup.hostId}:${setup.repoId}`)
)
}
function reconcileReadoptedSshWorktreeState(
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'sortEpoch'>,
readoptions: readonly SshRepoReadoption[]
): Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'sortEpoch'> {
const worktreesByRepo = reconcileReadoptedSshWorktreesByRepo(state.worktreesByRepo, readoptions)
const detectedRows = Object.fromEntries(
Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => [
repoId,
result.worktrees
])
)
const reconciledDetectedRows = reconcileReadoptedSshWorktreesByRepo(detectedRows, readoptions)
const detectedWorktreesByRepo =
reconciledDetectedRows === detectedRows
? state.detectedWorktreesByRepo
: Object.fromEntries(
Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => [
repoId,
{ ...result, worktrees: reconciledDetectedRows[repoId] }
])
)
return {
worktreesByRepo,
detectedWorktreesByRepo,
sortEpoch: worktreesByRepo === state.worktreesByRepo ? state.sortEpoch : state.sortEpoch + 1
}
}
function projectCompatibilityForReconciledRepos(
repos: readonly Repo[],
fetched: ProjectHostSetupProjection
): Pick<RepoSlice, 'projects' | 'projectHostSetups'> {
return mergeProjectHostSetupCompatibility(projectCompatibilityFromRepos(repos), fetched)
}
function filterTrustedOrcaHooksToValidRepos(
@@ -969,17 +1049,6 @@ function clearRestoredFolderWorkspaceSessionOwners(
return next
}
async function fetchReposForTarget(
target: ReturnType<typeof getActiveRuntimeTarget>,
currentRepos: readonly Repo[]
): Promise<{
repos: Repo[]
projectCompatibility: Pick<RepoSlice, 'projects' | 'projectHostSetups'>
hostId: ReturnType<typeof getRuntimeTargetHostId>
}> {
return mergeFetchedRepoCatalog(await fetchRepoCatalogForTarget(target), currentRepos)
}
async function fetchProjectGroupCatalogForTarget(
target: ReturnType<typeof getActiveRuntimeTarget>
): Promise<FetchedProjectGroupCatalog> {
@@ -1319,6 +1388,8 @@ export type RepoSlice = {
activeRepoId: string | null
// Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020).
reposFetchGeneration: number
pendingSshRepoReadoptions: SshRepoReadoption[]
recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void
fetchRepos: () => Promise<void>
fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise<Repo[]>
@@ -1440,6 +1511,32 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
folderWorkspacePathStatuses: {},
activeRepoId: null,
reposFetchGeneration: 0,
pendingSshRepoReadoptions: [],
recordSshRepoReadoptions: (readoptions) =>
set((s) => {
const pendingSshRepoReadoptions = mergeSshRepoReadoptions(
s.pendingSshRepoReadoptions,
readoptions
)
const reconciliation = reconcileReadoptedSshRepoRows(s.repos, pendingSshRepoReadoptions)
const repos = reconciliation.repos
const worktreeState = reconcileReadoptedSshWorktreeState(s, pendingSshRepoReadoptions)
const projectHostSetups = filterSetupsForPrunedRepoRows(s.projectHostSetups, s.repos, repos)
const compatibility = mergeProjectHostSetupCompatibility(
projectCompatibilityFromRepos(repos),
{
projects: s.projects,
setups: projectHostSetups
}
)
return {
repos,
pendingSshRepoReadoptions: reconciliation.pendingReadoptions,
...worktreeState,
...compatibility
}
}),
fetchRepos: async () => {
// Why: overlapping repos:changed fetches can resolve out of order; an earlier
@@ -1451,36 +1548,45 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
})
try {
const target = getActiveRuntimeTarget(get().settings)
const {
repos: reconciledRepos,
projectCompatibility,
hostId
} = await fetchReposForTarget(target, get().repos)
const catalog = await fetchRepoCatalogForTarget(target)
// A newer fetchRepos superseded us while we awaited — drop this stale result.
if (get().reposFetchGeneration !== generation) {
return
}
let finalizedHostRepos: Repo[] = []
set((s) => {
// Why: after re-adoption re-points a repo onto a re-added SSH target, the
// per-host merge leaves the stale row on the old (removed) target id — a
// ghost a terminal pane can bind to and fail with "SSH target not found".
// Drop rows on unknown SSH targets that a live-host sibling supersedes.
const prunedRepos = pruneSupersededSshRepoRows(
reconciledRepos,
new Set(s.sshTargetLabels.keys())
)
const result = mergeFetchedRepoCatalog(catalog, s.repos)
const reconciliation = reconcileSupersededSshRepos(result.repos, s)
const prunedRepos = reconciliation.repos
const validRepoIds = new Set(prunedRepos.map((repo) => repo.id))
const projectCompatibility = projectCompatibilityForReconciledRepos(
prunedRepos,
catalog.projectHostSetupCompatibility
)
const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({
previous: {
projects: s.projects,
projectHostSetups: s.projectHostSetups
projectHostSetups: filterSetupsForPrunedRepoRows(
s.projectHostSetups,
result.repos,
prunedRepos
)
},
fetched: projectCompatibility,
repos: prunedRepos,
hostId
hostId: result.hostId
})
finalizedHostRepos = prunedRepos.filter(
(repo) => getRepoExecutionHostId(repo) === result.hostId
)
return {
repos: prunedRepos,
pendingSshRepoReadoptions: reconciliation.pendingReadoptions,
...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions),
...mergedProjectCompatibility,
folderWorkspacePathStatuses: {},
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
@@ -1491,10 +1597,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
)
}
})
scheduleSafeAutoForkSync(
get,
reconciledRepos.filter((repo) => getRepoExecutionHostId(repo) === hostId)
)
scheduleSafeAutoForkSync(get, finalizedHostRepos)
} catch (err) {
console.error('Failed to fetch repos:', err)
}
@@ -1503,24 +1606,37 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
fetchRuntimeEnvironmentRepos: async (environmentId) => {
try {
const target = { kind: 'environment' as const, environmentId }
const {
repos: reconciledRepos,
projectCompatibility,
hostId
} = await fetchReposForTarget(target, get().repos)
const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id))
const catalog = await fetchRepoCatalogForTarget(target)
let finalizedHostRepos: Repo[] = []
set((s) => {
const result = mergeFetchedRepoCatalog(catalog, s.repos)
const reconciliation = reconcileSupersededSshRepos(result.repos, s)
const finalizedRepos = reconciliation.repos
const validRepoIds = new Set(finalizedRepos.map((repo) => repo.id))
const projectCompatibility = projectCompatibilityForReconciledRepos(
finalizedRepos,
catalog.projectHostSetupCompatibility
)
const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({
previous: {
projects: s.projects,
projectHostSetups: s.projectHostSetups
projectHostSetups: filterSetupsForPrunedRepoRows(
s.projectHostSetups,
result.repos,
finalizedRepos
)
},
fetched: projectCompatibility,
repos: reconciledRepos,
hostId
repos: finalizedRepos,
hostId: result.hostId
})
finalizedHostRepos = finalizedRepos.filter(
(repo) => getRepoExecutionHostId(repo) === result.hostId
)
return {
repos: reconciledRepos,
repos: finalizedRepos,
pendingSshRepoReadoptions: reconciliation.pendingReadoptions,
...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions),
...mergedProjectCompatibility,
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
@@ -1530,11 +1646,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
)
}
})
const fetchedHostRepos = reconciledRepos.filter(
(repo) => getRepoExecutionHostId(repo) === hostId
)
scheduleSafeAutoForkSync(get, fetchedHostRepos)
return fetchedHostRepos
scheduleSafeAutoForkSync(get, finalizedHostRepos)
return finalizedHostRepos
} catch (err) {
console.error(`Failed to fetch repos for runtime environment ${environmentId}:`, err)
return []
@@ -1542,6 +1655,11 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
},
fetchReposForAllHosts: async (options) => {
let generation = 0
set((s) => {
generation = s.reposFetchGeneration + 1
return { reposFetchGeneration: generation }
})
// Why: a cold start that restores a remote workspace re-activates that
// remote runtime environment, and fetching only the active host hides every
// other host's repos (notably all local repos), which reads as "my projects
@@ -1550,21 +1668,38 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// environment is active. Each host fails soft: an unreachable/disconnected
// host is skipped without blocking the others.
const applyCatalog = (catalog: FetchedRepoCatalog): void => {
// Why: repos:changed can start another all-host refresh while this one is
// in flight. Never let the older catalog resurrect a migrated SSH owner.
if (get().reposFetchGeneration !== generation) {
return
}
let hostRepos: Repo[] = []
set((s) => {
const result = mergeFetchedRepoCatalog(catalog, s.repos)
const reconciliation = reconcileSupersededSshRepos(result.repos, s)
const finalizedRepos = reconciliation.repos
const projectCompatibility = projectCompatibilityForReconciledRepos(
finalizedRepos,
catalog.projectHostSetupCompatibility
)
const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({
previous: {
projects: s.projects,
projectHostSetups: s.projectHostSetups
projectHostSetups: filterSetupsForPrunedRepoRows(
s.projectHostSetups,
result.repos,
finalizedRepos
)
},
fetched: result.projectCompatibility,
repos: result.repos,
fetched: projectCompatibility,
repos: finalizedRepos,
hostId: result.hostId
})
hostRepos = result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId)
hostRepos = finalizedRepos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId)
return {
repos: result.repos,
repos: finalizedRepos,
pendingSshRepoReadoptions: reconciliation.pendingReadoptions,
...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions),
...mergedProjectCompatibility,
folderWorkspacePathStatuses: {},
activeRepoId: s.activeRepoId,
@@ -1600,6 +1735,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
failed = true
console.error('Failed to fetch local repos for all-host load:', err)
}
if (get().reposFetchGeneration !== generation) {
return
}
if (options?.remoteHosts === 'skip') {
return
}
@@ -1625,7 +1763,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// Why: first-paint startup intentionally loads only local repos before
// remotes answer. Validate repo-scoped UI only once every configured host has
// answered; otherwise an offline runtime would erase its saved filters.
if (!failed) {
if (!failed && get().reposFetchGeneration === generation) {
validateRepoScopedUi()
}
},
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { pruneSupersededSshRepoRows } from './superseded-ssh-repo-rows'
import { describe, expect, it } from 'vitest'
import type { Repo } from '../../../../shared/types'
import { mergeSshRepoReadoptions, reconcileReadoptedSshRepoRows } from './superseded-ssh-repo-rows'
function repo(overrides: Partial<Repo>): Repo {
return {
@@ -13,44 +13,71 @@ function repo(overrides: Partial<Repo>): Repo {
}
}
describe('pruneSupersededSshRepoRows', () => {
it('drops a stale dead-SSH row when a live-host sibling shares the id', () => {
// The re-adoption leftover: same id on a dead target + a live one.
const repos = [
repo({ id: 'shared', connectionId: 'ssh-dead' }),
repo({ id: 'shared', connectionId: 'ssh-live' })
]
const result = pruneSupersededSshRepoRows(repos, new Set(['ssh-live']))
expect(result.map((r) => r.connectionId)).toEqual(['ssh-live'])
const readoption = {
oldTargetId: 'ssh-old',
newTargetId: 'ssh-new',
repoIds: ['shared']
}
describe('reconcileReadoptedSshRepoRows', () => {
it('drops only the exact old-host row after the new-host row arrives', () => {
const local = repo({ id: 'shared', path: '/local' })
const oldSsh = repo({ id: 'shared', connectionId: 'ssh-old' })
const newSsh = repo({ id: 'shared', connectionId: 'ssh-new' })
const result = reconcileReadoptedSshRepoRows([local, oldSsh, newSsh], [readoption])
expect(result.repos).toEqual([local, newSsh])
expect(result.pendingReadoptions).toEqual([])
})
it('KEEPS a lone project-only ghost (no live sibling) so it can still be forgotten', () => {
const repos = [repo({ id: 'ghost', connectionId: 'ssh-dead' })]
const result = pruneSupersededSshRepoRows(repos, new Set())
expect(result).toHaveLength(1)
expect(result[0].connectionId).toBe('ssh-dead')
it('keeps evidence pending when repos:changed has not delivered the new row yet', () => {
const oldSsh = repo({ id: 'shared', connectionId: 'ssh-old' })
const result = reconcileReadoptedSshRepoRows([oldSsh], [readoption])
expect(result.repos).toEqual([oldSsh])
expect(result.pendingReadoptions).toEqual([readoption])
})
it('drops a dead-SSH row superseded by a LOCAL sibling', () => {
// A repo id on both local and a removed SSH host: the local row is the live
// sibling, so the SSH ghost is a re-adoption/duplicate leftover → drop it.
const repos = [repo({ id: 'shared' }), repo({ id: 'shared', connectionId: 'ssh-dead' })]
const result = pruneSupersededSshRepoRows(repos, new Set())
expect(result.map((r) => r.connectionId ?? 'local')).toEqual(['local'])
it('keeps a removed SSH ghost when a local repo shares its UUID without evidence', () => {
const repos = [repo({ id: 'shared' }), repo({ id: 'shared', connectionId: 'ssh-old' })]
expect(reconcileReadoptedSshRepoRows(repos, []).repos).toEqual(repos)
})
it('leaves live SSH rows untouched', () => {
const repos = [repo({ id: 'a', connectionId: 'ssh-live' }), repo({ id: 'b' })]
const result = pruneSupersededSshRepoRows(repos, new Set(['ssh-live']))
expect(result).toHaveLength(2)
it('does not accept a local or runtime sibling as the mapped new SSH row', () => {
const oldSsh = repo({ id: 'shared', connectionId: 'ssh-old' })
const local = repo({ id: 'shared' })
const runtime = repo({
id: 'shared',
connectionId: 'ssh-new',
executionHostId: 'runtime:env-1'
})
const result = reconcileReadoptedSshRepoRows([oldSsh, local, runtime], [readoption])
expect(result.repos).toEqual([oldSsh, local, runtime])
expect(result.pendingReadoptions).toEqual([readoption])
})
it('never prunes runtime-owned SSH rows', () => {
const repos = [
repo({ id: 'shared', connectionId: 'runtime-ssh-abc' }),
repo({ id: 'shared', connectionId: 'ssh-live' })
]
const result = pruneSupersededSshRepoRows(repos, new Set(['ssh-live']))
expect(result).toHaveLength(2)
it('leaves unrelated same-UUID SSH rows untouched', () => {
const other = repo({ id: 'shared', connectionId: 'ssh-other' })
const newSsh = repo({ id: 'shared', connectionId: 'ssh-new' })
const result = reconcileReadoptedSshRepoRows([other, newSsh], [readoption])
expect(result.repos).toEqual([other, newSsh])
})
})
describe('mergeSshRepoReadoptions', () => {
it('combines repo ids for the same old-to-new migration', () => {
const result = mergeSshRepoReadoptions(
[{ ...readoption, repoIds: ['a'] }],
[{ ...readoption, repoIds: ['a', 'b'] }]
)
expect(result).toEqual([{ ...readoption, repoIds: ['a', 'b'] }])
})
})
@@ -1,50 +1,82 @@
import type { SshRepoReadoption } from '../../../../shared/ssh-types'
import type { Repo } from '../../../../shared/types'
import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host'
import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
export type SshRepoReconciliation = {
repos: Repo[]
pendingReadoptions: SshRepoReadoption[]
}
function repoBelongsToTarget(repo: Repo, targetId: string): boolean {
return (
repo.connectionId === targetId &&
getRepoExecutionHostId(repo) === toSshExecutionHostId(targetId)
)
}
function repoOwnerKey(hostId: string, repoId: string): string {
return `${hostId}\0${repoId}`
}
/**
* Drops repo rows left stranded on a removed SSH target after re-adoption
* re-pointed the same repo onto a re-added host.
*
* Re-adoption (main) rewrites a repo's connectionId from the old (dead) target
* id to the new one, but the renderer's per-host repo merge preserves rows on
* "other hosts" and the dead SSH host still looks like another host, so its
* stale row lingers. A terminal pane bound to that ghost row then fails with
* "SSH target not found".
*
* A row is superseded (and pruned) only when ALL hold:
* - it targets an SSH connection that is NOT a currently-known target, and
* - the same repo id also exists on a DIFFERENT host that IS known/live.
*
* This never removes a legitimate lone project-only ghost host (its repo id
* exists only on the dead host, so there is no live sibling to supersede it)
* those are kept on purpose so the sidebar can still surface and forget them.
* Drops old-host rows only when main reports the exact repo re-adoption and the
* renderer has received the corresponding new-host row. Evidence stays pending
* across the add-response/repos:changed race until that row arrives.
*/
export function pruneSupersededSshRepoRows(
export function reconcileReadoptedSshRepoRows(
repos: readonly Repo[],
knownSshTargetIds: ReadonlySet<string>
): Repo[] {
const isDeadSshRow = (repo: Repo): boolean => {
const connectionId = repo.connectionId?.trim()
if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) {
return false
}
return !knownSshTargetIds.has(connectionId)
}
readoptions: readonly SshRepoReadoption[]
): SshRepoReconciliation {
const prunedOwners = new Set<string>()
const pendingReadoptions: SshRepoReadoption[] = []
const directSshOwners = new Set(
repos.flatMap((repo) =>
repo.connectionId && repoBelongsToTarget(repo, repo.connectionId)
? [repoOwnerKey(getRepoExecutionHostId(repo), repo.id)]
: []
)
)
// Repo ids that have at least one row on a known/live host (local or a live
// SSH/runtime target). Only these can supersede a dead-host sibling.
const idsWithLiveHost = new Set<string>()
for (const repo of repos) {
if (!isDeadSshRow(repo)) {
idsWithLiveHost.add(repo.id)
for (const readoption of readoptions) {
const pendingRepoIds: string[] = []
for (const repoId of readoption.repoIds) {
const newOwner = repoOwnerKey(toSshExecutionHostId(readoption.newTargetId), repoId)
const hasNewRow = directSshOwners.has(newOwner)
if (!hasNewRow) {
pendingRepoIds.push(repoId)
continue
}
prunedOwners.add(repoOwnerKey(toSshExecutionHostId(readoption.oldTargetId), repoId))
}
if (pendingRepoIds.length > 0) {
pendingReadoptions.push({ ...readoption, repoIds: pendingRepoIds })
}
}
return repos.filter((repo) => {
if (!isDeadSshRow(repo)) {
return true
}
// Keep a lone ghost (no live sibling); drop only a superseded leftover.
return !idsWithLiveHost.has(repo.id)
if (prunedOwners.size === 0) {
return { repos: [...repos], pendingReadoptions }
}
return {
repos: repos.filter(
(repo) => !prunedOwners.has(repoOwnerKey(getRepoExecutionHostId(repo), repo.id))
),
pendingReadoptions
}
}
export function mergeSshRepoReadoptions(
pending: readonly SshRepoReadoption[],
incoming: readonly SshRepoReadoption[]
): SshRepoReadoption[] {
const repoIdsByMigration = new Map<string, Set<string>>()
for (const readoption of [...pending, ...incoming]) {
const key = `${readoption.oldTargetId}\0${readoption.newTargetId}`
const repoIds = repoIdsByMigration.get(key) ?? new Set<string>()
readoption.repoIds.forEach((repoId) => repoIds.add(repoId))
repoIdsByMigration.set(key, repoIds)
}
return [...repoIdsByMigration].map(([key, repoIds]) => {
const [oldTargetId, newTargetId] = key.split('\0')
return { oldTargetId, newTargetId, repoIds: [...repoIds] }
})
}
+64 -22
View File
@@ -352,6 +352,21 @@ function repoHostId(
return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID
}
function repoHasExecutionHost(
state: Pick<AppState, 'repos'>,
repoId: string,
hostId: ExecutionHostId,
ownerWasMissingAtStart: boolean
): boolean {
const repoOwners = state.repos.filter((repo) => repo.id === repoId)
// Why: worktrees can load before the repo catalog during startup; only reject
// a missing owner when this request previously observed an owned repo.
return (
(repoOwners.length === 0 && ownerWasMissingAtStart) ||
repoOwners.some((repo) => getRepoExecutionHostId(repo) === hostId)
)
}
function toVisibleWorktrees(
result: DetectedWorktreeListResult,
hostId: ExecutionHostId,
@@ -2253,6 +2268,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
try {
const ownerState = get()
const hostId = repoHostId(ownerState, repoId)
const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId)
const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId)
const result = await listDetectedWorktreesForRepoCoalesced(
settingsForRepoOwner(ownerState, repoId, hostId),
@@ -2260,6 +2276,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
{ executionHostId: hostId }
)
set((s) => {
if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) {
return s
}
// Why: detected-only refreshes can overlap host-scoped visible refreshes;
// keep detected state stamped/merged so SSH/runtime rows are not clobbered.
const mergedDetected = mergeDetectedWorktreesForHost(
@@ -2287,6 +2306,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
try {
const ownerState = get()
const hostId = repoHostId(ownerState, repoId)
const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId)
const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId)
const settings = settingsForRepoOwner(ownerState, repoId, hostId)
const detected = await listDetectedWorktreesForRepoCoalesced(settings, repoId, {
@@ -2307,6 +2327,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
)
if (areWorktreesEqual(currentForHost, worktrees)) {
set((s) => {
if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) {
return s
}
const matchOptions = worktreeHostMatchOptions(s, repoId, hostId)
const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(
s,
@@ -2360,22 +2383,30 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// to-navigate silently fails because findWorktreeById returns undefined.
// Keep the stale-but-correct data until the next successful refresh.
if (!detected.authoritative && worktrees.length === 0 && currentForHost.length > 0) {
set((s) => ({
detectedWorktreesByRepo: {
...s.detectedWorktreesByRepo,
[repoId]: mergeDetectedWorktreesForHost(
s.detectedWorktreesByRepo[repoId],
detected,
hostId,
setup,
worktreeHostMatchOptions(s, repoId, hostId)
)
set((s) => {
if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) {
return s
}
}))
return {
detectedWorktreesByRepo: {
...s.detectedWorktreesByRepo,
[repoId]: mergeDetectedWorktreesForHost(
s.detectedWorktreesByRepo[repoId],
detected,
hostId,
setup,
worktreeHostMatchOptions(s, repoId, hostId)
)
}
}
})
return false
}
set((s) => {
if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) {
return s
}
// Why: hidden worktrees are not in worktreesByRepo. Purge decisions
// must diff against the previous authoritative detected list so hiding
// does not delete state, and deleting a hidden worktree still does.
@@ -2437,6 +2468,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
get().worktreesByRepo[r.id]
)
set((s) => {
if (!repoHasExecutionHost(s, r.id, hostId, false)) {
return s
}
const matchOptions = worktreeHostMatchOptions(s, r.id, hostId)
const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(
s,
@@ -2522,6 +2556,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
!(list.length === 0 && currentForHost.length > 0 && !detected.authoritative)
) {
set((s) => {
if (!repoHasExecutionHost(s, r.id, hostId, false)) {
return s
}
const matchOptions = worktreeHostMatchOptions(s, r.id, hostId)
return {
worktreesByRepo: {
@@ -2542,18 +2579,23 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
})
} else {
set((s) => ({
detectedWorktreesByRepo: {
...s.detectedWorktreesByRepo,
[r.id]: mergeDetectedWorktreesForHost(
s.detectedWorktreesByRepo[r.id],
detected,
hostId,
setup,
worktreeHostMatchOptions(s, r.id, hostId)
)
set((s) => {
if (!repoHasExecutionHost(s, r.id, hostId, false)) {
return s
}
}))
return {
detectedWorktreesByRepo: {
...s.detectedWorktreesByRepo,
[r.id]: mergeDetectedWorktreesForHost(
s.detectedWorktreesByRepo[r.id],
detected,
hostId,
setup,
worktreeHostMatchOptions(s, r.id, hostId)
)
}
}
})
}
return { repoId: r.id, ok: detected.authoritative, detected }
} catch (err) {
+1 -1
View File
@@ -2823,7 +2823,7 @@ function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
updateTarget: () =>
Promise.reject(new Error('SSH target management is unavailable in the web client.')),
removeTarget: () => Promise.resolve(),
importConfig: () => Promise.resolve([]),
importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }),
connect: async (args) => {
const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>(
'ssh.connect',
+17
View File
@@ -68,6 +68,23 @@ export type RemovedSshTargetTombstone = {
removedAt: number
}
/** Exact repo ownership changes made while re-adopting a removed SSH host. */
export type SshRepoReadoption = {
oldTargetId: string
newTargetId: string
repoIds: string[]
}
export type SshTargetAddResult = {
target: SshTarget
repoReadoptions: SshRepoReadoption[]
}
export type SshConfigImportResult = {
targets: SshTarget[]
repoReadoptions: SshRepoReadoption[]
}
export type SavedPortForward = {
localPort: number
remoteHost: string
@@ -39,7 +39,7 @@ export async function connectDockerRemote(
void window.api.ssh.submitCredential({ requestId: request.requestId, value: null })
})
try {
const createdTarget = await window.api.ssh.addTarget({
const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({
target: {
label: `Docker SSH Codex Artifact Repro ${Date.now()}`,
host: '127.0.0.1',
@@ -50,6 +50,7 @@ export async function connectDockerRemote(
relayGracePeriodSeconds: 1
}
})
store.getState().recordSshRepoReadoptions(repoReadoptions)
const state = await window.api.ssh.connect({ targetId: createdTarget.id })
if (!state || state.status !== 'connected') {
throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`)
+2 -1
View File
@@ -103,7 +103,7 @@ async function connectDockerRemote(
void window.api.ssh.submitCredential({ requestId: request.requestId, value: null })
})
try {
const createdTarget = await window.api.ssh.addTarget({
const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({
target: {
label: `Docker SSH Relay Perf ${Date.now()}`,
host: '127.0.0.1',
@@ -114,6 +114,7 @@ async function connectDockerRemote(
relayGracePeriodSeconds: 1
}
})
store.getState().recordSshRepoReadoptions(repoReadoptions)
const state = await window.api.ssh.connect({ targetId: createdTarget.id })
if (!state || state.status !== 'connected') {
throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`)
+2 -1
View File
@@ -170,7 +170,7 @@ test.describe('Localhost SSH', () => {
})
try {
const createdTarget = await window.api.ssh.addTarget({
const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({
target: {
...target,
// Why: local-only E2E should not leave a long-lived relay process
@@ -178,6 +178,7 @@ test.describe('Localhost SSH', () => {
relayGracePeriodSeconds: 1
}
})
store.getState().recordSshRepoReadoptions(repoReadoptions)
let state
try {
@@ -47,7 +47,7 @@ async function connectDockerRemote(
void window.api.ssh.submitCredential({ requestId: request.requestId, value: null })
})
try {
const createdTarget = await window.api.ssh.addTarget({
const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({
target: {
label: `Docker SSH Pi-Compatible Agent ${Date.now()}`,
host: '127.0.0.1',
@@ -58,6 +58,7 @@ async function connectDockerRemote(
relayGracePeriodSeconds: 1
}
})
store.getState().recordSshRepoReadoptions(repoReadoptions)
const state = await window.api.ssh.connect({ targetId: createdTarget.id })
if (!state || state.status !== 'connected') {
throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`)