mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix: reconcile SSH repo rows after host re-add (#8201)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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] }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
Reference in New Issue
Block a user