mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
rebase(sta-4343): replay #14606 host-qualified removal onto main
Squashed replay of the 14 commits on brennanb2025/sta-4343-fix. Twelve of them predate the branch's own main-merge, so a commit-by-commit rebase re-conflicts against the worktrees/persistence slice splits every time. Re-homed by hand where main moved the code: - persistence.ts -> persistence/loading-store/store.ts (removeWorktreeMeta) - persistence.test.ts -> persistence-host-partitioned-sessions.test.ts - ipc/worktrees.test.ts -> worktrees-remove-host-disambiguation.test.ts, worktrees-removal-recovery.test.ts, worktrees-preserved-branch-fork-remote.test.ts Kept main's side where it moved ahead: the folder-workspace deleteRemoteWorktreeHistory call, and cleanup success toasts no longer firing from the background-removal path.
This commit is contained in:
@@ -277,8 +277,20 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
printResult(result, json, formatWorktreeShow)
|
||||
},
|
||||
'worktree rm': async ({ flags, client, cwd, json }) => {
|
||||
const worktree = await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
|
||||
const resolved = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
|
||||
worktree
|
||||
})
|
||||
const hostId = resolved.result.worktree.hostId
|
||||
if (!hostId) {
|
||||
throw new RuntimeClientError(
|
||||
'worktree_host_unresolved',
|
||||
'Orca cannot tell which host owns this workspace. Refresh projects and try again.'
|
||||
)
|
||||
}
|
||||
const result = await client.call<RuntimeWorktreeRemoveResult>('worktree.rm', {
|
||||
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
|
||||
worktree,
|
||||
hostId,
|
||||
force: flags.get('force') === true,
|
||||
// Why (#11960): --force is explicit here, so it may also waive PTY-stop proof.
|
||||
allowUnverifiedPtyStop: flags.get('force') === true,
|
||||
|
||||
+31
-6
@@ -88,27 +88,52 @@ describe('command aliases dispatch to the canonical handler', () => {
|
||||
})
|
||||
|
||||
it('runs `worktree remove` as the canonical `worktree rm` (the incident)', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { removed: true }))
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_show', { worktree: { hostId: 'local' } }),
|
||||
okFixture('req', { removed: true })
|
||||
)
|
||||
|
||||
await main(['worktree', 'remove', '--worktree', 'id:wt-1', '--force', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'worktree.rm',
|
||||
expect.objectContaining({ worktree: 'id:wt-1', force: true })
|
||||
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'local', force: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('runs `worktree delete` as the canonical `worktree rm`', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { removed: true }))
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_show', { worktree: { hostId: 'runtime:env-1' } }),
|
||||
okFixture('req', { removed: true })
|
||||
)
|
||||
|
||||
await main(['worktree', 'delete', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'worktree.rm',
|
||||
expect.objectContaining({ worktree: 'id:wt-1' })
|
||||
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'runtime:env-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when worktree removal cannot resolve a host', async () => {
|
||||
queueFixtures(callMock, okFixture('req_show', { worktree: { id: 'wt-1' } }))
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
try {
|
||||
await main(['worktree', 'rm', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.show', { worktree: 'id:wt-1' })
|
||||
} finally {
|
||||
process.exitCode = priorExitCode
|
||||
}
|
||||
})
|
||||
|
||||
it('still runs `terminal focus` after the handler de-duplication', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { focus: { ok: true } }))
|
||||
|
||||
|
||||
@@ -183,6 +183,53 @@ describe('workspace cleanup broad scan opt-in', () => {
|
||||
).toBe('runtime:env-1')
|
||||
})
|
||||
|
||||
it('does not borrow same-id local metadata for a connected SSH cleanup row', async () => {
|
||||
const sharedPath = '/shared/workspace'
|
||||
const worktreeId = `${REPO.id}::${sharedPath}`
|
||||
const localRepo = { ...REPO, path: '/local/repo' }
|
||||
const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' }
|
||||
const sharedWorktree: GitWorktreeInfo = {
|
||||
path: sharedPath,
|
||||
head: 'shared123',
|
||||
branch: 'refs/heads/shared',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const localMeta = makeWorktreeMeta({
|
||||
displayName: 'Local metadata only',
|
||||
hostId: 'local',
|
||||
isPinned: true,
|
||||
lastActivityAt: NOW - 40 * DAY_MS
|
||||
})
|
||||
listRepoWorktreesMock.mockResolvedValue([sharedWorktree])
|
||||
getSshGitProviderMock.mockReturnValue({
|
||||
listWorktrees: vi.fn().mockResolvedValue([sharedWorktree])
|
||||
})
|
||||
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore([localRepo, sshRepo], { [worktreeId]: localMeta }),
|
||||
{ includeAllWorkspaces: true, skipGitWorktreeIds: [worktreeId] }
|
||||
)
|
||||
|
||||
expect(result.candidates).toHaveLength(2)
|
||||
expect(result.candidates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: 'Local metadata only',
|
||||
blockers: ['pinned']
|
||||
}),
|
||||
expect.objectContaining({
|
||||
worktreeId,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
displayName: 'shared',
|
||||
blockers: []
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the legacy suggestion-only projection when the flag is absent', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore())
|
||||
|
||||
@@ -245,6 +292,31 @@ describe('workspace cleanup broad scan opt-in', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not publish a same-id local folder instance on an SSH host', async () => {
|
||||
const localFolderRepo = { ...FOLDER_REPO }
|
||||
const sshFolderRepo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
|
||||
const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555`
|
||||
getSshGitProviderMock.mockReturnValue({})
|
||||
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore([localFolderRepo, sshFolderRepo], {
|
||||
[instanceId]: makeWorktreeMeta({
|
||||
displayName: 'Local folder session',
|
||||
hostId: 'local'
|
||||
})
|
||||
}),
|
||||
{ includeAllWorkspaces: true }
|
||||
)
|
||||
|
||||
expect(result.candidates).toHaveLength(3)
|
||||
expect(result.candidates.filter((candidate) => candidate.worktreeId === instanceId)).toEqual([
|
||||
expect.objectContaining({
|
||||
executionHostId: 'local',
|
||||
displayName: 'Local folder session'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('does not mark connected SSH folder workspaces as disconnected', async () => {
|
||||
const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
|
||||
getSshGitProviderMock.mockReturnValue({})
|
||||
@@ -414,6 +486,34 @@ describe('workspace cleanup broad scan opt-in', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not synthesize a disconnected SSH row from same-id local metadata', async () => {
|
||||
const sharedPath = '/shared/workspace'
|
||||
const worktreeId = `${REPO.id}::${sharedPath}`
|
||||
const localRepo = { ...REPO, path: '/local/repo' }
|
||||
const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' }
|
||||
const sharedWorktree: GitWorktreeInfo = {
|
||||
path: sharedPath,
|
||||
head: 'shared123',
|
||||
branch: 'refs/heads/shared',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
listRepoWorktreesMock.mockResolvedValue([sharedWorktree])
|
||||
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore([localRepo, sshRepo], {
|
||||
[worktreeId]: makeWorktreeMeta({ hostId: 'local' })
|
||||
}),
|
||||
{ includeAllWorkspaces: true, skipGitWorktreeIds: [worktreeId] }
|
||||
)
|
||||
|
||||
expect(result.candidates).toHaveLength(1)
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
worktreeId,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the backing path for disconnected SSH folder instances', async () => {
|
||||
const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
|
||||
const instanceId = `${sshFolderRepo.id}::${sshFolderRepo.path}::workspace:11111111-2222-4333-8444-555555555555`
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
getWorkspaceCleanupInactivityReasonsForWorkspace,
|
||||
isWorkspaceInactiveForCleanup
|
||||
} from './workspace-cleanup-candidate'
|
||||
import { getRepoOwnedWorktreeMeta, isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership'
|
||||
|
||||
export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
scannedAt: number,
|
||||
repoOwnerCount: number,
|
||||
targetWorktreeIds?: ReadonlySet<string>,
|
||||
includeAllWorkspaces = false
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
@@ -32,7 +34,7 @@ export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
continue
|
||||
}
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
if (meta) {
|
||||
if (isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount)) {
|
||||
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
|
||||
}
|
||||
}
|
||||
@@ -45,7 +47,7 @@ export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) {
|
||||
continue
|
||||
}
|
||||
const meta = allMeta[worktreeId]
|
||||
const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount)
|
||||
if (!meta || (!includeAllWorkspaces && !isWorkspaceInactiveForCleanup(meta, scannedAt))) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2,21 +2,36 @@ import type { Store } from '../persistence'
|
||||
import { createFolderWorktree } from '../repo-worktrees'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id'
|
||||
import { mergeWorktree } from './worktree-logic'
|
||||
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
|
||||
|
||||
export function listWorkspaceCleanupFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
|
||||
export function listWorkspaceCleanupFolderWorkspaces(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
repoOwnerCount: number
|
||||
): Worktree[] {
|
||||
const rootId = `${repo.id}::${repo.path}`
|
||||
const instancePrefix = `${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
const allMeta: Readonly<Record<string, WorktreeMeta>> = store.getAllWorktreeMeta()
|
||||
const worktreeIds = [
|
||||
rootId,
|
||||
...Object.keys(allMeta).filter((worktreeId) => worktreeId.startsWith(instancePrefix))
|
||||
...Object.keys(allMeta).filter(
|
||||
(worktreeId) =>
|
||||
worktreeId.startsWith(instancePrefix) &&
|
||||
getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount) !== undefined
|
||||
)
|
||||
]
|
||||
const folderWorktree = createFolderWorktree(repo)
|
||||
|
||||
return worktreeIds.map((worktreeId) => ({
|
||||
...mergeWorktree(repo.id, folderWorktree, store.getWorktreeMeta(worktreeId), repo.displayName),
|
||||
...mergeWorktree(
|
||||
repo.id,
|
||||
folderWorktree,
|
||||
getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount),
|
||||
repo.displayName
|
||||
),
|
||||
id: worktreeId,
|
||||
isMainWorktree: worktreeId === rootId
|
||||
}))
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
getTargetWorktreeIdsByRepo,
|
||||
hasTargetedWorkspaceCleanupScan
|
||||
} from './workspace-cleanup-scan-targets'
|
||||
import { isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership'
|
||||
|
||||
const WORKTREE_SCAN_CONCURRENCY = 3
|
||||
// Why: SSH repos pay a worktree-list round trip each; strictly serial repos
|
||||
@@ -66,10 +67,15 @@ export async function scanWorkspaceCleanup(
|
||||
if (hasTargetedWorkspaceCleanupScan(args) && targetWorktreeIdsByRepo.size === 0) {
|
||||
return { scannedAt, candidates: [], errors: [] }
|
||||
}
|
||||
const allRepos = store.getRepos()
|
||||
const repos =
|
||||
targetWorktreeIdsByRepo.size > 0
|
||||
? store.getRepos().filter((repo) => targetWorktreeIdsByRepo.has(repo.id))
|
||||
: store.getRepos()
|
||||
? allRepos.filter((repo) => targetWorktreeIdsByRepo.has(repo.id))
|
||||
: allRepos
|
||||
const repoOwnerCountById = new Map<string, number>()
|
||||
for (const repo of allRepos) {
|
||||
repoOwnerCountById.set(repo.id, (repoOwnerCountById.get(repo.id) ?? 0) + 1)
|
||||
}
|
||||
const progress = createWorkspaceCleanupProgressEmitter(args.scanId, scannedAt, options)
|
||||
const errors: WorkspaceCleanupScanResult['errors'] = []
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
@@ -83,6 +89,7 @@ export async function scanWorkspaceCleanup(
|
||||
return scanRepoWorkspaces({
|
||||
store,
|
||||
repo,
|
||||
repoOwnerCount: repoOwnerCountById.get(repo.id) ?? 1,
|
||||
scannedAt,
|
||||
targetWorktreeIds: targetWorktreeIdsByRepo.get(repo.id),
|
||||
refreshTargetActivity: args.worktreeId !== undefined || args.refreshActivity === true,
|
||||
@@ -109,6 +116,7 @@ async function scanRepoWorkspaces(
|
||||
args: {
|
||||
store: Store
|
||||
repo: Repo
|
||||
repoOwnerCount: number
|
||||
scannedAt: number
|
||||
targetWorktreeIds?: ReadonlySet<string>
|
||||
refreshTargetActivity: boolean
|
||||
@@ -120,6 +128,7 @@ async function scanRepoWorkspaces(
|
||||
const {
|
||||
store,
|
||||
repo,
|
||||
repoOwnerCount,
|
||||
scannedAt,
|
||||
targetWorktreeIds,
|
||||
refreshTargetActivity,
|
||||
@@ -161,6 +170,7 @@ async function scanRepoWorkspaces(
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
repoOwnerCount,
|
||||
targetWorktreeIds,
|
||||
includeAllWorkspaces
|
||||
)
|
||||
@@ -174,11 +184,12 @@ async function scanRepoWorkspaces(
|
||||
|
||||
const mergedWorktrees =
|
||||
repoIsFolder && includeAllWorkspaces
|
||||
? listWorkspaceCleanupFolderWorkspaces(store, repo)
|
||||
? listWorkspaceCleanupFolderWorkspaces(store, repo, repoOwnerCount)
|
||||
: gitWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
const ownedMeta = isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount) ? meta : undefined
|
||||
return mergeWorktree(repo.id, gitWorktree, ownedMeta, repo.displayName)
|
||||
})
|
||||
// Why: with includeAllWorkspaces the browser shows every workspace and lets
|
||||
// filters narrow it; an age threshold here would hide rows from all views.
|
||||
|
||||
@@ -260,6 +260,41 @@ describe('workspace cleanup snapshot IPC', () => {
|
||||
expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan')
|
||||
})
|
||||
|
||||
it('persists same-id dismissals by host and prunes every key by worktree id', async () => {
|
||||
let dismissals = {}
|
||||
const store = {
|
||||
...makeEmptyStore(),
|
||||
getUI: () => ({ workspaceCleanup: { dismissals } }),
|
||||
updateUI: (update: { workspaceCleanup: { dismissals: typeof dismissals } }) => {
|
||||
dismissals = update.workspaceCleanup.dismissals
|
||||
}
|
||||
} as unknown as Store
|
||||
registerWorkspaceCleanupHandlers(store)
|
||||
const handler = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls)[
|
||||
'workspaceCleanup:dismiss'
|
||||
]
|
||||
const base = {
|
||||
worktreeId: 'repo-1::/same',
|
||||
dismissedAt: NOW,
|
||||
fingerprint: 'fp',
|
||||
classifierVersion: 2
|
||||
}
|
||||
|
||||
await handler?.({} as never, {
|
||||
dismissals: [
|
||||
{ ...base, executionHostId: 'local' },
|
||||
{ ...base, executionHostId: 'ssh:ssh-1' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(Object.keys(dismissals).sort()).toEqual(
|
||||
['local\0repo-1::/same', 'ssh:ssh-1\0repo-1::/same'].sort()
|
||||
)
|
||||
|
||||
await handler?.({} as never, { dismissals: [], removedWorktreeIds: [base.worktreeId] })
|
||||
expect(dismissals).toEqual({})
|
||||
})
|
||||
|
||||
it('routes a validated removal snapshot prune batch through its explicit boundary', async () => {
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type WorkspaceCleanupSnapshotPruneRecordArgs
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { parseExecutionHostId } from '../../shared/execution-host'
|
||||
import { getWorkspaceCleanupHostIdentity } from '../../shared/workspace-cleanup-host-identity'
|
||||
import { scanWorkspaceCleanup } from './workspace-cleanup-scan'
|
||||
import { hasTargetedWorkspaceCleanupScan } from './workspace-cleanup-scan-targets'
|
||||
import {
|
||||
@@ -135,16 +136,24 @@ export function registerWorkspaceCleanupHandlers(
|
||||
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
|
||||
const next = { ...store.getUI().workspaceCleanup?.dismissals }
|
||||
for (const worktreeId of args.removedWorktreeIds ?? []) {
|
||||
delete next[worktreeId]
|
||||
for (const [identity, dismissal] of Object.entries(next)) {
|
||||
if (dismissal.worktreeId === worktreeId) {
|
||||
delete next[identity]
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const dismissal of args.dismissals ?? []) {
|
||||
if (
|
||||
dismissal &&
|
||||
dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION &&
|
||||
typeof dismissal.worktreeId === 'string' &&
|
||||
typeof dismissal.fingerprint === 'string'
|
||||
typeof dismissal.fingerprint === 'string' &&
|
||||
(dismissal.executionHostId === undefined || parseExecutionHostId(dismissal.executionHostId))
|
||||
) {
|
||||
next[dismissal.worktreeId] = dismissal
|
||||
const identity = dismissal.executionHostId
|
||||
? getWorkspaceCleanupHostIdentity(dismissal.executionHostId, dismissal.worktreeId)
|
||||
: dismissal.worktreeId
|
||||
next[identity] = dismissal
|
||||
}
|
||||
}
|
||||
store.updateUI({ workspaceCleanup: { dismissals: next } })
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('force-deletes an SSH branch that was preserved by safe worktree removal', async () => {
|
||||
it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
@@ -151,6 +151,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
worktreeBaseRef: null
|
||||
}
|
||||
const worktreeId = 'repo-ssh::/remote/feature-wt'
|
||||
const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined }
|
||||
const provider = {
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -175,16 +176,21 @@ describe('registerWorktreeHandlers', () => {
|
||||
}),
|
||||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getRepos.mockReturnValue([localRepo, repo])
|
||||
store.getRepo.mockReturnValue(localRepo)
|
||||
store.getWorktreeMeta.mockReturnValue(
|
||||
makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true })
|
||||
)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() })
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId })
|
||||
await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' })
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||||
const result = await handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||||
worktreeId,
|
||||
branchName: 'feature/test',
|
||||
expectedHead: 'def456'
|
||||
expectedHead: 'def456',
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ deleted: true })
|
||||
|
||||
@@ -511,6 +511,9 @@ describe('registerWorktreeHandlers', () => {
|
||||
includeProviderInventory: true,
|
||||
includeLocalRegistry: false
|
||||
})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1')
|
||||
expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fences a mirrored runtime folder workspace sweep to its environment', async () => {
|
||||
|
||||
@@ -338,4 +338,42 @@ describe('registerWorktreeHandlers', () => {
|
||||
finishFirst()
|
||||
await expect(first).resolves.toEqual({})
|
||||
})
|
||||
|
||||
// The repos are gone (host removed), so only the single global meta key is left to
|
||||
// key on — and it names one host. It must not merge two hosts' forgets into one.
|
||||
it('does not let stale metadata coalesce an explicit host forget onto another host', async () => {
|
||||
const worktreeId = 'repo-gone::/same/path'
|
||||
store.getRepos.mockReturnValue([])
|
||||
store.getRepo.mockReturnValue(undefined)
|
||||
store.getWorktreeMeta.mockReturnValue({ hostId: 'local' })
|
||||
let finishFirst!: () => void
|
||||
killAllProcessesForWorktreeMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishFirst = () =>
|
||||
resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 })
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 })
|
||||
|
||||
const first = handlers['worktrees:forgetLocal'](null, {
|
||||
worktreeId,
|
||||
hostId: 'local'
|
||||
}) as Promise<unknown>
|
||||
await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:forgetLocal'](null, {
|
||||
worktreeId,
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
).resolves.toEqual({})
|
||||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
finishFirst()
|
||||
await expect(first).resolves.toEqual({})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1')
|
||||
})
|
||||
})
|
||||
|
||||
+32
-24
@@ -215,21 +215,15 @@ function getRepoForWorktreeRemoval(
|
||||
repoId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): Repo | undefined {
|
||||
const matches = store
|
||||
.getRepos()
|
||||
.filter((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
|
||||
// Why: deletion must never guess between host owners; legacy unscoped calls work only while the repo id has one unique owner.
|
||||
if (matches.length === 1) {
|
||||
return matches[0]
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return undefined
|
||||
}
|
||||
const legacyMatch = store.getRepo(repoId)
|
||||
return legacyMatch && (!hostId || getRepoExecutionHostId(legacyMatch) === hostId)
|
||||
? legacyMatch
|
||||
: undefined
|
||||
const owner = resolveWorktreeRemovalRepoOwner(store, repoId, hostId)
|
||||
return owner.kind === 'resolved' ? owner.repo : undefined
|
||||
}
|
||||
import {
|
||||
hasWorktreeRemovalRepoOwnerOnOtherHost,
|
||||
resolveWorktreeRemovalMetadata,
|
||||
resolveWorktreeRemovalRepoOwner
|
||||
} from '../worktree-removal-repo-owner'
|
||||
import { classifyWorkspaceCreateError } from './workspace-create-error-classifier'
|
||||
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||||
import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy'
|
||||
@@ -328,8 +322,8 @@ function resolveWorktreeRemovalOwnerHostId(
|
||||
fallbackHostId?: ExecutionHostId
|
||||
): ExecutionHostId | undefined {
|
||||
return (
|
||||
store.getWorktreeMeta(worktreeId)?.hostId ??
|
||||
(repo ? getRepoExecutionHostId(repo) : fallbackHostId)
|
||||
fallbackHostId ??
|
||||
(repo ? getRepoExecutionHostId(repo) : store.getWorktreeMeta(worktreeId)?.hostId)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -339,19 +333,28 @@ function removeWorktreeMetadataAndTransientState(
|
||||
hostId?: ExecutionHostId,
|
||||
snapshotPruneBatchId?: string
|
||||
): void {
|
||||
const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const preservesSameIdOwner = Boolean(
|
||||
hostId &&
|
||||
((persistedHostId && persistedHostId !== hostId) ||
|
||||
hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId))
|
||||
)
|
||||
// Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace.
|
||||
if (hostId) {
|
||||
store.removeWorktreeMeta(worktreeId, hostId)
|
||||
} else {
|
||||
store.removeWorktreeMeta(worktreeId)
|
||||
}
|
||||
advertisedUrlWatcher.forgetWorktree(worktreeId)
|
||||
// Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session.
|
||||
localhostWorktreeLabelProxy.unregisterWorktree(worktreeId)
|
||||
// Why: schedule async history tree removal — never recursive-rmSync on the delete critical path.
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
// Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep).
|
||||
pruneWorktreePRRefreshAliases(worktreeId)
|
||||
if (!preservesSameIdOwner) {
|
||||
advertisedUrlWatcher.forgetWorktree(worktreeId)
|
||||
// Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session.
|
||||
localhostWorktreeLabelProxy.unregisterWorktree(worktreeId)
|
||||
// Why: schedule async history tree removal — never recursive-rmSync on the delete critical path.
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
// Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep).
|
||||
pruneWorktreePRRefreshAliases(worktreeId)
|
||||
}
|
||||
// Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots.
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
if (snapshotPruneBatchId) {
|
||||
@@ -2549,7 +2552,12 @@ export function registerWorktreeHandlers(
|
||||
: hasLocalWorktreeGitOptions
|
||||
? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions)
|
||||
: await listGitWorktreesStrict(repo.path)
|
||||
const removedMeta = store.getWorktreeMeta(args.worktreeId)
|
||||
const removedMeta = resolveWorktreeRemovalMetadata(
|
||||
store,
|
||||
repoId,
|
||||
args.worktreeId,
|
||||
removalHostId
|
||||
)
|
||||
const removedPushTarget = removedMeta?.pushTarget
|
||||
const registeredWorktree = findRegisteredDeletableWorktree(
|
||||
repo.path,
|
||||
@@ -3146,7 +3154,7 @@ export function registerWorktreeHandlers(
|
||||
args.worktreeId,
|
||||
repo
|
||||
? getRepoExecutionHostId(repo)
|
||||
: (store.getWorktreeMeta(args.worktreeId)?.hostId ?? args.hostId)
|
||||
: (args.hostId ?? store.getWorktreeMeta(args.worktreeId)?.hostId)
|
||||
)
|
||||
const optionsKey = 'forget-local'
|
||||
const inFlight = worktreeRemovalsInFlight.get(inFlightKey)
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
writeDataFile,
|
||||
readDataFile,
|
||||
makeRepo,
|
||||
makeTerminalTab
|
||||
makeTerminalTab,
|
||||
makeWorktreeLineage,
|
||||
makeWorkspaceLineage
|
||||
} from './persistence-test-harness'
|
||||
import { worktreeWorkspaceKey } from '../shared/workspace-scope'
|
||||
import { TEST_LEAF_1 } from './persistence-session-fixtures'
|
||||
|
||||
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
|
||||
@@ -516,14 +519,15 @@ describe('Store host-partitioned workspace sessions', () => {
|
||||
|
||||
expect(
|
||||
store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.['repo-split']
|
||||
).toBe(1)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.getWorkspaceSession('local').terminalTopologyRevisionByRepoId?.['repo-split']
|
||||
).toBeUndefined()
|
||||
expect(store.getWorkspaceSession('local').tabsByWorktree[otherWorktreeId]).toHaveLength(1)
|
||||
expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe('runtime:env-a')
|
||||
})
|
||||
|
||||
it('trusts persisted ownership over a stale caller hostId', async () => {
|
||||
it('preserves a same-id persisted owner when another qualified host is removed', async () => {
|
||||
const store = await createStore()
|
||||
const worktreeId = 'repo-split::/workspace/stale'
|
||||
const session = {
|
||||
@@ -532,16 +536,62 @@ describe('Store host-partitioned workspace sessions', () => {
|
||||
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })]
|
||||
}
|
||||
}
|
||||
store.setWorkspaceSession(session, 'runtime:env-a')
|
||||
store.setWorkspaceSession(session, 'local')
|
||||
store.setWorkspaceSession(session, 'runtime:env-b')
|
||||
store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' })
|
||||
store.setWorktreeMeta(worktreeId, { hostId: 'local' })
|
||||
const worktreeLineage = makeWorktreeLineage({ worktreeId })
|
||||
const workspaceLineage = makeWorkspaceLineage({
|
||||
childWorkspaceKey: worktreeWorkspaceKey(worktreeId)
|
||||
})
|
||||
store.setWorktreeLineage(worktreeId, worktreeLineage)
|
||||
store.setWorkspaceLineage(workspaceLineage)
|
||||
|
||||
// A caller's hostId comes from live routing and can go stale mid-removal; the same
|
||||
// repoId::path can name a live worktree on env-b, whose tabs must survive.
|
||||
// The confirmed removal target is env-b. Bare metadata belongs to the same-id
|
||||
// local owner and must not redirect the post-delete purge back to local.
|
||||
store.removeWorktreeMeta(worktreeId, 'runtime:env-b')
|
||||
|
||||
expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined()
|
||||
expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toHaveLength(1)
|
||||
expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe('local')
|
||||
expect(store.getWorktreeLineage(worktreeId)).toEqual(worktreeLineage)
|
||||
expect(store.getWorkspaceLineage(workspaceLineage.childWorkspaceKey)).toEqual(workspaceLineage)
|
||||
expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toHaveLength(1)
|
||||
expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a surviving local session when removed-host metadata owns the shared id', async () => {
|
||||
const store = await createStore()
|
||||
const worktreeId = 'repo-split::/workspace/stale'
|
||||
store.addRepo(makeRepo({ id: 'repo-split', path: '/local/repo' }))
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
id: 'repo-split',
|
||||
path: '/remote/repo',
|
||||
connectionId: 'ssh-b',
|
||||
executionHostId: 'ssh:ssh-b'
|
||||
})
|
||||
)
|
||||
const localSession = {
|
||||
...makeHostSession('repo-split'),
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [makeTerminalTab({ id: 'same-id-local-tab', worktreeId })]
|
||||
}
|
||||
}
|
||||
const remoteSession = {
|
||||
...makeHostSession('repo-split'),
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [makeTerminalTab({ id: 'removed-remote-tab', worktreeId })]
|
||||
}
|
||||
}
|
||||
store.setWorkspaceSession(localSession, 'local')
|
||||
store.setWorkspaceSession(remoteSession, 'ssh:ssh-b')
|
||||
store.setWorktreeMeta(worktreeId, { hostId: 'ssh:ssh-b' })
|
||||
|
||||
store.removeWorktreeMeta(worktreeId, 'ssh:ssh-b')
|
||||
|
||||
expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toEqual([
|
||||
expect.objectContaining({ id: 'same-id-local-tab' })
|
||||
])
|
||||
expect(store.getWorkspaceSession('ssh:ssh-b').tabsByWorktree[worktreeId]).toBeUndefined()
|
||||
expect(store.getWorktreeMeta(worktreeId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the caller hostId only when no ownership was recorded', async () => {
|
||||
|
||||
@@ -117,6 +117,7 @@ import {
|
||||
type RetiredNameRegistry
|
||||
} from '../../../shared/worktree/retired-name-registry'
|
||||
import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id'
|
||||
import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner'
|
||||
import { isPathInsideOrEqual } from '../../../shared/cross-platform-path'
|
||||
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
|
||||
import { normalizeTaskProviderSettings } from '../../../shared/task-providers'
|
||||
@@ -2484,17 +2485,34 @@ export class Store {
|
||||
}
|
||||
|
||||
removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void {
|
||||
// Persisted ownership beats stale live routing; hostId is only an ownerless fallback.
|
||||
const owner = this.state.worktreeMeta[worktreeId]?.hostId ?? hostId
|
||||
// A host-qualified removal names the owner; the persisted host is the fallback.
|
||||
const persistedOwner = this.state.worktreeMeta[worktreeId]?.hostId
|
||||
const owner = hostId ?? persistedOwner
|
||||
const preservesDifferentPersistedOwner = Boolean(
|
||||
hostId && persistedOwner && persistedOwner !== hostId
|
||||
)
|
||||
const ownerPartition = workspaceSessionOwnerPartitionForHost(owner)
|
||||
const preservesSameIdSessionOwner = Boolean(
|
||||
preservesDifferentPersistedOwner ||
|
||||
(owner &&
|
||||
hasWorktreeRemovalRepoOwnerOnOtherHost(
|
||||
this,
|
||||
getRepoIdFromWorktreeId(worktreeId),
|
||||
ownerPartition
|
||||
))
|
||||
)
|
||||
// Skip partitions main never wrote: materializing one fences every sibling worktree of the repo.
|
||||
const partitions = new Set<ExecutionHostId>(
|
||||
workspaceSessionPartitionIdsForHost(owner).filter((partition) =>
|
||||
this.hasPersistedWorkspaceSession(partition)
|
||||
workspaceSessionPartitionIdsForHost(owner).filter(
|
||||
(partition) =>
|
||||
this.hasPersistedWorkspaceSession(partition) &&
|
||||
// The local partition can be a remote spill surface or a same-id owner.
|
||||
// Preserve it whenever another owner may still use the bare id.
|
||||
(!preservesSameIdSessionOwner || partition === ownerPartition)
|
||||
)
|
||||
)
|
||||
// A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill
|
||||
// partition that never held this worktree has no claim on the repo at all.
|
||||
const ownerPartition = workspaceSessionOwnerPartitionForHost(owner)
|
||||
const fencedPartitions = new Set(
|
||||
[...partitions].filter(
|
||||
(partition) =>
|
||||
@@ -2503,9 +2521,11 @@ export class Store {
|
||||
!this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition))
|
||||
)
|
||||
)
|
||||
delete this.state.worktreeMeta[worktreeId]
|
||||
delete this.state.worktreeLineageById[worktreeId]
|
||||
delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)]
|
||||
if (!preservesDifferentPersistedOwner) {
|
||||
delete this.state.worktreeMeta[worktreeId]
|
||||
delete this.state.worktreeLineageById[worktreeId]
|
||||
delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)]
|
||||
}
|
||||
for (const partition of partitions) {
|
||||
this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, {
|
||||
advanceTerminalTopologyRevision: fencedPartitions.has(partition)
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types'
|
||||
import {
|
||||
reviewHeadRemoteRefComponent,
|
||||
@@ -2441,6 +2442,97 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(closeTab).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves bare-id runtime state when removing a different qualified owner', () => {
|
||||
const removeWorktreeMeta = vi.fn()
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'local' }),
|
||||
removeWorktreeMeta
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const internals = runtime as unknown as {
|
||||
mobileSessionTabsByWorktree: Map<string, unknown>
|
||||
removeWorktreeMetadataAndHistory: (
|
||||
runtimeStore: typeof store,
|
||||
worktreeId: string,
|
||||
hostId: string
|
||||
) => void
|
||||
}
|
||||
const localSession = { tabs: [{ id: 'local-tab' }] }
|
||||
internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, localSession)
|
||||
|
||||
internals.removeWorktreeMetadataAndHistory(
|
||||
runtimeStore as typeof store,
|
||||
TEST_WORKTREE_ID,
|
||||
'runtime:env-b'
|
||||
)
|
||||
|
||||
expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'runtime:env-b')
|
||||
expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(localSession)
|
||||
})
|
||||
|
||||
it('preserves bare-id runtime state when removed-host metadata masks another owner', () => {
|
||||
const localRepo = store.getRepo(TEST_REPO_ID)!
|
||||
const remoteRepo = { ...localRepo, connectionId: 'ssh-1' }
|
||||
const removeWorktreeMeta = vi.fn()
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [localRepo, remoteRepo],
|
||||
getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'ssh:ssh-1' }),
|
||||
removeWorktreeMeta
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const internals = runtime as unknown as {
|
||||
mobileSessionTabsByWorktree: Map<string, unknown>
|
||||
removeWorktreeMetadataAndHistory: (
|
||||
runtimeStore: typeof store,
|
||||
worktreeId: string,
|
||||
hostId: string
|
||||
) => void
|
||||
}
|
||||
const survivingSession = { tabs: [{ id: 'same-id-local-tab' }] }
|
||||
internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, survivingSession)
|
||||
deleteWorktreeHistoryDirMock.mockClear()
|
||||
|
||||
internals.removeWorktreeMetadataAndHistory(
|
||||
runtimeStore as typeof store,
|
||||
TEST_WORKTREE_ID,
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
|
||||
expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'ssh:ssh-1')
|
||||
expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(survivingSession)
|
||||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a qualified removal when only another host has persisted ownership', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'local' })
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const internals = runtime as unknown as {
|
||||
listResolvedWorktrees: () => Promise<unknown[]>
|
||||
resolveWorktreeRemovalTarget: (
|
||||
worktreeSelector: string,
|
||||
requiredHostId?: string
|
||||
) => Promise<unknown>
|
||||
}
|
||||
internals.listResolvedWorktrees = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: TEST_WORKTREE_ID,
|
||||
repoId: TEST_REPO_ID,
|
||||
path: TEST_WORKTREE_PATH,
|
||||
hostId: 'local'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
internals.resolveWorktreeRemovalTarget(TEST_WORKTREE_ID, 'runtime:env-b')
|
||||
).rejects.toThrow('selector_not_found')
|
||||
expect(internals.listResolvedWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims the first window as authoritative and ignores later windows', () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
@@ -41072,6 +41164,58 @@ describe('OrcaRuntimeService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps colliding folder workspace metadata scoped to its owning host', async () => {
|
||||
const localRepo = {
|
||||
id: 'folder-repo',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'local-folder',
|
||||
badgeColor: 'blue' as const,
|
||||
addedAt: 1,
|
||||
kind: 'folder' as const
|
||||
}
|
||||
const remoteRepo = {
|
||||
...localRepo,
|
||||
displayName: 'remote-folder',
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const rootId = `${localRepo.id}::${localRepo.path}`
|
||||
const childId = `${rootId}::workspace:local-child`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[rootId]: makeWorktreeMeta({
|
||||
hostId: 'local',
|
||||
displayName: 'local-root-name',
|
||||
comment: 'local-only-comment',
|
||||
isPinned: true
|
||||
}),
|
||||
[childId]: makeWorktreeMeta({
|
||||
hostId: 'local',
|
||||
instanceId: 'local-child',
|
||||
displayName: 'local-child-name'
|
||||
})
|
||||
}
|
||||
const setWorktreeMeta = vi.fn()
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepos: () => [localRepo, remoteRepo],
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta
|
||||
} as never)
|
||||
|
||||
const result = await runtime.listDetectedManagedWorktrees(`id:${remoteRepo.id}`, 'ssh-1')
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rootId,
|
||||
hostId: 'ssh:ssh-1',
|
||||
displayName: 'remote-folder',
|
||||
comment: '',
|
||||
isPinned: false
|
||||
})
|
||||
])
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides agent scratch created inside a linked checkout from runtime listings', async () => {
|
||||
const linkedCheckoutPath = '/tmp/worktree-a'
|
||||
const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`
|
||||
@@ -41153,6 +41297,135 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(listWorktrees).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('worktree scan cache: keeps colliding local and SSH owners warm independently', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
const localRepo = store.getRepo(TEST_REPO_ID)!
|
||||
const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' }
|
||||
const provider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/worktree')])
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepos: () => [localRepo, remoteRepo]
|
||||
} as never)
|
||||
vi.mocked(listWorktrees).mockResolvedValue([makeWorktreeInfo(TEST_WORKTREE_PATH)])
|
||||
|
||||
try {
|
||||
await runtime.listManagedWorktrees()
|
||||
runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID)
|
||||
await runtime.listManagedWorktrees()
|
||||
|
||||
expect(listWorktrees).toHaveBeenCalledTimes(1)
|
||||
expect(provider.listWorktrees).toHaveBeenCalledTimes(1)
|
||||
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
const replacementProvider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/replacement')])
|
||||
}
|
||||
registerSshGitProvider('ssh-1', replacementProvider as never)
|
||||
runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID)
|
||||
await runtime.listManagedWorktrees()
|
||||
|
||||
expect(listWorktrees).toHaveBeenCalledTimes(1)
|
||||
expect(replacementProvider.listWorktrees).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps detected metadata scoped to the selected colliding host', async () => {
|
||||
const localRepo = store.getRepo(TEST_REPO_ID)!
|
||||
const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' }
|
||||
const worktreePath = '/same/worktree'
|
||||
const worktreeId = `${TEST_REPO_ID}::${worktreePath}`
|
||||
const localMeta = makeWorktreeMeta({
|
||||
hostId: 'local',
|
||||
displayName: 'local-only-name',
|
||||
comment: 'local-only-comment',
|
||||
isPinned: true
|
||||
})
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [localRepo, remoteRepo],
|
||||
getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined),
|
||||
getAllWorktreeMeta: () => ({ [worktreeId]: localMeta }),
|
||||
getWorktreeMeta: (id: string) => (id === worktreeId ? localMeta : undefined)
|
||||
}
|
||||
const provider = { listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo(worktreePath)]) }
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
try {
|
||||
const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`, 'ssh-1')
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: worktreeId,
|
||||
hostId: 'ssh:ssh-1',
|
||||
comment: '',
|
||||
isPinned: false
|
||||
})
|
||||
])
|
||||
expect(result.worktrees[0]?.displayName).not.toBe('local-only-name')
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not project one colliding host lineage onto another host rows', async () => {
|
||||
const localRepo = store.getRepo(TEST_REPO_ID)!
|
||||
const remoteRepo = { ...localRepo, connectionId: 'ssh-1' }
|
||||
const parentPath = '/same/parent'
|
||||
const childPath = '/same/child'
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', instanceId: 'remote-parent' }),
|
||||
[childId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', instanceId: 'remote-child' })
|
||||
}
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'remote-child',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'remote-parent',
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [localRepo, remoteRepo],
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (id: string) => metaById[id],
|
||||
getAllWorktreeLineage: () => lineageById
|
||||
}
|
||||
const rows = [makeWorktreeInfo(childPath), makeWorktreeInfo(parentPath)]
|
||||
vi.mocked(listWorktrees).mockResolvedValue(rows)
|
||||
const provider = { listWorktrees: vi.fn().mockResolvedValue(rows) }
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
try {
|
||||
const resolved = await (
|
||||
runtime as unknown as { listResolvedWorktrees: () => Promise<Worktree[]> }
|
||||
).listResolvedWorktrees()
|
||||
const localChild = resolved.find(
|
||||
(worktree) => worktree.id === childId && worktree.hostId === 'local'
|
||||
)
|
||||
const remoteChild = resolved.find(
|
||||
(worktree) => worktree.id === childId && worktree.hostId === 'ssh:ssh-1'
|
||||
)
|
||||
|
||||
expect(localChild).toMatchObject({ parentWorktreeId: null, lineage: null })
|
||||
expect(remoteChild).toMatchObject({ parentWorktreeId: parentId })
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('worktree scan cache: rescans immediately after worktree invalidation', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
const runtime = createRuntime()
|
||||
@@ -47592,6 +47865,55 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(stopAndWait).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed before teardown when a qualified target loses its host owner', async () => {
|
||||
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID, {
|
||||
hostId: 'local'
|
||||
})
|
||||
const orphanStore = {
|
||||
...runtimeStore,
|
||||
getRepos: () => [],
|
||||
getRepo: () => undefined
|
||||
}
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => [{ id: `${TEST_WORKTREE_ID}@@1` }]),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(orphanStore as never, undefined, {
|
||||
getLocalProvider: () => localProvider as never
|
||||
})
|
||||
const stopAndWait = vi.fn().mockResolvedValue(true)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
stopAndWait,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
syncSinglePty(runtime, 'local-pty-1')
|
||||
runtime.registerPty('local-pty-1', TEST_WORKTREE_ID)
|
||||
const internals = runtime as unknown as {
|
||||
resolveWorktreeRemovalTarget: () => Promise<{
|
||||
id: string
|
||||
repoId: string
|
||||
path: string
|
||||
}>
|
||||
}
|
||||
internals.resolveWorktreeRemovalTarget = vi.fn().mockResolvedValue({
|
||||
id: TEST_WORKTREE_ID,
|
||||
repoId: TEST_REPO_ID,
|
||||
path: TEST_WORKTREE_PATH
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'runtime:env-b')
|
||||
).rejects.toThrow('no longer belongs to runtime:env-b')
|
||||
|
||||
expect(localProvider.listProcesses).not.toHaveBeenCalled()
|
||||
expect(localProvider.shutdown).not.toHaveBeenCalled()
|
||||
expect(stopAndWait).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(closeLocalWatcherForWorktreePathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still sweeps the local host for an ownerless orphan', async () => {
|
||||
const { runtimeStore } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
|
||||
const orphanStore = {
|
||||
@@ -48271,6 +48593,85 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('force-deletes a preserved branch on the qualified host when repo ids collide', async () => {
|
||||
const localRepo = store.getRepo(TEST_REPO_ID)!
|
||||
const remoteRepo = {
|
||||
...localRepo,
|
||||
path: '/remote/repo',
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const remoteWorktree = {
|
||||
path: TEST_WORKTREE_PATH,
|
||||
head: 'def456',
|
||||
branch: 'feature/test',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[TEST_WORKTREE_ID]: makeWorktreeMeta({
|
||||
hostId: 'local',
|
||||
preserveBranchOnDelete: true
|
||||
})
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [localRepo, remoteRepo],
|
||||
getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
removeWorktreeMeta: (worktreeId: string, hostId?: string) => {
|
||||
if (!hostId || metaById[worktreeId]?.hostId === hostId) {
|
||||
delete metaById[worktreeId]
|
||||
}
|
||||
}
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: remoteRepo.path,
|
||||
head: 'main',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
remoteWorktree
|
||||
]),
|
||||
removeWorktree: vi.fn().mockResolvedValue({
|
||||
preservedBranch: { branchName: 'feature/test', head: 'def456' }
|
||||
})
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
|
||||
try {
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'ssh:ssh-1')
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false)
|
||||
expect(metaById[TEST_WORKTREE_ID]?.hostId).toBe('local')
|
||||
const result = await runtime.forceDeletePreservedBranch(
|
||||
TEST_WORKTREE_ID,
|
||||
'feature/test',
|
||||
'def456',
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
|
||||
expect(result).toEqual({ deleted: true })
|
||||
expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith(
|
||||
remoteRepo.path,
|
||||
'feature/test',
|
||||
'def456'
|
||||
)
|
||||
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('routes runtime preserved-branch force-delete through the selected WSL project runtime', async () => {
|
||||
setPlatform('win32')
|
||||
const runtimeStore = {
|
||||
@@ -48347,6 +48748,44 @@ describe('OrcaRuntimeService', () => {
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([{}, {}])
|
||||
})
|
||||
|
||||
it('does not coalesce concurrent same-id removals on different hosts', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{ ...store.getRepos()[0], executionHostId: 'local' },
|
||||
{ ...store.getRepos()[0], executionHostId: 'runtime:env-1' }
|
||||
]
|
||||
}
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() })
|
||||
const bothStarted = deferred<void>()
|
||||
const finishRemovals = deferred<void>()
|
||||
let startedCount = 0
|
||||
vi.mocked(removeWorktree).mockImplementation(async () => {
|
||||
startedCount += 1
|
||||
if (startedCount === 2) {
|
||||
bothStarted.resolve()
|
||||
}
|
||||
await finishRemovals.promise
|
||||
return {}
|
||||
})
|
||||
|
||||
const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local')
|
||||
const paired = runtime.removeManagedWorktree(
|
||||
TEST_WORKTREE_ID,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'runtime:env-1'
|
||||
)
|
||||
|
||||
await bothStarted.promise
|
||||
expect(removeWorktree).toHaveBeenCalledTimes(2)
|
||||
|
||||
finishRemovals.resolve()
|
||||
await expect(Promise.all([local, paired])).resolves.toEqual([{}, {}])
|
||||
})
|
||||
|
||||
it('rejects concurrent runtime worktree removals for the same id with different options', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
const removeStarted = deferred<void>()
|
||||
|
||||
@@ -823,6 +823,7 @@ import {
|
||||
RESOLVED_WORKTREE_REPO_TIMEOUT_MS,
|
||||
type RepoWorktreeRowDeps
|
||||
} from './repo-worktree-row-resolution'
|
||||
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
import {
|
||||
getLocalWorktreePathAccess,
|
||||
@@ -1088,6 +1089,11 @@ import {
|
||||
stripOrcaProvenanceMetaUpdates,
|
||||
UNREGISTERED_MISSING_WORKTREE_MESSAGE
|
||||
} from '../worktree-removal-safety'
|
||||
import {
|
||||
hasWorktreeRemovalRepoOwnerOnOtherHost,
|
||||
resolveWorktreeRemovalMetadata,
|
||||
resolveWorktreeRemovalRepoOwner
|
||||
} from '../worktree-removal-repo-owner'
|
||||
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
|
||||
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
|
||||
import {
|
||||
@@ -2326,13 +2332,17 @@ function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: strin
|
||||
}
|
||||
|
||||
function listRuntimeFolderWorkspaces(
|
||||
store: Pick<RuntimeStore, 'getAllWorktreeMeta' | 'setWorktreeMeta'>,
|
||||
store: Pick<RuntimeStore, 'getAllWorktreeMeta' | 'getRepos' | 'setWorktreeMeta'>,
|
||||
repo: Repo
|
||||
): Worktree[] {
|
||||
const rootId = getRuntimeFolderWorkspaceRootId(repo)
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
const ids = Object.keys(allMeta).filter((worktreeId) =>
|
||||
isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId)
|
||||
const expectedHostId = getRepoExecutionHostId(repo)
|
||||
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
|
||||
const ids = Object.keys(allMeta).filter(
|
||||
(worktreeId) =>
|
||||
isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) &&
|
||||
(repoOwnerCount === 1 || allMeta[worktreeId]?.hostId === expectedHostId)
|
||||
)
|
||||
if (!ids.includes(rootId)) {
|
||||
ids.unshift(rootId)
|
||||
@@ -2349,14 +2359,19 @@ function listRuntimeFolderWorkspaces(
|
||||
}
|
||||
|
||||
return ids.map((worktreeId) => {
|
||||
const existing = allMeta[worktreeId]
|
||||
const meta = existing?.instanceId
|
||||
const existing = getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount)
|
||||
const meta: Partial<WorktreeMeta> = existing?.instanceId
|
||||
? existing
|
||||
: store.setWorktreeMeta(worktreeId, {
|
||||
instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId),
|
||||
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
|
||||
})
|
||||
return mergeRuntimeFolderWorkspace(repo, worktreeId, meta)
|
||||
: existing || repoOwnerCount === 1
|
||||
? store.setWorktreeMeta(worktreeId, {
|
||||
instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId),
|
||||
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
|
||||
})
|
||||
: {}
|
||||
return {
|
||||
...mergeRuntimeFolderWorkspace(repo, worktreeId, meta),
|
||||
hostId: repoOwnerCount === 1 ? (meta.hostId ?? expectedHostId) : expectedHostId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22259,12 +22274,20 @@ export class OrcaRuntimeService {
|
||||
const visibilitySettings = { ...settings, worktreeVisibilityDefaults: visibilityDefaults }
|
||||
if (isFolderRepo(repo)) {
|
||||
const worktrees = listRuntimeFolderWorkspaces(store, repo)
|
||||
const metaById = store.getAllWorktreeMeta()
|
||||
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
|
||||
const matcher = createWorktreeVisibilitySourceMatcher(
|
||||
[repo.path, ...worktrees.map((worktree) => worktree.path)],
|
||||
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
|
||||
)
|
||||
const detected = worktrees.map((worktree) =>
|
||||
this.toRuntimeDetectedWorktree(repo, worktree, matcher, visibilitySettings)
|
||||
this.toRuntimeDetectedWorktree(
|
||||
repo,
|
||||
worktree,
|
||||
matcher,
|
||||
visibilitySettings,
|
||||
getRepoOwnedWorktreeMeta(repo, worktree.id, metaById, repoOwnerCount) ?? null
|
||||
)
|
||||
)
|
||||
return {
|
||||
repoId: repo.id,
|
||||
@@ -22286,18 +22309,22 @@ export class OrcaRuntimeService {
|
||||
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
|
||||
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
|
||||
)
|
||||
const expectedHostId = getRepoExecutionHostId(repo)
|
||||
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
|
||||
const metaById = store.getAllWorktreeMeta()
|
||||
const detected = scan.worktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
|
||||
const worktree = {
|
||||
...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
|
||||
hostId: meta?.hostId ?? getRepoExecutionHostId(repo)
|
||||
hostId: repoOwnerCount === 1 ? (meta?.hostId ?? expectedHostId) : expectedHostId
|
||||
}
|
||||
const detectedWorktree = this.toRuntimeDetectedWorktree(
|
||||
repo,
|
||||
worktree,
|
||||
worktreeVisibilitySourceMatcher,
|
||||
visibilitySettings
|
||||
visibilitySettings,
|
||||
meta ?? null
|
||||
)
|
||||
if (scan.ok) {
|
||||
return detectedWorktree
|
||||
@@ -22402,7 +22429,8 @@ export class OrcaRuntimeService {
|
||||
repo: Repo,
|
||||
worktree: Worktree,
|
||||
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher,
|
||||
providedSettings?: ReturnType<RuntimeStore['getSettings']>
|
||||
providedSettings?: ReturnType<RuntimeStore['getSettings']>,
|
||||
providedMeta?: WorktreeMeta | null
|
||||
): DetectedWorktree {
|
||||
const settings = providedSettings ?? this.store?.getSettings()
|
||||
if (!settings) {
|
||||
@@ -22416,7 +22444,10 @@ export class OrcaRuntimeService {
|
||||
return toDetectedWorktree({
|
||||
repo,
|
||||
worktree,
|
||||
meta: this.store?.getWorktreeMeta(worktree.id),
|
||||
meta:
|
||||
providedMeta === undefined
|
||||
? this.store?.getWorktreeMeta(worktree.id)
|
||||
: (providedMeta ?? undefined),
|
||||
settings,
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo),
|
||||
isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo),
|
||||
@@ -25523,10 +25554,18 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private async resolveWorktreeRemovalTarget(
|
||||
worktreeSelector: string
|
||||
worktreeSelector: string,
|
||||
requiredHostId?: ExecutionHostId
|
||||
): Promise<RuntimeWorktreeRemovalTarget> {
|
||||
try {
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
const exactTarget = parseExactWorktreeIdSelector(worktreeSelector)
|
||||
const worktree =
|
||||
exactTarget && requiredHostId
|
||||
? ((await this.resolveExplicitWorktreeIdScoped(exactTarget.id, requiredHostId)) ??
|
||||
(() => {
|
||||
throw new Error('selector_not_found')
|
||||
})())
|
||||
: await this.resolveWorktreeSelector(worktreeSelector)
|
||||
const removalTarget = {
|
||||
id: worktree.id,
|
||||
repoId: worktree.repoId,
|
||||
@@ -25541,7 +25580,11 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const removalTarget = parseExactWorktreeIdSelector(worktreeSelector)
|
||||
const meta = removalTarget ? this.store?.getWorktreeMeta(removalTarget.id) : undefined
|
||||
if (!removalTarget || !meta) {
|
||||
if (
|
||||
!removalTarget ||
|
||||
!meta ||
|
||||
(requiredHostId !== undefined && meta.hostId !== requiredHostId)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
// Why: delete requests can arrive after Git no longer lists the worktree.
|
||||
@@ -25551,21 +25594,33 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private removeWorktreeMetadataAndHistory(store: RuntimeStore, worktreeId: string): void {
|
||||
private removeWorktreeMetadataAndHistory(
|
||||
store: RuntimeStore,
|
||||
worktreeId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): void {
|
||||
// Why: worktree IDs are path-derived and can be recreated, so removal must
|
||||
// purge history and process-local caches before the ID points at new state.
|
||||
const hostId = store.getWorktreeMeta(worktreeId)?.hostId
|
||||
const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId
|
||||
const repoId = splitWorktreeId(worktreeId)?.repoId
|
||||
const preservesSameIdOwner = Boolean(
|
||||
hostId &&
|
||||
((persistedHostId && persistedHostId !== hostId) ||
|
||||
(repoId && hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)))
|
||||
)
|
||||
if (hostId) {
|
||||
store.removeWorktreeMeta(worktreeId, hostId)
|
||||
} else {
|
||||
store.removeWorktreeMeta(worktreeId)
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.delete(worktreeId)
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId)
|
||||
this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId)
|
||||
advertisedUrlWatcher.forgetWorktree(worktreeId)
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
this.closeHeadlessBrowserPagesForWorktree(worktreeId)
|
||||
if (!preservesSameIdOwner) {
|
||||
this.mobileSessionTabsByWorktree.delete(worktreeId)
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId)
|
||||
this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId)
|
||||
advertisedUrlWatcher.forgetWorktree(worktreeId)
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
this.closeHeadlessBrowserPagesForWorktree(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: headless offscreen browser pages are main-process BrowserWindows that
|
||||
@@ -25662,7 +25717,17 @@ export class OrcaRuntimeService {
|
||||
throw new Error(`No preserved branch cleanup is pending for "${branchName}".`)
|
||||
}
|
||||
|
||||
const repo = this.store.getRepo(removalTarget.repoId)
|
||||
const repoOwner = resolveWorktreeRemovalRepoOwner(
|
||||
this.store,
|
||||
removalTarget.repoId,
|
||||
cleanupTarget.hostId
|
||||
)
|
||||
if (repoOwner.kind === 'ambiguous') {
|
||||
throw new Error(
|
||||
`Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.`
|
||||
)
|
||||
}
|
||||
const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined
|
||||
if (!repo) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
@@ -25729,13 +25794,13 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const store = this.store
|
||||
const cleanupHostId = parseExecutionHostId(hostId)?.id
|
||||
const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector)
|
||||
const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId)
|
||||
const cleanupScopeKey = preservedBranchCleanupScopeKey({
|
||||
worktreeId: removalTarget.id,
|
||||
hostId: cleanupHostId
|
||||
})
|
||||
const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop)
|
||||
const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalTarget.id)
|
||||
const inFlightRemoval = this.removeManagedWorktreeInFlight.get(cleanupScopeKey)
|
||||
if (inFlightRemoval) {
|
||||
if (inFlightRemoval.optionsKey === optionsKey) {
|
||||
return inFlightRemoval.promise
|
||||
@@ -25744,14 +25809,32 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
// Why: runtime callers can race the same workspace through CLI/mobile
|
||||
// retries. Share one destructive Git/filesystem operation per worktree ID.
|
||||
// retries. Share one destructive operation per host-qualified workspace.
|
||||
const removal = (async (): Promise<RemoveWorktreeResult & { warning?: string }> => {
|
||||
// Why: CLI, mobile and headless serve delete through here rather than the IPC handler; without
|
||||
// this span their freezes are as invisible as desktop deletes were before `worktree.remove`.
|
||||
return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => {
|
||||
const repo = store.getRepo(removalTarget.repoId)
|
||||
// Why (STA-4343): a repo id can exist once per host. Honor the caller's
|
||||
// host qualifier, and refuse an unqualified delete the runtime cannot
|
||||
// pin to one owner rather than deleting a same-id workspace elsewhere.
|
||||
const repoOwner = resolveWorktreeRemovalRepoOwner(
|
||||
store,
|
||||
removalTarget.repoId,
|
||||
cleanupHostId
|
||||
)
|
||||
if (repoOwner.kind === 'ambiguous') {
|
||||
throw new Error(
|
||||
`Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.`
|
||||
)
|
||||
}
|
||||
const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined
|
||||
if (!repo) {
|
||||
const orphanHost = parseExecutionHostId(store.getWorktreeMeta(removalTarget.id)?.hostId)
|
||||
if (cleanupHostId && orphanHost?.id !== cleanupHostId) {
|
||||
throw new Error(
|
||||
`Workspace identity for ${removalTarget.id} no longer belongs to ${cleanupHostId}. Refresh projects and try again.`
|
||||
)
|
||||
}
|
||||
const sshPtyProvider =
|
||||
orphanHost?.kind === 'ssh' ? this.getSshProviderFn?.(orphanHost.targetId) : undefined
|
||||
const ptyProvider = sshPtyProvider ?? this.getLocalProvider()
|
||||
@@ -25796,7 +25879,11 @@ export class OrcaRuntimeService {
|
||||
.catch(() => {})
|
||||
}
|
||||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(
|
||||
store,
|
||||
removalTarget.id,
|
||||
cleanupHostId ?? orphanHost?.id
|
||||
)
|
||||
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
|
||||
@@ -25839,7 +25926,7 @@ export class OrcaRuntimeService {
|
||||
console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err)
|
||||
})
|
||||
}
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
|
||||
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifyWorktreesChanged(repo.id)
|
||||
@@ -25856,7 +25943,12 @@ export class OrcaRuntimeService {
|
||||
: hasLocalWorktreeGitOptions
|
||||
? await listWorktreesStrict(repo.path, localWorktreeGitOptions)
|
||||
: await listWorktreesStrict(repo.path)
|
||||
const removedMeta = store.getWorktreeMeta(removalTarget.id)
|
||||
const removedMeta = resolveWorktreeRemovalMetadata(
|
||||
store,
|
||||
removalTarget.repoId,
|
||||
removalTarget.id,
|
||||
cleanupHostId ?? getRepoExecutionHostId(repo)
|
||||
)
|
||||
const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget
|
||||
const registeredWorktree = findRegisteredDeletableWorktree(
|
||||
repo.path,
|
||||
@@ -25944,7 +26036,7 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
|
||||
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
|
||||
@@ -25993,7 +26085,7 @@ export class OrcaRuntimeService {
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
|
||||
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
|
||||
@@ -26029,7 +26121,7 @@ export class OrcaRuntimeService {
|
||||
localWorktreeGitOptions
|
||||
))
|
||||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
|
||||
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
|
||||
@@ -26083,7 +26175,7 @@ export class OrcaRuntimeService {
|
||||
removedPushTarget
|
||||
)
|
||||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
@@ -26129,7 +26221,7 @@ export class OrcaRuntimeService {
|
||||
removedPushTarget
|
||||
)
|
||||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
@@ -26285,7 +26377,7 @@ export class OrcaRuntimeService {
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
|
||||
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
|
||||
@@ -26319,7 +26411,7 @@ export class OrcaRuntimeService {
|
||||
removedPushTarget
|
||||
)
|
||||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
@@ -26330,7 +26422,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
})
|
||||
})()
|
||||
this.removeManagedWorktreeInFlight.set(removalTarget.id, { optionsKey, promise: removal })
|
||||
this.removeManagedWorktreeInFlight.set(cleanupScopeKey, { optionsKey, promise: removal })
|
||||
try {
|
||||
const result = await removal
|
||||
this.emitWorktreeLifecycle({
|
||||
@@ -26340,8 +26432,8 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
return result
|
||||
} finally {
|
||||
if (this.removeManagedWorktreeInFlight.get(removalTarget.id)?.promise === removal) {
|
||||
this.removeManagedWorktreeInFlight.delete(removalTarget.id)
|
||||
if (this.removeManagedWorktreeInFlight.get(cleanupScopeKey)?.promise === removal) {
|
||||
this.removeManagedWorktreeInFlight.delete(cleanupScopeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30773,9 +30865,9 @@ export class OrcaRuntimeService {
|
||||
async (repo) => await resolveRepoWorktreeRows(deps, repo, metaById, projectRuntimeByRepoId)
|
||||
)
|
||||
)
|
||||
const worktrees = projectResolvedWorktreeLineage(
|
||||
perRepoWorktrees.flat(),
|
||||
this.store?.getAllWorktreeLineage?.() ?? {}
|
||||
const lineageById = this.store?.getAllWorktreeLineage?.() ?? {}
|
||||
const worktrees = perRepoWorktrees.flatMap((rows) =>
|
||||
projectResolvedWorktreeLineage(rows, lineageById)
|
||||
)
|
||||
// Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca.
|
||||
// Why stamped on completion, not entry: a compute that spent longer than the TTL would otherwise publish an
|
||||
@@ -30802,12 +30894,13 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private async resolveExplicitWorktreeIdScoped(
|
||||
worktreeId: string
|
||||
worktreeId: string,
|
||||
requiredHostId?: ExecutionHostId
|
||||
): Promise<ResolvedWorktree | null> {
|
||||
if (!this.store) {
|
||||
return null
|
||||
}
|
||||
return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId)
|
||||
return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId, requiredHostId)
|
||||
}
|
||||
|
||||
private async listRepoWorktreesForResolution(
|
||||
@@ -30815,12 +30908,13 @@ export class OrcaRuntimeService {
|
||||
projectRuntimeByRepoId?: ReadonlyMap<string, ProjectExecutionRuntimeResolution>
|
||||
): Promise<RuntimeWorktreeScanResult> {
|
||||
const now = Date.now()
|
||||
const generation = this.worktreeScanGenerations.get(repo.id) ?? 0
|
||||
const projectRuntime = projectRuntimeByRepoId
|
||||
? projectRuntimeByRepoId.get(repo.id)
|
||||
: !repo.connectionId
|
||||
? resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
|
||||
: undefined
|
||||
const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}`
|
||||
const generation = this.worktreeScanGenerations.get(scanScopeKey) ?? 0
|
||||
const projectRuntime = repo.connectionId
|
||||
? undefined
|
||||
: projectRuntimeByRepoId
|
||||
? projectRuntimeByRepoId.get(repo.id)
|
||||
: resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
|
||||
const runtimeKey = projectRuntime
|
||||
? projectRuntime.status === 'resolved'
|
||||
? projectRuntime.runtime.cacheKey
|
||||
@@ -30828,7 +30922,7 @@ export class OrcaRuntimeService {
|
||||
: repo.connectionId
|
||||
? `ssh:${repo.connectionId}:${getSshGitProviderGeneration(repo.connectionId)}`
|
||||
: 'local:default'
|
||||
const cached = this.worktreeScanCache.get(repo.id)
|
||||
const cached = this.worktreeScanCache.get(scanScopeKey)
|
||||
if (
|
||||
cached?.generation === generation &&
|
||||
cached.runtimeKey === runtimeKey &&
|
||||
@@ -30836,21 +30930,21 @@ export class OrcaRuntimeService {
|
||||
) {
|
||||
return cached.result
|
||||
}
|
||||
const inFlight = this.worktreeScanInFlight.get(repo.id)
|
||||
const inFlight = this.worktreeScanInFlight.get(scanScopeKey)
|
||||
if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) {
|
||||
return (await inFlight.promise).result
|
||||
}
|
||||
const reusableCached =
|
||||
cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null
|
||||
const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached)
|
||||
this.worktreeScanInFlight.set(repo.id, { generation, runtimeKey, promise })
|
||||
this.worktreeScanInFlight.set(scanScopeKey, { generation, runtimeKey, promise })
|
||||
try {
|
||||
const refresh = await promise
|
||||
// Why: back off local spawn failures under resource pressure while disconnected SSH can recover on the next poll.
|
||||
if (
|
||||
(refresh.result.ok || !repo.connectionId) &&
|
||||
generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) &&
|
||||
this.worktreeScanInFlight.get(repo.id)?.promise === promise
|
||||
generation === (this.worktreeScanGenerations.get(scanScopeKey) ?? 0) &&
|
||||
this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise
|
||||
) {
|
||||
const entry: RuntimeWorktreeScanCache = {
|
||||
generation,
|
||||
@@ -30860,19 +30954,19 @@ export class OrcaRuntimeService {
|
||||
adminFingerprint: refresh.adminFingerprint,
|
||||
scannedAt: refresh.scannedAt
|
||||
}
|
||||
this.worktreeScanCache.set(repo.id, entry)
|
||||
this.worktreeScanCache.set(scanScopeKey, entry)
|
||||
// Why a writeback instead of storing the promise: a probe that never settles must not be
|
||||
// awaited by a later refresh. Identity check keeps a stale probe out of a newer entry.
|
||||
void refresh.adminFingerprintProbe?.then((fingerprint) => {
|
||||
if (this.worktreeScanCache.get(repo.id) === entry) {
|
||||
if (this.worktreeScanCache.get(scanScopeKey) === entry) {
|
||||
entry.adminFingerprint = fingerprint
|
||||
}
|
||||
})
|
||||
}
|
||||
return refresh.result
|
||||
} finally {
|
||||
if (this.worktreeScanInFlight.get(repo.id)?.promise === promise) {
|
||||
this.worktreeScanInFlight.delete(repo.id)
|
||||
if (this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise) {
|
||||
this.worktreeScanInFlight.delete(scanScopeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30980,22 +31074,43 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private invalidateWorktreeScanCacheForRepo(repoId: string): void {
|
||||
this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1)
|
||||
this.worktreeScanCache.delete(repoId)
|
||||
this.worktreeScanInFlight.delete(repoId)
|
||||
const prefix = `${repoId}\0`
|
||||
const scopeKeys = new Set(
|
||||
this.store
|
||||
?.getRepos()
|
||||
.filter((repo) => repo.id === repoId)
|
||||
.map((repo) => `${repoId}\0${getRepoExecutionHostId(repo)}`) ?? []
|
||||
)
|
||||
for (const keys of [
|
||||
this.worktreeScanGenerations.keys(),
|
||||
this.worktreeScanCache.keys(),
|
||||
this.worktreeScanInFlight.keys()
|
||||
]) {
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(prefix)) {
|
||||
scopeKeys.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of scopeKeys) {
|
||||
this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1)
|
||||
this.worktreeScanCache.delete(key)
|
||||
this.worktreeScanInFlight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateSshWorktreeScanCacheInternal(targetId: string): void {
|
||||
const repos = this.store?.getRepos() ?? []
|
||||
const affectedRepoIds = new Set(
|
||||
repos.filter((repo) => repo.connectionId === targetId).map((repo) => repo.id)
|
||||
const affectedRepos = repos.filter((repo) => repo.connectionId === targetId)
|
||||
const affectedScopeKeys = new Set(
|
||||
affectedRepos.map((repo) => `${repo.id}\0${getRepoExecutionHostId(repo)}`)
|
||||
)
|
||||
for (const repoId of affectedRepoIds) {
|
||||
this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1)
|
||||
this.worktreeScanCache.delete(repoId)
|
||||
this.worktreeScanInFlight.delete(repoId)
|
||||
for (const key of affectedScopeKeys) {
|
||||
this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1)
|
||||
this.worktreeScanCache.delete(key)
|
||||
this.worktreeScanInFlight.delete(key)
|
||||
}
|
||||
if (affectedRepoIds.size > 0) {
|
||||
if (affectedScopeKeys.size > 0) {
|
||||
this.resolvedWorktreeGeneration += 1
|
||||
this.resolvedWorktreeCache = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
resolveScopedWorktreeIdRow,
|
||||
type RepoWorktreeRowDeps
|
||||
} from './repo-worktree-row-resolution'
|
||||
|
||||
function repo(
|
||||
id: string,
|
||||
path: string,
|
||||
options: Pick<Repo, 'connectionId' | 'executionHostId' | 'kind'> = {}
|
||||
): Repo {
|
||||
return {
|
||||
id,
|
||||
path,
|
||||
displayName: id,
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
function gitWorktree(path: string): GitWorktreeInfo {
|
||||
return {
|
||||
path,
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
}
|
||||
|
||||
function createDeps(repos: Repo[]): RepoWorktreeRowDeps & {
|
||||
metaById: Record<string, WorktreeMeta>
|
||||
scanRepo: ReturnType<typeof vi.fn<RepoWorktreeRowDeps['scanRepo']>>
|
||||
listFolderWorkspaces: ReturnType<typeof vi.fn<RepoWorktreeRowDeps['listFolderWorkspaces']>>
|
||||
} {
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const store = {
|
||||
getRepos: () => repos,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getProjects: () => [],
|
||||
getSettings: () => ({}),
|
||||
setWorktreeMeta: (worktreeId: string, updates: Partial<WorktreeMeta>) => {
|
||||
const next = { ...metaById[worktreeId], ...updates } as WorktreeMeta
|
||||
metaById[worktreeId] = next
|
||||
return next
|
||||
}
|
||||
} as unknown as Store
|
||||
const scanRepo = vi.fn<RepoWorktreeRowDeps['scanRepo']>(async (owner) => ({
|
||||
ok: true,
|
||||
worktrees: [gitWorktree(owner.id === 'unrelated' ? '/unrelated/worktree' : '/same/worktree')]
|
||||
}))
|
||||
const listFolderWorkspaces = vi.fn<RepoWorktreeRowDeps['listFolderWorkspaces']>(() => [])
|
||||
return { store, metaById, scanRepo, listFolderWorkspaces }
|
||||
}
|
||||
|
||||
describe('host-qualified scoped worktree resolution', () => {
|
||||
it('scans only the selected local or SSH owner when repo ids and paths collide', async () => {
|
||||
const owners = [
|
||||
repo('shared', '/local/repo', { executionHostId: 'local' }),
|
||||
repo('shared', '/remote/repo', {
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
}),
|
||||
repo('unrelated', '/unrelated/repo', { connectionId: 'slow-box' })
|
||||
]
|
||||
const deps = createDeps(owners)
|
||||
const worktreeId = 'shared::/same/worktree'
|
||||
|
||||
await expect(
|
||||
resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder')
|
||||
).resolves.toMatchObject({ id: worktreeId, hostId: 'ssh:builder' })
|
||||
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([owners[1]])
|
||||
|
||||
deps.scanRepo.mockClear()
|
||||
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toMatchObject({
|
||||
id: worktreeId,
|
||||
hostId: 'local'
|
||||
})
|
||||
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([owners[0]])
|
||||
})
|
||||
|
||||
it('does not project another host metadata onto a colliding scoped row', async () => {
|
||||
const deps = createDeps([
|
||||
repo('shared', '/local/repo', { executionHostId: 'local' }),
|
||||
repo('shared', '/remote/repo', {
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
})
|
||||
])
|
||||
const worktreeId = 'shared::/same/worktree'
|
||||
deps.metaById[worktreeId] = {
|
||||
displayName: 'local workspace',
|
||||
hostId: 'local',
|
||||
instanceId: 'local-instance',
|
||||
preserveBranchOnDelete: true
|
||||
} as WorktreeMeta
|
||||
|
||||
await expect(
|
||||
resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder')
|
||||
).resolves.toMatchObject({
|
||||
id: worktreeId,
|
||||
hostId: 'ssh:builder',
|
||||
displayName: 'feature'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['runtime:windows', String.raw`C:\Users\dev\orca worktree`],
|
||||
['local', '/mnt/c/Users/dev/orca worktree']
|
||||
] satisfies [ExecutionHostId, string][])(
|
||||
'keeps %s path resolution scoped',
|
||||
async (hostId, path) => {
|
||||
const target = repo('shared', path, { executionHostId: hostId })
|
||||
const deps = createDeps([
|
||||
target,
|
||||
repo('shared', '/other-host/repo', { executionHostId: 'ssh:unrelated' }),
|
||||
repo('unrelated', '/unrelated/repo')
|
||||
])
|
||||
deps.scanRepo.mockImplementation(async () => ({ ok: true, worktrees: [gitWorktree(path)] }))
|
||||
const worktreeId = `shared::${path}`
|
||||
|
||||
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, hostId)).resolves.toMatchObject({
|
||||
id: worktreeId,
|
||||
hostId,
|
||||
path
|
||||
})
|
||||
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([target])
|
||||
}
|
||||
)
|
||||
|
||||
it('resolves only the selected folder-workspace owner without scanning Git providers', async () => {
|
||||
const local = repo('folders', '/local/folders', {
|
||||
kind: 'folder',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
const remote = repo('folders', '/remote/folders', {
|
||||
kind: 'folder',
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
})
|
||||
const deps = createDeps([local, remote, repo('unrelated', '/unrelated/repo')])
|
||||
const worktreeId = 'folders::/shared/folder'
|
||||
deps.listFolderWorkspaces.mockImplementation((owner) =>
|
||||
owner === remote
|
||||
? ([
|
||||
{
|
||||
id: worktreeId,
|
||||
repoId: owner.id,
|
||||
path: '/shared/folder',
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'folder',
|
||||
comment: '',
|
||||
hostId: 'ssh:builder'
|
||||
} as unknown as Worktree
|
||||
] as Worktree[])
|
||||
: []
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder')
|
||||
).resolves.toMatchObject({ id: worktreeId, hostId: 'ssh:builder' })
|
||||
expect(deps.listFolderWorkspaces).toHaveBeenCalledExactlyOnceWith(remote)
|
||||
expect(deps.scanRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not scan when the requested host does not own the repo id', async () => {
|
||||
const deps = createDeps([
|
||||
repo('shared', '/local/repo', { executionHostId: 'local' }),
|
||||
repo('unrelated', '/unrelated/repo', { executionHostId: 'runtime:other' })
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveScopedWorktreeIdRow(deps, 'shared::/same/worktree', 'ssh:builder')
|
||||
).resolves.toBeNull()
|
||||
expect(deps.scanRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
@@ -11,6 +11,7 @@ import type { ProjectExecutionRuntimeResolution } from '../../shared/project-exe
|
||||
import type { Store } from '../persistence'
|
||||
import { areWorktreePathsEqual, mergeWorktree } from '../ipc/worktree-logic'
|
||||
import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning'
|
||||
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
|
||||
import { resolveLocalProjectRuntimesForRepos } from '../project-runtime-git-options'
|
||||
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
|
||||
|
||||
@@ -120,15 +121,21 @@ export async function resolveRepoWorktreeRows(
|
||||
if (scan.ok) {
|
||||
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
|
||||
}
|
||||
const expectedHostId = getRepoExecutionHostId(repo)
|
||||
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
|
||||
return gitWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
// Why: lineage validation needs a durable instance ID even when the runtime sees a workspace before renderer discovery-stamp.
|
||||
const existingMeta = metaById[worktreeId]
|
||||
const meta =
|
||||
existingMeta && existingMeta.instanceId ? existingMeta : store.setWorktreeMeta(worktreeId, {})
|
||||
const ownedExistingMeta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
|
||||
const meta = ownedExistingMeta?.instanceId
|
||||
? ownedExistingMeta
|
||||
: ownedExistingMeta || (!existingMeta && repoOwnerCount === 1)
|
||||
? store.setWorktreeMeta(worktreeId, {})
|
||||
: undefined
|
||||
const merged = {
|
||||
...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
|
||||
hostId: existingMeta?.hostId ?? meta?.hostId ?? getRepoExecutionHostId(repo)
|
||||
hostId: repoOwnerCount === 1 ? (existingMeta?.hostId ?? expectedHostId) : expectedHostId
|
||||
}
|
||||
return {
|
||||
...merged,
|
||||
@@ -157,16 +164,23 @@ export async function resolveRepoWorktreeRows(
|
||||
*/
|
||||
export async function resolveScopedWorktreeIdRow(
|
||||
deps: RepoWorktreeRowDeps,
|
||||
worktreeId: string
|
||||
worktreeId: string,
|
||||
requiredHostId?: ExecutionHostId
|
||||
): Promise<RepoWorktreeRow | null> {
|
||||
const { store } = deps
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
if (!parsed?.repoId || !parsed.worktreePath) {
|
||||
return null
|
||||
}
|
||||
const owners = store.getRepos().filter((repo) => repo.id === parsed.repoId)
|
||||
const owners = store
|
||||
.getRepos()
|
||||
.filter(
|
||||
(repo) =>
|
||||
repo.id === parsed.repoId &&
|
||||
(requiredHostId === undefined || getRepoExecutionHostId(repo) === requiredHostId)
|
||||
)
|
||||
// Why: one repo id can be registered on several execution hosts, and only the fleet scan decides
|
||||
// between their rows. Hand those back to the unscoped path rather than guessing.
|
||||
// between unqualified rows. A host qualifier narrows the same-id set without scanning other owners.
|
||||
if (owners.length !== 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ describe('workspaceCleanup client-ui schema', () => {
|
||||
expect(WorkspaceCleanup.parse({ dismissals: {} })).toEqual({ dismissals: {} })
|
||||
})
|
||||
|
||||
it('preserves the optional host qualifier on a dismissal', () => {
|
||||
const qualified = { ...DISMISSAL, executionHostId: 'ssh:ssh-1' }
|
||||
const parsed = WorkspaceCleanup.parse({ dismissals: { 'ssh:ssh-1\0wt-1': qualified } })
|
||||
|
||||
expect(parsed.dismissals['ssh:ssh-1\0wt-1']).toEqual(qualified)
|
||||
})
|
||||
|
||||
// The wire contract: a NEWER client's filter groups must never fail an older
|
||||
// host's schema, or the whole ui.set payload is refused (dismissals included).
|
||||
it('accepts a browse state carrying unknown future fields and narrows it', () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ const WorkspaceCleanupDismissal = z.object({
|
||||
worktreeId: z.string(),
|
||||
dismissedAt: z.number().finite(),
|
||||
fingerprint: z.string(),
|
||||
classifierVersion: z.number().finite()
|
||||
classifierVersion: z.number().finite(),
|
||||
executionHostId: z.string().min(1).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { WORKTREE_METHODS } from './worktree'
|
||||
|
||||
function makeRuntime(): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
showManagedWorktree: vi.fn().mockResolvedValue({ id: 'wt-1', hostId: 'local' }),
|
||||
removeManagedWorktree: vi.fn().mockResolvedValue({})
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
function makeRequest(params: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method: 'worktree.rm', params }
|
||||
}
|
||||
|
||||
describe('worktree.rm host qualification', () => {
|
||||
it('routes an explicitly qualified removal to that host', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest({ worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false })
|
||||
)
|
||||
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'local'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { removed: true } })
|
||||
})
|
||||
|
||||
it('resolves an old-client removal through the ambiguity-aware worktree lookup', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest({ worktree: 'id:wt-1', force: true, runHooks: false })
|
||||
)
|
||||
|
||||
expect(runtime.showManagedWorktree).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'local'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { removed: true } })
|
||||
})
|
||||
|
||||
it('fails closed when an old-client selector is ambiguous', async () => {
|
||||
const runtime = makeRuntime()
|
||||
vi.mocked(runtime.showManagedWorktree).mockRejectedValue(new Error('selector_ambiguous'))
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest({ worktree: 'id:wt-1', force: true, runHooks: false })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { message: 'selector_ambiguous' } })
|
||||
expect(runtime.removeManagedWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when an old-client row has no host evidence', async () => {
|
||||
const runtime = makeRuntime()
|
||||
vi.mocked(runtime.showManagedWorktree).mockResolvedValue({ id: 'wt-1' } as never)
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest({ worktree: 'id:wt-1', force: true, runHooks: false })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: 'worktree.rm could not resolve the workspace host' }
|
||||
})
|
||||
expect(runtime.removeManagedWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -25,10 +25,22 @@ describe('worktree.rm PTY-stop waiver', () => {
|
||||
id: 'req-1',
|
||||
authToken: 'tok',
|
||||
method: 'worktree.rm',
|
||||
params: { worktree: 'id:wt-1', force: true, allowUnverifiedPtyStop: true, runHooks: false }
|
||||
params: {
|
||||
worktree: 'id:wt-1',
|
||||
hostId: 'local',
|
||||
force: true,
|
||||
allowUnverifiedPtyStop: true,
|
||||
runHooks: false
|
||||
}
|
||||
} satisfies RpcRequest)
|
||||
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', true, false, true)
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
'local'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not infer a waiver from force alone', async () => {
|
||||
@@ -39,9 +51,15 @@ describe('worktree.rm PTY-stop waiver', () => {
|
||||
id: 'req-1',
|
||||
authToken: 'tok',
|
||||
method: 'worktree.rm',
|
||||
params: { worktree: 'id:wt-1', force: true, runHooks: false }
|
||||
params: { worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false }
|
||||
} satisfies RpcRequest)
|
||||
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', true, false, false)
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'local'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,27 +76,6 @@ describe('worktree RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes dirty-file force to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
dedupeWorktreeCreate: passthroughDedupe,
|
||||
removeManagedWorktree: vi.fn().mockResolvedValue({})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('worktree.rm', {
|
||||
worktree: 'id:wt-1',
|
||||
force: true,
|
||||
runHooks: false
|
||||
})
|
||||
)
|
||||
|
||||
// Why (#11960): dirty-file force alone must not waive the PTY-stop proof.
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', true, false, false)
|
||||
expect(response).toMatchObject({ ok: true, result: { removed: true } })
|
||||
})
|
||||
|
||||
it('routes create options to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -201,15 +201,18 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
name: 'worktree.rm',
|
||||
params: WorktreeRemove,
|
||||
handler: async (params, { runtime }) => {
|
||||
// Older mobile clients omit hostId, so resolve through the ambiguity gate before pinning removal.
|
||||
const hostId = params.hostId ?? (await runtime.showManagedWorktree(params.worktree)).hostId
|
||||
if (!hostId) {
|
||||
throw new Error('worktree.rm could not resolve the workspace host')
|
||||
}
|
||||
const removalArgs = [
|
||||
params.worktree,
|
||||
params.force === true,
|
||||
params.runHooks === true,
|
||||
params.allowUnverifiedPtyStop === true
|
||||
] as const
|
||||
const result = params.hostId
|
||||
? await runtime.removeManagedWorktree(...removalArgs, params.hostId)
|
||||
: await runtime.removeManagedWorktree(...removalArgs)
|
||||
const result = await runtime.removeManagedWorktree(...removalArgs, hostId)
|
||||
return { removed: true, ...result }
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -24,7 +24,7 @@ export function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string
|
||||
export function mergeRuntimeFolderWorkspace(
|
||||
repo: Repo,
|
||||
worktreeId: string,
|
||||
meta: WorktreeMeta
|
||||
meta: Partial<WorktreeMeta>
|
||||
): Worktree {
|
||||
const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta.creatorProvenance)
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getRepoExecutionHostId } from '../shared/execution-host'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../shared/worktree/meta-types'
|
||||
|
||||
/** Same repo/worktree ids can exist on several hosts; bare metadata cannot choose between them. */
|
||||
export function getRepoOwnedWorktreeMeta(
|
||||
repo: Repo,
|
||||
worktreeId: string,
|
||||
metaById: Readonly<Record<string, WorktreeMeta>>,
|
||||
repoOwnerCount: number
|
||||
): WorktreeMeta | undefined {
|
||||
const existingMeta = metaById[worktreeId]
|
||||
return isWorktreeMetaOwnedByRepo(repo, existingMeta, repoOwnerCount) ? existingMeta : undefined
|
||||
}
|
||||
|
||||
export function isWorktreeMetaOwnedByRepo(
|
||||
repo: Repo,
|
||||
meta: WorktreeMeta | undefined,
|
||||
repoOwnerCount: number
|
||||
): meta is WorktreeMeta {
|
||||
return Boolean(meta && (repoOwnerCount === 1 || meta.hostId === getRepoExecutionHostId(repo)))
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* STA-4343 (host side): a repo id registered on two hosts has no single owner,
|
||||
* so unqualified legacy resolution is ambiguous and a qualified call picks the
|
||||
* named host. The destructive RPC boundary rejects missing qualifiers earlier.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasWorktreeRemovalRepoOwnerOnOtherHost,
|
||||
resolveWorktreeRemovalMetadata,
|
||||
resolveWorktreeRemovalRepoOwner
|
||||
} from './worktree-removal-repo-owner'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id'>): Repo {
|
||||
return {
|
||||
path: '/repos/shared',
|
||||
displayName: 'Shared',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
...overrides
|
||||
} as Repo
|
||||
}
|
||||
|
||||
function makeStore(repos: readonly Repo[]): {
|
||||
getRepos: () => readonly Repo[]
|
||||
getRepo: (repoId: string) => Repo | undefined
|
||||
} {
|
||||
return {
|
||||
getRepos: () => repos,
|
||||
getRepo: (repoId) => repos.find((repo) => repo.id === repoId)
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveWorktreeRemovalRepoOwner', () => {
|
||||
const localRepo = makeRepo({ id: 'repo1' })
|
||||
const sshRepo = makeRepo({ id: 'repo1', connectionId: 'ssh-1' })
|
||||
|
||||
it('fails closed when an unqualified request matches two hosts', () => {
|
||||
expect(resolveWorktreeRemovalRepoOwner(makeStore([localRepo, sshRepo]), 'repo1')).toEqual({
|
||||
kind: 'ambiguous'
|
||||
})
|
||||
})
|
||||
|
||||
it('selects the named host even when a same-id repo is registered first', () => {
|
||||
const owner = resolveWorktreeRemovalRepoOwner(
|
||||
makeStore([localRepo, sshRepo]),
|
||||
'repo1',
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
expect(owner).toEqual({ kind: 'resolved', repo: sshRepo })
|
||||
})
|
||||
|
||||
it('reports missing when the named host owns no such repo', () => {
|
||||
expect(resolveWorktreeRemovalRepoOwner(makeStore([localRepo]), 'repo1', 'ssh:ssh-1')).toEqual({
|
||||
kind: 'missing'
|
||||
})
|
||||
expect(resolveWorktreeRemovalRepoOwner(makeStore([]), 'repo1')).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('detects a surviving same-id owner only on another host', () => {
|
||||
const store = makeStore([localRepo, sshRepo])
|
||||
|
||||
expect(hasWorktreeRemovalRepoOwnerOnOtherHost(store, 'repo1', 'ssh:ssh-1')).toBe(true)
|
||||
expect(hasWorktreeRemovalRepoOwnerOnOtherHost(store, 'repo1', 'local')).toBe(true)
|
||||
expect(hasWorktreeRemovalRepoOwnerOnOtherHost(makeStore([sshRepo]), 'repo1', 'ssh:ssh-1')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('does not borrow destructive metadata from a colliding host', () => {
|
||||
const localMeta = { hostId: 'local', preserveBranchOnDelete: true }
|
||||
const metadataStore = {
|
||||
getRepos: () => [localRepo, sshRepo],
|
||||
getWorktreeMeta: () => localMeta
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveWorktreeRemovalMetadata(
|
||||
metadataStore as never,
|
||||
'repo1',
|
||||
'repo1::/shared/worktree',
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
resolveWorktreeRemovalMetadata(
|
||||
metadataStore as never,
|
||||
'repo1',
|
||||
'repo1::/shared/worktree',
|
||||
'local'
|
||||
)
|
||||
).toBe(localMeta)
|
||||
})
|
||||
|
||||
it('keeps legacy persisted host metadata when the repo id has one owner', () => {
|
||||
const runtimeMeta = { hostId: 'runtime:env-1', preserveBranchOnDelete: true }
|
||||
|
||||
expect(
|
||||
resolveWorktreeRemovalMetadata(
|
||||
{
|
||||
getRepos: () => [localRepo],
|
||||
getWorktreeMeta: () => runtimeMeta
|
||||
} as never,
|
||||
'repo1',
|
||||
'repo1::/shared/worktree',
|
||||
'local'
|
||||
)
|
||||
).toBe(runtimeMeta)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../shared/execution-host'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../shared/worktree/meta-types'
|
||||
|
||||
export type WorktreeRemovalRepoSource = {
|
||||
getRepos: () => readonly Repo[]
|
||||
getRepo: (repoId: string) => Repo | undefined
|
||||
}
|
||||
|
||||
export type WorktreeRemovalRepoOwner =
|
||||
| { kind: 'resolved'; repo: Repo }
|
||||
| { kind: 'ambiguous' }
|
||||
| { kind: 'missing' }
|
||||
|
||||
/**
|
||||
* Which repo a destructive worktree removal belongs to.
|
||||
*
|
||||
* A repo id can be registered once per execution host. Destructive RPC callers
|
||||
* reject a missing host before reaching this resolver; the unqualified branch
|
||||
* remains only for direct legacy callers and still refuses multiple owners.
|
||||
*/
|
||||
export function resolveWorktreeRemovalRepoOwner(
|
||||
store: WorktreeRemovalRepoSource,
|
||||
repoId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): WorktreeRemovalRepoOwner {
|
||||
const matches = store
|
||||
.getRepos()
|
||||
.filter((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
|
||||
if (matches.length === 1 && matches[0]) {
|
||||
return { kind: 'resolved', repo: matches[0] }
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
const legacyMatch = store.getRepo(repoId)
|
||||
return legacyMatch && (!hostId || getRepoExecutionHostId(legacyMatch) === hostId)
|
||||
? { kind: 'resolved', repo: legacyMatch }
|
||||
: { kind: 'missing' }
|
||||
}
|
||||
|
||||
export function hasWorktreeRemovalRepoOwnerOnOtherHost(
|
||||
store: Pick<WorktreeRemovalRepoSource, 'getRepos'>,
|
||||
repoId: string,
|
||||
hostId: ExecutionHostId
|
||||
): boolean {
|
||||
return store
|
||||
.getRepos()
|
||||
.some((repo) => repo.id === repoId && getRepoExecutionHostId(repo) !== hostId)
|
||||
}
|
||||
|
||||
export function resolveWorktreeRemovalMetadata(
|
||||
store: Pick<WorktreeRemovalRepoSource, 'getRepos'> & {
|
||||
getWorktreeMeta: (worktreeId: string) => WorktreeMeta | undefined
|
||||
},
|
||||
repoId: string,
|
||||
worktreeId: string,
|
||||
hostId: ExecutionHostId
|
||||
): WorktreeMeta | undefined {
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
if (!meta) {
|
||||
return undefined
|
||||
}
|
||||
const repoOwnerCount = store.getRepos().filter((repo) => repo.id === repoId).length
|
||||
if (repoOwnerCount <= 1) {
|
||||
return meta
|
||||
}
|
||||
return meta.hostId === hostId ? meta : undefined
|
||||
}
|
||||
+2
-2
@@ -17,8 +17,8 @@ vi.mock('./use-workspace-cleanup-facet-rows', () => ({
|
||||
probes.facets()
|
||||
return {
|
||||
rows: [],
|
||||
selectableWorktreeIds: [],
|
||||
facetMatchedWorktreeIds: new Set(),
|
||||
selectableIdentities: [],
|
||||
facetMatchedIdentities: new Set(),
|
||||
matchedCount: 0,
|
||||
totalCount: 0,
|
||||
facetCounts: {
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ function rowNames(): string[] {
|
||||
}
|
||||
|
||||
function rowCheckbox(name: string): HTMLElement | null {
|
||||
return container?.querySelector<HTMLElement>(`[aria-label="Select ${name}"]`) ?? null
|
||||
return container?.querySelector<HTMLElement>(`[aria-label^="Select ${name} on "]`) ?? null
|
||||
}
|
||||
|
||||
function cachedFleet(): WorkspaceCleanupScanResult {
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
canQueueWorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupCandidate
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row'
|
||||
import { WorkspaceCleanupBrowseToolbar } from './workspace-cleanup-browse-toolbar'
|
||||
import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove'
|
||||
import { WorkspaceCleanupDialogHeader } from './workspace-cleanup-dialog-header'
|
||||
import { getWorkspaceCleanupDeletionPhaseByIdentity } from './workspace-cleanup-deletion-phases'
|
||||
import {
|
||||
WorkspaceCleanupInitialScanBanner,
|
||||
WorkspaceCleanupNotice,
|
||||
@@ -36,7 +38,7 @@ import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evid
|
||||
import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order'
|
||||
import {
|
||||
formatVanishedSelectionNotice,
|
||||
getDefaultSelectedWorkspaceCleanupIds,
|
||||
getDefaultSelectedWorkspaceCleanupIdentities,
|
||||
toggleSetMember
|
||||
} from './workspace-cleanup-selection-model'
|
||||
|
||||
@@ -68,7 +70,7 @@ function WorkspaceCleanupDialogContent({
|
||||
const refreshWorkspaceSpace = useAppStore((s) => s.refreshWorkspaceSpace)
|
||||
const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
|
||||
const workspaceSpaceProgress = useAppStore((s) => s.workspaceSpaceScanProgress)
|
||||
const deletionPhaseByWorktreeId = useAppStore(
|
||||
const genericDeletionPhaseByWorktreeId = useAppStore(
|
||||
useShallow((s) => {
|
||||
const phases: Record<string, WorkspaceCleanupDeletionPhase> = {}
|
||||
for (const [worktreeId, state] of Object.entries(s.deleteStateByWorktreeId)) {
|
||||
@@ -79,11 +81,6 @@ function WorkspaceCleanupDialogContent({
|
||||
return phases
|
||||
})
|
||||
)
|
||||
const deletingWorktreeIds = useMemo(
|
||||
() => new Set(Object.keys(deletionPhaseByWorktreeId)),
|
||||
[deletionPhaseByWorktreeId]
|
||||
)
|
||||
|
||||
const browse = useWorkspaceCleanupBrowseState()
|
||||
// Why: facet counts/options are only rendered inside the filter popover;
|
||||
// tracking its open state lets their O(N) passes skip while it is closed.
|
||||
@@ -113,6 +110,19 @@ function WorkspaceCleanupDialogContent({
|
||||
}, [open])
|
||||
|
||||
const { removalInFlightRef } = removal
|
||||
const deletionPhaseByIdentity = useMemo(
|
||||
() =>
|
||||
getWorkspaceCleanupDeletionPhaseByIdentity(
|
||||
scan?.candidates ?? [],
|
||||
removal.deletionPhaseByIdentity,
|
||||
genericDeletionPhaseByWorktreeId
|
||||
),
|
||||
[genericDeletionPhaseByWorktreeId, removal.deletionPhaseByIdentity, scan?.candidates]
|
||||
)
|
||||
const deletingIdentities = useMemo(
|
||||
() => new Set(Object.keys(deletionPhaseByIdentity)),
|
||||
[deletionPhaseByIdentity]
|
||||
)
|
||||
|
||||
const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates])
|
||||
const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort)
|
||||
@@ -122,8 +132,8 @@ function WorkspaceCleanupDialogContent({
|
||||
scannedAt: scan?.scannedAt ?? null
|
||||
})
|
||||
const evidencedCandidates = useMemo(
|
||||
() => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByWorktreeId),
|
||||
[candidates, gitEvidence.evidenceByWorktreeId]
|
||||
() => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByIdentity),
|
||||
[candidates, gitEvidence.evidenceByIdentity]
|
||||
)
|
||||
// Why: a live clock would re-run every facet on each render; the newer of
|
||||
// scan time and open time is the honest stable "as of" moment — a fresh scan
|
||||
@@ -141,8 +151,11 @@ function WorkspaceCleanupDialogContent({
|
||||
streaming: loading,
|
||||
sort: browse.sort
|
||||
})
|
||||
const candidateIds = useMemo(
|
||||
() => new Set(candidates.map((candidate) => candidate.worktreeId)),
|
||||
// Why (STA-4343): rows are keyed by host-qualified identity, not by
|
||||
// `worktreeId` — two hosts can list the same `repoId::path` workspace, and an
|
||||
// id-keyed selection would confirm one host's row and delete the other's.
|
||||
const candidateIdentities = useMemo(
|
||||
() => new Set(candidates.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))),
|
||||
[candidates]
|
||||
)
|
||||
|
||||
@@ -171,16 +184,16 @@ function WorkspaceCleanupDialogContent({
|
||||
}, [mountedRef, refreshWorkspaceSpace])
|
||||
|
||||
const selectedCandidates = useMemo(() => {
|
||||
const byId = new Map(rows.map((row) => [row.worktreeId, row.candidate]))
|
||||
const byIdentity = new Map(rows.map((row) => [row.identity, row.candidate]))
|
||||
return [...selectedIds]
|
||||
.map((id) => byId.get(id))
|
||||
.map((identity) => byIdentity.get(identity))
|
||||
.filter(
|
||||
(candidate): candidate is WorkspaceCleanupCandidate =>
|
||||
candidate != null &&
|
||||
canQueueWorkspaceCleanupCandidate(candidate) &&
|
||||
!deletingWorktreeIds.has(candidate.worktreeId)
|
||||
!deletingIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
)
|
||||
}, [deletingWorktreeIds, rows, selectedIds])
|
||||
}, [deletingIdentities, rows, selectedIds])
|
||||
const selectedCount = selectedCandidates.length
|
||||
|
||||
useEffect(() => {
|
||||
@@ -195,14 +208,17 @@ function WorkspaceCleanupDialogContent({
|
||||
return
|
||||
}
|
||||
defaultsAppliedForOpenRef.current = true
|
||||
setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds))
|
||||
}, [deletingWorktreeIds, loading, removalInFlightRef, scan, setSelectedIds])
|
||||
setSelectedIds(
|
||||
getDefaultSelectedWorkspaceCleanupIdentities(scan.candidates, deletingIdentities)
|
||||
)
|
||||
}, [deletingIdentities, loading, removalInFlightRef, scan, setSelectedIds])
|
||||
|
||||
const pruneSelectionToVisibleRows = useEffectEvent(() => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(
|
||||
[...current].filter(
|
||||
(id) => facetRows.facetMatchedWorktreeIds.has(id) && !deletingWorktreeIds.has(id)
|
||||
(identity) =>
|
||||
facetRows.facetMatchedIdentities.has(identity) && !deletingIdentities.has(identity)
|
||||
)
|
||||
)
|
||||
return next.size === current.size ? current : next
|
||||
@@ -221,14 +237,15 @@ function WorkspaceCleanupDialogContent({
|
||||
}, [browse.filters, open, removal.confirming])
|
||||
|
||||
const pruneVanishedSelections = useEffectEvent(() => {
|
||||
const isDeleting = (identity: string): boolean => deletingIdentities.has(identity)
|
||||
const kept = [...selectedIds].filter(
|
||||
(id) => candidateIds.has(id) && !deletingWorktreeIds.has(id)
|
||||
(identity) => candidateIdentities.has(identity) && !isDeleting(identity)
|
||||
)
|
||||
if (kept.length === selectedIds.size) {
|
||||
return
|
||||
}
|
||||
const vanishedCount = [...selectedIds].filter(
|
||||
(id) => !candidateIds.has(id) && !deletingWorktreeIds.has(id)
|
||||
(identity) => !candidateIdentities.has(identity) && !isDeleting(identity)
|
||||
).length
|
||||
setSelectedIds(new Set(kept))
|
||||
if (vanishedCount > 0) {
|
||||
@@ -240,10 +257,10 @@ function WorkspaceCleanupDialogContent({
|
||||
if (loading) {
|
||||
return
|
||||
}
|
||||
// Why: a settled refresh reconciles by worktreeId — selections survive it,
|
||||
// Why: a settled refresh reconciles by host-qualified identity — selections survive it,
|
||||
// and only rows that truly no longer exist drop (with a visible reason).
|
||||
pruneVanishedSelections()
|
||||
}, [candidateIds, deletingWorktreeIds, loading])
|
||||
}, [candidateIdentities, deletingIdentities, loading])
|
||||
|
||||
const ignoreCandidate = useCallback(
|
||||
(candidate: WorkspaceCleanupCandidate) => {
|
||||
@@ -252,7 +269,7 @@ function WorkspaceCleanupDialogContent({
|
||||
if (mountedRef.current) {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(candidate.worktreeId)
|
||||
next.delete(getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -272,31 +289,31 @@ function WorkspaceCleanupDialogContent({
|
||||
[dismissCandidates, mountedRef, setSelectedIds]
|
||||
)
|
||||
|
||||
const toggleExpandedRow = useCallback((worktreeId: string) => {
|
||||
setExpandedRowIds((current) => toggleSetMember(current, worktreeId))
|
||||
const toggleExpandedRow = useCallback((identity: string) => {
|
||||
setExpandedRowIds((current) => toggleSetMember(current, identity))
|
||||
}, [])
|
||||
|
||||
const toggleSelectedRow = useCallback(
|
||||
(worktreeId: string) => {
|
||||
(identity: string) => {
|
||||
selectionTouchedRef.current = true
|
||||
setSelectedIds((current) => toggleSetMember(current, worktreeId))
|
||||
setSelectedIds((current) => toggleSetMember(current, identity))
|
||||
},
|
||||
[setSelectedIds]
|
||||
)
|
||||
|
||||
const selectableWorktreeIds = useMemo(
|
||||
const selectableIdentities = useMemo(
|
||||
() =>
|
||||
removal.removalInFlight
|
||||
? []
|
||||
: facetRows.selectableWorktreeIds.filter((id) => !deletingWorktreeIds.has(id)),
|
||||
[deletingWorktreeIds, facetRows.selectableWorktreeIds, removal.removalInFlight]
|
||||
: facetRows.selectableIdentities.filter((identity) => !deletingIdentities.has(identity)),
|
||||
[deletingIdentities, facetRows.selectableIdentities, removal.removalInFlight]
|
||||
)
|
||||
const toggleSelectAll = useCallback(
|
||||
(selectAll: boolean) => {
|
||||
selectionTouchedRef.current = true
|
||||
setSelectedIds(selectAll ? new Set(selectableWorktreeIds) : new Set())
|
||||
setSelectedIds(selectAll ? new Set(selectableIdentities) : new Set())
|
||||
},
|
||||
[selectableWorktreeIds, setSelectedIds]
|
||||
[selectableIdentities, setSelectedIds]
|
||||
)
|
||||
|
||||
// Why: stable per-row handlers so React.memo keeps unchanged CandidateRow
|
||||
@@ -310,7 +327,7 @@ function WorkspaceCleanupDialogContent({
|
||||
return
|
||||
}
|
||||
selectionTouchedRef.current = true
|
||||
setSelectedIds(new Set([candidate.worktreeId]))
|
||||
setSelectedIds(new Set([getWorkspaceCleanupCandidateIdentity(candidate)]))
|
||||
openConfirmRemove([candidate])
|
||||
},
|
||||
[openConfirmRemove, removalInFlightRef, setSelectedIds]
|
||||
@@ -372,7 +389,7 @@ function WorkspaceCleanupDialogContent({
|
||||
facetRows={facetRows}
|
||||
facetPanelOpen={facetPanelOpen}
|
||||
onFacetPanelOpenChange={setFacetPanelOpen}
|
||||
selectableCount={selectableWorktreeIds.length}
|
||||
selectableCount={selectableIdentities.length}
|
||||
selectedCount={selectedCount}
|
||||
spaceScanning={workspaceSpaceScanning}
|
||||
spaceProgress={workspaceSpaceProgress}
|
||||
@@ -391,13 +408,12 @@ function WorkspaceCleanupDialogContent({
|
||||
scannedCount={candidates.length}
|
||||
hasScanned={scan != null}
|
||||
loading={loading}
|
||||
deletionPhaseByWorktreeId={deletionPhaseByWorktreeId}
|
||||
deletingWorktreeIds={deletingWorktreeIds}
|
||||
deletionPhaseByIdentity={deletionPhaseByIdentity}
|
||||
deletingIdentities={deletingIdentities}
|
||||
expandedRowIds={expandedRowIds}
|
||||
selectedIds={selectedIds}
|
||||
gitPendingWorktreeIds={gitEvidence.pendingWorktreeIds}
|
||||
rowFailures={removal.rowFailures}
|
||||
removalInFlight={removal.removalInFlight}
|
||||
scrollElement={rowsScrollElement}
|
||||
onClearFilters={browse.clearFilters}
|
||||
onToggleExpanded={toggleExpandedRow}
|
||||
|
||||
+9
-5
@@ -13,6 +13,7 @@ export type WorkspaceCleanupDialogLifecycle = {
|
||||
mountedContent: boolean
|
||||
loading: boolean
|
||||
closeModal: () => void
|
||||
/** Host-qualified row identities, not worktree ids (STA-4343). */
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: Dispatch<SetStateAction<Set<string>>>
|
||||
removal: WorkspaceCleanupRemovalController
|
||||
@@ -27,19 +28,22 @@ export function useWorkspaceCleanupDialogLifecycle(): WorkspaceCleanupDialogLife
|
||||
const open = activeModal === 'workspace-cleanup'
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const deselectRemovedIds = useCallback((removedIds: readonly string[]) => {
|
||||
if (removedIds.length === 0) {
|
||||
const deselectRemovedIdentities = useCallback((removedIdentities: readonly string[]) => {
|
||||
if (removedIdentities.length === 0) {
|
||||
return
|
||||
}
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const id of removedIds) {
|
||||
next.delete(id)
|
||||
for (const identity of removedIdentities) {
|
||||
next.delete(identity)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal })
|
||||
const removal = useWorkspaceCleanupRemoval({
|
||||
onDeselect: deselectRemovedIdentities,
|
||||
closeModal
|
||||
})
|
||||
const { removalInFlightRef, resetForReopen, resetRowFailures } = removal
|
||||
|
||||
const onFreshOpen = useCallback(() => {
|
||||
|
||||
+2
-2
@@ -208,7 +208,7 @@ describe('useWorkspaceCleanupFacetRows hot paths', () => {
|
||||
{ initialProps: { current: candidates } }
|
||||
)
|
||||
const initialCounts = { ...counts }
|
||||
const matched = view.result.current.facetMatchedWorktreeIds
|
||||
const matched = view.result.current.facetMatchedIdentities
|
||||
const rows = view.result.current.rows
|
||||
|
||||
// A no-op progress tick delivers a fresh array of the same candidate objects.
|
||||
@@ -216,7 +216,7 @@ describe('useWorkspaceCleanupFacetRows hot paths', () => {
|
||||
|
||||
expect(counts).toEqual(initialCounts)
|
||||
expect(view.result.current.rows).toBe(rows)
|
||||
expect(view.result.current.facetMatchedWorktreeIds).toBe(matched)
|
||||
expect(view.result.current.facetMatchedIdentities).toBe(matched)
|
||||
})
|
||||
|
||||
it('keeps facet identity for untouched rows when a tick replaces one candidate', () => {
|
||||
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
|
||||
export type WorkspaceCleanupFacetRows = {
|
||||
rows: WorkspaceCleanupFacets[]
|
||||
selectableWorktreeIds: string[]
|
||||
facetMatchedWorktreeIds: ReadonlySet<string>
|
||||
selectableIdentities: string[]
|
||||
facetMatchedIdentities: ReadonlySet<string>
|
||||
matchedCount: number
|
||||
totalCount: number
|
||||
facetCounts: WorkspaceCleanupFacetCounts
|
||||
@@ -238,9 +238,9 @@ export function useWorkspaceCleanupFacetRows({
|
||||
)
|
||||
// Identity churn here is harmless: the only consumer reads the latest set
|
||||
// inside a useEffectEvent body and never keys an effect on it.
|
||||
const facetMatchedWorktreeIds = useMemo<ReadonlySet<string>>(
|
||||
const facetMatchedIdentities = useMemo<ReadonlySet<string>>(
|
||||
() =>
|
||||
new Set(filterWorkspaceCleanupFacets(facets, facetFilters, now).map((row) => row.worktreeId)),
|
||||
new Set(filterWorkspaceCleanupFacets(facets, facetFilters, now).map((row) => row.identity)),
|
||||
[facetFilters, facets, now]
|
||||
)
|
||||
const measuredSizeCount = useMemo(() => countWorkspaceCleanupMeasuredRows(facets), [facets])
|
||||
@@ -275,8 +275,8 @@ export function useWorkspaceCleanupFacetRows({
|
||||
|
||||
return {
|
||||
rows: result.rows,
|
||||
selectableWorktreeIds: result.selectableWorktreeIds,
|
||||
facetMatchedWorktreeIds,
|
||||
selectableIdentities: result.selectableIdentities,
|
||||
facetMatchedIdentities,
|
||||
matchedCount: result.matchedCount,
|
||||
totalCount: result.totalCount,
|
||||
facetCounts,
|
||||
|
||||
+4
-4
@@ -85,7 +85,7 @@ describe('useWorkspaceCleanupGitEvidence', () => {
|
||||
})
|
||||
await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2))
|
||||
expect(view.result.current.pendingWorktreeIds.size).toBe(6)
|
||||
expect(view.result.current.evidenceByWorktreeId.size).toBe(0)
|
||||
expect(view.result.current.evidenceByIdentity.size).toBe(0)
|
||||
|
||||
await act(async () => {
|
||||
pending[1]?.onProgress?.({
|
||||
@@ -98,7 +98,7 @@ describe('useWorkspaceCleanupGitEvidence', () => {
|
||||
candidateMode: 'append'
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1))
|
||||
await waitFor(() => expect(view.result.current.evidenceByIdentity.size).toBe(1))
|
||||
expect(view.result.current.pendingWorktreeIds.size).toBe(5)
|
||||
})
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('useWorkspaceCleanupGitEvidence', () => {
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2))
|
||||
expect(view.result.current.evidenceByWorktreeId.size).toBe(0)
|
||||
expect(view.result.current.evidenceByIdentity.size).toBe(0)
|
||||
expect(view.result.current.pendingWorktreeIds).toEqual(new Set(['a']))
|
||||
|
||||
await act(async () => {
|
||||
@@ -187,6 +187,6 @@ describe('useWorkspaceCleanupGitEvidence', () => {
|
||||
errors: []
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1))
|
||||
await waitFor(() => expect(view.result.current.evidenceByIdentity.size).toBe(1))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import {
|
||||
selectWorkspaceCleanupGitEvidenceTargets,
|
||||
WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS
|
||||
} from './workspace-cleanup-git-evidence'
|
||||
|
||||
export type WorkspaceCleanupGitEvidenceState = {
|
||||
/** Focused re-scan results, keyed by worktree id. */
|
||||
evidenceByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
|
||||
/** Focused re-scan results, keyed by host-qualified identity. */
|
||||
evidenceByIdentity: ReadonlyMap<string, WorkspaceCleanupCandidate>
|
||||
pendingWorktreeIds: ReadonlySet<string>
|
||||
checkedCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = {
|
||||
evidenceByWorktreeId: new Map(),
|
||||
evidenceByIdentity: new Map(),
|
||||
pendingWorktreeIds: new Set(),
|
||||
checkedCount: 0,
|
||||
totalCount: 0
|
||||
@@ -61,7 +62,7 @@ export function useWorkspaceCleanupGitEvidence({
|
||||
}
|
||||
publishDirtyRef.current = false
|
||||
setState({
|
||||
evidenceByWorktreeId: new Map(evidenceRef.current),
|
||||
evidenceByIdentity: new Map(evidenceRef.current),
|
||||
pendingWorktreeIds: new Set([...queuedRef.current, ...inFlightRef.current]),
|
||||
checkedCount: totalRef.current - queuedRef.current.size - inFlightRef.current.size,
|
||||
totalCount: totalRef.current
|
||||
@@ -95,7 +96,7 @@ export function useWorkspaceCleanupGitEvidence({
|
||||
if (!activeRequestWorktreeIdsRef.current.has(candidate.worktreeId)) {
|
||||
continue
|
||||
}
|
||||
evidenceRef.current.set(candidate.worktreeId, candidate)
|
||||
evidenceRef.current.set(getWorkspaceCleanupCandidateIdentity(candidate), candidate)
|
||||
inFlightRef.current.delete(candidate.worktreeId)
|
||||
publishDirtyRef.current = true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import { useCallback, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateIdentity,
|
||||
getWorkspaceCleanupHostIdentity
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup'
|
||||
import {
|
||||
startWorkspaceCleanupBackgroundRemoval,
|
||||
type WorkspaceCleanupRemovalProgress
|
||||
@@ -13,8 +18,10 @@ export type WorkspaceCleanupRemovalController = {
|
||||
confirmCandidates: WorkspaceCleanupCandidate[]
|
||||
removalProgress: WorkspaceCleanupRemovalProgress | null
|
||||
removalInFlight: boolean
|
||||
deletionPhaseByIdentity: Record<string, 'queued' | 'deleting'>
|
||||
/** Synchronous guard; state alone lags a second confirm click. */
|
||||
removalInFlightRef: { current: boolean }
|
||||
/** Keyed by host-qualified identity so a failure marks only the confirmed host's row. */
|
||||
rowFailures: Record<string, string>
|
||||
resetRowFailures: () => void
|
||||
resetForReopen: () => void
|
||||
@@ -32,7 +39,7 @@ export function useWorkspaceCleanupRemoval({
|
||||
onDeselect,
|
||||
closeModal
|
||||
}: {
|
||||
onDeselect: (removedIds: readonly string[]) => void
|
||||
onDeselect: (removedIdentities: readonly string[]) => void
|
||||
closeModal: () => void
|
||||
}): WorkspaceCleanupRemovalController {
|
||||
const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates)
|
||||
@@ -48,6 +55,9 @@ export function useWorkspaceCleanupRemoval({
|
||||
// Why: `removalProgress` only arrives once the batch reports, so rendering
|
||||
// needs its own in-flight flag; removalInFlightRef stays the synchronous guard.
|
||||
const [removalInFlight, setRemovalInFlight] = useState(false)
|
||||
const [deletionPhaseByIdentity, setDeletionPhaseByIdentity] = useState<
|
||||
Record<string, 'queued' | 'deleting'>
|
||||
>({})
|
||||
const [rowFailures, setRowFailures] = useState<Record<string, string>>({})
|
||||
const removalInFlightRef = useRef(false)
|
||||
// Why: the dialog stays mounted across cleanup runs, so late settlements from
|
||||
@@ -112,6 +122,7 @@ export function useWorkspaceCleanupRemoval({
|
||||
setRemovalInFlight(false)
|
||||
setConfirming(false)
|
||||
setConfirmCandidates([])
|
||||
setDeletionPhaseByIdentity({})
|
||||
}, [])
|
||||
|
||||
const confirmRemove = useCallback(() => {
|
||||
@@ -134,7 +145,11 @@ export function useWorkspaceCleanupRemoval({
|
||||
// Why: a hung late settlement retains these callbacks for the renderer's
|
||||
// lifetime; capture only ids so it cannot pin the candidate objects.
|
||||
const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId)
|
||||
const removableIdentities = removableCandidates.map(getWorkspaceCleanupCandidateIdentity)
|
||||
setRowFailures({})
|
||||
setDeletionPhaseByIdentity(
|
||||
Object.fromEntries(removableIdentities.map((identity) => [identity, 'queued' as const]))
|
||||
)
|
||||
markWorktreesQueuedForDeletion(removableWorktreeIds)
|
||||
const handleRemovalError = (): void => {
|
||||
for (const worktreeId of removableWorktreeIds) {
|
||||
@@ -169,21 +184,33 @@ export function useWorkspaceCleanupRemoval({
|
||||
onProgress: (progress) => {
|
||||
if (mountedRef.current) {
|
||||
setRemovalProgress(progress)
|
||||
setDeletionPhaseByIdentity((current) =>
|
||||
Object.fromEntries(Object.keys(current).map((identity) => [identity, 'deleting']))
|
||||
)
|
||||
}
|
||||
},
|
||||
onRowFailed: (failure) => {
|
||||
clearQueuedDeleteState(failure.worktreeId)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
const identity = getWorkspaceCleanupFailureIdentity(failure)
|
||||
setDeletionPhaseByIdentity((current) => {
|
||||
const next = { ...current }
|
||||
delete next[identity]
|
||||
return next
|
||||
})
|
||||
},
|
||||
onResult: (result) => {
|
||||
const nextFailures: Record<string, string> = {}
|
||||
for (const failure of result.failures) {
|
||||
nextFailures[failure.worktreeId] = failure.message
|
||||
nextFailures[getWorkspaceCleanupFailureIdentity(failure)] = failure.message
|
||||
// Why: defensively covers failures that never reached onRowFailed.
|
||||
clearQueuedDeleteState(failure.worktreeId)
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setRowFailures(nextFailures)
|
||||
onDeselect(result.removedIds)
|
||||
onDeselect(result.removedIdentities)
|
||||
settle()
|
||||
}
|
||||
removalInFlightRef.current = false
|
||||
@@ -199,15 +226,15 @@ export function useWorkspaceCleanupRemoval({
|
||||
}
|
||||
setRowFailures((current) => {
|
||||
const next = { ...current }
|
||||
for (const id of result.removedIds) {
|
||||
delete next[id]
|
||||
for (const identity of result.removedIdentities) {
|
||||
delete next[identity]
|
||||
}
|
||||
for (const failure of result.failures) {
|
||||
next[failure.worktreeId] = failure.message
|
||||
next[getWorkspaceCleanupFailureIdentity(failure)] = failure.message
|
||||
}
|
||||
return next
|
||||
})
|
||||
onDeselect(result.removedIds)
|
||||
onDeselect(result.removedIdentities)
|
||||
},
|
||||
onError: handleRemovalError
|
||||
})
|
||||
@@ -230,6 +257,7 @@ export function useWorkspaceCleanupRemoval({
|
||||
confirmCandidates,
|
||||
removalProgress,
|
||||
removalInFlight,
|
||||
deletionPhaseByIdentity,
|
||||
removalInFlightRef,
|
||||
rowFailures,
|
||||
resetRowFailures,
|
||||
@@ -240,3 +268,10 @@ export function useWorkspaceCleanupRemoval({
|
||||
backToList
|
||||
}
|
||||
}
|
||||
|
||||
/** Row key for a failure; an id-only failure lands on the local row, as before. */
|
||||
function getWorkspaceCleanupFailureIdentity(failure: WorkspaceCleanupFailure): string {
|
||||
return failure.executionHostId
|
||||
? getWorkspaceCleanupHostIdentity(failure.executionHostId, failure.worktreeId)
|
||||
: getWorkspaceCleanupCandidateIdentity({ worktreeId: failure.worktreeId })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '../../../../shared/cross-platform-path'
|
||||
import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getWorkspaceCleanupCandidateHostId } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
|
||||
// Why: an ancestor skip is provisional while every blocking descendant is still
|
||||
// removing; it hardens or lifts once the blockers settle authoritatively.
|
||||
@@ -31,7 +32,7 @@ export function isStrictWorkspaceCleanupDescendant(
|
||||
child: WorkspaceCleanupCandidate
|
||||
): boolean {
|
||||
return (
|
||||
parent.connectionId === child.connectionId &&
|
||||
getWorkspaceCleanupCandidateHostId(parent) === getWorkspaceCleanupCandidateHostId(child) &&
|
||||
isStrictWorkspaceCleanupDescendantPath(parent.path, child.path)
|
||||
)
|
||||
}
|
||||
|
||||
+55
-18
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup'
|
||||
import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal'
|
||||
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
|
||||
|
||||
@@ -46,23 +47,27 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
branch: 'other',
|
||||
path: '/repo/other'
|
||||
})
|
||||
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
let resolveUnrelated: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveChild = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveUnrelated = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [parent.worktreeId],
|
||||
removedIdentities: [parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
@@ -76,9 +81,17 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(2)
|
||||
resolveChild({ removedIds: [child.worktreeId], failures: [] })
|
||||
resolveChild({
|
||||
removedIds: [child.worktreeId],
|
||||
removedIdentities: [child.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] })
|
||||
resolveUnrelated({
|
||||
removedIds: [unrelated.worktreeId],
|
||||
removedIdentities: [unrelated.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], {
|
||||
@@ -86,6 +99,7 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
|
||||
removedIdentities: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
@@ -116,24 +130,28 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
branch: 'second-child',
|
||||
path: '/repo/bb/c'
|
||||
})
|
||||
let resolveFirstChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
let resolveSecondChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
let resolveFirstChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
let resolveSecondChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveFirstChild = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveSecondChild = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(() => new Promise(() => {}))
|
||||
.mockResolvedValueOnce({ removedIds: [secondParent.worktreeId], failures: [] })
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [secondParent.worktreeId],
|
||||
removedIdentities: [secondParent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
const onLateResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
@@ -148,7 +166,11 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await settleBackgroundRemoval()
|
||||
resolveFirstChild({ removedIds: [firstChild.worktreeId], failures: [] })
|
||||
resolveFirstChild({
|
||||
removedIds: [firstChild.worktreeId],
|
||||
removedIdentities: [firstChild.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(3, [firstParent.worktreeId], {
|
||||
@@ -156,14 +178,20 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
})
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(1, {
|
||||
removedIds: [firstChild.worktreeId],
|
||||
removedIdentities: [firstChild.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
|
||||
resolveSecondChild({ removedIds: [secondChild.worktreeId], failures: [] })
|
||||
resolveSecondChild({
|
||||
removedIds: [secondChild.worktreeId],
|
||||
removedIdentities: [secondChild.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(2, {
|
||||
removedIds: [secondChild.worktreeId],
|
||||
removedIdentities: [secondChild.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(3)
|
||||
@@ -176,6 +204,7 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
})
|
||||
expect(onLateResult).toHaveBeenCalledWith({
|
||||
removedIds: [secondParent.worktreeId],
|
||||
removedIdentities: [secondParent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1')
|
||||
@@ -194,16 +223,20 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
branch: 'child',
|
||||
path: '/repo/parent/child'
|
||||
})
|
||||
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveChild = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [parent.worktreeId],
|
||||
removedIdentities: [parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
vi.mocked(toast.success).mockImplementationOnce(() => {
|
||||
throw new Error('toast renderer failed')
|
||||
})
|
||||
@@ -220,7 +253,11 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await settleBackgroundRemoval()
|
||||
resolveChild({ removedIds: [child.worktreeId], failures: [] })
|
||||
resolveChild({
|
||||
removedIds: [child.worktreeId],
|
||||
removedIdentities: [child.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ it('holds one snapshot prune batch across the sequential cleanup removals', asyn
|
||||
const events: string[] = []
|
||||
const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => {
|
||||
events.push(`remove:${worktreeIds[0]}`)
|
||||
return { removedIds: [...worktreeIds], failures: [] }
|
||||
return { removedIds: [...worktreeIds], removedIdentities: [...worktreeIds], failures: [] }
|
||||
})
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
|
||||
+104
-442
@@ -5,6 +5,7 @@ import {
|
||||
type WorkspaceCleanupBackgroundRemovalArgs
|
||||
} from './workspace-cleanup-background-removal'
|
||||
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
|
||||
import { getWorkspaceCleanupHostIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
@@ -50,7 +51,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
|
||||
expect(removeCandidates).not.toHaveBeenCalled()
|
||||
expect(onProgress).not.toHaveBeenCalled()
|
||||
expect(onResult).toHaveBeenCalledWith({ removedIds: [], failures: [] })
|
||||
expect(onResult).toHaveBeenCalledWith({ removedIds: [], removedIdentities: [], failures: [] })
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -89,7 +90,11 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
})
|
||||
expect(onResult).not.toHaveBeenCalled()
|
||||
|
||||
resolveRemoval!({ removedIds: [candidate.worktreeId], failures: [] })
|
||||
resolveRemoval!({
|
||||
removedIds: [candidate.worktreeId],
|
||||
removedIdentities: [candidate.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(onProgress).toHaveBeenLastCalledWith({
|
||||
@@ -99,7 +104,11 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
failedCount: 0
|
||||
})
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(onResult).toHaveBeenCalledWith({ removedIds: [candidate.worktreeId], failures: [] })
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [candidate.worktreeId],
|
||||
removedIdentities: [candidate.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
})
|
||||
|
||||
it('removes candidates one at a time for per-row progress', async () => {
|
||||
@@ -112,8 +121,16 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
})
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ removedIds: [first.worktreeId], failures: [] })
|
||||
.mockResolvedValueOnce({ removedIds: [second.worktreeId], failures: [] })
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [first.worktreeId],
|
||||
removedIdentities: [first.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [second.worktreeId],
|
||||
removedIdentities: [second.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
const onProgress = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
@@ -163,11 +180,13 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [first.worktreeId],
|
||||
removedIdentities: [first.worktreeId],
|
||||
failures: [],
|
||||
preservedBranches: [firstBranch]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [second.worktreeId],
|
||||
removedIdentities: [second.worktreeId],
|
||||
failures: [],
|
||||
preservedBranches: [secondBranch]
|
||||
}),
|
||||
@@ -178,6 +197,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [first.worktreeId, second.worktreeId],
|
||||
removedIdentities: [first.worktreeId, second.worktreeId],
|
||||
failures: [],
|
||||
preservedBranches: [firstBranch, secondBranch]
|
||||
})
|
||||
@@ -200,6 +220,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
})
|
||||
const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => ({
|
||||
removedIds: [...worktreeIds],
|
||||
removedIdentities: [...worktreeIds],
|
||||
failures: []
|
||||
}))
|
||||
|
||||
@@ -233,6 +254,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
})
|
||||
const removeCandidates = vi.fn().mockResolvedValueOnce({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }]
|
||||
})
|
||||
const onProgress = vi.fn()
|
||||
@@ -258,10 +280,12 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' },
|
||||
{
|
||||
worktreeId: parent.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: parent.displayName,
|
||||
message: 'Skipped because a nested workspace could not be removed.'
|
||||
}
|
||||
@@ -284,6 +308,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
})
|
||||
const removeCandidates = vi.fn().mockResolvedValueOnce({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }]
|
||||
})
|
||||
const onRowFailed = vi.fn()
|
||||
@@ -325,15 +350,21 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: failedChild.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: failedChild.displayName,
|
||||
message: 'busy'
|
||||
}
|
||||
]
|
||||
})
|
||||
.mockResolvedValueOnce({ removedIds: [unrelatedParent.worktreeId], failures: [] })
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [unrelatedParent.worktreeId],
|
||||
removedIdentities: [unrelatedParent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
@@ -352,7 +383,69 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [unrelatedParent.worktreeId],
|
||||
failures: [{ worktreeId: failedChild.worktreeId, displayName: 'child', message: 'busy' }]
|
||||
removedIdentities: [unrelatedParent.worktreeId],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: failedChild.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: 'child',
|
||||
message: 'busy'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not skip a paired-runtime ancestor after another runtime child fails', async () => {
|
||||
const failedChild = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent/child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/repo/parent/child',
|
||||
executionHostId: 'runtime:hub-a'
|
||||
})
|
||||
const unrelatedParent = makeCandidate({
|
||||
worktreeId: 'repo-2::/repo/parent',
|
||||
repoId: 'repo-2',
|
||||
repoName: 'Repo 2',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/repo/parent',
|
||||
executionHostId: 'runtime:hub-b'
|
||||
})
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: failedChild.worktreeId,
|
||||
executionHostId: failedChild.executionHostId,
|
||||
displayName: failedChild.displayName,
|
||||
message: 'busy'
|
||||
}
|
||||
]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [unrelatedParent.worktreeId],
|
||||
removedIdentities: [
|
||||
getWorkspaceCleanupHostIdentity('runtime:hub-b', unrelatedParent.worktreeId)
|
||||
],
|
||||
failures: []
|
||||
})
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [unrelatedParent, failedChild],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn()
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(1, [failedChild.worktreeId], {
|
||||
approvedCandidates: [failedChild]
|
||||
})
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(2, [unrelatedParent.worktreeId], {
|
||||
approvedCandidates: [unrelatedParent]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -363,6 +456,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
candidates: [candidate],
|
||||
removeCandidates: vi.fn().mockResolvedValue({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{ worktreeId: candidate.worktreeId, displayName: candidate.displayName, message: 'busy' }
|
||||
]
|
||||
@@ -377,441 +471,6 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a success confirmed after the initial timeout and proceeds with its parent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/repo/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent/child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/repo/parent/child'
|
||||
})
|
||||
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
resolveChild = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
resolveChild({ removedIds: [child.worktreeId], failures: [] })
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
|
||||
approvedCandidates: [parent]
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [child.worktreeId, parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a definitive late failure and skips its parent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::C:\\repo\\parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: 'C:\\repo\\parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::C:\\repo\\parent\\child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: 'C:\\repo\\parent\\child'
|
||||
})
|
||||
let rejectChild: (error: Error) => void = () => {}
|
||||
const removeCandidates = vi.fn(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => {
|
||||
rejectChild = reject
|
||||
})
|
||||
)
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
rejectChild(new Error('remote removal failed'))
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: child.worktreeId,
|
||||
displayName: child.displayName,
|
||||
message: 'remote removal failed'
|
||||
},
|
||||
{
|
||||
worktreeId: parent.worktreeId,
|
||||
displayName: parent.displayName,
|
||||
message: 'Skipped because a nested workspace could not be removed.'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a timeout, skips its parent, then reports the authoritative result', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/repo/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent/child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/repo/parent/child'
|
||||
})
|
||||
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
resolveChild = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
|
||||
const onProgress = vi.fn()
|
||||
const onResult = vi.fn()
|
||||
const onLateResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child],
|
||||
removeCandidates,
|
||||
onProgress,
|
||||
onResult,
|
||||
onLateResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect(onResult).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(onProgress).toHaveBeenLastCalledWith({
|
||||
totalCount: 2,
|
||||
processedCount: 2,
|
||||
removedCount: 0,
|
||||
failedCount: 2
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: child.worktreeId,
|
||||
displayName: child.displayName,
|
||||
message:
|
||||
'Removing child is taking longer than expected. It will keep running in the background.'
|
||||
},
|
||||
{
|
||||
worktreeId: parent.worktreeId,
|
||||
displayName: parent.displayName,
|
||||
message: 'Skipped because a nested workspace has not finished removing.'
|
||||
}
|
||||
]
|
||||
})
|
||||
// The still-pending row is reported as still removing, not as a failure.
|
||||
expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1')
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Workspaces not removed: 1',
|
||||
expect.objectContaining({
|
||||
description: 'Skipped because a nested workspace has not finished removing.'
|
||||
})
|
||||
)
|
||||
|
||||
resolveChild({ removedIds: [child.worktreeId], failures: [] })
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
// Why: post-batch child success must reclassify the provisional parent skip
|
||||
// and retry the parent so rowFailures do not keep the stale skip message.
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
|
||||
approvedCandidates: [parent]
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledTimes(1)
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(1, {
|
||||
removedIds: [child.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(2, {
|
||||
removedIds: [parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hardens a provisional parent skip after the child late-fails post-batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/repo/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent/child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/repo/parent/child'
|
||||
})
|
||||
let rejectChild: (error: Error) => void = () => {}
|
||||
const onLateResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child],
|
||||
removeCandidates: vi.fn(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => {
|
||||
rejectChild = reject
|
||||
})
|
||||
),
|
||||
onProgress: vi.fn(),
|
||||
onResult: vi.fn(),
|
||||
onLateResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await settleBackgroundRemoval()
|
||||
rejectChild(new Error('remote removal failed'))
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(1, {
|
||||
removedIds: [],
|
||||
failures: [
|
||||
expect.objectContaining({
|
||||
worktreeId: child.worktreeId,
|
||||
message: 'remote removal failed'
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(2, {
|
||||
removedIds: [],
|
||||
failures: [
|
||||
expect.objectContaining({
|
||||
worktreeId: parent.worktreeId,
|
||||
message: 'Skipped because a nested workspace could not be removed.'
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a skipped parent after its blocking child succeeds mid-batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/rp/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/rp/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/rp/parent/c',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/rp/parent/c'
|
||||
})
|
||||
const unrelated = makeCandidate({
|
||||
worktreeId: 'repo-1::/zz',
|
||||
displayName: 'other',
|
||||
branch: 'other',
|
||||
path: '/zz'
|
||||
})
|
||||
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
resolveChild = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
resolveUnrelated = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child, unrelated],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
// Child times out, the parent is provisionally skipped, and the loop is
|
||||
// held open by the unrelated row when the child's success reconciles.
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(2)
|
||||
resolveChild({ removedIds: [child.worktreeId], failures: [] })
|
||||
await settleBackgroundRemoval()
|
||||
resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] })
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], {
|
||||
approvedCandidates: [parent]
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(toast.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hardens a provisional skip into a definitive one after the child late-fails mid-batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/rp/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/rp/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/rp/parent/c',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/rp/parent/c'
|
||||
})
|
||||
const unrelated = makeCandidate({
|
||||
worktreeId: 'repo-1::/zz',
|
||||
displayName: 'other',
|
||||
branch: 'other',
|
||||
path: '/zz'
|
||||
})
|
||||
let rejectChild: (error: Error) => void = () => {}
|
||||
let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => {
|
||||
rejectChild = reject
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
resolveUnrelated = resolve
|
||||
})
|
||||
)
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child, unrelated],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
rejectChild(new Error('remote removal failed'))
|
||||
await settleBackgroundRemoval()
|
||||
resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] })
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(2)
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [unrelated.worktreeId],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: parent.worktreeId,
|
||||
displayName: parent.displayName,
|
||||
message: 'Skipped because a nested workspace could not be removed.'
|
||||
},
|
||||
{
|
||||
worktreeId: child.worktreeId,
|
||||
displayName: child.displayName,
|
||||
message: 'remote removal failed'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Workspaces not removed: 2',
|
||||
expect.objectContaining({
|
||||
description:
|
||||
'Skipped because a nested workspace could not be removed.; remote removal failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an authoritative rejection after the timeout result', async () => {
|
||||
vi.useFakeTimers()
|
||||
const candidate = makeCandidate()
|
||||
let rejectRemoval: (error: Error) => void = () => {}
|
||||
const onLateResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [candidate],
|
||||
removeCandidates: vi.fn(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => {
|
||||
rejectRemoval = reject
|
||||
})
|
||||
),
|
||||
onProgress: vi.fn(),
|
||||
onLateResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
rejectRemoval(new Error('remote removal failed'))
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(onLateResult).toHaveBeenCalledWith({
|
||||
removedIds: [],
|
||||
failures: [expect.objectContaining({ message: 'remote removal failed' })]
|
||||
})
|
||||
expect(toast.error).toHaveBeenLastCalledWith(
|
||||
'Workspaces not removed: 1',
|
||||
expect.objectContaining({ description: 'remote removal failed' })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps removal outcome toasts when the result callback throws', async () => {
|
||||
const candidate = makeCandidate()
|
||||
|
||||
@@ -819,6 +478,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
candidates: [candidate],
|
||||
removeCandidates: vi.fn().mockResolvedValue({
|
||||
removedIds: [candidate.worktreeId],
|
||||
removedIdentities: [candidate.worktreeId],
|
||||
failures: []
|
||||
}),
|
||||
onProgress: vi.fn(),
|
||||
@@ -851,12 +511,14 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{ worktreeId: first.worktreeId, displayName: first.displayName, message: 'busy' }
|
||||
]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{ worktreeId: second.worktreeId, displayName: second.displayName, message: 'dirty' }
|
||||
]
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@ import type {
|
||||
WorkspaceCleanupRemoveResult
|
||||
} from '@/store/slices/workspace-cleanup'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getWorkspaceCleanupCandidateHostId } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import {
|
||||
getSkippedAncestorMessage,
|
||||
isStrictWorkspaceCleanupDescendant,
|
||||
@@ -71,7 +72,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
}: WorkspaceCleanupBackgroundRemovalArgs): void {
|
||||
if (candidates.length === 0) {
|
||||
try {
|
||||
onResult?.({ removedIds: [], failures: [] })
|
||||
onResult?.({ removedIds: [], removedIdentities: [], failures: [] })
|
||||
} catch (callbackError) {
|
||||
console.error('Workspace cleanup result callback failed', callbackError)
|
||||
}
|
||||
@@ -80,6 +81,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
|
||||
const count = candidates.length
|
||||
const removedIds: string[] = []
|
||||
const removedIdentities: string[] = []
|
||||
const failures: WorkspaceCleanupFailure[] = []
|
||||
const preservedBranches: NonNullable<WorkspaceCleanupRemoveResult['preservedBranches']> = []
|
||||
const failedCandidates: WorkspaceCleanupCandidate[] = []
|
||||
@@ -141,6 +143,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
const provisional = blockers.every((blocker) => provisionallyBlocked.has(blocker))
|
||||
const failure: WorkspaceCleanupFailure = {
|
||||
worktreeId: candidate.worktreeId,
|
||||
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
|
||||
displayName: candidate.displayName,
|
||||
message: getSkippedAncestorMessage(provisional)
|
||||
}
|
||||
@@ -222,6 +225,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
pendingSettlementFailures.delete(timeoutFailure)
|
||||
provisionallyBlocked.delete(candidate)
|
||||
removedIds.push(...lateResult.removedIds)
|
||||
removedIdentities.push(...(lateResult.removedIdentities ?? []))
|
||||
preservedBranches.push(...(lateResult.preservedBranches ?? []))
|
||||
reportFailures(lateResult.failures)
|
||||
if (lateResult.failures.length === 0) {
|
||||
@@ -235,6 +239,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
}
|
||||
const result = outcome.result
|
||||
removedIds.push(...result.removedIds)
|
||||
removedIdentities.push(...(result.removedIdentities ?? []))
|
||||
preservedBranches.push(...(result.preservedBranches ?? []))
|
||||
reportFailures(result.failures)
|
||||
if (result.failures.length > 0) {
|
||||
@@ -245,6 +250,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
reportFailures([
|
||||
{
|
||||
worktreeId: candidate.worktreeId,
|
||||
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
|
||||
displayName: candidate.displayName,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -279,6 +285,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
)
|
||||
const result: WorkspaceCleanupRemoveResult = {
|
||||
removedIds,
|
||||
removedIdentities,
|
||||
failures,
|
||||
...(preservedBranches.length > 0 ? { preservedBranches } : {})
|
||||
}
|
||||
|
||||
+55
-3
@@ -3,7 +3,9 @@ import type { ReactNode } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getWorkspaceCleanupCandidateIdentity } from './workspace-cleanup-host-identity'
|
||||
import { CandidateRow } from './workspace-cleanup-candidate-row'
|
||||
import { getWorkspaceCleanupCandidateAccessibleName } from './workspace-cleanup-host-label'
|
||||
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
@@ -37,6 +39,7 @@ describe('CandidateRow', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
@@ -68,6 +71,7 @@ describe('CandidateRow', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
@@ -103,6 +107,7 @@ describe('CandidateRow', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
@@ -124,7 +129,9 @@ describe('CandidateRow', () => {
|
||||
)
|
||||
})
|
||||
|
||||
const openButton = container?.querySelector(`[aria-label="Open ${candidate.displayName}"]`)
|
||||
const openButton = container?.querySelector(
|
||||
`[aria-label="Open ${getWorkspaceCleanupCandidateAccessibleName(candidate)}"]`
|
||||
)
|
||||
expect(openButton?.querySelector('.lucide-external-link')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -134,6 +141,7 @@ describe('CandidateRow', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
@@ -156,8 +164,9 @@ describe('CandidateRow', () => {
|
||||
)
|
||||
})
|
||||
|
||||
expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull()
|
||||
expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull()
|
||||
const accessibleName = getWorkspaceCleanupCandidateAccessibleName(candidate)
|
||||
expect(container?.querySelector(`[aria-label="Select ${accessibleName}"]`)).toBeNull()
|
||||
expect(container?.querySelector(`[aria-label="Remove ${accessibleName}"]`)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -169,6 +178,7 @@ describe('CandidateRow', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
deletionPhase={deletionPhase}
|
||||
expanded={false}
|
||||
@@ -195,4 +205,46 @@ describe('CandidateRow', () => {
|
||||
expect(container?.textContent).toContain(label)
|
||||
expect(container?.textContent).not.toContain('Ready')
|
||||
})
|
||||
|
||||
it('visibly and accessibly distinguishes colliding local and SSH rows', () => {
|
||||
const local = makeCandidate({ executionHostId: 'local' })
|
||||
const remote = makeCandidate({ connectionId: 'builder', executionHostId: 'ssh:builder' })
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<>
|
||||
{[local, remote].map((candidate) => (
|
||||
<CandidateRow
|
||||
key={candidate.executionHostId}
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last={false}
|
||||
lastActivityLabel="1d ago"
|
||||
reviewInfo={{
|
||||
hasReview: false,
|
||||
label: null,
|
||||
provider: null,
|
||||
state: null,
|
||||
title: null
|
||||
}}
|
||||
selected={false}
|
||||
onIgnore={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onToggleExpanded={vi.fn()}
|
||||
onToggleSelected={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const localName = getWorkspaceCleanupCandidateAccessibleName(local)
|
||||
const remoteName = getWorkspaceCleanupCandidateAccessibleName(remote)
|
||||
expect(localName).not.toBe(remoteName)
|
||||
expect(container?.querySelector(`[aria-label="Select ${localName}"]`)).not.toBeNull()
|
||||
expect(container?.querySelector(`[aria-label="Select ${remoteName}"]`)).not.toBeNull()
|
||||
expect(container?.querySelector(`[aria-label="Host: builder"]`)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
GitPullRequest,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Monitor,
|
||||
SquareTerminal,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
@@ -41,11 +42,17 @@ import {
|
||||
} from './workspace-cleanup-candidate-row-data'
|
||||
import { StatusPill } from './workspace-cleanup-status-pill'
|
||||
import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateAccessibleName,
|
||||
getWorkspaceCleanupCandidateHostLabel
|
||||
} from './workspace-cleanup-host-label'
|
||||
|
||||
export type WorkspaceCleanupDeletionPhase = 'deleting' | 'queued'
|
||||
|
||||
type CandidateRowProps = {
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
/** Host-qualified row key; the same `worktreeId` can appear once per host. */
|
||||
identity: string
|
||||
deletionPhase?: WorkspaceCleanupDeletionPhase
|
||||
expanded: boolean
|
||||
failure?: string
|
||||
@@ -62,8 +69,8 @@ type CandidateRowProps = {
|
||||
selected: boolean
|
||||
onIgnore: (candidate: WorkspaceCleanupCandidate) => void
|
||||
onRemove: (candidate: WorkspaceCleanupCandidate) => void
|
||||
onToggleExpanded: (worktreeId: string) => void
|
||||
onToggleSelected: (worktreeId: string) => void
|
||||
onToggleExpanded: (identity: string) => void
|
||||
onToggleSelected: (identity: string) => void
|
||||
onView: (candidate: WorkspaceCleanupCandidate) => void
|
||||
}
|
||||
|
||||
@@ -74,6 +81,7 @@ type CandidateRowProps = {
|
||||
// prop identity changes); virtualization, not memo, bounds that cost.
|
||||
export const CandidateRow = React.memo(function CandidateRow({
|
||||
candidate,
|
||||
identity,
|
||||
deletionPhase,
|
||||
expanded,
|
||||
failure,
|
||||
@@ -104,6 +112,8 @@ export const CandidateRow = React.memo(function CandidateRow({
|
||||
const gitLabel = getWorkspaceCleanupGitLabel(candidate)
|
||||
const showGitMetadataChip = shouldShowGitMetadataChip(candidate)
|
||||
const contextCount = getContextCount(candidate)
|
||||
const candidateAccessibleName = getWorkspaceCleanupCandidateAccessibleName(candidate)
|
||||
const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate)
|
||||
const sizeValue =
|
||||
sizeLabel ?? translate('components.workspace.cleanup.browse.notMeasured', 'Not measured')
|
||||
const hasExpandableDetails =
|
||||
@@ -131,9 +141,9 @@ export const CandidateRow = React.memo(function CandidateRow({
|
||||
aria-label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.bbb1ab6a6f',
|
||||
'Select {{value0}}',
|
||||
{ value0: candidate.displayName }
|
||||
{ value0: candidateAccessibleName }
|
||||
)}
|
||||
onClick={() => onToggleSelected(candidate.worktreeId)}
|
||||
onClick={() => onToggleSelected(identity)}
|
||||
className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{selected ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||
@@ -149,6 +159,13 @@ export const CandidateRow = React.memo(function CandidateRow({
|
||||
<span data-workspace-cleanup-row-name className="min-w-0 truncate text-sm font-medium">
|
||||
{candidate.displayName}
|
||||
</span>
|
||||
<WorkspaceCleanupMetadataChip
|
||||
icon={Monitor}
|
||||
label={translate('components.workspace.cleanup.host.label', 'Host: {{value0}}', {
|
||||
value0: hostLabel
|
||||
})}
|
||||
value={hostLabel}
|
||||
/>
|
||||
{deletionPhase ? (
|
||||
<StatusPill tone="destructive">
|
||||
{deletionPhase === 'queued'
|
||||
@@ -272,7 +289,7 @@ export const CandidateRow = React.memo(function CandidateRow({
|
||||
)
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onToggleExpanded(candidate.worktreeId)}
|
||||
onClick={() => onToggleExpanded(identity)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
|
||||
@@ -300,7 +317,7 @@ export const CandidateRow = React.memo(function CandidateRow({
|
||||
aria-label={translate(
|
||||
'components.workspace.cleanup.browse.openWorkspaceNamed',
|
||||
'Open {{value0}}',
|
||||
{ value0: candidate.displayName }
|
||||
{ value0: candidateAccessibleName }
|
||||
)}
|
||||
onClick={() => onView(candidate)}
|
||||
>
|
||||
@@ -320,7 +337,7 @@ export const CandidateRow = React.memo(function CandidateRow({
|
||||
aria-label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.a9957007eb',
|
||||
'Ignore {{value0}}',
|
||||
{ value0: candidate.displayName }
|
||||
{ value0: candidateAccessibleName }
|
||||
)}
|
||||
onClick={() => onIgnore(candidate)}
|
||||
>
|
||||
@@ -344,7 +361,7 @@ export const CandidateRow = React.memo(function CandidateRow({
|
||||
aria-label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.3828408538',
|
||||
'Remove {{value0}}',
|
||||
{ value0: candidate.displayName }
|
||||
{ value0: candidateAccessibleName }
|
||||
)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onRemove(candidate)}
|
||||
|
||||
+42
@@ -4,6 +4,10 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
|
||||
import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateAccessibleName,
|
||||
getWorkspaceCleanupCandidateHostLabel
|
||||
} from './workspace-cleanup-host-label'
|
||||
|
||||
const listProbe = vi.hoisted(() => vi.fn())
|
||||
|
||||
@@ -31,6 +35,12 @@ vi.mock('@/components/ui/scroll-area', () => ({
|
||||
ScrollArea: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
@@ -73,4 +83,36 @@ describe('WorkspaceCleanupConfirmRemove', () => {
|
||||
expect(listProbe).toHaveBeenCalledWith(candidates.length)
|
||||
expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('visibly and accessibly identifies each host for colliding confirmations', () => {
|
||||
const local = makeFacetCandidate({ executionHostId: 'local' })
|
||||
const remote = makeFacetCandidate({
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
})
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceCleanupConfirmRemove
|
||||
candidates={[local, remote]}
|
||||
now={Date.now()}
|
||||
reviewInfoByWorktreeId={new Map()}
|
||||
progress={null}
|
||||
onBack={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(getWorkspaceCleanupCandidateAccessibleName(local)).not.toBe(
|
||||
getWorkspaceCleanupCandidateAccessibleName(remote)
|
||||
)
|
||||
for (const candidate of [local, remote]) {
|
||||
const name = getWorkspaceCleanupCandidateAccessibleName(candidate)
|
||||
const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate)
|
||||
expect(container.querySelector(`[role="group"][aria-label="${name}"]`)).not.toBeNull()
|
||||
expect(container.querySelector(`[aria-label="Host: ${hostLabel}"]`)).not.toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react'
|
||||
import { AlertTriangle, Loader2, Monitor, Trash2, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
@@ -21,8 +21,14 @@ import { StatusPill } from './workspace-cleanup-status-pill'
|
||||
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateHostId,
|
||||
getWorkspaceCleanupCandidateIdentity,
|
||||
getWorkspaceCleanupHostIdentity
|
||||
} from './workspace-cleanup-host-identity'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateAccessibleName,
|
||||
getWorkspaceCleanupCandidateHostLabel
|
||||
} from './workspace-cleanup-host-label'
|
||||
import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip'
|
||||
|
||||
const CONFIRM_REMOVE_ROW_ESTIMATE_PX = 76
|
||||
|
||||
@@ -138,7 +144,7 @@ export function WorkspaceCleanupConfirmRemove({
|
||||
estimatedRowHeight={CONFIRM_REMOVE_ROW_ESTIMATE_PX}
|
||||
renderRow={(candidate, index) => (
|
||||
<ConfirmRemoveRow
|
||||
key={candidate.worktreeId}
|
||||
key={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
now={now}
|
||||
reviewInfo={
|
||||
@@ -200,10 +206,22 @@ function ConfirmRemoveRow({
|
||||
const contextPillLabel = getContextPillLabel(candidate)
|
||||
const showGitMetadataChip = shouldShowGitMetadataChip(candidate)
|
||||
const factStatus = getCandidateFactStatus(candidate)
|
||||
const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate)
|
||||
return (
|
||||
<div className={cn('border-b border-border/60 px-5 py-2.5', last && 'border-b-0')}>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={getWorkspaceCleanupCandidateAccessibleName(candidate)}
|
||||
className={cn('border-b border-border/60 px-5 py-2.5', last && 'border-b-0')}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="min-w-0 truncate text-sm font-medium">{candidate.displayName}</span>
|
||||
<WorkspaceCleanupMetadataChip
|
||||
icon={Monitor}
|
||||
label={translate('components.workspace.cleanup.host.label', 'Host: {{value0}}', {
|
||||
value0: hostLabel
|
||||
})}
|
||||
value={hostLabel}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc',
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
|
||||
import { getWorkspaceCleanupDeletionPhaseByIdentity } from './workspace-cleanup-deletion-phases'
|
||||
|
||||
describe('workspace cleanup deletion phases', () => {
|
||||
it('marks only the qualified row during a same-id cleanup removal', () => {
|
||||
const local = makeCandidate({ executionHostId: 'local' })
|
||||
const remote = makeCandidate({
|
||||
worktreeId: local.worktreeId,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
const localIdentity = getWorkspaceCleanupCandidateIdentity(local)
|
||||
const remoteIdentity = getWorkspaceCleanupCandidateIdentity(remote)
|
||||
|
||||
expect(
|
||||
getWorkspaceCleanupDeletionPhaseByIdentity(
|
||||
[local, remote],
|
||||
{ [remoteIdentity]: 'deleting' },
|
||||
{ [local.worktreeId]: 'deleting' }
|
||||
)
|
||||
).toEqual({ [remoteIdentity]: 'deleting' })
|
||||
expect(localIdentity).not.toBe(remoteIdentity)
|
||||
})
|
||||
|
||||
it('applies a non-cleanup deletion to every ambiguous same-id row', () => {
|
||||
const local = makeCandidate({ executionHostId: 'local' })
|
||||
const remote = makeCandidate({
|
||||
worktreeId: local.worktreeId,
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
|
||||
expect(
|
||||
getWorkspaceCleanupDeletionPhaseByIdentity(
|
||||
[local, remote],
|
||||
{},
|
||||
{ [local.worktreeId]: 'queued' }
|
||||
)
|
||||
).toEqual({
|
||||
[getWorkspaceCleanupCandidateIdentity(local)]: 'queued',
|
||||
[getWorkspaceCleanupCandidateIdentity(remote)]: 'queued'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateIdentity,
|
||||
getWorkspaceCleanupIdentityWorktreeId
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row'
|
||||
|
||||
export function getWorkspaceCleanupDeletionPhaseByIdentity(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
cleanupPhases: Readonly<Record<string, WorkspaceCleanupDeletionPhase>>,
|
||||
genericPhasesByWorktreeId: Readonly<Record<string, WorkspaceCleanupDeletionPhase>>
|
||||
): Record<string, WorkspaceCleanupDeletionPhase> {
|
||||
const cleanupWorktreeIds = new Set(
|
||||
Object.keys(cleanupPhases).map(getWorkspaceCleanupIdentityWorktreeId)
|
||||
)
|
||||
const phases = { ...cleanupPhases }
|
||||
for (const candidate of candidates) {
|
||||
if (cleanupWorktreeIds.has(candidate.worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const phase = genericPhasesByWorktreeId[candidate.worktreeId]
|
||||
if (phase) {
|
||||
phases[getWorkspaceCleanupCandidateIdentity(candidate)] = phase
|
||||
}
|
||||
}
|
||||
return phases
|
||||
}
|
||||
+3
-1
@@ -500,7 +500,9 @@ describe('query pipeline', () => {
|
||||
)
|
||||
expect(result.totalCount).toBe(1)
|
||||
expect(result.matchedCount).toBe(1)
|
||||
expect(result.selectableWorktreeIds).toEqual(['repo-1::/repo/alpha'])
|
||||
expect(result.selectableIdentities).toEqual([
|
||||
getWorkspaceCleanupHostIdentity('local', 'repo-1::/repo/alpha')
|
||||
])
|
||||
})
|
||||
|
||||
it('counts per-facet matches independently of the other groups', () => {
|
||||
|
||||
@@ -37,6 +37,8 @@ export type WorkspaceCleanupFacetSources = {
|
||||
export type WorkspaceCleanupFacets = {
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
worktreeId: string
|
||||
/** Host-qualified row key; `worktreeId` alone repeats across hosts (STA-4343). */
|
||||
identity: string
|
||||
repoId: string
|
||||
repoName: string
|
||||
displayName: string
|
||||
@@ -107,6 +109,7 @@ export function buildWorkspaceCleanupFacets(
|
||||
const facets: Omit<WorkspaceCleanupFacets, 'searchText'> = {
|
||||
candidate,
|
||||
worktreeId: candidate.worktreeId,
|
||||
identity: hostIdentity,
|
||||
repoId: candidate.repoId,
|
||||
repoName: candidate.repoName,
|
||||
displayName: candidate.displayName,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { CandidateRow } from './workspace-cleanup-candidate-row'
|
||||
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
|
||||
import { FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture'
|
||||
import { getWorkspaceCleanupHostIdentity } from './workspace-cleanup-host-identity'
|
||||
import { runWorkspaceCleanupQuery } from './workspace-cleanup-query'
|
||||
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
|
||||
|
||||
@@ -41,7 +42,8 @@ function renderRows(rows: readonly WorkspaceCleanupFacets[]): void {
|
||||
scrollElement={null}
|
||||
renderRow={(row, index) => (
|
||||
<CandidateRow
|
||||
key={row.worktreeId}
|
||||
key={row.identity}
|
||||
identity={row.identity}
|
||||
candidate={row.candidate}
|
||||
reviewInfo={row.review}
|
||||
expanded={false}
|
||||
@@ -124,7 +126,9 @@ describe('workspace cleanup flat list', () => {
|
||||
})
|
||||
|
||||
it('reports only the rows the user could actually queue for deletion', () => {
|
||||
expect(query().selectableWorktreeIds).not.toContain('repo-1::/repo/protected-one')
|
||||
expect(query().selectableIdentities).not.toContain(
|
||||
getWorkspaceCleanupHostIdentity('local', 'repo-1::/repo/protected-one')
|
||||
)
|
||||
})
|
||||
|
||||
it('shows status and disk-size facts without expanding a row', () => {
|
||||
|
||||
+30
-2
@@ -1,3 +1,4 @@
|
||||
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
|
||||
@@ -102,11 +103,35 @@ describe('applyWorkspaceCleanupGitEvidence', () => {
|
||||
worktreeId: 'a',
|
||||
git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 }
|
||||
})
|
||||
const [row] = applyWorkspaceCleanupGitEvidence([deferred], new Map([['a', refreshed]]))
|
||||
const [row] = applyWorkspaceCleanupGitEvidence(
|
||||
[deferred],
|
||||
new Map([[getWorkspaceCleanupCandidateIdentity(refreshed), refreshed]])
|
||||
)
|
||||
expect(row.git.clean).toBe(false)
|
||||
expect(row.git.checkedAt).toBe(5)
|
||||
})
|
||||
|
||||
it("never applies one host's git evidence to another host's same-id row", () => {
|
||||
// STA-4343: a dirty remote row must not mark the local same-id row dirty
|
||||
// (or vice versa) — the evidence index is keyed by host, not by id.
|
||||
const localRow = { ...deferredCandidate('a'), executionHostId: 'local' as const }
|
||||
const remoteEvidence = {
|
||||
...makeFacetCandidate({
|
||||
worktreeId: 'a',
|
||||
blockers: ['dirty-files'],
|
||||
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 5 }
|
||||
}),
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1' as const
|
||||
}
|
||||
const [row] = applyWorkspaceCleanupGitEvidence(
|
||||
[localRow],
|
||||
new Map([[getWorkspaceCleanupCandidateIdentity(remoteEvidence), remoteEvidence]])
|
||||
)
|
||||
expect(row.git).toEqual(localRow.git)
|
||||
expect(row.blockers).not.toContain('dirty-files')
|
||||
})
|
||||
|
||||
it('returns the input untouched when nothing was re-scanned', () => {
|
||||
const candidates = [deferredCandidate('a')]
|
||||
expect(applyWorkspaceCleanupGitEvidence(candidates, new Map())).toBe(candidates)
|
||||
@@ -125,7 +150,10 @@ describe('applyWorkspaceCleanupGitEvidence', () => {
|
||||
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 5 }
|
||||
})
|
||||
|
||||
const [row] = applyWorkspaceCleanupGitEvidence([current], new Map([['a', refreshed]]))
|
||||
const [row] = applyWorkspaceCleanupGitEvidence(
|
||||
[current],
|
||||
new Map([[getWorkspaceCleanupCandidateIdentity(refreshed), refreshed]])
|
||||
)
|
||||
|
||||
expect(row.displayName).toBe('current name')
|
||||
expect(row.fingerprint).toBe('current-fingerprint')
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
applyWorkspaceCleanupPolicy,
|
||||
WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import type {
|
||||
WorkspaceCleanupFilterState,
|
||||
WorkspaceCleanupSortField,
|
||||
@@ -72,16 +73,20 @@ export function selectWorkspaceCleanupGitEvidenceTargets(
|
||||
return targets
|
||||
}
|
||||
|
||||
/** Focused re-scan results win over the deferred broad-scan row. */
|
||||
/**
|
||||
* Focused re-scan results win over the deferred broad-scan row. Keyed by
|
||||
* host-qualified identity: applying one host's git state to another host's
|
||||
* same-id row would mislabel a dirty workspace as safe to delete (STA-4343).
|
||||
*/
|
||||
export function applyWorkspaceCleanupGitEvidence(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
evidenceByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
|
||||
evidenceByIdentity: ReadonlyMap<string, WorkspaceCleanupCandidate>
|
||||
): readonly WorkspaceCleanupCandidate[] {
|
||||
if (evidenceByWorktreeId.size === 0) {
|
||||
if (evidenceByIdentity.size === 0) {
|
||||
return candidates
|
||||
}
|
||||
return candidates.map((candidate) => {
|
||||
const evidence = evidenceByWorktreeId.get(candidate.worktreeId)
|
||||
const evidence = evidenceByIdentity.get(getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
if (
|
||||
evidence === undefined ||
|
||||
(candidate.git.checkedAt !== null &&
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
@@ -12,6 +10,15 @@ import type {
|
||||
WorkspaceSpaceWorktreeMeasurement
|
||||
} from '../../../../shared/workspace-space-types'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import { getWorkspaceCleanupHostIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
|
||||
export {
|
||||
getWorkspaceCleanupCandidateHostId,
|
||||
getWorkspaceCleanupCandidateIdentity,
|
||||
getWorkspaceCleanupHostIdentity,
|
||||
getWorkspaceCleanupIdentityWorktreeId,
|
||||
resolveWorkspaceCleanupRemovalHostId
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
|
||||
/** Structural subset of `Worktree` kept in sync with the persisted source. */
|
||||
export type WorkspaceCleanupWorktreeFacts = Pick<Worktree, 'id'> &
|
||||
@@ -35,21 +42,6 @@ export type WorkspaceCleanupWorktreeFacts = Pick<Worktree, 'id'> &
|
||||
>
|
||||
>
|
||||
|
||||
export function getWorkspaceCleanupCandidateHostId(
|
||||
candidate: Pick<WorkspaceCleanupCandidate, 'connectionId' | 'executionHostId'>
|
||||
): ExecutionHostId {
|
||||
return (
|
||||
normalizeExecutionHostId(candidate.executionHostId) ??
|
||||
(candidate.connectionId
|
||||
? toSshExecutionHostId(candidate.connectionId)
|
||||
: LOCAL_EXECUTION_HOST_ID)
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string {
|
||||
return `${hostId}\0${id}`
|
||||
}
|
||||
|
||||
function countIds<Row extends { worktreeId: string }>(
|
||||
rows: readonly Row[],
|
||||
include: (row: Row) => boolean = () => true
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import { resolveWorkspaceCleanupRemovalHostId } from './workspace-cleanup-host-identity'
|
||||
|
||||
export function getWorkspaceCleanupCandidateHostLabel(
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
): string {
|
||||
const hostId = resolveWorkspaceCleanupRemovalHostId(candidate)
|
||||
return hostId
|
||||
? getExecutionHostLabel(hostId)
|
||||
: translate('components.workspace.cleanup.host.unknown', 'Unknown host')
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupCandidateAccessibleName(
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
): string {
|
||||
return translate('components.workspace.cleanup.host.candidateName', '{{value0}} on {{value1}}', {
|
||||
value0: candidate.displayName,
|
||||
value1: getWorkspaceCleanupCandidateHostLabel(candidate)
|
||||
})
|
||||
}
|
||||
+19
-5
@@ -17,6 +17,10 @@ import {
|
||||
type WorkspaceCleanupLateSettlementReporter
|
||||
} from './workspace-cleanup-removal-settlement'
|
||||
import { reclassifySkippedWorkspaceCleanupAncestors } from './workspace-cleanup-skipped-ancestor-reclassification'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateHostId,
|
||||
getWorkspaceCleanupCandidateIdentity
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
|
||||
type WorkspaceCleanupRemoveCandidates = (
|
||||
worktreeIds: readonly string[],
|
||||
@@ -69,7 +73,8 @@ export function createPostBatchLateSettlementReporter({
|
||||
provisionallyBlocked.has(candidate) &&
|
||||
retainedSkippedAncestors.some(
|
||||
(entry) =>
|
||||
entry.candidate.worktreeId === candidate.worktreeId ||
|
||||
getWorkspaceCleanupCandidateIdentity(entry.candidate) ===
|
||||
getWorkspaceCleanupCandidateIdentity(candidate) ||
|
||||
isStrictWorkspaceCleanupDescendant(entry.candidate, candidate)
|
||||
)
|
||||
)
|
||||
@@ -103,7 +108,9 @@ export function createPostBatchLateSettlementReporter({
|
||||
})
|
||||
reportResult(result)
|
||||
}
|
||||
const retainedCandidateIds = new Set(retainedCandidates.map((candidate) => candidate.worktreeId))
|
||||
const retainedCandidateIdentities = new Set(
|
||||
retainedCandidates.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
)
|
||||
const reportWithoutReconciliation: WorkspaceCleanupLateSettlementReporter = (
|
||||
_candidate,
|
||||
result
|
||||
@@ -111,7 +118,7 @@ export function createPostBatchLateSettlementReporter({
|
||||
reportResult(result)
|
||||
}
|
||||
return (candidate) =>
|
||||
retainedCandidateIds.has(candidate.worktreeId)
|
||||
retainedCandidateIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
? reportLateSettlement
|
||||
: reportWithoutReconciliation
|
||||
}
|
||||
@@ -125,8 +132,9 @@ async function reconcilePostBatchLateSettlement(
|
||||
result: WorkspaceCleanupRemoveResult
|
||||
pendingSettlementFailures?: ReadonlySet<WorkspaceCleanupFailure>
|
||||
}> {
|
||||
const settledIdentity = getWorkspaceCleanupCandidateIdentity(settledCandidateIdentity)
|
||||
const settledCandidate = state.failedCandidates.find(
|
||||
(candidate) => candidate.worktreeId === settledCandidateIdentity.worktreeId
|
||||
(candidate) => getWorkspaceCleanupCandidateIdentity(candidate) === settledIdentity
|
||||
)
|
||||
if (settledCandidate) {
|
||||
state.provisionallyBlocked.delete(settledCandidate)
|
||||
@@ -136,6 +144,7 @@ async function reconcilePostBatchLateSettlement(
|
||||
}
|
||||
|
||||
const removedIds: string[] = []
|
||||
const removedIdentities: string[] = []
|
||||
const lateFailures: WorkspaceCleanupFailure[] = []
|
||||
const pendingSettlementFailures = new Set<WorkspaceCleanupFailure>()
|
||||
const findBlockingDescendants = (
|
||||
@@ -163,6 +172,7 @@ async function reconcilePostBatchLateSettlement(
|
||||
const provisional = blockers.every((blocker) => state.provisionallyBlocked.has(blocker))
|
||||
const failure: WorkspaceCleanupFailure = {
|
||||
worktreeId: ancestor.worktreeId,
|
||||
executionHostId: getWorkspaceCleanupCandidateHostId(ancestor),
|
||||
displayName: ancestor.displayName,
|
||||
message: getSkippedAncestorMessage(provisional)
|
||||
}
|
||||
@@ -189,6 +199,7 @@ async function reconcilePostBatchLateSettlement(
|
||||
state.failedCandidates.push(ancestor)
|
||||
lateFailures.push({
|
||||
worktreeId: ancestor.worktreeId,
|
||||
executionHostId: getWorkspaceCleanupCandidateHostId(ancestor),
|
||||
displayName: ancestor.displayName,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
@@ -214,9 +225,11 @@ async function reconcilePostBatchLateSettlement(
|
||||
? outcome.result
|
||||
: {
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: ancestor.worktreeId,
|
||||
executionHostId: getWorkspaceCleanupCandidateHostId(ancestor),
|
||||
displayName: ancestor.displayName,
|
||||
message:
|
||||
outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
|
||||
@@ -224,6 +237,7 @@ async function reconcilePostBatchLateSettlement(
|
||||
]
|
||||
}
|
||||
removedIds.push(...result.removedIds)
|
||||
removedIdentities.push(...(result.removedIdentities ?? []))
|
||||
if (result.failures.length > 0) {
|
||||
state.failedCandidates.push(ancestor)
|
||||
lateFailures.push(...result.failures)
|
||||
@@ -232,7 +246,7 @@ async function reconcilePostBatchLateSettlement(
|
||||
|
||||
releaseSettledPostBatchState(state)
|
||||
return {
|
||||
result: { removedIds, failures: lateFailures },
|
||||
result: { removedIds, removedIdentities, failures: lateFailures },
|
||||
pendingSettlementFailures:
|
||||
pendingSettlementFailures.size > 0 ? pendingSettlementFailures : undefined
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ export type WorkspaceCleanupQuery = {
|
||||
export type WorkspaceCleanupQueryResult = {
|
||||
/** Sorted rows that passed every facet. */
|
||||
rows: WorkspaceCleanupFacets[]
|
||||
/** Rows the user could actually queue for deletion, for the select-all affordance. */
|
||||
selectableWorktreeIds: string[]
|
||||
/** Host-qualified keys of rows the user could queue, for the select-all affordance. */
|
||||
selectableIdentities: string[]
|
||||
totalCount: number
|
||||
matchedCount: number
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function runWorkspaceCleanupQuery(
|
||||
const rows = sortWorkspaceCleanupFacets(matched, query.sort)
|
||||
return {
|
||||
rows,
|
||||
selectableWorktreeIds: rows.filter((row) => row.isSelectable).map((row) => row.worktreeId),
|
||||
selectableIdentities: rows.filter((row) => row.isSelectable).map((row) => row.identity),
|
||||
totalCount: facets.length,
|
||||
matchedCount: rows.length
|
||||
}
|
||||
|
||||
+9
-1
@@ -20,8 +20,10 @@ describe('workspace cleanup late settlement tracking', () => {
|
||||
reconcileBeforeBatchResult
|
||||
)
|
||||
|
||||
// The compact copy keeps the host facts a late failure needs to name its row.
|
||||
expect(tracker.candidate).toEqual({
|
||||
worktreeId: candidate.worktreeId,
|
||||
connectionId: candidate.connectionId,
|
||||
displayName: candidate.displayName
|
||||
})
|
||||
expect(tracker.candidate).not.toBe(candidate)
|
||||
@@ -32,12 +34,18 @@ describe('workspace cleanup late settlement tracking', () => {
|
||||
|
||||
expect(reconcileBeforeBatchResult).not.toHaveBeenCalled()
|
||||
expect(reportAfterBatchResult).toHaveBeenCalledWith(
|
||||
{ worktreeId: candidate.worktreeId, displayName: candidate.displayName },
|
||||
{
|
||||
worktreeId: candidate.worktreeId,
|
||||
connectionId: candidate.connectionId,
|
||||
displayName: candidate.displayName
|
||||
},
|
||||
{
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: candidate.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: candidate.displayName,
|
||||
message: 'remote removal failed'
|
||||
}
|
||||
|
||||
+7
-1
@@ -4,6 +4,7 @@ import type {
|
||||
WorkspaceCleanupRemoveResult
|
||||
} from '@/store/slices/workspace-cleanup'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getWorkspaceCleanupCandidateHostId } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
|
||||
export type WorkspaceCleanupRemovalSettlement =
|
||||
| { status: 'fulfilled'; result: WorkspaceCleanupRemoveResult }
|
||||
@@ -15,7 +16,7 @@ export type WorkspaceCleanupRemovalWaitResult =
|
||||
|
||||
export type WorkspaceCleanupLateSettlementCandidate = Pick<
|
||||
WorkspaceCleanupCandidate,
|
||||
'worktreeId' | 'displayName'
|
||||
'worktreeId' | 'displayName' | 'connectionId' | 'executionHostId'
|
||||
>
|
||||
|
||||
export type WorkspaceCleanupLateSettlementReporter = (
|
||||
@@ -51,6 +52,7 @@ export function getWorkspaceCleanupTimeoutFailure(
|
||||
): WorkspaceCleanupFailure {
|
||||
return {
|
||||
worktreeId: candidate.worktreeId,
|
||||
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
|
||||
displayName: candidate.displayName,
|
||||
message: translate(
|
||||
'auto.components.workspace.cleanup.backgroundRemoval.timedOut',
|
||||
@@ -67,6 +69,8 @@ export function trackWorkspaceCleanupLateSettlement(
|
||||
): WorkspaceCleanupLateSettlementTracker {
|
||||
const candidateIdentity: WorkspaceCleanupLateSettlementCandidate = {
|
||||
worktreeId: candidate.worktreeId,
|
||||
connectionId: candidate.connectionId,
|
||||
...(candidate.executionHostId ? { executionHostId: candidate.executionHostId } : {}),
|
||||
displayName: candidate.displayName
|
||||
}
|
||||
const state: {
|
||||
@@ -127,9 +131,11 @@ function toWorkspaceCleanupRemoveResult(
|
||||
}
|
||||
return {
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: candidate.worktreeId,
|
||||
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
|
||||
displayName: candidate.displayName,
|
||||
message:
|
||||
settlement.error instanceof Error ? settlement.error.message : String(settlement.error)
|
||||
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Timeout-boundary behavior of the cleanup removal batch: renderer IPC cannot be
|
||||
* cancelled, so a row past its deadline stays provisional and its authoritative
|
||||
* settlement — success or failure — must still reconcile the batch's row state,
|
||||
* its ancestor skips, and its toasts. Split from the batch-behavior suite.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup'
|
||||
import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal'
|
||||
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
async function settleBackgroundRemoval(): Promise<void> {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace cleanup removal timeout recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('uses a success confirmed after the initial timeout and proceeds with its parent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/repo/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent/child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/repo/parent/child'
|
||||
})
|
||||
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveChild = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [parent.worktreeId],
|
||||
removedIdentities: [parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
resolveChild({
|
||||
removedIds: [child.worktreeId],
|
||||
removedIdentities: [child.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
|
||||
approvedCandidates: [parent]
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [child.worktreeId, parent.worktreeId],
|
||||
removedIdentities: [child.worktreeId, parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a definitive late failure and skips its parent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::C:\\repo\\parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: 'C:\\repo\\parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::C:\\repo\\parent\\child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: 'C:\\repo\\parent\\child'
|
||||
})
|
||||
let rejectChild: (error: Error) => void = () => {}
|
||||
const removeCandidates = vi.fn(
|
||||
() =>
|
||||
new Promise<WorkspaceCleanupRemoveResult>((_resolve, reject) => {
|
||||
rejectChild = reject
|
||||
})
|
||||
)
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
rejectChild(new Error('remote removal failed'))
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: child.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: child.displayName,
|
||||
message: 'remote removal failed'
|
||||
},
|
||||
{
|
||||
worktreeId: parent.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: parent.displayName,
|
||||
message: 'Skipped because a nested workspace could not be removed.'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a timeout, skips its parent, then reports the authoritative result', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/repo/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent/child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/repo/parent/child'
|
||||
})
|
||||
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveChild = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [parent.worktreeId],
|
||||
removedIdentities: [parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
const onProgress = vi.fn()
|
||||
const onResult = vi.fn()
|
||||
const onLateResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child],
|
||||
removeCandidates,
|
||||
onProgress,
|
||||
onResult,
|
||||
onLateResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect(onResult).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(onProgress).toHaveBeenLastCalledWith({
|
||||
totalCount: 2,
|
||||
processedCount: 2,
|
||||
removedCount: 0,
|
||||
failedCount: 2
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: child.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: child.displayName,
|
||||
message:
|
||||
'Removing child is taking longer than expected. It will keep running in the background.'
|
||||
},
|
||||
{
|
||||
worktreeId: parent.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: parent.displayName,
|
||||
message: 'Skipped because a nested workspace has not finished removing.'
|
||||
}
|
||||
]
|
||||
})
|
||||
// The still-pending row is reported as still removing, not as a failure.
|
||||
expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1')
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Workspaces not removed: 1',
|
||||
expect.objectContaining({
|
||||
description: 'Skipped because a nested workspace has not finished removing.'
|
||||
})
|
||||
)
|
||||
|
||||
resolveChild({
|
||||
removedIds: [child.worktreeId],
|
||||
removedIdentities: [child.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
// Why: post-batch child success must reclassify the provisional parent skip
|
||||
// and retry the parent so rowFailures do not keep the stale skip message.
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
|
||||
approvedCandidates: [parent]
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledTimes(1)
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(1, {
|
||||
removedIds: [child.worktreeId],
|
||||
removedIdentities: [child.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(2, {
|
||||
removedIds: [parent.worktreeId],
|
||||
removedIdentities: [parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.success).toHaveBeenLastCalledWith('Removed workspaces: 1')
|
||||
})
|
||||
|
||||
it('hardens a provisional parent skip after the child late-fails post-batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/repo/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent/child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/repo/parent/child'
|
||||
})
|
||||
let rejectChild: (error: Error) => void = () => {}
|
||||
const onLateResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child],
|
||||
removeCandidates: vi.fn(
|
||||
() =>
|
||||
new Promise<WorkspaceCleanupRemoveResult>((_resolve, reject) => {
|
||||
rejectChild = reject
|
||||
})
|
||||
),
|
||||
onProgress: vi.fn(),
|
||||
onResult: vi.fn(),
|
||||
onLateResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await settleBackgroundRemoval()
|
||||
rejectChild(new Error('remote removal failed'))
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(1, {
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
expect.objectContaining({
|
||||
worktreeId: child.worktreeId,
|
||||
message: 'remote removal failed'
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(2, {
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
expect.objectContaining({
|
||||
worktreeId: parent.worktreeId,
|
||||
message: 'Skipped because a nested workspace could not be removed.'
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a skipped parent after its blocking child succeeds mid-batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/rp/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/rp/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/rp/parent/c',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/rp/parent/c'
|
||||
})
|
||||
const unrelated = makeCandidate({
|
||||
worktreeId: 'repo-1::/zz',
|
||||
displayName: 'other',
|
||||
branch: 'other',
|
||||
path: '/zz'
|
||||
})
|
||||
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
let resolveUnrelated: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveChild = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveUnrelated = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
removedIds: [parent.worktreeId],
|
||||
removedIdentities: [parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child, unrelated],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
// Child times out, the parent is provisionally skipped, and the loop is
|
||||
// held open by the unrelated row when the child's success reconciles.
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(2)
|
||||
resolveChild({
|
||||
removedIds: [child.worktreeId],
|
||||
removedIdentities: [child.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
resolveUnrelated({
|
||||
removedIds: [unrelated.worktreeId],
|
||||
removedIdentities: [unrelated.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], {
|
||||
approvedCandidates: [parent]
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
|
||||
removedIdentities: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(toast.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hardens a provisional skip into a definitive one after the child late-fails mid-batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/rp/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/rp/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/rp/parent/c',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/rp/parent/c'
|
||||
})
|
||||
const unrelated = makeCandidate({
|
||||
worktreeId: 'repo-1::/zz',
|
||||
displayName: 'other',
|
||||
branch: 'other',
|
||||
path: '/zz'
|
||||
})
|
||||
let rejectChild: (error: Error) => void = () => {}
|
||||
let resolveUnrelated: (result: WorkspaceCleanupRemoveResult) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<WorkspaceCleanupRemoveResult>((_resolve, reject) => {
|
||||
rejectChild = reject
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
|
||||
resolveUnrelated = resolve
|
||||
})
|
||||
)
|
||||
const onResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child, unrelated],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
rejectChild(new Error('remote removal failed'))
|
||||
await settleBackgroundRemoval()
|
||||
resolveUnrelated({
|
||||
removedIds: [unrelated.worktreeId],
|
||||
removedIdentities: [unrelated.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(2)
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
removedIds: [unrelated.worktreeId],
|
||||
removedIdentities: [unrelated.worktreeId],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: parent.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: parent.displayName,
|
||||
message: 'Skipped because a nested workspace could not be removed.'
|
||||
},
|
||||
{
|
||||
worktreeId: child.worktreeId,
|
||||
executionHostId: 'local',
|
||||
displayName: child.displayName,
|
||||
message: 'remote removal failed'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Workspaces not removed: 2',
|
||||
expect.objectContaining({
|
||||
description:
|
||||
'Skipped because a nested workspace could not be removed.; remote removal failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an authoritative rejection after the timeout result', async () => {
|
||||
vi.useFakeTimers()
|
||||
const candidate = makeCandidate()
|
||||
let rejectRemoval: (error: Error) => void = () => {}
|
||||
const onLateResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [candidate],
|
||||
removeCandidates: vi.fn(
|
||||
() =>
|
||||
new Promise<WorkspaceCleanupRemoveResult>((_resolve, reject) => {
|
||||
rejectRemoval = reject
|
||||
})
|
||||
),
|
||||
onProgress: vi.fn(),
|
||||
onLateResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
rejectRemoval(new Error('remote removal failed'))
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(onLateResult).toHaveBeenCalledWith({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [expect.objectContaining({ message: 'remote removal failed' })]
|
||||
})
|
||||
expect(toast.error).toHaveBeenLastCalledWith(
|
||||
'Workspaces not removed: 1',
|
||||
expect.objectContaining({ description: 'remote removal failed' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -16,17 +16,18 @@ export type WorkspaceCleanupRowListState = {
|
||||
scannedCount: number
|
||||
hasScanned: boolean
|
||||
loading: boolean
|
||||
deletionPhaseByWorktreeId: Record<string, WorkspaceCleanupDeletionPhase>
|
||||
deletingWorktreeIds: ReadonlySet<string>
|
||||
deletionPhaseByIdentity: Record<string, WorkspaceCleanupDeletionPhase>
|
||||
deletingIdentities: ReadonlySet<string>
|
||||
/** Host-qualified identities (see WorkspaceCleanupFacets.identity). */
|
||||
expandedRowIds: ReadonlySet<string>
|
||||
selectedIds: ReadonlySet<string>
|
||||
gitPendingWorktreeIds: ReadonlySet<string>
|
||||
/** Keyed by host-qualified identity so a failure marks only its own host's row. */
|
||||
rowFailures: Record<string, string>
|
||||
removalInFlight: boolean
|
||||
scrollElement: HTMLDivElement | null
|
||||
onClearFilters: () => void
|
||||
onToggleExpanded: (worktreeId: string) => void
|
||||
onToggleSelected: (worktreeId: string) => void
|
||||
onToggleExpanded: (identity: string) => void
|
||||
onToggleSelected: (identity: string) => void
|
||||
onView: (candidate: WorkspaceCleanupCandidate) => void
|
||||
onIgnore: (candidate: WorkspaceCleanupCandidate) => void
|
||||
onRemove: (candidate: WorkspaceCleanupCandidate) => void
|
||||
@@ -67,24 +68,24 @@ export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): Re
|
||||
scrollElement={props.scrollElement}
|
||||
renderRow={(row, index) => (
|
||||
<CandidateRow
|
||||
key={row.worktreeId}
|
||||
key={row.identity}
|
||||
identity={row.identity}
|
||||
candidate={row.candidate}
|
||||
reviewInfo={row.review}
|
||||
last={rows.length > 1 && index === rows.length - 1}
|
||||
expanded={props.expandedRowIds.has(row.worktreeId)}
|
||||
expanded={props.expandedRowIds.has(row.identity)}
|
||||
lastActivityLabel={formatWorkspaceCleanupRelativeTime(row.lastActivityAt, props.now)}
|
||||
sizeLabel={row.sizeBytes === null ? null : formatBytes(row.sizeBytes)}
|
||||
workspaceStatusLabel={row.workspaceStatusLabel}
|
||||
gitEvidencePending={props.gitPendingWorktreeIds.has(row.worktreeId)}
|
||||
deletionPhase={props.deletionPhaseByWorktreeId[row.worktreeId]}
|
||||
deletionPhase={props.deletionPhaseByIdentity[row.identity]}
|
||||
// Why: a background rescan must not lock rows; only an actual
|
||||
// removal batch or this row's own deletion disables it.
|
||||
removing={props.removalInFlight || props.deletingWorktreeIds.has(row.worktreeId)}
|
||||
removing={props.deletingIdentities.has(row.identity)}
|
||||
selected={
|
||||
props.selectedIds.has(row.worktreeId) &&
|
||||
!props.deletingWorktreeIds.has(row.worktreeId)
|
||||
props.selectedIds.has(row.identity) && !props.deletingIdentities.has(row.identity)
|
||||
}
|
||||
failure={props.rowFailures[row.worktreeId]}
|
||||
failure={props.rowFailures[row.identity]}
|
||||
onToggleExpanded={props.onToggleExpanded}
|
||||
onToggleSelected={props.onToggleSelected}
|
||||
onView={props.onView}
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import type { GitStatusResult } from '../../../../shared/git-status-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { GitWorktreeInfo } from '../../../../shared/worktree/types'
|
||||
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import type {
|
||||
WorkspaceCleanupScanArgs,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
|
||||
const {
|
||||
listRepoWorktreesMock,
|
||||
getStatusMock,
|
||||
gitExecFileAsyncMock,
|
||||
getLocalProjectWorktreeGitOptionsMock,
|
||||
getSshGitProviderMock
|
||||
} = vi.hoisted(() => ({
|
||||
listRepoWorktreesMock: vi.fn(),
|
||||
getStatusMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getLocalProjectWorktreeGitOptionsMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../main/repo-worktrees', () => ({
|
||||
listRepoWorktrees: listRepoWorktreesMock,
|
||||
createFolderWorktree: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../../main/git/status', () => ({ getStatus: getStatusMock }))
|
||||
vi.mock('../../../../main/git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
vi.mock('../../../../main/project-runtime-git-options', () => ({
|
||||
getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock
|
||||
}))
|
||||
vi.mock('../../../../main/providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } }))
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { makeWorktree } from '@/store/slices/store-test-helpers'
|
||||
import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from '@/store/slices/worktrees'
|
||||
import { useWorkspaceCleanupRemoval } from './use-workspace-cleanup-removal'
|
||||
|
||||
const SSH_CONNECTION_ID = 'host-b'
|
||||
const HOST_A_ID = 'local'
|
||||
const HOST_B_ID = `ssh:${SSH_CONNECTION_ID}` as const
|
||||
const REPO_ID = 'colliding-repo'
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const SCANNER_MODULE_PATH = ['../../../../main/ipc/', 'workspace-cleanup-scan'].join('')
|
||||
const initialState = useAppStore.getInitialState()
|
||||
const temporaryRoots: string[] = []
|
||||
|
||||
type ScanWorkspaceCleanup = (
|
||||
store: unknown,
|
||||
args?: WorkspaceCleanupScanArgs
|
||||
) => Promise<WorkspaceCleanupScanResult>
|
||||
|
||||
function makeRepo(overrides: Partial<Repo>): Repo {
|
||||
return {
|
||||
id: REPO_ID,
|
||||
path: '/repo',
|
||||
displayName: 'Colliding repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function toHostRelativePath(worktreeId: string): string {
|
||||
const workspacePath = worktreeId.slice(worktreeId.indexOf('::') + 2)
|
||||
return workspacePath
|
||||
.replace(/^[a-zA-Z]:/, '')
|
||||
.split(/[/\\]+/)
|
||||
.filter(Boolean)
|
||||
.join(path.sep)
|
||||
}
|
||||
|
||||
function createMarker(root: string, worktreeId: string, name: string): string {
|
||||
const worktreePath = path.join(root, toHostRelativePath(worktreeId))
|
||||
fs.mkdirSync(worktreePath, { recursive: true })
|
||||
const markerPath = path.join(worktreePath, name)
|
||||
fs.writeFileSync(markerPath, name)
|
||||
return markerPath
|
||||
}
|
||||
|
||||
describe('workspace cleanup scanned host confirmation removal', () => {
|
||||
beforeEach(() => {
|
||||
resetAuthoritativelyRemovedWorktreeMemoryForTests()
|
||||
getStatusMock.mockReset().mockResolvedValue({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
|
||||
} satisfies GitStatusResult)
|
||||
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' })
|
||||
getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initialState, true)
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
vi.clearAllMocks()
|
||||
for (const root of temporaryRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('deletes only host B after its real scanned candidate crosses the confirmation path', async () => {
|
||||
const scanRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-scan-'))
|
||||
const hostARoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-host-a-'))
|
||||
const hostBRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-host-b-'))
|
||||
temporaryRoots.push(scanRoot, hostARoot, hostBRoot)
|
||||
const sharedPath = path.join(scanRoot, 'shared-workspace')
|
||||
const gitPath = path.join(sharedPath, '.git')
|
||||
fs.mkdirSync(gitPath, { recursive: true })
|
||||
const oldTime = new Date(Date.now() - 40 * DAY_MS)
|
||||
fs.utimesSync(sharedPath, oldTime, oldTime)
|
||||
fs.utimesSync(gitPath, oldTime, oldTime)
|
||||
const worktreeId = `${REPO_ID}::${sharedPath}`
|
||||
const hostAMarker = createMarker(hostARoot, worktreeId, 'HOST_A_MARKER')
|
||||
const hostBMarker = createMarker(hostBRoot, worktreeId, 'HOST_B_MARKER')
|
||||
const localRepo = makeRepo({ executionHostId: HOST_A_ID })
|
||||
const sshRepo = makeRepo({
|
||||
path: '/remote/repo',
|
||||
connectionId: SSH_CONNECTION_ID,
|
||||
executionHostId: HOST_B_ID
|
||||
})
|
||||
const gitWorktree: GitWorktreeInfo = {
|
||||
path: sharedPath,
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/shared-workspace',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const sshProvider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree]),
|
||||
getStatus: vi.fn().mockResolvedValue({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
|
||||
} satisfies GitStatusResult),
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '0\n', stderr: '' })
|
||||
}
|
||||
listRepoWorktreesMock.mockResolvedValue([gitWorktree])
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider)
|
||||
const scanStore = {
|
||||
getRepos: () => [localRepo, sshRepo],
|
||||
getWorktreeMeta: () => undefined,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} })
|
||||
}
|
||||
const { scanWorkspaceCleanup } = await vi.importActual<{
|
||||
scanWorkspaceCleanup: ScanWorkspaceCleanup
|
||||
}>(SCANNER_MODULE_PATH)
|
||||
const scan = vi.fn((args) => scanWorkspaceCleanup(scanStore, args))
|
||||
const routedHostIds: string[] = []
|
||||
const remove = vi.fn(async (args: { worktreeId: string; hostId?: string }) => {
|
||||
routedHostIds.push(args.hostId ?? '<missing>')
|
||||
const root =
|
||||
args.hostId === HOST_A_ID ? hostARoot : args.hostId === HOST_B_ID ? hostBRoot : null
|
||||
if (root) {
|
||||
fs.rmSync(path.join(root, toHostRelativePath(args.worktreeId)), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
}
|
||||
return { ok: true as const }
|
||||
})
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
worktrees: { remove, forgetLocal: vi.fn() },
|
||||
hooks: { check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null }) },
|
||||
workspaceCleanup: {
|
||||
scan,
|
||||
cancelScan: vi.fn().mockResolvedValue(undefined),
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
clearDismissals: vi.fn().mockResolvedValue(undefined),
|
||||
recordRemovalSnapshotPrune: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
pty: { kill: vi.fn().mockResolvedValue(undefined) },
|
||||
runtimeEnvironments: { call: vi.fn() },
|
||||
ephemeralVm: { listRuntimes: vi.fn().mockResolvedValue([]), cleanup: vi.fn() }
|
||||
}
|
||||
})
|
||||
useAppStore.setState({
|
||||
repos: [localRepo, sshRepo],
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_ID,
|
||||
worktreesByRepo: {
|
||||
[REPO_ID]: [
|
||||
makeWorktree({ id: worktreeId, repoId: REPO_ID, path: sharedPath, hostId: HOST_A_ID }),
|
||||
makeWorktree({ id: worktreeId, repoId: REPO_ID, path: sharedPath, hostId: HOST_B_ID })
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(fs.existsSync(hostAMarker)).toBe(true)
|
||||
expect(fs.existsSync(hostBMarker)).toBe(true)
|
||||
const scanned = await useAppStore.getState().scanWorkspaceCleanup()
|
||||
const hostBCandidate = scanned.candidates.find(
|
||||
(candidate) => candidate.executionHostId === HOST_B_ID
|
||||
)
|
||||
expect(hostBCandidate).toMatchObject({ worktreeId, executionHostId: HOST_B_ID })
|
||||
expect(
|
||||
scanned.candidates.filter((candidate) => candidate.worktreeId === worktreeId)
|
||||
).toHaveLength(2)
|
||||
const onDeselect = vi.fn()
|
||||
const removal = renderHook(() =>
|
||||
useWorkspaceCleanupRemoval({ onDeselect, closeModal: vi.fn() })
|
||||
)
|
||||
|
||||
act(() => removal.result.current.openConfirmRemove([hostBCandidate!]))
|
||||
expect(removal.result.current.confirmCandidates[0]).toBe(hostBCandidate)
|
||||
act(() => removal.result.current.confirmRemove())
|
||||
await waitFor(() => expect(removal.result.current.removalInFlight).toBe(false))
|
||||
|
||||
expect(fs.existsSync(hostAMarker), 'host A marker must survive host B confirmation').toBe(true)
|
||||
expect(fs.existsSync(hostBMarker), 'host B marker must be deleted').toBe(false)
|
||||
expect(routedHostIds).toEqual([HOST_B_ID])
|
||||
expect(remove).toHaveBeenCalledTimes(1)
|
||||
expect(scan).toHaveBeenCalledTimes(2)
|
||||
expect(scan.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({ worktreeIds: [worktreeId], refreshActivity: true })
|
||||
)
|
||||
expect(onDeselect).toHaveBeenCalledWith([getWorkspaceCleanupCandidateIdentity(hostBCandidate!)])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
|
||||
export function formatVanishedSelectionNotice(count: number): string {
|
||||
return count === 1
|
||||
@@ -14,16 +15,19 @@ export function formatVanishedSelectionNotice(count: number): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function getDefaultSelectedWorkspaceCleanupIds(
|
||||
/** Host-qualified identities, so a default selection cannot span two hosts' rows. */
|
||||
export function getDefaultSelectedWorkspaceCleanupIdentities(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
deletingWorktreeIds: ReadonlySet<string> = new Set()
|
||||
deletingIdentities: ReadonlySet<string> = new Set()
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
candidates
|
||||
.filter(
|
||||
(candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId)
|
||||
(candidate) =>
|
||||
candidate.selectedByDefault &&
|
||||
!deletingIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
)
|
||||
.map((candidate) => candidate.worktreeId)
|
||||
.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16123,6 +16123,11 @@
|
||||
"minutesAgo": "{{value0}}m ago",
|
||||
"hoursAgo": "{{value0}}h ago",
|
||||
"daysAgo": "{{value0}}d ago"
|
||||
},
|
||||
"host": {
|
||||
"label": "Host: {{value0}}",
|
||||
"unknown": "Unknown host",
|
||||
"candidateName": "{{value0}} on {{value1}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Owner-catalog half of worktree operation routing: resolve a route from the
|
||||
* worktree/detected-worktree rows and their repos, with any disagreement
|
||||
* between owners reported as `ambiguous` rather than silently picked.
|
||||
*/
|
||||
import { getRepoExecutionHostId } from '../../../shared/execution-host'
|
||||
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route'
|
||||
import {
|
||||
findIndexedDetectedWorktrees,
|
||||
resolveIndexedWorktreeOwner
|
||||
} from './worktree-runtime-owner-index'
|
||||
// Type-only, so routing's shared vocabulary stays in one place without a runtime cycle.
|
||||
import type {
|
||||
WorktreeOperationRoute,
|
||||
WorktreeOperationRouteResolution,
|
||||
WorktreeOperationRouteState
|
||||
} from './worktree-operation-route'
|
||||
|
||||
const repoOperationRouteIndexCache = new WeakMap<
|
||||
NonNullable<WorktreeOperationRouteState['repos']>,
|
||||
ReadonlyMap<string, WorktreeOperationRouteResolution>
|
||||
>()
|
||||
|
||||
export function resolveExplicitWorktreeOperationRouteResult(
|
||||
state: WorktreeOperationRouteState,
|
||||
worktreeId: string
|
||||
): WorktreeOperationRouteResolution {
|
||||
const exactRoutes = new Map<string, WorktreeOperationRoute>()
|
||||
const exactRepoIds = new Set<string>()
|
||||
const indexedWorktree = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId)
|
||||
if (indexedWorktree.kind === 'ambiguous') {
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
if (indexedWorktree.kind === 'resolved') {
|
||||
exactRepoIds.add(indexedWorktree.owner.repoId)
|
||||
const resolution = resolveExactWorktreeRoute(state, indexedWorktree.owner)
|
||||
if (resolution.kind === 'ambiguous') {
|
||||
return resolution
|
||||
}
|
||||
if (resolution.kind === 'resolved') {
|
||||
addRoute(exactRoutes, resolution.route)
|
||||
}
|
||||
}
|
||||
for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) {
|
||||
exactRepoIds.add(worktree.repoId)
|
||||
const resolution = resolveExactWorktreeRoute(state, worktree)
|
||||
if (resolution.kind === 'ambiguous') {
|
||||
return resolution
|
||||
}
|
||||
if (resolution.kind === 'resolved') {
|
||||
addRoute(exactRoutes, resolution.route)
|
||||
}
|
||||
}
|
||||
if (exactRoutes.size > 0) {
|
||||
const route = exactRoutes.values().next().value
|
||||
return exactRoutes.size === 1 && route ? { kind: 'resolved', route } : { kind: 'ambiguous' }
|
||||
}
|
||||
if (exactRepoIds.size === 0) {
|
||||
exactRepoIds.add(getRepoIdFromWorktreeId(worktreeId))
|
||||
}
|
||||
const repoRoutes = new Map<string, WorktreeOperationRoute>()
|
||||
for (const repoId of exactRepoIds) {
|
||||
const resolution = resolveIndexedRepoOperationRoute(state.repos, repoId)
|
||||
if (resolution.kind === 'ambiguous') {
|
||||
return resolution
|
||||
}
|
||||
if (resolution.kind === 'resolved') {
|
||||
addRoute(repoRoutes, resolution.route)
|
||||
}
|
||||
}
|
||||
const route = repoRoutes.values().next().value
|
||||
if (repoRoutes.size === 1 && route) {
|
||||
return { kind: 'resolved', route }
|
||||
}
|
||||
if (repoRoutes.size > 1) {
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
|
||||
function resolveIndexedRepoOperationRoute(
|
||||
repos: WorktreeOperationRouteState['repos'],
|
||||
repoId: string
|
||||
): WorktreeOperationRouteResolution {
|
||||
if (!repos) {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
let index = repoOperationRouteIndexCache.get(repos)
|
||||
if (!index) {
|
||||
const next = new Map<string, WorktreeOperationRouteResolution>()
|
||||
for (const repo of repos) {
|
||||
const repoId = repo.id
|
||||
if (!repo.executionHostId?.trim() && !repo.connectionId?.trim()) {
|
||||
continue
|
||||
}
|
||||
const route = routeForOwner({ hostId: getRepoExecutionHostId(repo) })
|
||||
if (!route) {
|
||||
continue
|
||||
}
|
||||
const current = next.get(repoId)
|
||||
if (!current) {
|
||||
next.set(repoId, { kind: 'resolved', route })
|
||||
} else if (
|
||||
current.kind === 'resolved' &&
|
||||
JSON.stringify(current.route) !== JSON.stringify(route)
|
||||
) {
|
||||
next.set(repoId, { kind: 'ambiguous' })
|
||||
}
|
||||
}
|
||||
index = next
|
||||
repoOperationRouteIndexCache.set(repos, index)
|
||||
}
|
||||
return index.get(repoId) ?? { kind: 'missing' }
|
||||
}
|
||||
@@ -1,23 +1,22 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route'
|
||||
import { resolveExactWorktreeRoute } from './worktree-owner-route'
|
||||
import {
|
||||
findIndexedDetectedWorktrees,
|
||||
hasIndexedDetectedWorktree,
|
||||
resolveIndexedWorktreeOwner
|
||||
} from './worktree-runtime-owner-index'
|
||||
import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route'
|
||||
import {
|
||||
findFolderWorkspaceOwner,
|
||||
getExecutionHostIdForFolderWorkspace,
|
||||
type FolderWorkspaceRuntimeOwnerState
|
||||
} from './folder-workspace-runtime-owner'
|
||||
|
||||
export { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route'
|
||||
|
||||
export type WorktreeOperationRoute = {
|
||||
executionHostId: ExecutionHostId | null
|
||||
runtimeEnvironmentId: string | null
|
||||
@@ -44,11 +43,6 @@ export type WorktreeOperationRouteState = FolderWorkspaceRuntimeOwnerState & {
|
||||
removedRuntimeEnvironmentIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
const repoOperationRouteIndexCache = new WeakMap<
|
||||
NonNullable<WorktreeOperationRouteState['repos']>,
|
||||
ReadonlyMap<string, WorktreeOperationRouteResolution>
|
||||
>()
|
||||
|
||||
function ownerRecordsOnHost(
|
||||
state: WorktreeOperationRouteState,
|
||||
worktreeId: string,
|
||||
@@ -86,18 +80,50 @@ export function resolveActiveWorkspaceRoute(
|
||||
state.activeWorktreeId === worktreeId
|
||||
? parseExecutionHostId(state.activeWorkspaceExecutionHostId)
|
||||
: null
|
||||
if (!activeHost) {
|
||||
return null
|
||||
}
|
||||
if (activeHost.kind === 'runtime') {
|
||||
return { executionHostId: activeHost.id, runtimeEnvironmentId: activeHost.environmentId }
|
||||
return activeHost ? resolveSelectedHostRoute(state, worktreeId, activeHost) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an operation at the host the CALLER named rather than whichever host
|
||||
* the active workspace happens to select. `repoId::path` ids repeat across
|
||||
* hosts, so a destructive path that resolves host-blind can delete the same-id
|
||||
* workspace on the wrong machine (STA-4343); qualified callers resolve here.
|
||||
*/
|
||||
export function resolveWorktreeOperationRouteResultForHost(
|
||||
state: WorktreeOperationRouteState,
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId
|
||||
): WorktreeOperationRouteResolution {
|
||||
const host = parseExecutionHostId(executionHostId)
|
||||
// Why: an unparseable qualifier is not evidence of an owner — fail closed.
|
||||
return host
|
||||
? { kind: 'resolved', route: resolveSelectedHostRoute(state, worktreeId, host) }
|
||||
: { kind: 'missing' }
|
||||
}
|
||||
|
||||
export function resolveWorktreeOperationRouteForHost(
|
||||
state: WorktreeOperationRouteState,
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId
|
||||
): WorktreeOperationRoute | null {
|
||||
const resolution = resolveWorktreeOperationRouteResultForHost(state, worktreeId, executionHostId)
|
||||
return resolution.kind === 'resolved' ? resolution.route : null
|
||||
}
|
||||
|
||||
function resolveSelectedHostRoute(
|
||||
state: WorktreeOperationRouteState,
|
||||
worktreeId: string,
|
||||
selectedHost: NonNullable<ReturnType<typeof parseExecutionHostId>>
|
||||
): WorktreeOperationRoute {
|
||||
if (selectedHost.kind === 'runtime') {
|
||||
return { executionHostId: selectedHost.id, runtimeEnvironmentId: selectedHost.environmentId }
|
||||
}
|
||||
// Why: only an `ssh:` selection can hide a paired HUB owner, so local stays an O(1) hot path.
|
||||
if (activeHost.kind !== 'ssh') {
|
||||
return { executionHostId: activeHost.id, runtimeEnvironmentId: null }
|
||||
if (selectedHost.kind !== 'ssh') {
|
||||
return { executionHostId: selectedHost.id, runtimeEnvironmentId: null }
|
||||
}
|
||||
const environmentIds = new Set<string>()
|
||||
for (const owner of ownerRecordsOnHost(state, worktreeId, activeHost.id)) {
|
||||
for (const owner of ownerRecordsOnHost(state, worktreeId, selectedHost.id)) {
|
||||
const resolution = resolveExactWorktreeRoute(state, owner)
|
||||
if (resolution.kind === 'resolved' && resolution.route.runtimeEnvironmentId) {
|
||||
environmentIds.add(resolution.route.runtimeEnvironmentId)
|
||||
@@ -105,12 +131,39 @@ export function resolveActiveWorkspaceRoute(
|
||||
}
|
||||
const environmentId = environmentIds.values().next().value
|
||||
return {
|
||||
executionHostId: activeHost.id,
|
||||
executionHostId: selectedHost.id,
|
||||
// Why: rival HUBs projecting the same host cannot be disambiguated by the host selection alone.
|
||||
runtimeEnvironmentId: environmentIds.size === 1 && environmentId ? environmentId : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct execution hosts the store knows as owners of this id. More than one
|
||||
* means an unqualified destructive call cannot pick a target without guessing;
|
||||
* empty means no owner row carries host provenance (legacy hydration).
|
||||
*/
|
||||
export function getWorktreeOperationOwnerHostIds(
|
||||
state: WorktreeOperationRouteState,
|
||||
worktreeId: string
|
||||
): ExecutionHostId[] {
|
||||
const hostIds = new Set<ExecutionHostId>()
|
||||
for (const worktrees of Object.values(state.worktreesByRepo ?? {})) {
|
||||
for (const worktree of worktrees) {
|
||||
const hostId = worktree.id === worktreeId ? parseExecutionHostId(worktree.hostId)?.id : null
|
||||
if (hostId) {
|
||||
hostIds.add(hostId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) {
|
||||
const hostId = parseExecutionHostId(worktree.hostId)?.id
|
||||
if (hostId) {
|
||||
hostIds.add(hostId)
|
||||
}
|
||||
}
|
||||
return [...hostIds]
|
||||
}
|
||||
|
||||
export function resolveWorktreeOperationRoute(
|
||||
state: WorktreeOperationRouteState,
|
||||
worktreeId: string
|
||||
@@ -197,98 +250,6 @@ function resolveFolderWorkspaceOperationRoute(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveExplicitWorktreeOperationRouteResult(
|
||||
state: WorktreeOperationRouteState,
|
||||
worktreeId: string
|
||||
): WorktreeOperationRouteResolution {
|
||||
const exactRoutes = new Map<string, WorktreeOperationRoute>()
|
||||
const exactRepoIds = new Set<string>()
|
||||
const indexedWorktree = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId)
|
||||
if (indexedWorktree.kind === 'ambiguous') {
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
if (indexedWorktree.kind === 'resolved') {
|
||||
exactRepoIds.add(indexedWorktree.owner.repoId)
|
||||
const resolution = resolveExactWorktreeRoute(state, indexedWorktree.owner)
|
||||
if (resolution.kind === 'ambiguous') {
|
||||
return resolution
|
||||
}
|
||||
if (resolution.kind === 'resolved') {
|
||||
addRoute(exactRoutes, resolution.route)
|
||||
}
|
||||
}
|
||||
for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) {
|
||||
exactRepoIds.add(worktree.repoId)
|
||||
const resolution = resolveExactWorktreeRoute(state, worktree)
|
||||
if (resolution.kind === 'ambiguous') {
|
||||
return resolution
|
||||
}
|
||||
if (resolution.kind === 'resolved') {
|
||||
addRoute(exactRoutes, resolution.route)
|
||||
}
|
||||
}
|
||||
if (exactRoutes.size > 0) {
|
||||
const route = exactRoutes.values().next().value
|
||||
return exactRoutes.size === 1 && route ? { kind: 'resolved', route } : { kind: 'ambiguous' }
|
||||
}
|
||||
if (exactRepoIds.size === 0) {
|
||||
exactRepoIds.add(getRepoIdFromWorktreeId(worktreeId))
|
||||
}
|
||||
const repoRoutes = new Map<string, WorktreeOperationRoute>()
|
||||
for (const repoId of exactRepoIds) {
|
||||
const resolution = resolveIndexedRepoOperationRoute(state.repos, repoId)
|
||||
if (resolution.kind === 'ambiguous') {
|
||||
return resolution
|
||||
}
|
||||
if (resolution.kind === 'resolved') {
|
||||
addRoute(repoRoutes, resolution.route)
|
||||
}
|
||||
}
|
||||
const route = repoRoutes.values().next().value
|
||||
if (repoRoutes.size === 1 && route) {
|
||||
return { kind: 'resolved', route }
|
||||
}
|
||||
if (repoRoutes.size > 1) {
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
|
||||
function resolveIndexedRepoOperationRoute(
|
||||
repos: WorktreeOperationRouteState['repos'],
|
||||
repoId: string
|
||||
): WorktreeOperationRouteResolution {
|
||||
if (!repos) {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
let index = repoOperationRouteIndexCache.get(repos)
|
||||
if (!index) {
|
||||
const next = new Map<string, WorktreeOperationRouteResolution>()
|
||||
for (const repo of repos) {
|
||||
const repoId = repo.id
|
||||
if (!repo.executionHostId?.trim() && !repo.connectionId?.trim()) {
|
||||
continue
|
||||
}
|
||||
const route = routeForOwner({ hostId: getRepoExecutionHostId(repo) })
|
||||
if (!route) {
|
||||
continue
|
||||
}
|
||||
const current = next.get(repoId)
|
||||
if (!current) {
|
||||
next.set(repoId, { kind: 'resolved', route })
|
||||
} else if (
|
||||
current.kind === 'resolved' &&
|
||||
JSON.stringify(current.route) !== JSON.stringify(route)
|
||||
) {
|
||||
next.set(repoId, { kind: 'ambiguous' })
|
||||
}
|
||||
}
|
||||
index = next
|
||||
repoOperationRouteIndexCache.set(repos, index)
|
||||
}
|
||||
return index.get(repoId) ?? { kind: 'missing' }
|
||||
}
|
||||
|
||||
export function settingsForWorktreeOperationRoute(
|
||||
settings: AppState['settings'],
|
||||
route: WorktreeOperationRoute
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* STA-4343 (list state): two hosts publishing the same `repoId::path` row must
|
||||
* stay two rows. Before the fix the streamed-progress merge, the dismissal
|
||||
* match, and the post-removal prune all keyed on `worktreeId` alone, so one
|
||||
* host's row silently replaced — or silently hid, or silently dropped — the
|
||||
* other's, which is what let a user confirm a row that was not the one shown.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateIdentity,
|
||||
getWorkspaceCleanupHostIdentity,
|
||||
resolveWorkspaceCleanupRemovalHostId
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import {
|
||||
shouldHideWorkspaceCleanupCandidate,
|
||||
WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
|
||||
type WorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupDismissal
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { resolveWorkspaceCleanupRemovalTargets } from './workspace-cleanup-removal-targets'
|
||||
import type { AppState } from '../types'
|
||||
import { createTestStore, seedStore } from './store-test-helpers'
|
||||
|
||||
const dismissMock = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
// @ts-expect-error -- minimal window.api stub for dismissal persistence
|
||||
globalThis.window = { api: { workspaceCleanup: { dismiss: dismissMock } } }
|
||||
|
||||
const WORKTREE_ID = 'repo1::/shared/workspace'
|
||||
|
||||
function makeCandidate(
|
||||
executionHostId: WorkspaceCleanupCandidate['executionHostId'],
|
||||
connectionId: string | null = null
|
||||
): WorkspaceCleanupCandidate {
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
repoName: 'Repo 1',
|
||||
connectionId,
|
||||
...(executionHostId ? { executionHostId } : {}),
|
||||
displayName: 'shared-workspace',
|
||||
branch: 'old',
|
||||
path: '/shared/workspace',
|
||||
tier: 'ready',
|
||||
selectedByDefault: true,
|
||||
reasons: ['idle-clean'],
|
||||
blockers: [],
|
||||
lastActivityAt: 0,
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
},
|
||||
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 },
|
||||
fingerprint: 'fp-1'
|
||||
}
|
||||
}
|
||||
|
||||
function makeDismissal(
|
||||
executionHostId?: WorkspaceCleanupDismissal['executionHostId']
|
||||
): WorkspaceCleanupDismissal {
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
dismissedAt: 0,
|
||||
fingerprint: 'fp-1',
|
||||
classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace cleanup host-qualified identity', () => {
|
||||
it('separates two hosts publishing the same worktree id', () => {
|
||||
const local = makeCandidate('local')
|
||||
const remote = makeCandidate('ssh:ssh-1', 'ssh-1')
|
||||
expect(getWorkspaceCleanupCandidateIdentity(local)).not.toBe(
|
||||
getWorkspaceCleanupCandidateIdentity(remote)
|
||||
)
|
||||
expect(getWorkspaceCleanupCandidateIdentity(remote)).toBe(
|
||||
getWorkspaceCleanupHostIdentity('ssh:ssh-1', WORKTREE_ID)
|
||||
)
|
||||
})
|
||||
|
||||
it('reports no removal host for a row with no host evidence', () => {
|
||||
expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined))).toBeNull()
|
||||
// A pre-executionHostId remote row still names its connection.
|
||||
expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined, 'ssh-1'))).toBe(
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace cleanup dismissals', () => {
|
||||
it('hides only the dismissed host row', () => {
|
||||
const dismissal = makeDismissal('ssh:ssh-1')
|
||||
expect(
|
||||
shouldHideWorkspaceCleanupCandidate(makeCandidate('ssh:ssh-1', 'ssh-1'), dismissal)
|
||||
).toBe(true)
|
||||
expect(shouldHideWorkspaceCleanupCandidate(makeCandidate('local'), dismissal)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a legacy id-only dismissal hiding its row', () => {
|
||||
expect(shouldHideWorkspaceCleanupCandidate(makeCandidate('local'), makeDismissal())).toBe(true)
|
||||
})
|
||||
|
||||
it('stores same-id dismissals independently and still reads a legacy id-only key', async () => {
|
||||
const local = makeCandidate('local')
|
||||
const remote = makeCandidate('ssh:ssh-1', 'ssh-1')
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
workspaceCleanupScan: { scannedAt: 1, candidates: [local, remote], errors: [] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().dismissWorkspaceCleanupCandidates([local, remote])
|
||||
|
||||
expect(Object.keys(store.getState().workspaceCleanupDismissals).sort()).toEqual(
|
||||
[
|
||||
getWorkspaceCleanupCandidateIdentity(local),
|
||||
getWorkspaceCleanupCandidateIdentity(remote)
|
||||
].sort()
|
||||
)
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([
|
||||
expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) }),
|
||||
expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) })
|
||||
])
|
||||
|
||||
seedStore(store, {
|
||||
workspaceCleanupDismissals: { [WORKTREE_ID]: makeDismissal() },
|
||||
workspaceCleanupScan: { scannedAt: 1, candidates: [local], errors: [] }
|
||||
} as Partial<AppState>)
|
||||
await store.getState().dismissWorkspaceCleanupCandidates([])
|
||||
|
||||
expect(store.getState().workspaceCleanupScan?.candidates[0]?.blockers).toContain('dismissed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace cleanup removal targets', () => {
|
||||
const emptyState = { worktreesByRepo: {}, detectedWorktreesByRepo: {} } as unknown as AppState
|
||||
|
||||
it('carries the confirmed host through as the removal owner', () => {
|
||||
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
|
||||
approvedCandidates: [makeCandidate('ssh:ssh-1', 'ssh-1')]
|
||||
})
|
||||
expect(target).toMatchObject({ kind: 'target', executionHostId: 'ssh:ssh-1' })
|
||||
})
|
||||
|
||||
it('fails closed when one id is confirmed for two hosts at once', () => {
|
||||
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
|
||||
approvedCandidates: [makeCandidate('local'), makeCandidate('ssh:ssh-1', 'ssh-1')]
|
||||
})
|
||||
expect(target?.kind).toBe('unresolved')
|
||||
})
|
||||
|
||||
it('fails closed for a confirmed row with no host evidence despite one known owner', () => {
|
||||
const state = {
|
||||
worktreesByRepo: { repo1: [{ id: WORKTREE_ID, repoId: 'repo1', hostId: 'ssh:ssh-1' }] },
|
||||
detectedWorktreesByRepo: {}
|
||||
} as unknown as AppState
|
||||
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], state, {
|
||||
approvedCandidates: [makeCandidate(undefined)]
|
||||
})
|
||||
expect(target?.kind).toBe('unresolved')
|
||||
})
|
||||
|
||||
it('fails closed for a confirmed row when no owner row carries a host', () => {
|
||||
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
|
||||
approvedCandidates: [makeCandidate(undefined)]
|
||||
})
|
||||
expect(target?.kind).toBe('unresolved')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
getWorkspaceCleanupCandidateIdentity,
|
||||
getWorkspaceCleanupHostIdentity
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup'
|
||||
@@ -68,6 +72,9 @@ describe('workspace cleanup removal and protection', () => {
|
||||
.removeWorkspaceCleanupCandidates(candidates.map((candidate) => candidate.worktreeId))
|
||||
).resolves.toEqual({
|
||||
removedIds: expect.arrayContaining(candidates.map((candidate) => candidate.worktreeId)),
|
||||
removedIdentities: expect.arrayContaining(
|
||||
candidates.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
),
|
||||
failures: []
|
||||
})
|
||||
|
||||
@@ -103,6 +110,7 @@ describe('workspace cleanup removal and protection', () => {
|
||||
store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId])
|
||||
).resolves.toEqual({
|
||||
removedIds: [candidate.worktreeId],
|
||||
removedIdentities: [getWorkspaceCleanupHostIdentity('local', candidate.worktreeId)],
|
||||
failures: [],
|
||||
preservedBranches: [
|
||||
{
|
||||
@@ -242,6 +250,7 @@ describe('workspace cleanup removal and protection', () => {
|
||||
|
||||
await expect(removal).resolves.toEqual({
|
||||
removedIds: [WORKTREE_ID],
|
||||
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
|
||||
failures: []
|
||||
})
|
||||
expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, {
|
||||
@@ -355,6 +364,7 @@ describe('workspace cleanup removal and protection', () => {
|
||||
await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual(
|
||||
{
|
||||
removedIds: [WORKTREE_ID],
|
||||
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
|
||||
failures: []
|
||||
}
|
||||
)
|
||||
@@ -364,8 +374,9 @@ describe('workspace cleanup removal and protection', () => {
|
||||
})
|
||||
|
||||
it('fails a queued removal that now needs a force the user never approved', async () => {
|
||||
const approvedCandidate = makeCandidate()
|
||||
const approvedCandidate = makeCandidate({ executionHostId: 'local' })
|
||||
const dirtySinceConfirmation = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
tier: 'review',
|
||||
blockers: ['dirty-files'],
|
||||
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
|
||||
@@ -385,9 +396,11 @@ describe('workspace cleanup removal and protection', () => {
|
||||
})
|
||||
).resolves.toEqual({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: 'local',
|
||||
displayName: 'old-workspace',
|
||||
message: 'Workspace changed after confirmation. Refresh to review it before removing.'
|
||||
}
|
||||
@@ -398,6 +411,7 @@ describe('workspace cleanup removal and protection', () => {
|
||||
|
||||
it('still force-removes rows whose approved candidate already carried git risk', async () => {
|
||||
const approvedCandidate = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
tier: 'review',
|
||||
blockers: ['dirty-files'],
|
||||
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
|
||||
@@ -415,19 +429,26 @@ describe('workspace cleanup removal and protection', () => {
|
||||
store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
|
||||
approvedCandidates: [approvedCandidate]
|
||||
})
|
||||
).resolves.toEqual({ removedIds: [WORKTREE_ID], failures: [] })
|
||||
).resolves.toEqual({
|
||||
removedIds: [WORKTREE_ID],
|
||||
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
|
||||
failures: []
|
||||
})
|
||||
expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, true, {
|
||||
suppressPreservedBranchToast: true
|
||||
suppressPreservedBranchToast: true,
|
||||
requiredExecutionHostId: 'local'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails a removal that reveals concrete git risk after an unverified force approval', async () => {
|
||||
const approvedCandidate = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
tier: 'review',
|
||||
blockers: ['git-status-error'],
|
||||
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }
|
||||
})
|
||||
const nowRevealsUnpushed = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
tier: 'review',
|
||||
blockers: ['unpushed-commits'],
|
||||
git: { clean: true, upstreamAhead: 3, upstreamBehind: 0, checkedAt: NOW }
|
||||
@@ -447,9 +468,11 @@ describe('workspace cleanup removal and protection', () => {
|
||||
})
|
||||
).resolves.toEqual({
|
||||
removedIds: [],
|
||||
removedIdentities: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: 'local',
|
||||
displayName: 'old-workspace',
|
||||
message: 'Workspace changed after confirmation. Refresh to review it before removing.'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Host-qualified removal identity for workspace cleanup (STA-4343).
|
||||
*
|
||||
* A cleanup row's `worktreeId` is `repoId::path`, which two execution hosts can
|
||||
* both own. Selection, confirmation, preflight and removal therefore travel as
|
||||
* a (worktreeId, executionHostId) pair. Where the owner cannot be pinned — an
|
||||
* id-only row that the store knows on several hosts, a confirmation naming two
|
||||
* hosts at once, or a refreshed scan that no longer shows the row on the
|
||||
* confirmed host — the target resolves to a failure instead of a best guess:
|
||||
* deleting the wrong host's workspace destroys uncommitted work.
|
||||
*/
|
||||
import type { AppState } from '../types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
canQueueWorkspaceCleanupCandidate,
|
||||
shouldForceWorkspaceCleanupRemoval,
|
||||
WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT,
|
||||
type WorkspaceCleanupCandidate
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateIdentity,
|
||||
getWorkspaceCleanupHostIdentity,
|
||||
resolveWorkspaceCleanupRemovalHostId
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import { getWorktreeOperationOwnerHostIds } from '@/lib/worktree-operation-route'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupFailure } from './workspace-cleanup'
|
||||
|
||||
export type WorkspaceCleanupRemovalTarget = {
|
||||
kind: 'target'
|
||||
worktreeId: string
|
||||
/**
|
||||
* The host the user confirmed. Null is reserved for legacy internal callers
|
||||
* that did not supply confirmation candidates.
|
||||
*/
|
||||
executionHostId: ExecutionHostId | null
|
||||
displayName: string
|
||||
approvedCandidate?: WorkspaceCleanupCandidate
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupUnresolvedTarget = {
|
||||
kind: 'unresolved'
|
||||
failure: WorkspaceCleanupFailure
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupRemovalTargetResolution =
|
||||
| WorkspaceCleanupRemovalTarget
|
||||
| WorkspaceCleanupUnresolvedTarget
|
||||
|
||||
export type WorkspaceCleanupPreflightResult =
|
||||
| { ok: true; target: WorkspaceCleanupRemovalTarget; candidate: WorkspaceCleanupCandidate }
|
||||
| { ok: false; failure: WorkspaceCleanupFailure }
|
||||
|
||||
type WorkspaceCleanupRemovalTargetState = Pick<
|
||||
AppState,
|
||||
'worktreesByRepo' | 'detectedWorktreesByRepo'
|
||||
>
|
||||
|
||||
function ambiguousHostFailure(
|
||||
worktreeId: string,
|
||||
displayName: string
|
||||
): WorkspaceCleanupUnresolvedTarget {
|
||||
return {
|
||||
kind: 'unresolved',
|
||||
failure: {
|
||||
worktreeId,
|
||||
displayName,
|
||||
message: translate(
|
||||
'auto.store.slices.workspace.cleanup.hostUnresolved',
|
||||
'Orca cannot tell which host owns this workspace. Refresh projects and review it again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWorkspaceCleanupRemovalTargets(
|
||||
worktreeIds: readonly string[],
|
||||
state: WorkspaceCleanupRemovalTargetState,
|
||||
options: { approvedCandidates?: readonly WorkspaceCleanupCandidate[] } = {}
|
||||
): WorkspaceCleanupRemovalTargetResolution[] {
|
||||
const approvedByWorktreeId = new Map<string, WorkspaceCleanupCandidate[]>()
|
||||
for (const candidate of options.approvedCandidates ?? []) {
|
||||
const approved = approvedByWorktreeId.get(candidate.worktreeId) ?? []
|
||||
approved.push(candidate)
|
||||
approvedByWorktreeId.set(candidate.worktreeId, approved)
|
||||
}
|
||||
|
||||
return worktreeIds.map((worktreeId) => {
|
||||
const approved = approvedByWorktreeId.get(worktreeId) ?? []
|
||||
const displayName = approved[0]?.displayName ?? worktreeId
|
||||
// Why: one id confirmed for two hosts in a single batch cannot say which
|
||||
// row the user meant, and picking either one may delete the other's work.
|
||||
if (
|
||||
new Set(approved.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))).size > 1
|
||||
) {
|
||||
return ambiguousHostFailure(worktreeId, displayName)
|
||||
}
|
||||
const confirmedCandidate = approved[0]
|
||||
const confirmedHostId = confirmedCandidate
|
||||
? resolveWorkspaceCleanupRemovalHostId(confirmedCandidate)
|
||||
: null
|
||||
if (confirmedCandidate && confirmedHostId) {
|
||||
return {
|
||||
kind: 'target',
|
||||
worktreeId,
|
||||
executionHostId: confirmedHostId,
|
||||
displayName,
|
||||
approvedCandidate: confirmedCandidate
|
||||
}
|
||||
}
|
||||
// A displayed row without host evidence cannot prove where the user
|
||||
// intended to delete, even if the current catalog happens to list one owner.
|
||||
if (confirmedCandidate) {
|
||||
return ambiguousHostFailure(worktreeId, displayName)
|
||||
}
|
||||
// No host evidence on the row: accept it only while the store itself knows
|
||||
// a single owner. This compatibility path is reachable only by internal
|
||||
// callers that did not provide a confirmed candidate.
|
||||
const ownerHostIds = getWorktreeOperationOwnerHostIds(state, worktreeId)
|
||||
if (ownerHostIds.length > 1) {
|
||||
return ambiguousHostFailure(worktreeId, displayName)
|
||||
}
|
||||
return {
|
||||
kind: 'target',
|
||||
worktreeId,
|
||||
executionHostId: ownerHostIds[0] ?? null,
|
||||
displayName,
|
||||
...(confirmedCandidate ? { approvedCandidate: confirmedCandidate } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function preflightWorkspaceCleanupCandidates(
|
||||
targets: readonly WorkspaceCleanupRemovalTarget[],
|
||||
getState: () => AppState,
|
||||
enrich: (
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
state: AppState
|
||||
) => Promise<WorkspaceCleanupCandidate[]>
|
||||
): Promise<WorkspaceCleanupPreflightResult[]> {
|
||||
// Why: one batched scan per chunk replaces a git worktree-list + activity
|
||||
// read per row; chunks stay under main's silent target truncation limit.
|
||||
const candidatesByIdentity = new Map<string, WorkspaceCleanupCandidate>()
|
||||
const identitiesByWorktreeId = new Map<string, Set<string>>()
|
||||
const worktreeIds = targets.map((target) => target.worktreeId)
|
||||
for (let start = 0; start < worktreeIds.length; start += WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) {
|
||||
const chunk = worktreeIds.slice(start, start + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT)
|
||||
const scan = await window.api.workspaceCleanup.scan({
|
||||
worktreeIds: [...chunk],
|
||||
scanId: crypto.randomUUID(),
|
||||
refreshActivity: true
|
||||
})
|
||||
const enriched = await enrich(scan.candidates, getState())
|
||||
for (const candidate of enriched) {
|
||||
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
|
||||
candidatesByIdentity.set(identity, candidate)
|
||||
const identities = identitiesByWorktreeId.get(candidate.worktreeId) ?? new Set<string>()
|
||||
identities.add(identity)
|
||||
identitiesByWorktreeId.set(candidate.worktreeId, identities)
|
||||
}
|
||||
}
|
||||
return targets.map((target) =>
|
||||
evaluateWorkspaceCleanupPreflight(target, candidatesByIdentity, identitiesByWorktreeId)
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePreflightCandidate(
|
||||
target: WorkspaceCleanupRemovalTarget,
|
||||
candidatesByIdentity: ReadonlyMap<string, WorkspaceCleanupCandidate>,
|
||||
identitiesByWorktreeId: ReadonlyMap<string, ReadonlySet<string>>
|
||||
): { ok: true; candidate: WorkspaceCleanupCandidate | undefined } | { ok: false } {
|
||||
const identities = identitiesByWorktreeId.get(target.worktreeId)
|
||||
if (target.executionHostId) {
|
||||
// Why: the refreshed row must be the SAME host's row. Another host's
|
||||
// evidence would decide force/blockers for a workspace it does not own.
|
||||
return {
|
||||
ok: true,
|
||||
candidate: candidatesByIdentity.get(
|
||||
getWorkspaceCleanupHostIdentity(target.executionHostId, target.worktreeId)
|
||||
)
|
||||
}
|
||||
}
|
||||
// An unqualified target can only proceed while the rescan agrees there is one owner.
|
||||
if ((identities?.size ?? 0) > 1) {
|
||||
return { ok: false }
|
||||
}
|
||||
const identity = identities?.values().next().value
|
||||
return { ok: true, candidate: identity ? candidatesByIdentity.get(identity) : undefined }
|
||||
}
|
||||
|
||||
export function evaluateWorkspaceCleanupPreflight(
|
||||
target: WorkspaceCleanupRemovalTarget,
|
||||
candidatesByIdentity: ReadonlyMap<string, WorkspaceCleanupCandidate>,
|
||||
identitiesByWorktreeId: ReadonlyMap<string, ReadonlySet<string>>
|
||||
): WorkspaceCleanupPreflightResult {
|
||||
const resolved = resolvePreflightCandidate(target, candidatesByIdentity, identitiesByWorktreeId)
|
||||
if (!resolved.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: ambiguousHostFailure(target.worktreeId, target.displayName).failure
|
||||
}
|
||||
}
|
||||
const candidate = resolved.candidate
|
||||
if (!candidate) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
worktreeId: target.worktreeId,
|
||||
...(target.executionHostId ? { executionHostId: target.executionHostId } : {}),
|
||||
displayName: target.displayName,
|
||||
message: translate(
|
||||
'auto.store.slices.workspace.cleanup.9d6e531da6',
|
||||
'Workspace no longer exists.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
const failure = (message: string): WorkspaceCleanupPreflightResult => ({
|
||||
ok: false,
|
||||
failure: {
|
||||
worktreeId: target.worktreeId,
|
||||
...(target.executionHostId ? { executionHostId: target.executionHostId } : {}),
|
||||
displayName: candidate.displayName,
|
||||
message
|
||||
}
|
||||
})
|
||||
if (!canQueueWorkspaceCleanupCandidate(candidate)) {
|
||||
return failure(
|
||||
candidate.blockers.length
|
||||
? candidate.blockers.join(', ')
|
||||
: 'Workspace needs another look before removal.'
|
||||
)
|
||||
}
|
||||
// Why: this row may be removed minutes after the confirm click. If it now
|
||||
// needs a force removal the user never approved (new dirt, unpushed work,
|
||||
// or a git error since confirmation), fail it instead of force-deleting.
|
||||
const approvedCandidate = target.approvedCandidate
|
||||
if (approvedCandidate) {
|
||||
const escalatedToForce =
|
||||
shouldForceWorkspaceCleanupRemoval(candidate) &&
|
||||
!shouldForceWorkspaceCleanupRemoval(approvedCandidate)
|
||||
// Why: an approved row that was already force-flagged for an unverifiable
|
||||
// reason must still fail when real dirt/unpushed work is now visible.
|
||||
const revealedConcreteRisk = WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some(
|
||||
(blocker) =>
|
||||
candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker)
|
||||
)
|
||||
if (escalatedToForce || revealedConcreteRisk) {
|
||||
return failure(
|
||||
translate(
|
||||
'auto.store.slices.workspace.cleanup.changedSinceConfirmation',
|
||||
'Workspace changed after confirmation. Refresh to review it before removing.'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return { ok: true, target, candidate }
|
||||
}
|
||||
|
||||
// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base
|
||||
// and git-status-error only mean "couldn't verify". A row approved while
|
||||
// unverifiable must still fail if real work becomes visible before removal.
|
||||
const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const
|
||||
@@ -114,6 +114,54 @@ describe('workspace cleanup scan progress', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps same-id host rows distinct across streamed enrichment frames', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const localCandidate = makeCandidate({ executionHostId: 'local' })
|
||||
const remoteCandidate = makeCandidate({ executionHostId: 'ssh:ssh-1', connectionId: 'ssh-1' })
|
||||
const scan = vi.fn((_args, progressCallback) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
})
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 1,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [localCandidate],
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
|
||||
})
|
||||
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [remoteCandidate],
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2)
|
||||
})
|
||||
|
||||
pending.resolve({ scannedAt: NOW, candidates: [localCandidate, remoteCandidate], errors: [] })
|
||||
await scanPromise
|
||||
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([
|
||||
expect.objectContaining({ executionHostId: 'local' }),
|
||||
expect.objectContaining({ executionHostId: 'ssh:ssh-1' })
|
||||
])
|
||||
})
|
||||
|
||||
it('does not re-probe previously enriched rows during cumulative progress updates', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* STA-4343: workspace cleanup must delete on the host whose row was confirmed.
|
||||
*
|
||||
* Two hosts can expose cleanup candidates with the SAME `repoId::path` identity
|
||||
* (the scan composes `worktreeId` from `repo.id` + `::` + the workspace path
|
||||
* with no host component). Before the fix, selection, confirmation, preflight
|
||||
* and removal kept only `worktreeId` and removal routing preferred the ACTIVE
|
||||
* workspace's host, so confirming host B's row issued the destructive IPC
|
||||
* against host A and destroyed host A's real directory (and uncommitted work).
|
||||
*
|
||||
* Rig: two REAL temp directories stand in for the two hosts. The removal
|
||||
* boundary (`window.api.worktrees.remove` for the local/SSH transport,
|
||||
* `callRuntimeRpc('worktree.rm')` for a paired runtime) is the only fake, and
|
||||
* it ACTUALLY deletes the directory on the host it is routed to. The gate is
|
||||
* filesystem state, not arguments: after confirming host B's row, host A's
|
||||
* marker must still exist and host B's must be gone.
|
||||
*
|
||||
* The soundness controls matter as much as the wrong-host cases: the same rig
|
||||
* with host B ACTIVE (and with host A confirmed) still deletes exactly the
|
||||
* confirmed row, so a passing assertion cannot come from a rig that never
|
||||
* deletes anything.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
const HOST_A_HOST_ID: ExecutionHostId = 'local'
|
||||
const HOST_B_HOST_ID: ExecutionHostId = 'ssh:ssh-1'
|
||||
const HOST_B_RUNTIME_HOST_ID: ExecutionHostId = 'runtime:hub-1'
|
||||
|
||||
const mockApi = {
|
||||
worktrees: {
|
||||
remove: vi.fn(),
|
||||
forgetLocal: vi.fn().mockResolvedValue({ ok: false, error: 'unused in this scenario' })
|
||||
},
|
||||
hooks: {
|
||||
check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false })
|
||||
},
|
||||
workspaceCleanup: {
|
||||
scan: vi.fn(),
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
clearDismissals: vi.fn().mockResolvedValue(undefined),
|
||||
recordRemovalSnapshotPrune: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
pty: { kill: vi.fn().mockResolvedValue(undefined) },
|
||||
runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} } as never) },
|
||||
ephemeralVm: {
|
||||
listRuntimes: vi.fn().mockResolvedValue([]),
|
||||
cleanup: vi.fn().mockResolvedValue({})
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error -- minimal window.api stub for the store under test
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
const runtimeRpc = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof RuntimeRpcClientModule>()),
|
||||
callRuntimeRpc: runtimeRpc.callRuntimeRpc
|
||||
}))
|
||||
|
||||
import { createTestStore, seedStore, makeTab, makeWorktree } from './store-test-helpers'
|
||||
import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees'
|
||||
import type { AppState } from '../types'
|
||||
import type {
|
||||
WorkspaceCleanupCandidate,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
|
||||
function makeHostCandidate(
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId | undefined,
|
||||
worktreePath: string,
|
||||
connectionId: string | null = executionHostId === HOST_B_HOST_ID ? 'ssh-1' : null
|
||||
): WorkspaceCleanupCandidate {
|
||||
return {
|
||||
worktreeId,
|
||||
repoId: 'repo1',
|
||||
repoName: 'Repo 1',
|
||||
connectionId,
|
||||
...(executionHostId ? { executionHostId } : {}),
|
||||
displayName: 'shared-workspace',
|
||||
branch: 'old-branch',
|
||||
path: worktreePath,
|
||||
tier: 'ready',
|
||||
selectedByDefault: true,
|
||||
reasons: ['idle-clean'],
|
||||
blockers: [],
|
||||
lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000,
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
},
|
||||
git: {
|
||||
clean: true,
|
||||
upstreamAhead: 0,
|
||||
upstreamBehind: 0,
|
||||
checkedAt: NOW
|
||||
},
|
||||
fingerprint: `fingerprint-${executionHostId ?? 'unqualified'}`
|
||||
}
|
||||
}
|
||||
|
||||
function parseIdentityPath(worktreeId: string): string {
|
||||
const separatorIndex = worktreeId.indexOf('::')
|
||||
return worktreeId.slice(separatorIndex + 2)
|
||||
}
|
||||
|
||||
/** Maps a workspace id onto a per-host temp directory; Windows ids keep their shape. */
|
||||
function toHostRelativePath(worktreeId: string): string {
|
||||
return parseIdentityPath(worktreeId)
|
||||
.replace(/^[a-zA-Z]:/, '')
|
||||
.split(/[/\\]+/)
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(path.sep)
|
||||
}
|
||||
|
||||
type HostDirectories = {
|
||||
hostARoot: string
|
||||
hostBRoot: string
|
||||
hostAMarkerPath: string
|
||||
hostBMarkerPath: string
|
||||
}
|
||||
|
||||
const hostDirCleanup: (() => void)[] = []
|
||||
|
||||
function createHostDirectories(worktreeId: string): HostDirectories {
|
||||
const hostARoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sta4343-host-a-'))
|
||||
const hostBRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sta4343-host-b-'))
|
||||
hostDirCleanup.push(() => {
|
||||
fs.rmSync(hostARoot, { recursive: true, force: true })
|
||||
fs.rmSync(hostBRoot, { recursive: true, force: true })
|
||||
})
|
||||
const relativePath = toHostRelativePath(worktreeId)
|
||||
const hostAWorktreeDir = path.join(hostARoot, relativePath)
|
||||
const hostBWorktreeDir = path.join(hostBRoot, relativePath)
|
||||
fs.mkdirSync(hostAWorktreeDir, { recursive: true })
|
||||
fs.mkdirSync(hostBWorktreeDir, { recursive: true })
|
||||
const hostAMarkerPath = path.join(hostAWorktreeDir, 'HOST_A_MARKER')
|
||||
const hostBMarkerPath = path.join(hostBWorktreeDir, 'HOST_B_MARKER')
|
||||
fs.writeFileSync(hostAMarkerPath, 'uncommitted-data-on-host-a')
|
||||
fs.writeFileSync(hostBMarkerPath, 'uncommitted-data-on-host-b')
|
||||
return { hostARoot, hostBRoot, hostAMarkerPath, hostBMarkerPath }
|
||||
}
|
||||
|
||||
function parseRuntimeSelector(selector: string): string {
|
||||
return selector.startsWith('id:') ? selector.slice('id:'.length) : selector
|
||||
}
|
||||
|
||||
/** Deletes for real on whichever host the removal is routed to. */
|
||||
function installRemovalTransports(
|
||||
hostRootsByHostId: Record<string, string>,
|
||||
routedHostIds: string[],
|
||||
options: { preservedBranch?: { branchName: string; head: string } } = {}
|
||||
): void {
|
||||
const deleteOnHost = (hostId: string | undefined, worktreeId: string): void => {
|
||||
routedHostIds.push(hostId ?? '<missing>')
|
||||
const hostRoot = hostId ? hostRootsByHostId[hostId] : undefined
|
||||
if (hostRoot) {
|
||||
fs.rmSync(path.join(hostRoot, toHostRelativePath(worktreeId)), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
}
|
||||
}
|
||||
mockApi.worktrees.remove.mockImplementation(
|
||||
async (args: { worktreeId: string; hostId?: string }) => {
|
||||
deleteOnHost(args.hostId, args.worktreeId)
|
||||
return options.preservedBranch ? { preservedBranch: options.preservedBranch } : { ok: true }
|
||||
}
|
||||
)
|
||||
runtimeRpc.callRuntimeRpc.mockImplementation(
|
||||
async (_target: unknown, method: string, params: { hostId?: string; worktree?: string }) => {
|
||||
if (method !== 'worktree.rm') {
|
||||
return { hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
deleteOnHost(params.hostId, parseRuntimeSelector(params.worktree ?? ''))
|
||||
return { removed: true }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function seedCollidingScan(candidates: readonly WorkspaceCleanupCandidate[]): void {
|
||||
mockApi.workspaceCleanup.scan.mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [...candidates],
|
||||
errors: []
|
||||
} satisfies WorkspaceCleanupScanResult)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthoritativelyRemovedWorktreeMemoryForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
for (const cleanup of hostDirCleanup.splice(0)) {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
describe('STA-4343 wrong-host cleanup removal (git worktree identity)', () => {
|
||||
it('confirming host B row deletes host B and leaves ACTIVE host A intact', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
// Host A owns the ACTIVE workspace, so removal routing used to prefer host A.
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_A_HOST_ID
|
||||
}),
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_B_HOST_ID
|
||||
})
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [makeTab({ id: 'host-a-tab', worktreeId })]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
// The user confirms HOST B's cleanup row.
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.removedIds).toEqual([worktreeId])
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(
|
||||
fs.existsSync(hosts.hostAMarkerPath),
|
||||
`HOST A (active) worktree directory must still exist after confirming HOST B's row. worktrees.remove was routed to hostId=${routedHostIds.join(',')}`
|
||||
).toBe(true)
|
||||
expect(
|
||||
fs.existsSync(hosts.hostBMarkerPath),
|
||||
'HOST B (confirmed) worktree directory must be gone'
|
||||
).toBe(false)
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
expect(store.getState().worktreesByRepo.repo1).toEqual([
|
||||
expect.objectContaining({ id: worktreeId, hostId: HOST_A_HOST_ID })
|
||||
])
|
||||
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([
|
||||
expect.objectContaining({ id: 'host-a-tab' })
|
||||
])
|
||||
expect(store.getState().activeWorktreeId).toBe(worktreeId)
|
||||
})
|
||||
|
||||
it('soundness control: with host B ACTIVE, confirming host B still deletes only host B', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_B_HOST_ID
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
})
|
||||
|
||||
it('soundness control: confirming ACTIVE host A deletes host A and leaves host B intact', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostACandidate = makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath)
|
||||
seedCollidingScan([hostACandidate, makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostACandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([HOST_A_HOST_ID])
|
||||
})
|
||||
|
||||
it('keeps the confirmed host on the preserved-branch follow-up', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds,
|
||||
{ preservedBranch: { branchName: 'feature/kept', head: 'abc123' } }
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.preservedBranches).toEqual([
|
||||
{
|
||||
worktreeId,
|
||||
branchName: 'feature/kept',
|
||||
expectedHead: 'abc123',
|
||||
hostId: HOST_B_HOST_ID
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('routes a Windows-path colliding identity to the confirmed host', async () => {
|
||||
const worktreeId = 'repo1::C:\\Users\\me\\workspaces\\shared'
|
||||
const worktreePath = 'C:\\Users\\me\\workspaces\\shared'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A (active) must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4343 wrong-host cleanup removal (folder workspace identity)', () => {
|
||||
it('folder workspace identity deletes the confirmed host, not the ACTIVE one', async () => {
|
||||
const worktreeId = 'repo1::/shared/folder-workspace'
|
||||
const worktreePath = '/shared/folder-workspace'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
// Folder workspaces are not Git worktrees: no worktree owner rows exist.
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [makeTab({ id: 'host-a-folder-tab', worktreeId })]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.removedIds).toEqual([worktreeId])
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(
|
||||
fs.existsSync(hosts.hostAMarkerPath),
|
||||
`HOST A (active) folder workspace must still exist. worktrees.remove was routed to hostId=${routedHostIds.join(',')}`
|
||||
).toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([
|
||||
expect.objectContaining({ id: 'host-a-folder-tab' })
|
||||
])
|
||||
expect(store.getState().activeWorktreeId).toBe(worktreeId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4343 wrong-host cleanup removal (paired runtime host)', () => {
|
||||
it('confirming a paired runtime row deletes over the runtime RPC, not the local host', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_RUNTIME_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_RUNTIME_HOST_ID, worktreePath, null)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'local host A must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
|
||||
expect(routedHostIds).toEqual([HOST_B_RUNTIME_HOST_ID])
|
||||
// The destructive call left over the paired transport, not the local IPC.
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'hub-1' },
|
||||
'worktree.rm',
|
||||
expect.objectContaining({ hostId: HOST_B_RUNTIME_HOST_ID }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4343 cleanup removal fails closed on an unresolved owner', () => {
|
||||
it('deletes nothing when the confirmed host no longer lists the row', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
// The confirm-time list had both hosts; the preflight rescan sees only host A.
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath)])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.removedIds).toEqual([])
|
||||
expect(removal.failures).toHaveLength(1)
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([])
|
||||
})
|
||||
|
||||
it('deletes nothing for an unqualified row the store knows on two hosts', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
// A row published before host-qualified candidates existed: no host evidence.
|
||||
const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null)
|
||||
seedCollidingScan([legacyCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_A_HOST_ID
|
||||
}),
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_B_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] })
|
||||
|
||||
expect(removal.removedIds).toEqual([])
|
||||
expect(removal.failures).toHaveLength(1)
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([])
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes nothing for an old-client row with no host qualifier or catalog owner', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null)
|
||||
seedCollidingScan([legacyCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] })
|
||||
|
||||
expect(removal.removedIds).toEqual([])
|
||||
expect(removal.failures).toHaveLength(1)
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([])
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -3,15 +3,14 @@
|
||||
one store state contract. */
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
|
||||
WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT,
|
||||
applyWorkspaceCleanupPolicy,
|
||||
canQueueWorkspaceCleanupCandidate,
|
||||
canSelectWorkspaceCleanupCandidate,
|
||||
shouldForceWorkspaceCleanupRemoval,
|
||||
shouldHideWorkspaceCleanupCandidate,
|
||||
@@ -22,8 +21,17 @@ import {
|
||||
type WorkspaceCleanupScanProgress,
|
||||
type WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateHostId,
|
||||
getWorkspaceCleanupCandidateIdentity
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import { mapWithConcurrency } from '../../../../shared/map-with-concurrency'
|
||||
import { hydrateWorkspaceCleanupScanFromCache } from './workspace-cleanup-cache-hydration'
|
||||
import {
|
||||
preflightWorkspaceCleanupCandidates,
|
||||
resolveWorkspaceCleanupRemovalTargets,
|
||||
type WorkspaceCleanupRemovalTarget
|
||||
} from './workspace-cleanup-removal-targets'
|
||||
import {
|
||||
getInFlightWorkspaceCleanupScan,
|
||||
hasInFlightWorkspaceCleanupScan,
|
||||
@@ -34,21 +42,20 @@ import {
|
||||
throwIfWorkspaceCleanupScanSuperseded
|
||||
} from './workspace-cleanup-broad-scan-registry'
|
||||
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { resolveWorktreeOperationRoute } from '@/lib/worktree-operation-route'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { resolveWorkspaceCleanupRemovalHostId } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import { isWorkspaceCleanupRemovalHostCertain } from './workspace-cleanup-removal-host-guard'
|
||||
import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup'
|
||||
|
||||
export type WorkspaceCleanupFailure = {
|
||||
worktreeId: string
|
||||
/** Which host's row failed; absent only for a row with no host evidence. */
|
||||
executionHostId?: ExecutionHostId
|
||||
displayName: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupRemoveResult = {
|
||||
removedIds: string[]
|
||||
/** Host-qualified keys of the rows actually removed, parallel to removedIds. */
|
||||
removedIdentities: string[]
|
||||
failures: WorkspaceCleanupFailure[]
|
||||
preservedBranches?: PreservedBranchCleanup[]
|
||||
}
|
||||
@@ -108,11 +115,6 @@ type WorkspaceCleanupEnrichmentProjection = {
|
||||
const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000
|
||||
const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000
|
||||
export const WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY = 8
|
||||
// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base
|
||||
// and git-status-error only mean "couldn't verify". A row approved while
|
||||
// unverifiable must still fail if real work becomes visible before removal.
|
||||
const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const
|
||||
|
||||
let latestWorkspaceCleanupScanToken = 0
|
||||
let finalizedWorkspaceCleanupScanToken = 0
|
||||
let workspaceCleanupProgressQueue: {
|
||||
@@ -130,7 +132,7 @@ let workspaceCleanupProgressCandidateIndex: {
|
||||
scanToken: number
|
||||
scanId: string
|
||||
candidates: WorkspaceCleanupCandidate[]
|
||||
indexesByWorktreeId: Map<string, number>
|
||||
indexesByIdentity: Map<string, number>
|
||||
} | null = null
|
||||
|
||||
const SHELL_PROCESS_NAMES = new Set([
|
||||
@@ -311,6 +313,9 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
const now = Date.now()
|
||||
const dismissals = candidates.map((candidate) => ({
|
||||
worktreeId: candidate.worktreeId,
|
||||
// Why (STA-4343): ignoring one host's row must not hide the same-id row
|
||||
// on another host; the stored host scopes the match.
|
||||
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
|
||||
dismissedAt: now,
|
||||
fingerprint: candidate.fingerprint,
|
||||
classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION
|
||||
@@ -319,7 +324,12 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
set((state) => {
|
||||
const nextDismissals = { ...state.workspaceCleanupDismissals }
|
||||
for (const dismissal of dismissals) {
|
||||
nextDismissals[dismissal.worktreeId] = dismissal
|
||||
nextDismissals[
|
||||
getWorkspaceCleanupCandidateIdentity({
|
||||
worktreeId: dismissal.worktreeId,
|
||||
executionHostId: dismissal.executionHostId
|
||||
})
|
||||
] = dismissal
|
||||
}
|
||||
const nextScan = state.workspaceCleanupScan
|
||||
? {
|
||||
@@ -358,52 +368,51 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
|
||||
removeWorkspaceCleanupCandidates: async (worktreeIds, options) => {
|
||||
const removedIds: string[] = []
|
||||
const removedIdentities = new Set<string>()
|
||||
const failures: WorkspaceCleanupFailure[] = []
|
||||
const preservedBranches: PreservedBranchCleanup[] = []
|
||||
const approvedCandidatesByWorktreeId = new Map(
|
||||
(options?.approvedCandidates ?? []).map((candidate) => [candidate.worktreeId, candidate])
|
||||
|
||||
// Why (STA-4343): the confirmed row — not the id — names the host to delete
|
||||
// on. Everything below carries that owner so nothing re-derives it from the
|
||||
// active workspace, which owns the same `repoId::path` id on another host.
|
||||
const targets = resolveWorkspaceCleanupRemovalTargets(
|
||||
worktreeIds,
|
||||
get(),
|
||||
options?.approvedCandidates ? { approvedCandidates: options.approvedCandidates } : {}
|
||||
)
|
||||
const removableTargets: WorkspaceCleanupRemovalTarget[] = []
|
||||
for (const target of targets) {
|
||||
if (target.kind === 'unresolved') {
|
||||
failures.push(target.failure)
|
||||
continue
|
||||
}
|
||||
removableTargets.push(target)
|
||||
}
|
||||
|
||||
const preflights = await preflightWorkspaceCleanupCandidates(
|
||||
worktreeIds,
|
||||
removableTargets,
|
||||
get,
|
||||
approvedCandidatesByWorktreeId
|
||||
(candidates, state) =>
|
||||
enrichWorkspaceCleanupCandidates(candidates, state, { applyDismissals: false })
|
||||
)
|
||||
const candidatesToRemove: WorkspaceCleanupCandidate[] = []
|
||||
const targetsToRemove: {
|
||||
target: WorkspaceCleanupRemovalTarget
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
}[] = []
|
||||
|
||||
for (const preflight of preflights) {
|
||||
if (!preflight.ok) {
|
||||
failures.push(preflight.failure)
|
||||
continue
|
||||
}
|
||||
candidatesToRemove.push(preflight.candidate)
|
||||
targetsToRemove.push({ target: preflight.target, candidate: preflight.candidate })
|
||||
}
|
||||
|
||||
// Why: nested workspaces can belong to different repos; parent removal must
|
||||
// not race child cleanup hooks, PTY teardown, or metadata deletion.
|
||||
for (const candidate of [...candidatesToRemove].sort((a, b) => b.path.length - a.path.length)) {
|
||||
// Why: earlier rows can await long enough for active-host routing to change
|
||||
// after the batched preflight; recheck in the same turn as removeWorktree.
|
||||
const scannedHostId = resolveWorkspaceCleanupRemovalHostId(candidate)
|
||||
if (
|
||||
!isWorkspaceCleanupRemovalHostCertain({
|
||||
confirmedCandidate: candidate,
|
||||
scannedCandidate: candidate,
|
||||
scannedHostIds: [scannedHostId],
|
||||
routeHostId:
|
||||
resolveWorktreeOperationRoute(get(), candidate.worktreeId)?.executionHostId ?? null
|
||||
})
|
||||
) {
|
||||
failures.push({
|
||||
worktreeId: candidate.worktreeId,
|
||||
displayName: candidate.displayName,
|
||||
message: translate(
|
||||
'auto.store.slices.workspace.cleanup.hostUnresolved',
|
||||
'Orca cannot tell which host owns this workspace. Refresh projects and review it again.'
|
||||
)
|
||||
})
|
||||
continue
|
||||
}
|
||||
for (const { target, candidate } of [...targetsToRemove].sort(
|
||||
(a, b) => b.candidate.path.length - a.candidate.path.length
|
||||
)) {
|
||||
const result = await get().removeWorktree(
|
||||
candidate.worktreeId,
|
||||
shouldForceWorkspaceCleanupRemoval(candidate),
|
||||
@@ -413,21 +422,29 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
suppressPreservedBranchToast: true,
|
||||
...(options?.snapshotPruneBatchId
|
||||
? { snapshotPruneBatchId: options.snapshotPruneBatchId }
|
||||
: {})
|
||||
: {}),
|
||||
// Fails the removal closed when routing cannot land on this host.
|
||||
...(target.executionHostId ? { requiredExecutionHostId: target.executionHostId } : {})
|
||||
}
|
||||
)
|
||||
if (result.ok) {
|
||||
removedIds.push(candidate.worktreeId)
|
||||
removedIdentities.add(getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
if (result.preservedBranch) {
|
||||
preservedBranches.push({
|
||||
worktreeId: candidate.worktreeId,
|
||||
branchName: result.preservedBranch.branchName,
|
||||
expectedHead: result.preservedBranch.head
|
||||
expectedHead: result.preservedBranch.head,
|
||||
...(result.preservedBranch.hostId ? { hostId: result.preservedBranch.hostId } : {}),
|
||||
...(result.preservedBranch.runtimeEnvironmentId
|
||||
? { runtimeEnvironmentId: result.preservedBranch.runtimeEnvironmentId }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
} else {
|
||||
failures.push({
|
||||
worktreeId: candidate.worktreeId,
|
||||
...(target.executionHostId ? { executionHostId: target.executionHostId } : {}),
|
||||
displayName: candidate.displayName,
|
||||
message: result.error
|
||||
})
|
||||
@@ -436,37 +453,50 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
|
||||
if (removedIds.length > 0) {
|
||||
invalidateWorkspaceCleanupScanProgress()
|
||||
const removedIdSet = new Set(removedIds)
|
||||
set((state) => ({
|
||||
workspaceCleanupLoading: false,
|
||||
workspaceCleanupScan: state.workspaceCleanupScan
|
||||
? {
|
||||
...state.workspaceCleanupScan,
|
||||
candidates: state.workspaceCleanupScan.candidates.filter(
|
||||
(candidate) => !removedIdSet.has(candidate.worktreeId)
|
||||
)
|
||||
}
|
||||
: state.workspaceCleanupScan,
|
||||
// Why: dismissals and viewed marks for removed worktrees are dead
|
||||
// weight in the store and in every persisted-dismissals write.
|
||||
workspaceCleanupDismissals: pruneWorkspaceCleanupRecord(
|
||||
state.workspaceCleanupDismissals,
|
||||
removedIdSet
|
||||
),
|
||||
workspaceCleanupViewedCandidates: pruneWorkspaceCleanupRecord(
|
||||
state.workspaceCleanupViewedCandidates,
|
||||
removedIdSet
|
||||
let prunableWorktreeIds = new Set(removedIds)
|
||||
set((state) => {
|
||||
const remainingCandidates = state.workspaceCleanupScan?.candidates.filter(
|
||||
(candidate) => !removedIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
)
|
||||
}))
|
||||
void window.api.workspaceCleanup
|
||||
.dismiss({ dismissals: [], removedWorktreeIds: removedIds })
|
||||
.catch((error: unknown) => {
|
||||
console.warn('Failed to prune persisted cleanup dismissals', error)
|
||||
})
|
||||
// Why: a same-id row on another host survives this removal, and its
|
||||
// dismissal/viewed marks are keyed by worktree id alone — dropping them
|
||||
// would resurrect a row the user already ignored.
|
||||
const survivingWorktreeIds = new Set(
|
||||
(remainingCandidates ?? []).map((candidate) => candidate.worktreeId)
|
||||
)
|
||||
prunableWorktreeIds = new Set(
|
||||
removedIds.filter((worktreeId) => !survivingWorktreeIds.has(worktreeId))
|
||||
)
|
||||
return {
|
||||
workspaceCleanupLoading: false,
|
||||
workspaceCleanupScan:
|
||||
state.workspaceCleanupScan && remainingCandidates
|
||||
? { ...state.workspaceCleanupScan, candidates: remainingCandidates }
|
||||
: state.workspaceCleanupScan,
|
||||
// Why: dismissals and viewed marks for removed worktrees are dead
|
||||
// weight in the store and in every persisted-dismissals write.
|
||||
workspaceCleanupDismissals: pruneWorkspaceCleanupDismissals(
|
||||
state.workspaceCleanupDismissals,
|
||||
prunableWorktreeIds
|
||||
),
|
||||
workspaceCleanupViewedCandidates: pruneWorkspaceCleanupRecord(
|
||||
state.workspaceCleanupViewedCandidates,
|
||||
prunableWorktreeIds
|
||||
)
|
||||
}
|
||||
})
|
||||
if (prunableWorktreeIds.size > 0) {
|
||||
void window.api.workspaceCleanup
|
||||
.dismiss({ dismissals: [], removedWorktreeIds: [...prunableWorktreeIds] })
|
||||
.catch((error: unknown) => {
|
||||
console.warn('Failed to prune persisted cleanup dismissals', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
removedIds,
|
||||
removedIdentities: [...removedIdentities],
|
||||
failures,
|
||||
...(preservedBranches.length > 0 ? { preservedBranches } : {})
|
||||
}
|
||||
@@ -680,9 +710,12 @@ function mergeWorkspaceCleanupProgressCandidates({
|
||||
)
|
||||
const merged = [...indexCache.candidates]
|
||||
for (const candidate of nextCandidates) {
|
||||
const existingIndex = indexCache.indexesByWorktreeId.get(candidate.worktreeId)
|
||||
// Why (STA-4343): two hosts publish the same `repoId::path` id; keying the
|
||||
// merge on the id alone made one host's row overwrite the other's.
|
||||
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
|
||||
const existingIndex = indexCache.indexesByIdentity.get(identity)
|
||||
if (existingIndex === undefined) {
|
||||
indexCache.indexesByWorktreeId.set(candidate.worktreeId, merged.length)
|
||||
indexCache.indexesByIdentity.set(identity, merged.length)
|
||||
merged.push(candidate)
|
||||
continue
|
||||
}
|
||||
@@ -692,7 +725,7 @@ function mergeWorkspaceCleanupProgressCandidates({
|
||||
scanToken,
|
||||
scanId: progress.scanId,
|
||||
candidates: merged,
|
||||
indexesByWorktreeId: indexCache.indexesByWorktreeId
|
||||
indexesByIdentity: indexCache.indexesByIdentity
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -703,7 +736,7 @@ function getWorkspaceCleanupProgressCandidateIndex(
|
||||
scanToken: number
|
||||
): {
|
||||
candidates: WorkspaceCleanupCandidate[]
|
||||
indexesByWorktreeId: Map<string, number>
|
||||
indexesByIdentity: Map<string, number>
|
||||
} {
|
||||
if (
|
||||
workspaceCleanupProgressCandidateIndex?.scanToken === scanToken &&
|
||||
@@ -715,8 +748,8 @@ function getWorkspaceCleanupProgressCandidateIndex(
|
||||
|
||||
return {
|
||||
candidates: [...candidates],
|
||||
indexesByWorktreeId: new Map(
|
||||
candidates.map((candidate, index) => [candidate.worktreeId, index])
|
||||
indexesByIdentity: new Map(
|
||||
candidates.map((candidate, index) => [getWorkspaceCleanupCandidateIdentity(candidate), index])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -792,13 +825,14 @@ async function enrichWorkspaceCleanupCandidatesWithCache(
|
||||
candidates,
|
||||
WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY,
|
||||
async (candidate) => {
|
||||
const cached = cache.get(candidate.worktreeId)
|
||||
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
|
||||
const cached = cache.get(identity)
|
||||
if (options.localStateUnchanged === true && cached?.candidateRef === candidate) {
|
||||
return cached.candidate
|
||||
}
|
||||
const inputSignature = getWorkspaceCleanupCandidateInputSignature(candidate)
|
||||
const localSignature = getWorkspaceCleanupLocalStateSignature(
|
||||
candidate.worktreeId,
|
||||
candidate,
|
||||
state,
|
||||
projection,
|
||||
options
|
||||
@@ -809,7 +843,7 @@ async function enrichWorkspaceCleanupCandidatesWithCache(
|
||||
}
|
||||
|
||||
const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, projection, options)
|
||||
cache.set(candidate.worktreeId, {
|
||||
cache.set(identity, {
|
||||
candidateRef: candidate,
|
||||
inputSignature,
|
||||
localSignature,
|
||||
@@ -875,11 +909,12 @@ function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupC
|
||||
}
|
||||
|
||||
function getWorkspaceCleanupLocalStateSignature(
|
||||
worktreeId: string,
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
state: AppState,
|
||||
projection: WorkspaceCleanupEnrichmentProjection,
|
||||
options: EnrichOptions
|
||||
): string {
|
||||
const { worktreeId } = candidate
|
||||
const tabs = state.tabsByWorktree[worktreeId] ?? []
|
||||
const tabIds = tabs.map((tab) => tab.id)
|
||||
const tabIdSet = new Set(tabIds)
|
||||
@@ -911,7 +946,7 @@ function getWorkspaceCleanupLocalStateSignature(
|
||||
const dismissal =
|
||||
options.applyDismissals === false
|
||||
? null
|
||||
: (state.workspaceCleanupDismissals[worktreeId] ?? null)
|
||||
: (getWorkspaceCleanupDismissal(candidate, state.workspaceCleanupDismissals) ?? null)
|
||||
|
||||
return JSON.stringify({
|
||||
active: state.activeWorktreeId === worktreeId,
|
||||
@@ -1013,7 +1048,12 @@ function applyDismissal(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
dismissals: Record<string, WorkspaceCleanupDismissal>
|
||||
): WorkspaceCleanupCandidate {
|
||||
if (!shouldHideWorkspaceCleanupCandidate(candidate, dismissals[candidate.worktreeId])) {
|
||||
if (
|
||||
!shouldHideWorkspaceCleanupCandidate(
|
||||
candidate,
|
||||
getWorkspaceCleanupDismissal(candidate, dismissals)
|
||||
)
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
return applyWorkspaceCleanupPolicy({
|
||||
@@ -1022,9 +1062,26 @@ function applyDismissal(
|
||||
})
|
||||
}
|
||||
|
||||
type WorkspaceCleanupPreflightResult =
|
||||
| { ok: true; candidate: WorkspaceCleanupCandidate }
|
||||
| { ok: false; failure: WorkspaceCleanupFailure }
|
||||
function getWorkspaceCleanupDismissal(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
dismissals: Record<string, WorkspaceCleanupDismissal>
|
||||
): WorkspaceCleanupDismissal | undefined {
|
||||
return (
|
||||
dismissals[getWorkspaceCleanupCandidateIdentity(candidate)] ?? dismissals[candidate.worktreeId]
|
||||
)
|
||||
}
|
||||
|
||||
function pruneWorkspaceCleanupDismissals(
|
||||
dismissals: Record<string, WorkspaceCleanupDismissal>,
|
||||
removedIds: ReadonlySet<string>
|
||||
): Record<string, WorkspaceCleanupDismissal> {
|
||||
if (!Object.values(dismissals).some((dismissal) => removedIds.has(dismissal.worktreeId))) {
|
||||
return dismissals
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(dismissals).filter(([, dismissal]) => !removedIds.has(dismissal.worktreeId))
|
||||
)
|
||||
}
|
||||
|
||||
function pruneWorkspaceCleanupRecord<T>(
|
||||
record: Record<string, T>,
|
||||
@@ -1036,138 +1093,6 @@ function pruneWorkspaceCleanupRecord<T>(
|
||||
return Object.fromEntries(Object.entries(record).filter(([id]) => !removedIds.has(id)))
|
||||
}
|
||||
|
||||
async function preflightWorkspaceCleanupCandidates(
|
||||
worktreeIds: readonly string[],
|
||||
getState: () => AppState,
|
||||
approvedCandidatesByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
|
||||
): Promise<WorkspaceCleanupPreflightResult[]> {
|
||||
// Why: one batched scan per chunk replaces a git worktree-list + activity
|
||||
// read per row; chunks stay under main's silent target truncation limit.
|
||||
const candidatesByWorktreeId = new Map<string, WorkspaceCleanupCandidate>()
|
||||
// Why: candidatesByWorktreeId collapses a colliding id to one row, so record
|
||||
// every owner the scan reported before the collapse hides the collision.
|
||||
const scannedHostIdsByWorktreeId = new Map<string, Set<ExecutionHostId | null>>()
|
||||
for (let start = 0; start < worktreeIds.length; start += WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) {
|
||||
const chunk = worktreeIds.slice(start, start + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT)
|
||||
const scan = await window.api.workspaceCleanup.scan({
|
||||
worktreeIds: [...chunk],
|
||||
scanId: crypto.randomUUID(),
|
||||
refreshActivity: true
|
||||
})
|
||||
for (const candidate of scan.candidates) {
|
||||
const hostIds = scannedHostIdsByWorktreeId.get(candidate.worktreeId) ?? new Set()
|
||||
hostIds.add(resolveWorkspaceCleanupRemovalHostId(candidate))
|
||||
scannedHostIdsByWorktreeId.set(candidate.worktreeId, hostIds)
|
||||
}
|
||||
const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, getState(), {
|
||||
applyDismissals: false
|
||||
})
|
||||
for (const candidate of enriched) {
|
||||
candidatesByWorktreeId.set(candidate.worktreeId, candidate)
|
||||
}
|
||||
}
|
||||
return worktreeIds.map((worktreeId) => {
|
||||
const preflight = evaluateWorkspaceCleanupPreflight(
|
||||
worktreeId,
|
||||
candidatesByWorktreeId.get(worktreeId),
|
||||
approvedCandidatesByWorktreeId.get(worktreeId)
|
||||
)
|
||||
if (!preflight.ok) {
|
||||
return preflight
|
||||
}
|
||||
const scannedHostIds = [...(scannedHostIdsByWorktreeId.get(worktreeId) ?? [])]
|
||||
const distinctKnownHostCount = new Set(
|
||||
scannedHostIds.filter((hostId): hostId is ExecutionHostId => hostId !== null)
|
||||
).size
|
||||
// STA-4343: fail closed rather than delete another host's uncommitted work.
|
||||
const hostIsCertain = isWorkspaceCleanupRemovalHostCertain({
|
||||
confirmedCandidate: approvedCandidatesByWorktreeId.get(worktreeId),
|
||||
scannedCandidate: preflight.candidate,
|
||||
scannedHostIds,
|
||||
routeHostId: resolveWorktreeOperationRoute(getState(), worktreeId)?.executionHostId ?? null
|
||||
})
|
||||
return hostIsCertain
|
||||
? preflight
|
||||
: {
|
||||
ok: false as const,
|
||||
failure: {
|
||||
worktreeId,
|
||||
displayName: preflight.candidate.displayName,
|
||||
message:
|
||||
distinctKnownHostCount > 1
|
||||
? translate(
|
||||
'auto.store.slices.workspace.cleanup.hostCollision',
|
||||
'Error: this workspace exists on multiple hosts at the same path'
|
||||
)
|
||||
: translate(
|
||||
'auto.store.slices.workspace.cleanup.hostUnresolved',
|
||||
'Orca cannot tell which host owns this workspace. Refresh projects and review it again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function evaluateWorkspaceCleanupPreflight(
|
||||
worktreeId: string,
|
||||
candidate: WorkspaceCleanupCandidate | undefined,
|
||||
approvedCandidate?: WorkspaceCleanupCandidate
|
||||
): WorkspaceCleanupPreflightResult {
|
||||
if (!candidate) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
worktreeId,
|
||||
displayName: worktreeId,
|
||||
message: translate(
|
||||
'auto.store.slices.workspace.cleanup.9d6e531da6',
|
||||
'Workspace no longer exists.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!canQueueWorkspaceCleanupCandidate(candidate)) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
worktreeId,
|
||||
displayName: candidate.displayName,
|
||||
message: candidate.blockers.length
|
||||
? candidate.blockers.join(', ')
|
||||
: 'Workspace needs another look before removal.'
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: this row may be removed minutes after the confirm click. If it now
|
||||
// needs a force removal the user never approved (new dirt, unpushed work,
|
||||
// or a git error since confirmation), fail it instead of force-deleting.
|
||||
if (approvedCandidate) {
|
||||
const escalatedToForce =
|
||||
shouldForceWorkspaceCleanupRemoval(candidate) &&
|
||||
!shouldForceWorkspaceCleanupRemoval(approvedCandidate)
|
||||
// Why: an approved row that was already force-flagged for an unverifiable
|
||||
// reason must still fail when real dirt/unpushed work is now visible.
|
||||
const revealedConcreteRisk = WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some(
|
||||
(blocker) =>
|
||||
candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker)
|
||||
)
|
||||
if (escalatedToForce || revealedConcreteRisk) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
worktreeId,
|
||||
displayName: candidate.displayName,
|
||||
message: translate(
|
||||
'auto.store.slices.workspace.cleanup.changedSinceConfirmation',
|
||||
'Workspace changed after confirmation. Refresh to review it before removing.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, candidate }
|
||||
}
|
||||
|
||||
function buildWorkspaceCleanupAgentStatusIndex(
|
||||
state: AppState,
|
||||
includedTabIds?: ReadonlySet<string>
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import type { WorktreeForceDeleteReason } from '../../../../shared/worktree/removal'
|
||||
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { RemoveWorktreeOptions } from './worktree-removal-options'
|
||||
import type {
|
||||
HostQualifiedDetectedWorktreeResult,
|
||||
SshExecutionHostId
|
||||
@@ -246,17 +247,7 @@ export type WorktreeSlice = {
|
||||
removeWorktree: (
|
||||
worktreeId: string,
|
||||
force?: boolean,
|
||||
// 'forget-local' drops the workspace from Orca only (no remote Git/FS work)
|
||||
// for workspaces pinned to a removed/disconnected SSH host. Reuses the same
|
||||
// renderer-side teardown/purge as a normal remove.
|
||||
options?: {
|
||||
mode?: 'remove' | 'forget-local'
|
||||
suppressPreservedBranchToast?: boolean
|
||||
// Why (#11960): only an explicit Force Delete waives the proof that every
|
||||
// PTY stopped; `force` alone is set by the ordinary delete confirmation.
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
snapshotPruneBatchId?: string
|
||||
}
|
||||
options?: RemoveWorktreeOptions
|
||||
) => Promise<({ ok: true } & RendererRemoveWorktreeResult) | { ok: false; error: string }>
|
||||
markWorktreesDeleting: (worktreeIds: readonly string[]) => void
|
||||
markWorktreesQueuedForDeletion: (worktreeIds: readonly string[]) => void
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export type RemoveWorktreeOptions = {
|
||||
// 'forget-local' drops the workspace from Orca only (no remote Git/FS work)
|
||||
// for workspaces pinned to a removed/disconnected SSH host. Reuses the same
|
||||
// renderer-side teardown/purge as a normal remove.
|
||||
mode?: 'remove' | 'forget-local'
|
||||
suppressPreservedBranchToast?: boolean
|
||||
// Why (#11960): only an explicit Force Delete waives the proof that every
|
||||
// PTY stopped; `force` alone is set by the ordinary delete confirmation.
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
snapshotPruneBatchId?: string
|
||||
// Why (STA-4343): `repoId::path` ids repeat across hosts — pin the removal to
|
||||
// the confirmed host and fail closed if routing cannot land there.
|
||||
requiredExecutionHostId?: ExecutionHostId
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
export const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000
|
||||
export const WORKTREE_REMOVAL_AMBIGUOUS_ERROR =
|
||||
'Workspace identity is ambiguous across hosts. Refresh projects and try again.'
|
||||
// Why (STA-4343): the confirmed row names the host to delete on. If the route
|
||||
// no longer lands there, deleting anyway destroys another host's workspace.
|
||||
export const WORKTREE_REMOVAL_HOST_CHANGED_ERROR =
|
||||
'This workspace is no longer on the host you confirmed. Refresh and review it again.'
|
||||
export const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300
|
||||
export const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450
|
||||
export const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* STA-4343: a cleanup row is identified by `repoId::path`, which two execution
|
||||
* hosts can both publish. Once a caller confirms ONE host's row, every step of
|
||||
* the removal must stay pinned to that host — routing at the ACTIVE workspace's
|
||||
* host instead deletes another machine's uncommitted work.
|
||||
*/
|
||||
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
|
||||
import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types'
|
||||
import type { WorktreeSlice } from '../../worktree-helpers'
|
||||
import type { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { resolveWorkspaceCleanupRemovalHostId } from '../../../../../../shared/workspace-cleanup-host-identity'
|
||||
import {
|
||||
getWorktreeOperationOwnerHostIds,
|
||||
resolveWorktreeOperationRoute,
|
||||
resolveWorktreeOperationRouteForHost,
|
||||
type WorktreeOperationRoute
|
||||
} from '@/lib/worktree-operation-route'
|
||||
import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation'
|
||||
import { getRepoIdFromWorktreeId } from '../../worktree-helpers'
|
||||
import {
|
||||
WORKTREE_REMOVAL_AMBIGUOUS_ERROR,
|
||||
WORKTREE_REMOVAL_HOST_CHANGED_ERROR
|
||||
} from '../listing/worktree-slice-constants'
|
||||
import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord'
|
||||
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
|
||||
|
||||
import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup'
|
||||
import { rememberAuthoritativelyRemovedWorktrees } from '../listing/authoritative-worktree-removal-memory'
|
||||
import { preservedBranchRuntimeTargetByCleanupKey } from './preserved-branch-cleanup-target'
|
||||
import { worktreeHostMatchOptions, worktreeMatchesHost } from '../listing/worktree-host-ownership'
|
||||
|
||||
type PreservedBranchWorktree = Parameters<typeof showPreservedBranchToast>[1]
|
||||
type RemoveWorktreeSliceResult = Awaited<ReturnType<WorktreeSlice['removeWorktree']>>
|
||||
|
||||
/** Route at the confirmed host when there is one, else the ordinary active-host route. */
|
||||
function resolveHostQualifiedRemovalRoute(
|
||||
get: WorktreeSliceGet,
|
||||
worktreeId: string,
|
||||
requiredExecutionHostId: ExecutionHostId | undefined
|
||||
): WorktreeOperationRoute | null {
|
||||
return requiredExecutionHostId
|
||||
? resolveWorktreeOperationRouteForHost(get(), worktreeId, requiredExecutionHostId)
|
||||
: resolveWorktreeOperationRoute(get(), worktreeId)
|
||||
}
|
||||
|
||||
export type HostQualifiedRemovalStart =
|
||||
| { ok: false; error: string }
|
||||
| {
|
||||
ok: true
|
||||
removalRoute: WorktreeOperationRoute | null
|
||||
hostId: ExecutionHostId | undefined
|
||||
removalGenerationGuard: ReturnType<typeof captureWorktreeOperationGenerationGuard> | null
|
||||
sameIdSurvivesOnAnotherHost: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the route a removal may run on. A caller that confirmed one host's
|
||||
* row fails closed unless the route still lands on exactly that host.
|
||||
*/
|
||||
export function beginHostQualifiedRemoval(
|
||||
get: WorktreeSliceGet,
|
||||
worktreeId: string,
|
||||
requiredExecutionHostId: ExecutionHostId | undefined,
|
||||
forgetLocalOnly: boolean
|
||||
): HostQualifiedRemovalStart {
|
||||
const resolveRemovalRoute = (): WorktreeOperationRoute | null =>
|
||||
resolveHostQualifiedRemovalRoute(get, worktreeId, requiredExecutionHostId)
|
||||
const removalRoute = resolveRemovalRoute()
|
||||
if (!forgetLocalOnly && !removalRoute) {
|
||||
return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR }
|
||||
}
|
||||
// Fail closed rather than delete on a host the caller never confirmed.
|
||||
if (
|
||||
requiredExecutionHostId &&
|
||||
removalRoute &&
|
||||
removalRoute.executionHostId !== requiredExecutionHostId
|
||||
) {
|
||||
return { ok: false, error: WORKTREE_REMOVAL_HOST_CHANGED_ERROR }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
removalRoute,
|
||||
hostId: removalRoute?.executionHostId ?? requiredExecutionHostId ?? undefined,
|
||||
removalGenerationGuard: removalRoute
|
||||
? captureWorktreeOperationGenerationGuard(
|
||||
get,
|
||||
worktreeId,
|
||||
removalRoute,
|
||||
() =>
|
||||
new Error(
|
||||
requiredExecutionHostId
|
||||
? WORKTREE_REMOVAL_HOST_CHANGED_ERROR
|
||||
: WORKTREE_REMOVAL_AMBIGUOUS_ERROR
|
||||
),
|
||||
// Why: every mid-flight re-check must re-resolve at the CONFIRMED host,
|
||||
// or the active host's route would read as "ownership changed".
|
||||
requiredExecutionHostId ? resolveRemovalRoute : undefined
|
||||
)
|
||||
: null,
|
||||
sameIdSurvivesOnAnotherHost: preservesSameIdRendererState(
|
||||
get(),
|
||||
worktreeId,
|
||||
requiredExecutionHostId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The row on the confirmed host only — a same-id row elsewhere must not stand in for it. */
|
||||
export function findWorktreeOnConfirmedHost(
|
||||
get: WorktreeSliceGet,
|
||||
worktreeId: string,
|
||||
requiredExecutionHostId: ExecutionHostId | undefined
|
||||
): PreservedBranchWorktree {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
return get()
|
||||
.allWorktrees()
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.id === worktreeId &&
|
||||
(!requiredExecutionHostId ||
|
||||
worktreeMatchesHost(
|
||||
entry,
|
||||
requiredExecutionHostId,
|
||||
worktreeHostMatchOptions(get(), repoId, requiredExecutionHostId)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when some OTHER host still owns this same id, so the shared renderer
|
||||
* state (tabs, terminals, browsers, editor files) belongs to a workspace that
|
||||
* is still alive and must survive this removal.
|
||||
*/
|
||||
export function preservesSameIdRendererState(
|
||||
state: ReturnType<WorktreeSliceGet>,
|
||||
worktreeId: string,
|
||||
requiredExecutionHostId: ExecutionHostId | undefined
|
||||
): boolean {
|
||||
if (requiredExecutionHostId === undefined) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
getWorktreeOperationOwnerHostIds(state, worktreeId).some(
|
||||
(ownerHostId) => ownerHostId !== requiredExecutionHostId
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
state.activeWorktreeId === worktreeId &&
|
||||
state.activeWorkspaceExecutionHostId !== null &&
|
||||
state.activeWorkspaceExecutionHostId !== requiredExecutionHostId
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
state.workspaceCleanupScan?.candidates.some((candidate) => {
|
||||
const candidateHostId = resolveWorkspaceCleanupRemovalHostId(candidate)
|
||||
return (
|
||||
candidate.worktreeId === worktreeId &&
|
||||
candidateHostId !== null &&
|
||||
candidateHostId !== requiredExecutionHostId
|
||||
)
|
||||
}) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop only the confirmed host's row, leaving the surviving host's state whole. */
|
||||
function dropConfirmedHostRow(
|
||||
set: WorktreeSliceSet,
|
||||
worktreeId: string,
|
||||
requiredExecutionHostId: ExecutionHostId
|
||||
): void {
|
||||
requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]')
|
||||
set((state) => {
|
||||
const nextWorktreesByRepo = { ...state.worktreesByRepo }
|
||||
for (const [candidateRepoId, worktrees] of Object.entries(nextWorktreesByRepo)) {
|
||||
const matchOptions = worktreeHostMatchOptions(state, candidateRepoId, requiredExecutionHostId)
|
||||
nextWorktreesByRepo[candidateRepoId] = worktrees.filter(
|
||||
(worktree) =>
|
||||
worktree.id !== worktreeId ||
|
||||
!worktreeMatchesHost(worktree, requiredExecutionHostId, matchOptions)
|
||||
)
|
||||
}
|
||||
const nextDeleteState = { ...state.deleteStateByWorktreeId }
|
||||
delete nextDeleteState[worktreeId]
|
||||
return {
|
||||
worktreesByRepo: nextWorktreesByRepo,
|
||||
deleteStateByWorktreeId: nextDeleteState,
|
||||
sortEpoch: state.sortEpoch + 1
|
||||
}
|
||||
})
|
||||
if (parseExecutionHostId(requiredExecutionHostId)?.kind === 'ssh') {
|
||||
rememberAuthoritativelyRemovedWorktrees(requiredExecutionHostId, [worktreeId])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish a removal whose id still exists on another host: prune just the
|
||||
* confirmed host's row and keep the preserved-branch follow-up pinned to it.
|
||||
*/
|
||||
export function completeSameIdHostScopedRemoval(args: {
|
||||
set: WorktreeSliceSet
|
||||
get: WorktreeSliceGet
|
||||
worktreeId: string
|
||||
requiredExecutionHostId: ExecutionHostId
|
||||
removalResult: RemoveWorktreeResult | undefined
|
||||
removalRoute: WorktreeOperationRoute | null
|
||||
target: ReturnType<typeof getActiveRuntimeTarget>
|
||||
worktreeBeforeRemoval: PreservedBranchWorktree
|
||||
suppressPreservedBranchToast: boolean
|
||||
}): RemoveWorktreeSliceResult {
|
||||
const {
|
||||
set,
|
||||
get,
|
||||
worktreeId,
|
||||
requiredExecutionHostId,
|
||||
removalResult,
|
||||
removalRoute,
|
||||
target,
|
||||
worktreeBeforeRemoval,
|
||||
suppressPreservedBranchToast
|
||||
} = args
|
||||
dropConfirmedHostRow(set, worktreeId, requiredExecutionHostId)
|
||||
const preservedBranch = removalResult?.preservedBranch
|
||||
if (!preservedBranch) {
|
||||
return { ok: true as const }
|
||||
}
|
||||
const runtimeEnvironment = removalRoute?.runtimeEnvironmentId
|
||||
? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId }
|
||||
: {}
|
||||
const cleanup = {
|
||||
worktreeId,
|
||||
branchName: preservedBranch.branchName,
|
||||
expectedHead: preservedBranch.head,
|
||||
hostId: requiredExecutionHostId,
|
||||
...runtimeEnvironment
|
||||
}
|
||||
preservedBranchRuntimeTargetByCleanupKey.set(preservedBranchCleanupKey(cleanup), {
|
||||
cleanup,
|
||||
target
|
||||
})
|
||||
if (!suppressPreservedBranchToast) {
|
||||
showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => {
|
||||
void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, {
|
||||
hostId: requiredExecutionHostId,
|
||||
...runtimeEnvironment
|
||||
})
|
||||
})
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
preservedBranch: { ...preservedBranch, hostId: requiredExecutionHostId, ...runtimeEnvironment }
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,21 @@ import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-contro
|
||||
import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session'
|
||||
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
|
||||
import {
|
||||
resolveWorktreeOperationRoute,
|
||||
resolveWorktreeOperationRouteResult,
|
||||
resolveWorktreeOperationRouteResultForHost,
|
||||
settingsForWorktreeOperationRoute
|
||||
} from '@/lib/worktree-operation-route'
|
||||
import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation'
|
||||
import {
|
||||
beginHostQualifiedRemoval,
|
||||
completeSameIdHostScopedRemoval,
|
||||
findWorktreeOnConfirmedHost
|
||||
} from './host-qualified-worktree-removal'
|
||||
import {
|
||||
classifyWorktreeForceDeleteReason,
|
||||
getLockedWorktreeRemovalReason,
|
||||
isLockedWorktreeRemovalError
|
||||
} from '../../../../../../shared/worktree/removal'
|
||||
import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup'
|
||||
import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from '../listing/worktree-slice-constants'
|
||||
import { detachedHeadAutoDerivedDisplayNames } from '../metadata/detached-head-display-name'
|
||||
import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation'
|
||||
import { rememberAuthoritativelyRemovedWorktrees } from '../listing/authoritative-worktree-removal-memory'
|
||||
@@ -44,19 +47,19 @@ export function createRemoveWorktree(
|
||||
): WorktreeSlice['removeWorktree'] {
|
||||
return async (worktreeId, force, options) => {
|
||||
const forgetLocalOnly = options?.mode === 'forget-local'
|
||||
const removalRoute = resolveWorktreeOperationRoute(get(), worktreeId)
|
||||
if (!forgetLocalOnly && !removalRoute) {
|
||||
return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR }
|
||||
// Why (STA-4343): a qualified caller confirmed ONE host's row; route at that
|
||||
// host instead of the active workspace's, which owns the same id elsewhere.
|
||||
const requiredExecutionHostId = options?.requiredExecutionHostId
|
||||
const start = beginHostQualifiedRemoval(
|
||||
get,
|
||||
worktreeId,
|
||||
requiredExecutionHostId,
|
||||
forgetLocalOnly
|
||||
)
|
||||
if (!start.ok) {
|
||||
return { ok: false, error: start.error }
|
||||
}
|
||||
const hostId = removalRoute?.executionHostId ?? undefined
|
||||
const removalGenerationGuard = removalRoute
|
||||
? captureWorktreeOperationGenerationGuard(
|
||||
get,
|
||||
worktreeId,
|
||||
removalRoute,
|
||||
() => new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR)
|
||||
)
|
||||
: null
|
||||
const { removalRoute, hostId, removalGenerationGuard, sameIdSurvivesOnAnotherHost } = start
|
||||
set((s) => ({
|
||||
deleteStateByWorktreeId: {
|
||||
...s.deleteStateByWorktreeId,
|
||||
@@ -82,9 +85,11 @@ export function createRemoveWorktree(
|
||||
removalRoute?.runtimeEnvironmentId
|
||||
)) === 'skip'
|
||||
|
||||
const worktreeBeforeRemoval = get()
|
||||
.allWorktrees()
|
||||
.find((entry) => entry.id === worktreeId)
|
||||
const worktreeBeforeRemoval = findWorktreeOnConfirmedHost(
|
||||
get,
|
||||
worktreeId,
|
||||
requiredExecutionHostId
|
||||
)
|
||||
const terminalPtyIdsBeforeRemoval = (get().tabsByWorktree[worktreeId] ?? []).flatMap(
|
||||
(tab) => get().ptyIdsByTabId[tab.id] ?? []
|
||||
)
|
||||
@@ -142,7 +147,9 @@ export function createRemoveWorktree(
|
||||
(isRuntimeRepoNotFoundError(error) || isRuntimeSelectorNotFoundError(error))
|
||||
) {
|
||||
// Missing means stale mirror; ambiguous or changed ownership must fail closed.
|
||||
const currentResolution = resolveWorktreeOperationRouteResult(get(), worktreeId)
|
||||
const currentResolution = requiredExecutionHostId
|
||||
? resolveWorktreeOperationRouteResultForHost(get(), worktreeId, requiredExecutionHostId)
|
||||
: resolveWorktreeOperationRouteResult(get(), worktreeId)
|
||||
if (currentResolution.kind === 'ambiguous') {
|
||||
throw error
|
||||
}
|
||||
@@ -187,6 +194,23 @@ export function createRemoveWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
// Why (STA-4343): another host still owns this id, so the shared renderer
|
||||
// state (tabs, terminals, browsers) belongs to that live workspace — drop
|
||||
// only the confirmed host's row instead of tearing all of it down.
|
||||
if (sameIdSurvivesOnAnotherHost && requiredExecutionHostId) {
|
||||
return completeSameIdHostScopedRemoval({
|
||||
set,
|
||||
get,
|
||||
worktreeId,
|
||||
requiredExecutionHostId,
|
||||
removalResult,
|
||||
removalRoute,
|
||||
target,
|
||||
worktreeBeforeRemoval,
|
||||
suppressPreservedBranchToast: options?.suppressPreservedBranchToast === true
|
||||
})
|
||||
}
|
||||
|
||||
// Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement.
|
||||
forgetHugeRepoWarningDismissalsForWorktrees([worktreeId])
|
||||
// Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from './execution-host'
|
||||
import type { WorkspaceCleanupCandidate } from './workspace-cleanup'
|
||||
|
||||
/** Host evidence a cleanup row carries; `worktreeId` alone repeats across hosts. */
|
||||
export type WorkspaceCleanupHostFacts = {
|
||||
@@ -10,6 +12,22 @@ export type WorkspaceCleanupHostFacts = {
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupIdentityFacts = WorkspaceCleanupHostFacts &
|
||||
Pick<WorkspaceCleanupCandidate, 'worktreeId'>
|
||||
|
||||
const WORKSPACE_CLEANUP_IDENTITY_SEPARATOR = '\0'
|
||||
|
||||
export function getWorkspaceCleanupCandidateHostId(
|
||||
candidate: WorkspaceCleanupHostFacts
|
||||
): ExecutionHostId {
|
||||
return (
|
||||
resolveWorkspaceCleanupRemovalHostId(candidate) ??
|
||||
// Why: display/index surfaces still need a bucket for a pre-host row; only
|
||||
// the destructive path may not guess (see resolveWorkspaceCleanupRemovalHostId).
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner a destructive removal is allowed to target, or null when the row
|
||||
* carries no host evidence at all (a snapshot or publisher that predates
|
||||
@@ -26,3 +44,24 @@ export function resolveWorkspaceCleanupRemovalHostId(
|
||||
const connectionId = candidate.connectionId?.trim()
|
||||
return connectionId ? toSshExecutionHostId(connectionId) : null
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string {
|
||||
return `${hostId}${WORKSPACE_CLEANUP_IDENTITY_SEPARATOR}${id}`
|
||||
}
|
||||
|
||||
/** Stable key for one row of one host — the identity every cleanup surface keys on. */
|
||||
export function getWorkspaceCleanupCandidateIdentity(
|
||||
candidate: WorkspaceCleanupIdentityFacts
|
||||
): string {
|
||||
return getWorkspaceCleanupHostIdentity(
|
||||
getWorkspaceCleanupCandidateHostId(candidate),
|
||||
candidate.worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupIdentityWorktreeId(identity: string): string {
|
||||
const separatorIndex = identity.indexOf(WORKSPACE_CLEANUP_IDENTITY_SEPARATOR)
|
||||
return separatorIndex === -1
|
||||
? identity
|
||||
: identity.slice(separatorIndex + WORKSPACE_CLEANUP_IDENTITY_SEPARATOR.length)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Type-only, so the cycle back through workspace-cleanup-filter-model erases at build.
|
||||
import type { WorkspaceCleanupBrowseState } from './workspace-cleanup-browse-state'
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity'
|
||||
|
||||
export const WORKSPACE_CLEANUP_CLASSIFIER_VERSION = 2
|
||||
export const WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
@@ -38,6 +39,10 @@ export type WorkspaceCleanupDismissal = {
|
||||
dismissedAt: number
|
||||
fingerprint: string
|
||||
classifierVersion: number
|
||||
// Why (STA-4343): `repoId::path` ids repeat across hosts, so ignoring one
|
||||
// host's row must not hide another host's. Optional: a dismissal persisted
|
||||
// before this field keeps its legacy id-only match.
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupUIState = {
|
||||
@@ -266,12 +271,19 @@ export function isWorkspaceOldForCleanup(
|
||||
}
|
||||
|
||||
export function shouldHideWorkspaceCleanupCandidate(
|
||||
candidate: Pick<WorkspaceCleanupCandidate, 'worktreeId' | 'fingerprint'>,
|
||||
candidate: Pick<
|
||||
WorkspaceCleanupCandidate,
|
||||
'worktreeId' | 'fingerprint' | 'connectionId' | 'executionHostId'
|
||||
>,
|
||||
dismissal: WorkspaceCleanupDismissal | undefined
|
||||
): boolean {
|
||||
return (
|
||||
dismissal?.worktreeId === candidate.worktreeId &&
|
||||
dismissal.fingerprint === candidate.fingerprint &&
|
||||
dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION
|
||||
dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION &&
|
||||
// A host-qualified dismissal hides only its own host's row; a legacy one
|
||||
// (no host recorded) keeps hiding every row that matches the fingerprint.
|
||||
(dismissal.executionHostId === undefined ||
|
||||
dismissal.executionHostId === getWorkspaceCleanupCandidateHostId(candidate))
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user