From 25ecf2eea22e21fd839b877ad0c9decb98872aaa Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:20:55 -0700 Subject: [PATCH] fix: reconcile SSH repo rows after host re-add (#8201) Co-authored-by: Orca --- ...2026-07-10-ssh-repo-host-reconciliation.md | 131 ++++++++ src/main/ipc/ssh.test.ts | 33 +- src/main/ipc/ssh.ts | 21 +- src/main/persistence.test.ts | 16 +- src/main/persistence.ts | 18 +- src/main/ssh/ssh-connection-store.test.ts | 46 ++- src/main/ssh/ssh-connection-store.ts | 17 +- src/main/ssh/ssh-target-readoption.test.ts | 44 +-- src/main/ssh/ssh-target-readoption.ts | 25 +- src/preload/api-types.ts | 6 +- src/preload/index.ts | 6 +- .../src/components/settings/SshPane.tsx | 23 +- .../sidebar/AddRemoteHostDialog.tsx | 8 +- .../readopted-ssh-worktree-rows.test.ts | 45 +++ .../slices/readopted-ssh-worktree-rows.ts | 52 ++++ .../slices/repos-all-hosts-generation.test.ts | 114 +++++++ .../src/store/slices/repos-all-hosts.test.ts | 80 +++++ .../repos-ssh-host-reconciliation.test.ts | 291 ++++++++++++++++++ src/renderer/src/store/slices/repos.ts | 248 +++++++++++---- .../slices/superseded-ssh-repo-rows.test.ts | 93 ++++-- .../store/slices/superseded-ssh-repo-rows.ts | 110 ++++--- src/renderer/src/store/slices/worktrees.ts | 86 ++++-- src/renderer/src/web/web-preload-api.ts | 2 +- src/shared/ssh-types.ts | 17 + .../e2e/ssh-codex-reconnect-replay-driver.ts | 3 +- tests/e2e/ssh-docker-relay-perf.spec.ts | 3 +- tests/e2e/ssh-localhost.spec.ts | 3 +- .../e2e/ssh-pi-compatible-agent-title.spec.ts | 3 +- 28 files changed, 1304 insertions(+), 240 deletions(-) create mode 100644 docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md create mode 100644 src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts create mode 100644 src/renderer/src/store/slices/readopted-ssh-worktree-rows.ts create mode 100644 src/renderer/src/store/slices/repos-all-hosts-generation.test.ts create mode 100644 src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts diff --git a/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md b/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md new file mode 100644 index 00000000000..ac3249d306e --- /dev/null +++ b/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md @@ -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:` 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:` 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. diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 0c5a51d9ae2..51801ecdef5 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -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 () => { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 8b5756bff38..affb1aba004 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -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 ─────────────────────────────────────────── diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index a0d3f760779..4e921151e4f 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d4b68e7f4b4..0cac89b3f17 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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() 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 ────────────────────────────────────────── diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 02e69c6dc97..4bfa7259dab 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -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([]) }) }) }) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index df3e6cd8fee..295e7d57ac3 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -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 } } diff --git a/src/main/ssh/ssh-target-readoption.test.ts b/src/main/ssh/ssh-target-readoption.test.ts index f263fe01d88..2b066a0bdc4 100644 --- a/src/main/ssh/ssh-target-readoption.test.ts +++ b/src/main/ssh/ssh-target-readoption.test.ts @@ -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) }) }) diff --git a/src/main/ssh/ssh-target-readoption.ts b/src/main/ssh/ssh-target-readoption.ts index 06b0cefd8ec..321bbdb5c0a 100644 --- a/src/main/ssh/ssh-target-readoption.ts +++ b/src/main/ssh/ssh-target-readoption.ts @@ -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. */ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1bad72bb019..4babcd587bb 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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> - addTarget: (args: { target: Omit }) => Promise + addTarget: (args: { target: Omit }) => Promise updateTarget: (args: { id: string updates: Partial> }) => Promise removeTarget: (args: { id: string }) => Promise - importConfig: (args?: { reAdopt?: boolean }) => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 6f8692649b4..3c7af55f0af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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> => ipcRenderer.invoke('ssh:listRemovedTargetLabels'), - addTarget: (args: { target: Omit }): Promise => + addTarget: (args: { target: Omit }): Promise => ipcRenderer.invoke('ssh:addTarget', args), updateTarget: (args: { @@ -4047,7 +4049,7 @@ const api = { removeTarget: (args: { id: string }): Promise => ipcRenderer.invoke('ssh:removeTarget', args), - importConfig: (args?: { reAdopt?: boolean }): Promise => + importConfig: (args?: { reAdopt?: boolean }): Promise => ipcRenderer.invoke('ssh:importConfig', args), connect: (args: { targetId: string }): Promise => diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 534383be3b5..2e66ca3823a 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -19,9 +19,7 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' export { getSshPaneSearchEntries } from './ssh-search' -type SshPaneProps = Record - -export function SshPane(_props: SshPaneProps): React.JSX.Element { +export function SshPane(): React.JSX.Element { const [targets, setTargets] = useState([]) // 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' : '' } ) ) } diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index c6b7870381f..705d5537073 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -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) { diff --git a/src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts b/src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts new file mode 100644 index 00000000000..8213af797f1 --- /dev/null +++ b/src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts @@ -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 = { + 'repo-1': [{ id: 'other-host', hostId: 'ssh:ssh-other' }], + 'repo-2': [{ id: 'old-host', hostId: 'ssh:ssh-old' }] + } + + expect(reconcileReadoptedSshWorktreesByRepo(rows, [readoption])).toBe(rows) + }) +}) diff --git a/src/renderer/src/store/slices/readopted-ssh-worktree-rows.ts b/src/renderer/src/store/slices/readopted-ssh-worktree-rows.ts new file mode 100644 index 00000000000..0e4b1748669 --- /dev/null +++ b/src/renderer/src/store/slices/readopted-ssh-worktree-rows.ts @@ -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( + 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( + rowsByRepo: Readonly>, + readoptions: readonly SshRepoReadoption[] +): Record { + let result = rowsByRepo as Record + 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 +} diff --git a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts new file mode 100644 index 00000000000..a6859b21bb3 --- /dev/null +++ b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts @@ -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((resolve) => { + markOlderRemoteStarted = resolve + }) + const newerRemoteStarted = new Promise((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']) + }) +}) diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts index c54d0a7edb6..6a96f557ade 100644 --- a/src/renderer/src/store/slices/repos-all-hosts.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -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 diff --git a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts new file mode 100644 index 00000000000..f53f3342072 --- /dev/null +++ b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts @@ -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((resolve) => { + resolveOldSetups = resolve + }) + const oldSetupStarted = new Promise((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) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6fc31768e28..4538b91212e 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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 { - 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 + projectHostSetupCompatibility: ProjectHostSetupProjection hostId: ReturnType } { 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 +): 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, + readoptions: readonly SshRepoReadoption[] +): Pick { + 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 { + return mergeProjectHostSetupCompatibility(projectCompatibilityFromRepos(repos), fetched) } function filterTrustedOrcaHooksToValidRepos( @@ -969,17 +1049,6 @@ function clearRestoredFolderWorkspaceSessionOwners( return next } -async function fetchReposForTarget( - target: ReturnType, - currentRepos: readonly Repo[] -): Promise<{ - repos: Repo[] - projectCompatibility: Pick - hostId: ReturnType -}> { - return mergeFetchedRepoCatalog(await fetchRepoCatalogForTarget(target), currentRepos) -} - async function fetchProjectGroupCatalogForTarget( target: ReturnType ): Promise { @@ -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 fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise @@ -1440,6 +1511,32 @@ export const createRepoSlice: StateCreator = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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() } }, diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts index 64299ac3704..ec18aea767d 100644 --- a/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts @@ -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 { return { @@ -13,44 +13,71 @@ function repo(overrides: Partial): 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'] }]) }) }) diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts index c97d9bb96e0..da87da70291 100644 --- a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts @@ -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 -): 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() + 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() - 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>() + for (const readoption of [...pending, ...incoming]) { + const key = `${readoption.oldTargetId}\0${readoption.newTargetId}` + const repoIds = repoIdsByMigration.get(key) ?? new Set() + 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] } }) } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index e419631e736..315e23aaf70 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -352,6 +352,21 @@ function repoHostId( return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID } +function repoHasExecutionHost( + state: Pick, + 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 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 { 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 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 ) 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 // 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 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 !(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 } }) } 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) { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5e7c61a12ff..cd77c51acef 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2823,7 +2823,7 @@ function createSshApi(): NonNullable['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', diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 8627f485bb3..5794cb5226c 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -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 diff --git a/tests/e2e/ssh-codex-reconnect-replay-driver.ts b/tests/e2e/ssh-codex-reconnect-replay-driver.ts index 75fc21a47e5..87f1f120d30 100644 --- a/tests/e2e/ssh-codex-reconnect-replay-driver.ts +++ b/tests/e2e/ssh-codex-reconnect-replay-driver.ts @@ -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)}`) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index f36f44e9445..13f90d4399b 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -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)}`) diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index c406186d97c..1117fcb9409 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -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 { diff --git a/tests/e2e/ssh-pi-compatible-agent-title.spec.ts b/tests/e2e/ssh-pi-compatible-agent-title.spec.ts index 02634022540..d8c28d0efd0 100644 --- a/tests/e2e/ssh-pi-compatible-agent-title.spec.ts +++ b/tests/e2e/ssh-pi-compatible-agent-title.spec.ts @@ -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)}`)