diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index ab65f6faa22..070ea461906 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -119,8 +119,12 @@ function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { const META_BY_WORKTREE_ID: Record = { 'repo-1::/repo': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), 'repo-1::/repo-old': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), - 'repo-1::/repo-recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }), - 'repo-folder::/folder-workspace': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }) + 'repo-1::/repo-recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * DAY_MS + }), + 'repo-folder::/folder-workspace': makeWorktreeMeta({ + lastActivityAt: NOW - 40 * DAY_MS + }) } function makeStore(repos: Repo[] = [REPO], allMeta: Record = {}): Store { @@ -154,7 +158,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('emits every worktree when the client opts into the full workspace list', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) expect(result.errors).toEqual([]) expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ @@ -175,7 +181,9 @@ describe('workspace cleanup broad scan opt-in', () => { worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId] } as Store - const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(store, { + includeAllWorkspaces: true + }) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old') @@ -230,6 +238,56 @@ describe('workspace cleanup broad scan opt-in', () => { ) }) + it('reads git for a pinned target during focused preflight scans', async () => { + const worktreeId = 'repo-1::/repo-old' + const pinnedMeta = makeWorktreeMeta({ + isPinned: true, + lastActivityAt: NOW - 40 * DAY_MS + }) + const store = { + ...makeStore(), + getWorktreeMeta: (id: string) => (id === worktreeId ? pinnedMeta : META_BY_WORKTREE_ID[id]) + } as Store + + const result = await scanWorkspaceCleanup(store, { + worktreeIds: [worktreeId], + refreshActivity: true + }) + + expect(result.candidates[0]?.blockers).toContain('pinned') + expect(getStatusMock).toHaveBeenCalledTimes(1) + }) + + it('continues to skip pinned git reads during broad scans', async () => { + const worktreeId = 'repo-1::/repo-old' + const pinnedMeta = makeWorktreeMeta({ + isPinned: true, + lastActivityAt: NOW - 40 * DAY_MS + }) + const store = { + ...makeStore(), + getWorktreeMeta: (id: string) => (id === worktreeId ? pinnedMeta : META_BY_WORKTREE_ID[id]) + } as Store + listRepoWorktreesMock.mockResolvedValue([GIT_WORKTREES[1]]) + + await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + + expect(getStatusMock).not.toHaveBeenCalled() + }) + + it('qualifies targeted repo-list failures with the execution host', async () => { + listRepoWorktreesMock.mockRejectedValue(new Error('listing failed')) + + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: ['repo-1::/repo-old'], + refreshActivity: true + }) + + expect(result.errors).toEqual([ + expect.objectContaining({ repoId: 'repo-1', executionHostId: 'local' }) + ]) + }) + it('keeps the legacy suggestion-only projection when the flag is absent', async () => { const result = await scanWorkspaceCleanup(makeStore()) @@ -239,7 +297,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('reports recent workspaces with no cleanup reasons', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) const recent = result.candidates.find( (candidate) => candidate.worktreeId === 'repo-1::/repo-recent' @@ -253,7 +313,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('reports the main worktree with a main-worktree blocker', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) const main = result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo') expect(main).toMatchObject({ @@ -334,7 +396,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('defers git evidence for rows that no cleanup decision can use', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) // Why: only the inactive row can ever be queued, so it is the only row worth // a git read; the rest stream immediately with empty evidence. @@ -342,7 +406,12 @@ describe('workspace cleanup broad scan opt-in', () => { expect(getStatusMock).toHaveBeenCalledWith('/repo-old', expect.anything()) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-recent')?.git - ).toEqual({ clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }) + ).toEqual({ + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + }) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old')?.git ).toMatchObject({ clean: true, checkedAt: expect.any(Number) }) @@ -365,7 +434,9 @@ describe('workspace cleanup broad scan opt-in', () => { getWorktreeMeta: () => recentMeta } as unknown as Store - const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(store, { + includeAllWorkspaces: true + }) expect(result.candidates).toHaveLength(recentWorktrees.length) expect(readFileMock).not.toHaveBeenCalled() @@ -433,7 +504,9 @@ describe('workspace cleanup broad scan opt-in', () => { it('lists a repo once for a targeted Git-evidence batch', async () => { const targets = ['repo-1::/repo-recent', 'repo-1::/repo-old'] - const result = await scanWorkspaceCleanup(makeStore(), { worktreeIds: targets }) + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: targets + }) expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) expect(getStatusMock).toHaveBeenCalledTimes(2) @@ -469,7 +542,9 @@ describe('workspace cleanup broad scan opt-in', () => { it('lists disconnected SSH workspaces only on an opt-in scan', async () => { const sshRepo: Repo = { ...REPO, id: 'repo-ssh', connectionId: 'ssh-1' } const allMeta: Record = { - 'repo-ssh::/remote/recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }) + 'repo-ssh::/remote/recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * DAY_MS + }) } const legacy = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta)) @@ -536,7 +611,10 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('streams every row through scan progress on an opt-in scan', async () => { - const progress: { scannedWorktreeCount: number; totalWorktreeCount: number }[] = [] + const progress: { + scannedWorktreeCount: number + totalWorktreeCount: number + }[] = [] await scanWorkspaceCleanup( makeStore(), diff --git a/src/main/ipc/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup-candidate.ts index 58c56316acc..589244ea55f 100644 --- a/src/main/ipc/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -56,7 +56,7 @@ export async function buildWorkspaceCleanupCandidate(args: { : await readWorkspaceCleanupGitEvidence(worktree, repo, provider, signal) appendWorkspaceCleanupItems(blockers, gitEvidence.blockers) - const candidateWithoutFingerprint: WorkspaceCleanupCandidate = { + const candidateWithoutFingerprint = { worktreeId: worktree.id, repoId: repo.id, repoName: repo.displayName, @@ -65,8 +65,6 @@ export async function buildWorkspaceCleanupCandidate(args: { displayName: worktree.displayName, branch: shortWorkspaceCleanupBranchName(worktree.branch), path: worktree.path, - tier: 'review', - selectedByDefault: false, reasons, blockers: uniqueWorkspaceCleanupBlockers(blockers), lastActivityAt: worktree.lastActivityAt, @@ -110,8 +108,6 @@ export function buildWorkspaceCleanupCandidateFromError( displayName: worktree.displayName, branch: shortWorkspaceCleanupBranchName(worktree.branch), path: worktree.path, - tier: 'protected', - selectedByDefault: false, reasons: getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt), blockers: ['git-status-error'], lastActivityAt: worktree.lastActivityAt, @@ -193,7 +189,7 @@ function shouldReadWorkspaceCleanupGitEvidence(args: { return false } if ( - blockers.includes('pinned') || + (blockers.includes('pinned') && !forceGitCheck) || blockers.includes('main-worktree') || blockers.includes('folder-repo') ) { diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index 5e3cebf260c..c0e136b7b77 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -74,8 +74,6 @@ function createDisconnectedSshCandidate( displayName: meta.displayName || basename(path), branch: basename(path), path, - tier: 'protected', - selectedByDefault: false, reasons, blockers: ['ssh-disconnected'], lastActivityAt: meta.lastActivityAt, diff --git a/src/main/ipc/workspace-cleanup-scan-primitives.ts b/src/main/ipc/workspace-cleanup-scan-primitives.ts index 93af63562c2..8054d333e0d 100644 --- a/src/main/ipc/workspace-cleanup-scan-primitives.ts +++ b/src/main/ipc/workspace-cleanup-scan-primitives.ts @@ -1,4 +1,5 @@ import { basename } from 'node:path' +import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup' @@ -91,7 +92,8 @@ export function createWorkspaceCleanupScanError( return { repoId: repo.id, repoName: repo.displayName || basename(repo.path), - message + message, + executionHostId: getRepoExecutionHostId(repo) } } diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 278c3ae7ee8..b87667f0a63 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -252,8 +252,8 @@ async function scanRepoWorkspaces( worktree: worktreeWithActivity, scannedAt, provider, - // Why: a row with no inactivity reason can never be queued or selected, - // so full-fleet scans stream it now and let a focused scan read git later. + // Why: full-fleet scans defer git for recently active rows; removal preflight + // forces a fresh read before any selected row can be deleted. skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id) || !isInactive, forceGitCheck: Boolean(targetWorktreeIds), signal diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index b35167906c8..6f5d3d74a9b 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -322,6 +322,7 @@ describe('workspace cleanup scan', () => { { repoId: 'repo-1', repoName: 'Repo', + executionHostId: 'local', message: 'Git could not list worktrees.' } ]) @@ -344,6 +345,7 @@ describe('workspace cleanup scan', () => { { repoId: 'repo-1', repoName: 'Repo', + executionHostId: 'local', message: 'Timed out listing worktrees.' } ] diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 606d42aca3c..eee97ba0f0f 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -41,13 +41,13 @@ import { import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence' import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order' +import { openWorkspaceCleanupForgetLocally } from './workspace-cleanup-forget-locally-button' import { formatVanishedSelectionNotice, getDefaultSelectedWorkspaceCleanupIdentities, toggleSetMember } from './workspace-cleanup-selection-model' -/** One filterable list of every workspace Orca knows about. */ export default function WorkspaceCleanupDialog(): React.JSX.Element | null { const lifecycle = useWorkspaceCleanupDialogLifecycle() if (!lifecycle.mountedContent) { @@ -79,8 +79,6 @@ function WorkspaceCleanupDialogContent({ useShallow(selectWorkspaceCleanupDeletionPhases) ) 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. const [facetPanelOpen, setFacetPanelOpen] = useState(false) const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [rowsScrollElement, setRowsScrollElement] = useState(null) @@ -175,7 +173,9 @@ function WorkspaceCleanupDialogContent({ 'components.workspace.cleanup.browse.measureSizesFailed', 'Could not scan workspace sizes' ), - { description: scanError instanceof Error ? scanError.message : String(scanError) } + { + description: scanError instanceof Error ? scanError.message : String(scanError) + } ) }) }, [mountedRef, refreshWorkspaceSpace]) @@ -254,8 +254,6 @@ function WorkspaceCleanupDialogContent({ if (loading) { return } - // 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() }, [candidateIdentities, deletingIdentities, loading]) @@ -313,13 +311,9 @@ function WorkspaceCleanupDialogContent({ [selectableIdentities, setSelectedIds] ) - // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow - // instances from re-rendering on scan stream-in and selection changes. const openConfirmRemove = removal.openConfirmRemove const handleRemoveRow = useCallback( (candidate: WorkspaceCleanupCandidate) => { - // Why: rows stay actionable while a rescan streams; the confirm-time - // preflight is the safety gate, not the scan state. if (removalInFlightRef.current) { return } @@ -329,7 +323,6 @@ function WorkspaceCleanupDialogContent({ }, [openConfirmRemove, removalInFlightRef, setSelectedIds] ) - const handleViewCandidate = useCallback( (candidate: WorkspaceCleanupCandidate) => { markCandidateViewed(candidate) @@ -364,9 +357,6 @@ function WorkspaceCleanupDialogContent({ loading={loading} scannedAt={scan?.scannedAt ?? null} scanProgress={scanProgress} - // Why: leaving the progress view no longer closes the dialog, so the - // list is reachable mid-batch; a second batch would silently no-op. - // A streaming rescan does NOT gate deletion — preflight does. deleteDisabled={ selectedCount === 0 || removal.removalProgress !== null || removal.removalInFlight } @@ -421,6 +411,8 @@ function WorkspaceCleanupDialogContent({ onView={handleViewCandidate} onIgnore={ignoreCandidate} onRemove={handleRemoveRow} + onForgetLocally={openWorkspaceCleanupForgetLocally} + onDeleteAnyway={removal.confirmUnverifiedRemoval} /> diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts index 43093a10c8c..0bf71f3aee8 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts @@ -14,6 +14,8 @@ import { type WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' +import { createWorkspaceCleanupSnapshotPruneBatch } from './workspace-cleanup-snapshot-prune-batch' +import { useWorkspaceCleanupUnverifiedRemoval } from './use-workspace-cleanup-unverified-removal' export type WorkspaceCleanupRemovalController = { confirming: boolean @@ -24,11 +26,12 @@ export type WorkspaceCleanupRemovalController = { /** 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 + rowFailures: Record resetRowFailures: () => void resetForReopen: () => void openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void confirmRemove: () => void + confirmUnverifiedRemoval: (candidate: WorkspaceCleanupCandidate) => void cancelConfirmRemove: () => void backToList: () => void } @@ -60,7 +63,7 @@ export function useWorkspaceCleanupRemoval({ const [deletionPhaseByIdentity, setDeletionPhaseByIdentity] = useState< Record >({}) - const [rowFailures, setRowFailures] = useState>({}) + const [rowFailures, setRowFailures] = useState>({}) const removalInFlightRef = useRef(false) // Why: the dialog stays mounted across cleanup runs, so late settlements from // an earlier batch must not mutate a newer batch's row state. @@ -93,6 +96,13 @@ export function useWorkspaceCleanupRemoval({ [clearWorktreeDeleteState] ) + const confirmUnverifiedRemoval = useWorkspaceCleanupUnverifiedRemoval({ + setRowFailures, + setDeletionPhaseByIdentity, + clearQueuedDeleteState, + onDeselect + }) + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { const nextCandidates = filterWorkspaceCleanupRemovalCandidates( candidates, @@ -174,22 +184,7 @@ export function useWorkspaceCleanupRemoval({ removalInFlightRef.current = false } try { - const beginSnapshotPruneBatch = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch - const recordSnapshotPrune = window.api.workspaceCleanup.recordRemovalSnapshotPrune - const finishSnapshotPruneBatch = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch - const snapshotPruneBatch = - typeof beginSnapshotPruneBatch === 'function' && - typeof recordSnapshotPrune === 'function' && - typeof finishSnapshotPruneBatch === 'function' - ? (() => { - const batchId = crypto.randomUUID() - return { - batchId, - begin: () => beginSnapshotPruneBatch({ batchId }), - finish: () => finishSnapshotPruneBatch({ batchId }) - } - })() - : undefined + const snapshotPruneBatch = createWorkspaceCleanupSnapshotPruneBatch() startWorkspaceCleanupBackgroundRemoval({ candidates: removableCandidates, removeCandidates, @@ -215,9 +210,9 @@ export function useWorkspaceCleanupRemoval({ }) }, onResult: (result) => { - const nextFailures: Record = {} + const nextFailures: Record = {} for (const failure of result.failures) { - nextFailures[getWorkspaceCleanupFailureIdentity(failure)] = failure.message + nextFailures[getWorkspaceCleanupFailureIdentity(failure)] = failure // Why: defensively covers failures that never reached onRowFailed. clearQueuedDeleteState(failure.worktreeId, failure.executionHostId) } @@ -243,7 +238,7 @@ export function useWorkspaceCleanupRemoval({ delete next[identity] } for (const failure of result.failures) { - next[getWorkspaceCleanupFailureIdentity(failure)] = failure.message + next[getWorkspaceCleanupFailureIdentity(failure)] = failure } return next }) @@ -277,6 +272,7 @@ export function useWorkspaceCleanupRemoval({ resetForReopen, openConfirmRemove, confirmRemove, + confirmUnverifiedRemoval, cancelConfirmRemove, backToList } diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts new file mode 100644 index 00000000000..844155e332e --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts @@ -0,0 +1,146 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '@/store' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupCandidateIdentity, + getWorkspaceCleanupHostIdentity, + resolveWorkspaceCleanupRemovalHostId +} from '../../../../shared/workspace-cleanup-host-identity' +import type { + WorkspaceCleanupFailure, + WorkspaceCleanupRemoveResult +} from '@/store/slices/workspace-cleanup' +import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal' +import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' +import { createWorkspaceCleanupSnapshotPruneBatch } from './workspace-cleanup-snapshot-prune-batch' + +type UnverifiedRemovalArgs = { + setRowFailures: Dispatch>> + setDeletionPhaseByIdentity: Dispatch< + SetStateAction> + > + clearQueuedDeleteState: ( + worktreeId: string, + executionHostId?: WorkspaceCleanupFailure['executionHostId'] + ) => void + onDeselect: (removedIdentities: readonly string[]) => void +} + +export function useWorkspaceCleanupUnverifiedRemoval({ + setRowFailures, + setDeletionPhaseByIdentity, + clearQueuedDeleteState, + onDeselect +}: UnverifiedRemovalArgs): (candidate: WorkspaceCleanupCandidate) => void { + const beginConsent = useAppStore((state) => state.beginUnverifiedRemovalConsent) + const removeCandidates = useAppStore((state) => state.removeWorkspaceCleanupCandidates) + const markQueued = useAppStore((state) => state.markWorktreesQueuedForDeletion) + const mountedRef = useMountedRef() + + return useCallback( + (candidate) => { + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const attemptId = beginConsent(identity) + if (!attemptId) { + return + } + const hostId = resolveWorkspaceCleanupRemovalHostId(candidate) + setRowFailures((current) => withoutIdentity(current, identity)) + setDeletionPhaseByIdentity((current) => ({ ...current, [identity]: 'queued' })) + markQueued([hostId ? { id: candidate.worktreeId, hostId } : candidate.worktreeId]) + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates, + snapshotPruneBatch: createWorkspaceCleanupSnapshotPruneBatch(), + getRemoveOptions: () => ({ + unverifiedRemovalConsent: { identity, attemptId } + }), + onProgress: () => { + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => ({ ...current, [identity]: 'deleting' })) + } + }, + onRowFailed: (failure) => + clearQueuedDeleteState(failure.worktreeId, failure.executionHostId), + onResult: (result) => { + clearQueuedDeleteState(candidate.worktreeId, hostId ?? undefined) + if (mountedRef.current) { + applyResult(result, identity, setRowFailures, setDeletionPhaseByIdentity, onDeselect) + } + }, + onLateResult: (result) => { + if (mountedRef.current) { + applyLateResult(result, setRowFailures, onDeselect) + } + }, + onError: () => { + clearQueuedDeleteState(candidate.worktreeId, hostId ?? undefined) + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => withoutIdentity(current, identity)) + } + } + }) + }, + [ + beginConsent, + clearQueuedDeleteState, + markQueued, + mountedRef, + onDeselect, + removeCandidates, + setDeletionPhaseByIdentity, + setRowFailures + ] + ) +} + +function applyResult( + result: WorkspaceCleanupRemoveResult, + identity: string, + setFailures: UnverifiedRemovalArgs['setRowFailures'], + setPhases: UnverifiedRemovalArgs['setDeletionPhaseByIdentity'], + onDeselect: UnverifiedRemovalArgs['onDeselect'] +): void { + setPhases((current) => withoutIdentity(current, identity)) + setFailures((current) => addFailures(withoutIdentity(current, identity), result.failures)) + onDeselect(result.removedIdentities) +} + +function applyLateResult( + result: WorkspaceCleanupRemoveResult, + setFailures: UnverifiedRemovalArgs['setRowFailures'], + onDeselect: UnverifiedRemovalArgs['onDeselect'] +): void { + setFailures((current) => { + let next = current + for (const identity of result.removedIdentities) { + next = withoutIdentity(next, identity) + } + return addFailures(next, result.failures) + }) + onDeselect(result.removedIdentities) +} + +function addFailures( + current: Record, + failures: readonly WorkspaceCleanupFailure[] +): Record { + const next = { ...current } + for (const failure of failures) { + next[getWorkspaceCleanupFailureIdentity(failure)] = failure + } + return next +} + +function withoutIdentity(current: Record, identity: string): Record { + const next = { ...current } + delete next[identity] + return next +} + +function getWorkspaceCleanupFailureIdentity(failure: WorkspaceCleanupFailure): string { + return failure.executionHostId + ? getWorkspaceCleanupHostIdentity(failure.executionHostId, failure.worktreeId) + : getWorkspaceCleanupCandidateIdentity({ worktreeId: failure.worktreeId }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts index 1c45ddbc7ad..107981b5ef3 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { normalizeWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' import { hasActiveWorkspaceCleanupFilters, listActiveWorkspaceCleanupFacetGroups @@ -35,4 +36,14 @@ describe('listActiveWorkspaceCleanupFacetGroups', () => { expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([]) expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(true) }) + + it('drops retired verdict filters and recovers safety as inactive', () => { + const browse = normalizeWorkspaceCleanupBrowseState({ + filters: { safety: { tiers: ['ready'], selectableOnly: true } }, + sort: { field: 'tier', direction: 'desc' } + }) + + expect(listActiveWorkspaceCleanupFacetGroups(browse.filters)).not.toContain('safety') + expect(browse.sort).toEqual({ field: 'last-activity', direction: 'desc' }) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 0702e40a10f..1fd04777c39 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -56,6 +56,7 @@ export type WorkspaceCleanupBackgroundRemovalArgs = { begin: () => Promise finish: () => Promise } + getRemoveOptions?: (candidate: WorkspaceCleanupCandidate) => WorkspaceCleanupRemoveOptions } export function startWorkspaceCleanupBackgroundRemoval({ @@ -68,7 +69,8 @@ export function startWorkspaceCleanupBackgroundRemoval({ onRowFailed, removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS, removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS, - snapshotPruneBatch + snapshotPruneBatch, + getRemoveOptions }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { try { @@ -79,7 +81,6 @@ export function startWorkspaceCleanupBackgroundRemoval({ return } - const count = candidates.length const removedIds: string[] = [] const removedIdentities: string[] = [] const failures: WorkspaceCleanupFailure[] = [] @@ -95,7 +96,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const emitProgress = (): void => { onProgress({ - totalCount: count, + totalCount: candidates.length, processedCount, removedCount: removedIds.length, failedCount: failures.length @@ -199,6 +200,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate], + ...getRemoveOptions?.(candidate), ...(snapshotPruneBatchActive && snapshotPruneBatch ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } : {}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts index 7ed376b10b6..256646080bf 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - getCandidateFactStatus, + getCandidateFactStatuses, getDirtyGitLabel, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' @@ -8,26 +8,36 @@ import { makeCandidate } from './workspace-cleanup-presentation-fixtures' describe('workspace cleanup candidate row data', () => { it('shows facts instead of cleanup policy tiers', () => { - expect(getCandidateFactStatus(makeCandidate({ tier: 'ready' }))).toBeNull() - expect(getCandidateFactStatus(makeCandidate({ tier: 'review' }))).toBeNull() - expect( - getCandidateFactStatus(makeCandidate({ tier: 'ready', reasons: ['archived'] })) - ).toMatchObject({ label: 'Archived' }) + expect(getCandidateFactStatuses(makeCandidate({ tier: 'ready' }))).toEqual([]) + expect(getCandidateFactStatuses(makeCandidate({ tier: 'review' }))).toEqual([]) + expect(getCandidateFactStatuses(makeCandidate({ reasons: ['archived'] }))).toContainEqual( + expect.objectContaining({ label: 'Archived' }) + ) }) it('does not duplicate git status blockers as a separate git icon label', () => { const gitStatusError = makeCandidate({ blockers: ['git-status-error'], - git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } }) const unknownBase = makeCandidate({ blockers: ['unknown-base'], - git: { clean: true, upstreamAhead: null, upstreamBehind: null, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: 1 + } }) - expect(getDirtyGitLabel(gitStatusError)).toBeNull() + expect(getDirtyGitLabel(gitStatusError)).toBe('Git status check failed') expect(shouldShowGitMetadataChip(gitStatusError)).toBe(false) - expect(getDirtyGitLabel(unknownBase)).toBeNull() + expect(getDirtyGitLabel(unknownBase)).toBe('Git status could not be verified') expect(shouldShowGitMetadataChip(unknownBase)).toBe(false) }) @@ -35,7 +45,12 @@ describe('workspace cleanup candidate row data', () => { expect( shouldShowGitMetadataChip( makeCandidate({ - git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(true) @@ -43,14 +58,31 @@ describe('workspace cleanup candidate row data', () => { it('keeps unpushed risk visible for archived rows', () => { expect( - getCandidateFactStatus( + getCandidateFactStatuses( makeCandidate({ tier: 'review', reasons: ['archived'], - git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } }) ) - ).toMatchObject({ label: 'Unpushed commits' }) + ).toContainEqual(expect.objectContaining({ label: 'Unpushed commits' })) + }) + + it('renders every blocker fact instead of only the first', () => { + expect( + getCandidateFactStatuses(makeCandidate({ blockers: ['pinned', 'git-status-error'] })) + ).toEqual([ + expect.objectContaining({ label: 'Pinned' }), + expect.objectContaining({ + label: 'Git status unavailable', + tone: 'destructive' + }) + ]) }) it('suppresses the git metadata chip when the status pill already names git risk', () => { @@ -58,7 +90,12 @@ describe('workspace cleanup candidate row data', () => { shouldShowGitMetadataChip( makeCandidate({ blockers: ['unpushed-commits'], - git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(false) @@ -67,7 +104,12 @@ describe('workspace cleanup candidate row data', () => { makeCandidate({ tier: 'review', blockers: [], - git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(false) @@ -75,7 +117,12 @@ describe('workspace cleanup candidate row data', () => { shouldShowGitMetadataChip( makeCandidate({ blockers: ['dirty-files'], - git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(false) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index 42a7d0c58a5..e292f523520 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -23,50 +23,56 @@ export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCand return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker)) } -export function getCandidateFactStatus(candidate: WorkspaceCleanupCandidate): { +export function getCandidateFactStatuses(candidate: WorkspaceCleanupCandidate): { label: string tone: StatusPillTone -} | null { - if (candidate.blockers.includes('dismissed')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', - 'Ignored' - ), - tone: 'neutral' - } - } +}[] { if (candidate.blockers.length > 0) { - return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } + return candidate.blockers.map((blocker) => ({ + label: + blocker === 'dismissed' + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', + 'Ignored' + ) + : getWorkspaceCleanupBlockerLabel(blocker), + tone: blocker === 'git-status-error' || blocker === 'unknown-base' ? 'destructive' : 'neutral' + })) } if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', - 'Unpushed commits' - ), - tone: 'review' - } + return [ + { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', + 'Unpushed commits' + ), + tone: 'review' + } + ] } if (candidate.git.clean === false) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', - 'Dirty' - ), - tone: 'review' - } + return [ + { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', + 'Dirty' + ), + tone: 'review' + } + ] } if (candidate.reasons.includes('archived')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', - 'Archived' - ), - tone: 'neutral' - } + return [ + { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', + 'Archived' + ), + tone: 'neutral' + } + ] } - return null + return [] } export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { @@ -114,11 +120,17 @@ export function formatContextDetails(candidate: WorkspaceCleanupCandidate): stri } export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { - if ( - candidate.blockers.includes('unknown-base') || - candidate.blockers.includes('git-status-error') - ) { - return null + if (candidate.blockers.includes('git-status-error')) { + return translate( + 'components.workspace.cleanup.browse.gitStatusCheckFailed', + 'Git status check failed' + ) + } + if (candidate.blockers.includes('unknown-base')) { + return translate( + 'components.workspace.cleanup.browse.gitStatusUnverified', + 'Git status could not be verified' + ) } if (candidate.blockers.includes('unpushed-commits')) { if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { @@ -139,21 +151,19 @@ export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | } export function shouldShowGitMetadataChip(candidate: WorkspaceCleanupCandidate): boolean { - return ( - !candidate.blockers.includes('unknown-base') && - !candidate.blockers.includes('git-status-error') && - !hasGitStatusPill(candidate) - ) + return !hasGitStatusPill(candidate) } function hasGitStatusPill(candidate: WorkspaceCleanupCandidate): boolean { if ( candidate.blockers.includes('dirty-files') || - candidate.blockers.includes('unpushed-commits') + candidate.blockers.includes('unpushed-commits') || + candidate.blockers.includes('unknown-base') || + candidate.blockers.includes('git-status-error') ) { return true } - if (candidate.blockers.length > 0 || candidate.tier === 'ready') { + if (candidate.blockers.length > 0) { return false } return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.git.clean === false diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index d5a7416914e..bec347d570d 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -33,7 +33,7 @@ import { formatBranchSafetyDetails, formatContextDetails, formatGitStatus, - getCandidateFactStatus, + getCandidateFactStatuses, getContextCount, getDirtyGitLabel, getReviewPillTone, @@ -42,10 +42,13 @@ import { } from './workspace-cleanup-candidate-row-data' import { StatusPill } from './workspace-cleanup-status-pill' import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip' +import { WorkspaceCleanupForgetLocallyButton } from './workspace-cleanup-forget-locally-button' import { getWorkspaceCleanupCandidateAccessibleName, getWorkspaceCleanupCandidateHostLabel } from './workspace-cleanup-host-label' +import { formatCompactActivityLabel, getReviewTooltip } from './workspace-cleanup-row-labels' +import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup' export type WorkspaceCleanupDeletionPhase = 'deleting' | 'queued' @@ -55,7 +58,7 @@ type CandidateRowProps = { identity: string deletionPhase?: WorkspaceCleanupDeletionPhase expanded: boolean - failure?: string + failure?: WorkspaceCleanupFailure /** A focused git re-scan is in flight, so "Not checked" is provisional. */ gitEvidencePending?: boolean last: boolean @@ -69,6 +72,8 @@ type CandidateRowProps = { selected: boolean onIgnore: (candidate: WorkspaceCleanupCandidate) => void onRemove: (candidate: WorkspaceCleanupCandidate) => void + onForgetLocally?: (candidate: WorkspaceCleanupCandidate) => void + onDeleteAnyway?: (candidate: WorkspaceCleanupCandidate) => void onToggleExpanded: (identity: string) => void onToggleSelected: (identity: string) => void onView: (candidate: WorkspaceCleanupCandidate) => void @@ -95,6 +100,8 @@ export const CandidateRow = React.memo(function CandidateRow({ selected, onIgnore, onRemove, + onForgetLocally, + onDeleteAnyway, onToggleExpanded, onToggleSelected, onView @@ -107,7 +114,7 @@ export const CandidateRow = React.memo(function CandidateRow({ const blockers = getWorkspaceCleanupBlockerLabels(candidate) const contextDetails = formatContextDetails(candidate) const branchSafetyDetails = formatBranchSafetyDetails(candidate) - const factStatus = getCandidateFactStatus(candidate) + const factStatuses = getCandidateFactStatuses(candidate) const dirtyLabel = getDirtyGitLabel(candidate) const gitLabel = getWorkspaceCleanupGitLabel(candidate) const showGitMetadataChip = shouldShowGitMetadataChip(candidate) @@ -178,9 +185,13 @@ export const CandidateRow = React.memo(function CandidateRow({ 'Deleting…' )} - ) : factStatus ? ( - {factStatus.label} - ) : null} + ) : ( + factStatuses.map((status) => ( + + {status.label} + + )) + )} {workspaceStatusLabel ? ( - {failure} + {failure.message} + {failure.canDeleteAnyway && onDeleteAnyway ? ( + + ) : null} ) : null} @@ -377,26 +398,11 @@ export const CandidateRow = React.memo(function CandidateRow({ ) : null} + {candidate.blockers.includes('ssh-disconnected') && onForgetLocally ? ( + + ) : null} ) }) - -function formatCompactActivityLabel(label: string): string { - if (label === 'Just now') { - return 'now' - } - return label.replace(/ ago$/, '') -} - -function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { - const parts = [reviewInfo.label] - if (reviewInfo.state) { - parts.push(reviewInfo.state) - } - if (reviewInfo.title) { - parts.push(reviewInfo.title) - } - return parts.filter(Boolean).join(' · ') -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx index 0a7014cf1b2..00e9c3469d2 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx @@ -115,4 +115,45 @@ describe('WorkspaceCleanupConfirmRemove', () => { expect(container.querySelector(`[aria-label="Host: ${hostLabel}"]`)).not.toBeNull() } }) + + it('shows every destructive git risk and the fleet-level force projection', () => { + const failed = makeFacetCandidate({ + worktreeId: 'repo-1::/failed', + blockers: ['pinned', 'git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + const unknownBase = makeFacetCandidate({ + worktreeId: 'repo-1::/unknown-base', + blockers: ['unknown-base'] + }) + + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain('Pinned') + expect(container.textContent).toContain('Git status unavailable') + expect(container.textContent).toContain('Could not verify unpushed commits') + expect(container.textContent).toContain('2 currently show risk and may need a force delete') + expect( + [...container.querySelectorAll('.text-destructive')].map((node) => node.textContent) + ).toEqual( + expect.arrayContaining(['Git status unavailable', 'Could not verify unpushed commits']) + ) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index ee068fe3f21..b927afbaa65 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -6,10 +6,13 @@ import { Progress } from '@/components/ui/progress' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + shouldForceWorkspaceCleanupRemoval, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' import { - getCandidateFactStatus, + getCandidateFactStatuses, getContextPillLabel, getDirtyGitLabel, getReviewPillTone, @@ -60,6 +63,7 @@ export function WorkspaceCleanupConfirmRemove({ }): React.JSX.Element { const [scrollElement, setScrollElement] = React.useState(null) const count = candidates.length + const riskCount = candidates.filter(shouldForceWorkspaceCleanupRemoval).length const deleting = progress !== null const progressValue = progress ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) @@ -136,6 +140,15 @@ export function WorkspaceCleanupConfirmRemove({ { value0: count } )} + {riskCount > 0 ? ( +
+ {translate( + 'components.workspace.cleanup.browse.forceDeleteProjection', + '{{value0}} currently show risk and may need a force delete', + { value0: riskCount } + )} +
+ ) : null} - {factStatus ? {factStatus.label} : null} + {factStatuses.map((status) => ( + + {status.label} + + ))} {reviewInfo.label ? ( {reviewInfo.label} ) : null} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts index 5131f2f28f9..7d81a027ed0 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts @@ -26,6 +26,7 @@ import { queryWorkspaceCleanupCandidates } from './workspace-cleanup-query' import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' +import { canQueueWorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' function filters(patch: (state: WorkspaceCleanupFilterState) => void): WorkspaceCleanupFilterState { const state = createDefaultWorkspaceCleanupFilterState() @@ -100,7 +101,10 @@ describe('facet building', () => { it('keeps same-id worktrees and sizes isolated by host', () => { const candidates = [ makeFacetCandidate({ executionHostId: 'local' }), - makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + makeFacetCandidate({ + executionHostId: 'ssh:builder', + connectionId: 'builder' + }) ] const sizes = buildWorkspaceCleanupSizeIndex( [ @@ -130,7 +134,10 @@ describe('facet building', () => { it('does not apply a legacy size to duplicate candidate ids', () => { const candidates = [ makeFacetCandidate({ executionHostId: 'local' }), - makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + makeFacetCandidate({ + executionHostId: 'ssh:builder', + connectionId: 'builder' + }) ] const sizes = buildWorkspaceCleanupSizeIndex( [{ worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 10 }], @@ -213,7 +220,9 @@ describe('activity filter', () => { }) it('separates background activity from a real visit', () => { - const busyButUnopened = makeFacets({ candidate: { lastActivityAt: FACET_NOW } }) + const busyButUnopened = makeFacets({ + candidate: { lastActivityAt: FACET_NOW } + }) const state = filters((s) => { s.safety.dismissed = 'any' s.activity.neverVisited = true @@ -333,7 +342,9 @@ describe('agent, git, review, ticket, context, and location filters', () => { it('filters by git state, ahead counts, branch text, prunable and locked', () => { const unpushed = makeFacets({ - candidate: { git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } } + candidate: { + git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } + } }) expect(unpushed.gitState).toBe('unpushed') const state = filters((s) => { @@ -366,7 +377,9 @@ describe('agent, git, review, ticket, context, and location filters', () => { }) it('keeps review filtering provider-general and treats draft as a state', () => { - const draftMr = makeFacets({ review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } }) + const draftMr = makeFacets({ + review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } + }) const state = filters((s) => { s.safety.dismissed = 'any' s.review.presence = 'some' @@ -402,7 +415,10 @@ describe('agent, git, review, ticket, context, and location filters', () => { it('filters by local context and completely-empty', () => { const busy = makeFacets({ candidate: { - localContext: { ...makeFacetCandidate().localContext, terminalTabCount: 2 } + localContext: { + ...makeFacetCandidate().localContext, + terminalTabCount: 2 + } } }) const hasContext = filters((s) => { @@ -473,23 +489,90 @@ describe('safety filter', () => { expect(matchesWorkspaceCleanupFilters(dismissed, only, FACET_NOW)).toBe(true) expect(matchesWorkspaceCleanupFilters(makeFacets(), only, FACET_NOW)).toBe(false) }) - - it('restricts to rows the policy would actually let the user delete', () => { - const blocked = makeFacets({ candidate: { blockers: ['dirty-files'], tier: 'review' } }) - const state = filters((s) => { - s.safety.dismissed = 'any' - s.safety.selectableOnly = true - }) - expect(matchesWorkspaceCleanupFilters(blocked, state, FACET_NOW)).toBe(false) - expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true) - }) }) describe('query pipeline', () => { + it('bulk-selects label rows while preserving explicit exclusions', () => { + const candidates = [ + makeFacetCandidate({ + worktreeId: 'repo-1::/dirty', + blockers: ['dirty-files'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/pinned', + blockers: ['pinned'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/unknown', + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/active', + blockers: ['active-workspace'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/agent', + blockers: ['live-agent'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/ignored', + blockers: ['dismissed'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/main', + blockers: ['main-worktree'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/folder', + blockers: ['folder-repo'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/remote', + blockers: ['ssh-disconnected'] + }) + ] + const result = queryWorkspaceCleanupCandidates( + candidates, + { + filters: createDefaultWorkspaceCleanupFilterState(), + sort: { field: 'name', direction: 'asc' } + }, + {}, + FACET_NOW + ) + const selectableIds = result.rows + .filter((row) => result.selectableIdentities.includes(row.identity)) + .map((row) => row.worktreeId) + + expect(selectableIds).toEqual( + expect.arrayContaining(['repo-1::/dirty', 'repo-1::/pinned', 'repo-1::/unknown']) + ) + expect(selectableIds).not.toEqual( + expect.arrayContaining([ + 'repo-1::/active', + 'repo-1::/agent', + 'repo-1::/ignored', + 'repo-1::/main', + 'repo-1::/folder', + 'repo-1::/remote' + ]) + ) + expect(canQueueWorkspaceCleanupCandidate(candidates[3])).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(candidates[4])).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(candidates[5])).toBe(true) + }) + it('reports counts and selectable ids', () => { const rows = [ makeNamedFacets('alpha'), - makeNamedFacets('beta', { candidate: { blockers: ['dirty-files'], tier: 'review' } }) + makeNamedFacets('beta', { + candidate: { blockers: ['dirty-files'], tier: 'review' } + }) ] expect(filterWorkspaceCleanupFacets(rows, ANY, FACET_NOW)).toHaveLength(2) const result = queryWorkspaceCleanupCandidates( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts index 9ea2cd09adf..98f09a577d2 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts @@ -1,6 +1,5 @@ import { translate } from '@/i18n/i18n' import type { HostedReviewProvider } from '../../../../shared/hosted-review' -import type { WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupAgentState, WorkspaceCleanupBlockerMode, @@ -49,8 +48,6 @@ export function getWorkspaceCleanupSortFieldLabel(field: WorkspaceCleanupSortFie return translate('components.workspace.cleanup.browse.sort.ticket', 'Ticket') case 'local-context': return translate('components.workspace.cleanup.browse.sort.localContext', 'Open context') - case 'tier': - return translate('components.workspace.cleanup.browse.sort.tier', 'Safety') case 'blocker-count': return translate('components.workspace.cleanup.browse.sort.blockerCount', 'Blockers') } @@ -124,17 +121,6 @@ export function getWorkspaceCleanupTicketSourceLabel(source: WorkspaceCleanupTic } } -export function getWorkspaceCleanupTierLabel(tier: WorkspaceCleanupTier): string { - switch (tier) { - case 'ready': - return translate('components.workspace.cleanup.browse.tier.ready', 'Ready') - case 'review': - return translate('components.workspace.cleanup.browse.tier.review', 'Needs review') - case 'protected': - return translate('components.workspace.cleanup.browse.tier.protected', 'Protected') - } -} - export function getWorkspaceCleanupIdleSignalLabel(signal: WorkspaceCleanupIdleSignal): string { switch (signal) { case 'last-visited': diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts index 988a31c1489..d617e7c5ecb 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts @@ -170,15 +170,9 @@ export function matchesWorkspaceCleanupSafety( facets: WorkspaceCleanupFacets, filter: WorkspaceCleanupSafetyFilter ): boolean { - if (filter.tiers.length > 0 && !filter.tiers.includes(facets.tier)) { - return false - } if (!matchesWorkspaceCleanupTriState(filter.dismissed, facets.isDismissed)) { return false } - if (filter.selectableOnly && !facets.isSelectable) { - return false - } if (filter.blockers.length === 0) { return true } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts index 4da525a1352..fda63e1c0de 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts @@ -60,27 +60,41 @@ describe('ranked fields', () => { it('orders git states from safest to riskiest', () => { const rows = [ makeNamedFacets('unpushed', { - candidate: { git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } } + candidate: { + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } + } }), makeNamedFacets('clean'), makeNamedFacets('dirty', { - candidate: { git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } } + candidate: { + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1 + } + } }), makeNamedFacets('unknown', { - candidate: { git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null } } + candidate: { + git: { + clean: null, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: null + } + } }) ] expect(names(rows, 'git', 'asc')).toEqual(['clean', 'unknown', 'dirty', 'unpushed']) }) - it('orders tiers ready to protected and agents idle to permission', () => { - const tierRows = [ - makeNamedFacets('protectedRow', { candidate: { tier: 'protected' } }), - makeNamedFacets('readyRow', { candidate: { tier: 'ready' } }), - makeNamedFacets('reviewRow', { candidate: { tier: 'review' } }) - ] - expect(names(tierRows, 'tier', 'asc')).toEqual(['readyRow', 'reviewRow', 'protectedRow']) - + it('orders agents idle to permission', () => { const agentRows = [ makeNamedFacets('permission', { agentStatus: 'permission' }), makeNamedFacets('idle'), @@ -91,8 +105,12 @@ describe('ranked fields', () => { it('breaks equal blocker counts by the worst blocker', () => { const rows = [ - makeNamedFacets('softest', { candidate: { blockers: ['git-status-error'] } }), - makeNamedFacets('hardest', { candidate: { blockers: ['main-worktree'] } }) + makeNamedFacets('softest', { + candidate: { blockers: ['git-status-error'] } + }), + makeNamedFacets('hardest', { + candidate: { blockers: ['main-worktree'] } + }) ] expect(names(rows, 'blocker-count', 'desc')).toEqual(['hardest', 'softest']) }) @@ -101,8 +119,12 @@ describe('ranked fields', () => { describe('tie-breaks', () => { it('falls back to activity, repo, name, then worktree id', () => { const rows = [ - makeNamedFacets('zeta', { candidate: { lastActivityAt: FACET_NOW - DAY } }), - makeNamedFacets('alpha', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + makeNamedFacets('zeta', { + candidate: { lastActivityAt: FACET_NOW - DAY } + }), + makeNamedFacets('alpha', { + candidate: { lastActivityAt: FACET_NOW - 2 * DAY } + }) ] // Same size on both sides, so only the tie-break chain can order them. expect(names(rows, 'size', 'desc')).toEqual(['alpha', 'zeta']) @@ -111,7 +133,9 @@ describe('tie-breaks', () => { it('keeps tie-broken rows in the same order when direction flips', () => { const rows = [ makeNamedFacets('b', { candidate: { lastActivityAt: FACET_NOW - DAY } }), - makeNamedFacets('a', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + makeNamedFacets('a', { + candidate: { lastActivityAt: FACET_NOW - 2 * DAY } + }) ] expect(names(rows, 'git', 'asc')).toEqual(names(rows, 'git', 'desc')) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts index 50787aa6bd9..354e3c063e0 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts @@ -3,8 +3,7 @@ import { getWorkspaceCleanupAgentRank, getWorkspaceCleanupBlockerSeverity, getWorkspaceCleanupGitRank, - getWorkspaceCleanupReviewRank, - getWorkspaceCleanupTierRank + getWorkspaceCleanupReviewRank } from '../../../../shared/workspace-cleanup-facet-rankings' import type { WorkspaceCleanupSortDirectionState, @@ -161,8 +160,6 @@ function compareNatural( ) case 'local-context': return left.localContextCount - right.localContextCount - case 'tier': - return getWorkspaceCleanupTierRank(left.tier) - getWorkspaceCleanupTierRank(right.tier) case 'blocker-count': return ( left.blockerCount - right.blockerCount || diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts index c52a9a020e6..1df00659f51 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -4,10 +4,10 @@ import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/type import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' import { getWorkspaceStatus } from '../../../../shared/workspace-statuses' import { - canSelectWorkspaceCleanupCandidate, + canQueueWorkspaceCleanupCandidate, + WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS, type WorkspaceCleanupBlocker, - type WorkspaceCleanupCandidate, - type WorkspaceCleanupTier + type WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' import { getWorkspaceCleanupGitState } from './workspace-cleanup-filter-sort' import type { @@ -47,7 +47,6 @@ export type WorkspaceCleanupFacets = { path: string branch: string hostId: ExecutionHostId - tier: WorkspaceCleanupTier blockers: readonly WorkspaceCleanupBlocker[] blockerCount: number isDismissed: boolean @@ -118,14 +117,15 @@ export function buildWorkspaceCleanupFacets( path: candidate.path, branch, hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate), - tier: candidate.tier, blockers: candidate.blockers, blockerCount: candidate.blockers.length, isDismissed: (sources.dismissedIdentities?.has(getWorkspaceCleanupCandidateIdentity(candidate)) ?? false) || candidate.blockers.includes('dismissed'), - isSelectable: canSelectWorkspaceCleanupCandidate(candidate), + isSelectable: + canQueueWorkspaceCleanupCandidate(candidate) && + !candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS.has(blocker)), lastActivityAt: candidate.lastActivityAt, createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt), lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]), @@ -211,7 +211,6 @@ function buildSearchText(facets: Omit): st facets.review.title, facets.review.provider, facets.gitState, - facets.tier, ...facets.ticketSources, ...facets.blockers ] diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx index 6a1610b9763..9550b7b4631 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx @@ -26,10 +26,17 @@ let container: HTMLDivElement | null = null // One row per bucket the retired tab partition used to hide behind a tab. const FLEET: WorkspaceCleanupFacets[] = [ - makeNamedFacets('ready-one', { candidate: { tier: 'ready' }, sizeBytes: 4 * 1024 * 1024 }), + makeNamedFacets('ready-one', { + candidate: { tier: 'ready' }, + sizeBytes: 4 * 1024 * 1024 + }), makeNamedFacets('needs-review', { candidate: { tier: 'review' } }), makeNamedFacets('protected-one', { - candidate: { tier: 'protected', blockers: ['pinned'], selectedByDefault: false } + candidate: { + tier: 'protected', + blockers: ['pinned'], + selectedByDefault: false + } }), makeNamedFacets('ignored-one', { dismissed: true }) ] @@ -110,24 +117,17 @@ describe('workspace cleanup flat list', () => { it('switches the visible rows when filters are applied', () => { const filters = createDefaultWorkspaceCleanupFilterState() - filters.safety.tiers = ['ready'] filters.safety.dismissed = 'exclude' renderRows(query(filters).rows) - expect(renderedNames()).toEqual(['ready-one']) + expect(renderedNames()).not.toContain('ignored-one') - filters.safety.tiers = [] filters.safety.dismissed = 'only' renderRows(query(filters).rows) expect(renderedNames()).toEqual(['ignored-one']) - - filters.safety.dismissed = 'any' - filters.safety.tiers = ['protected'] - renderRows(query(filters).rows) - expect(renderedNames()).toEqual(['protected-one']) }) - it('reports only the rows the user could actually queue for deletion', () => { - expect(query().selectableIdentities).not.toContain( + it('lets select-all include pinned label rows', () => { + expect(query().selectableIdentities).toContain( getWorkspaceCleanupHostIdentity('local', 'repo-1::/repo/protected-one') ) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx new file mode 100644 index 00000000000..a139df881a6 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx @@ -0,0 +1,43 @@ +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { resolveWorkspaceCleanupRemovalHostId } from '../../../../shared/workspace-cleanup-host-identity' +import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' + +export function openWorkspaceCleanupForgetLocally(candidate: WorkspaceCleanupCandidate): void { + runWorktreeDelete(candidate.worktreeId, { + expectedHostId: resolveWorkspaceCleanupRemovalHostId(candidate) ?? undefined + }) +} + +export function WorkspaceCleanupForgetLocallyButton({ + candidate, + onForget +}: { + candidate: WorkspaceCleanupCandidate + onForget: (candidate: WorkspaceCleanupCandidate) => void +}): React.JSX.Element { + const label = translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.forget', + 'Remove from Orca' + ) + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx index 3db2300c4c5..337c164a2ba 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx @@ -8,12 +8,10 @@ import { WORKSPACE_CLEANUP_PRESENCE_VALUES, WORKSPACE_CLEANUP_REVIEW_STATE_VALUES, WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES, - WORKSPACE_CLEANUP_TIER_VALUES, WORKSPACE_CLEANUP_TRI_STATE_VALUES } from '../../../../shared/workspace-cleanup-facet-rankings' import { getWorkspaceCleanupBlockerLabel } from './workspace-cleanup-candidate-labels' import { - FacetCheckbox, FacetChoice, FacetNumberField, FacetSection, @@ -27,7 +25,6 @@ import { getWorkspaceCleanupReviewProviderLabel, getWorkspaceCleanupReviewStateLabel, getWorkspaceCleanupTicketSourceLabel, - getWorkspaceCleanupTierLabel, getWorkspaceCleanupTriStateLabel } from './workspace-cleanup-facet-labels' import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' @@ -166,13 +163,6 @@ export function WorkspaceCleanupGitReviewFacets({ matchCount={counts.safety} totalCount={totalCount} > - onPatch('safety', { tiers })} - /> onPatch('safety', { dismissed })} /> - onPatch('safety', { selectableOnly })} - /> ) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts index 17f5f2f7c05..3abf167b57e 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts @@ -3,7 +3,10 @@ import { translate } from '@/i18n/i18n' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + applyWorkspaceCleanupPolicy, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupFilters } from './workspace-cleanup-presentation' export const NOW = 1_700_000_000_000 @@ -27,7 +30,7 @@ export const DEFAULT_FILTERS: WorkspaceCleanupFilters = { export function makeCandidate( overrides: Partial = {} ): WorkspaceCleanupCandidate { - return { + return applyWorkspaceCleanupPolicy({ worktreeId: 'repo-1::/repo/alpha', repoId: 'repo-1', repoName: 'Repo', @@ -35,8 +38,6 @@ export function makeCandidate( displayName: 'alpha', branch: 'alpha', path: '/repo/alpha', - tier: 'ready', - selectedByDefault: true, reasons: ['idle-clean'], blockers: [], lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, @@ -56,7 +57,7 @@ export function makeCandidate( }, fingerprint: 'fingerprint', ...overrides - } + }) } export function makeWorktree(overrides: Partial = {}): Worktree { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts new file mode 100644 index 00000000000..887cb1c17d0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts @@ -0,0 +1,9 @@ +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' + +export function formatCompactActivityLabel(label: string): string { + return label === 'Just now' ? 'now' : label.replace(/ ago$/, '') +} + +export function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { + return [reviewInfo.label, reviewInfo.state, reviewInfo.title].filter(Boolean).join(' · ') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx index 6c1da8faed2..fe81bf57e08 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx @@ -7,6 +7,7 @@ import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-lis import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices' import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' +import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup' export type WorkspaceCleanupRowListState = { rows: readonly WorkspaceCleanupFacets[] @@ -23,7 +24,7 @@ export type WorkspaceCleanupRowListState = { selectedIds: ReadonlySet gitPendingWorktreeIds: ReadonlySet /** Keyed by host-qualified identity so a failure marks only its own host's row. */ - rowFailures: Record + rowFailures: Record scrollElement: HTMLDivElement | null onClearFilters: () => void onToggleExpanded: (identity: string) => void @@ -31,6 +32,8 @@ export type WorkspaceCleanupRowListState = { onView: (candidate: WorkspaceCleanupCandidate) => void onIgnore: (candidate: WorkspaceCleanupCandidate) => void onRemove: (candidate: WorkspaceCleanupCandidate) => void + onForgetLocally: (candidate: WorkspaceCleanupCandidate) => void + onDeleteAnyway: (candidate: WorkspaceCleanupCandidate) => void } export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): React.JSX.Element { @@ -92,6 +95,8 @@ export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): Re onView={props.onView} onIgnore={props.onIgnore} onRemove={props.onRemove} + onForgetLocally={props.onForgetLocally} + onDeleteAnyway={props.onDeleteAnyway} /> )} /> diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts new file mode 100644 index 00000000000..b633f610d93 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts @@ -0,0 +1,18 @@ +import type { WorkspaceCleanupBackgroundRemovalArgs } from './workspace-cleanup-background-removal' + +export function createWorkspaceCleanupSnapshotPruneBatch(): + | WorkspaceCleanupBackgroundRemovalArgs['snapshotPruneBatch'] + | undefined { + const begin = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch + const record = window.api.workspaceCleanup.recordRemovalSnapshotPrune + const finish = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch + if (typeof begin !== 'function' || typeof record !== 'function' || typeof finish !== 'function') { + return undefined + } + const batchId = crypto.randomUUID() + return { + batchId, + begin: () => begin({ batchId }), + finish: () => finish({ batchId }) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7f559834060..f924a55d6d1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -303,7 +303,10 @@ "9d6e531da6": "Workspace no longer exists.", "changedSinceConfirmation": "Workspace changed after confirmation. Refresh to review it before removing.", "hostCollision": "Error: this workspace exists on multiple hosts at the same path", - "hostUnresolved": "Orca cannot tell which host owns this workspace. Refresh projects and review it again." + "hostUnresolved": "Orca cannot tell which host owns this workspace. Refresh projects and review it again.", + "gitStatusUnavailable": "Orca couldn't check this workspace's git status. Try again, or delete it from its host-specific sidebar or project list.", + "gitStatusUnavailableOlderPeer": "Orca couldn't match this git-status failure to a host. Update the older connected peer, or delete the workspace from its host-specific sidebar or project list.", + "forceNeedsApproval": "Review and confirm this workspace before force deleting it." } }, "worktrees": { @@ -16226,7 +16229,11 @@ "measuringSizesProgress": "Scanning {{value0}}/{{value1}}", "measuringSizes": "Scanning sizes", "measureSizesFailed": "Could not scan workspace sizes", - "selectedCount": "{{value0}} selected" + "selectedCount": "{{value0}} selected", + "gitStatusCheckFailed": "Git status check failed", + "gitStatusUnverified": "Git status could not be verified", + "deleteAnyway": "Delete anyway", + "forceDeleteProjection": "{{value0}} currently show risk and may need a force delete" }, "scan": { "progress": "Scanning workspaces ({{value0}}/{{value1}})", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a360d1331ec..63cbb420134 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -232,7 +232,10 @@ "workspace": { "cleanup": { "9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다.", - "changedSinceConfirmation": "확인 후 워크스페이스가 변경되었습니다. 제거하기 전에 새로 고쳐 검토하세요." + "changedSinceConfirmation": "확인 후 워크스페이스가 변경되었습니다. 제거하기 전에 새로 고쳐 검토하세요.", + "gitStatusUnavailable": "Orca가 이 워크스페이스의 Git 상태를 확인할 수 없습니다. 다시 시도하거나 호스트별 사이드바 또는 프로젝트 목록에서 삭제하세요.", + "gitStatusUnavailableOlderPeer": "Orca가 이 Git 상태 확인 실패를 호스트와 연결할 수 없습니다. 이전 연결 피어를 업데이트하거나 호스트별 사이드바 또는 프로젝트 목록에서 워크스페이스를 삭제하세요.", + "forceNeedsApproval": "강제로 삭제하기 전에 이 워크스페이스를 검토하고 확인하세요." } }, "worktrees": { @@ -14552,6 +14555,12 @@ "presentation": { "gitlabMergeRequestNumber": "MR #{{value0}}", "githubPullRequestNumber": "PR #{{value0}}" + }, + "browse": { + "gitStatusCheckFailed": "Git 상태 확인 실패", + "gitStatusUnverified": "Git 상태를 확인할 수 없음", + "deleteAnyway": "그래도 삭제", + "forceDeleteProjection": "현재 {{value0}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1774a4afa4b..93ae879b48c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -232,7 +232,10 @@ "workspace": { "cleanup": { "9d6e531da6": "工作区不再存在。", - "changedSinceConfirmation": "工作区在确认后发生了更改。请刷新并在删除前重新检查。" + "changedSinceConfirmation": "工作区在确认后发生了更改。请刷新并在删除前重新检查。", + "gitStatusUnavailable": "Orca 无法检查此工作区的 Git 状态。请重试,或从对应主机的侧边栏或项目列表中将其删除。", + "gitStatusUnavailableOlderPeer": "Orca 无法将此 Git 状态检查失败与主机匹配。请更新较旧的已连接端,或从对应主机的侧边栏或项目列表中删除该工作区。", + "forceNeedsApproval": "请先检查并确认此工作区,然后再强制删除。" } }, "worktrees": { @@ -14543,6 +14546,12 @@ "presentation": { "gitlabMergeRequestNumber": "MR #{{value0}}", "githubPullRequestNumber": "PR #{{value0}}" + }, + "browse": { + "gitStatusCheckFailed": "Git 状态检查失败", + "gitStatusUnverified": "无法验证 Git 状态", + "deleteAnyway": "仍然删除", + "forceDeleteProjection": "当前有 {{value0}} 个工作区显示风险,可能需要强制删除" } } }, diff --git a/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts b/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts index bcd14783882..760678aa26c 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts @@ -86,7 +86,7 @@ describe('workspace cleanup browse slice', () => { const store = createStore(uiSet) const next = createDefaultWorkspaceCleanupBrowseState() - next.filters.safety.tiers = ['review'] + next.filters.safety.blockers = ['dirty-files'] next.sort = { field: 'size', direction: 'desc' } store.getState().updateWorkspaceCleanupBrowseState(next) await vi.advanceTimersByTimeAsync(WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS) diff --git a/src/renderer/src/store/slices/workspace-cleanup-preflight-failures.ts b/src/renderer/src/store/slices/workspace-cleanup-preflight-failures.ts new file mode 100644 index 00000000000..11b3c776468 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-preflight-failures.ts @@ -0,0 +1,106 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanError, + WorkspaceCleanupUnverifiedRemovalConsent +} from '../../../../shared/workspace-cleanup' +import { shouldForceWorkspaceCleanupRemoval } from '../../../../shared/workspace-cleanup' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupFailure } from './workspace-cleanup' + +type PreflightFailureTarget = { + worktreeId: string + executionHostId: ExecutionHostId | null + displayName: string +} + +export function getWorkspaceCleanupMissingFailure( + target: PreflightFailureTarget +): WorkspaceCleanupFailure { + return { + worktreeId: target.worktreeId, + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), + displayName: target.displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.9d6e531da6', + 'Workspace no longer exists.' + ) + } +} + +export function hasWorkspaceCleanupRiskEscalated( + candidate: WorkspaceCleanupCandidate, + approvedCandidate: WorkspaceCleanupCandidate +): boolean { + return ( + (shouldForceWorkspaceCleanupRemoval(candidate) && + !shouldForceWorkspaceCleanupRemoval(approvedCandidate)) || + WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some( + (blocker) => + candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker) + ) + ) +} + +export function hasValidWorkspaceCleanupUnverifiedConsent( + candidateIdentity: string, + consent: WorkspaceCleanupUnverifiedRemovalConsent | undefined, + getConsentAttemptId: ((identity: string) => string | undefined) | undefined +): boolean { + return Boolean( + consent && + consent.identity === candidateIdentity && + getConsentAttemptId?.(consent.identity) === consent.attemptId + ) +} + +export function getWorkspaceCleanupRepoScanFailure( + target: PreflightFailureTarget, + errors: readonly WorkspaceCleanupScanError[] +): WorkspaceCleanupFailure | null { + const error = errors.find( + (entry) => + entry.repoId === getRepoIdFromWorktreeId(target.worktreeId) && + (!entry.executionHostId || + target.executionHostId === null || + entry.executionHostId === target.executionHostId) + ) + if (!error) { + return null + } + return { + worktreeId: target.worktreeId, + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), + displayName: target.displayName, + message: error.executionHostId + ? translate( + 'auto.store.slices.workspace.cleanup.gitStatusUnavailable', + "Orca couldn't check this workspace's git status. Try again, or delete it from its host-specific sidebar or project list." + ) + : translate( + 'auto.store.slices.workspace.cleanup.gitStatusUnavailableOlderPeer', + "Orca couldn't match this git-status failure to a host. Update the older connected peer, or delete the workspace from its host-specific sidebar or project list." + ), + canDeleteAnyway: true + } +} + +export function getWorkspaceCleanupGitUnavailableFailure( + target: PreflightFailureTarget, + candidate: WorkspaceCleanupCandidate +): WorkspaceCleanupFailure { + return { + worktreeId: target.worktreeId, + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), + displayName: candidate.displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.gitStatusUnavailable', + "Orca couldn't check this workspace's git status. Try again, or delete it from its host-specific sidebar or project list." + ), + canDeleteAnyway: true + } +} + +// Unlike unknown-base and git-status-error, these facts prove known work is at risk. +const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts index 6b2d1bbe493..d9e656c3242 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -37,9 +37,7 @@ describe('workspace cleanup removal and protection', () => { worktreeId: 'repo-c::/other', repoId: 'repo-c', path: '/other', - displayName: 'other', - git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }, - blockers: ['git-status-error'] + displayName: 'other' }) ] const candidateById = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate])) @@ -90,7 +88,7 @@ describe('workspace cleanup removal and protection', () => { // internal-caller compatibility path leaves the host unqualified. expect(removeWorktree).toHaveBeenCalledWith( { id: 'repo-c::/other', executionHostId: null }, - true, + false, { suppressPreservedBranchToast: true } @@ -101,7 +99,11 @@ describe('workspace cleanup removal and protection', () => { it('returns preserved branches for the cleanup batch summary', async () => { const candidate = makeCandidate() installWorkspaceCleanupApi( - vi.fn(async () => ({ scannedAt: NOW, candidates: [candidate], errors: [] })) + vi.fn(async () => ({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + })) ) const removeWorktree = vi.fn().mockResolvedValue({ ok: true, @@ -109,7 +111,11 @@ describe('workspace cleanup removal and protection', () => { }) const store = createCleanupTestStore(removeWorktree) store.setState({ - workspaceCleanupScan: { scannedAt: NOW, candidates: [candidate], errors: [] } + workspaceCleanupScan: { + scannedAt: NOW, + candidates: [candidate], + errors: [] + } } as Partial) await expect( @@ -138,7 +144,11 @@ describe('workspace cleanup removal and protection', () => { it('forwards the snapshot batch through each successful removal', async () => { const candidate = makeCandidate({ executionHostId: 'ssh:ssh-1' }) installWorkspaceCleanupApi( - vi.fn(async () => ({ scannedAt: NOW, candidates: [candidate], errors: [] })) + vi.fn(async () => ({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + })) ) const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) const store = createCleanupTestStore(removeWorktree) @@ -176,8 +186,7 @@ describe('workspace cleanup removal and protection', () => { workspaceCleanupViewedCandidates: { [WORKTREE_ID]: { viewedAt: Date.now(), - fingerprint: 'fingerprint-1', - wasSuggested: true + fingerprint: 'fingerprint-1' } } }), @@ -197,8 +206,7 @@ describe('workspace cleanup removal and protection', () => { workspaceCleanupViewedCandidates: { [WORKTREE_ID]: { viewedAt: Date.now(), - fingerprint: 'fingerprint-1', - wasSuggested: true + fingerprint: 'fingerprint-1' } } }), @@ -335,7 +343,9 @@ describe('workspace cleanup removal and protection', () => { const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) const store = createCleanupTestStore(removeWorktree) - store.setState({ activeWorktreeId: 'repo1::/tmp/other-workspace' } as Partial) + store.setState({ + activeWorktreeId: 'repo1::/tmp/other-workspace' + } as Partial) await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) @@ -393,7 +403,12 @@ describe('workspace cleanup removal and protection', () => { executionHostId: 'local', tier: 'review', blockers: ['dirty-files'], - git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: NOW + } }) const scan = vi.fn().mockResolvedValue({ scannedAt: NOW, @@ -428,7 +443,12 @@ describe('workspace cleanup removal and protection', () => { executionHostId: 'local', tier: 'review', blockers: ['dirty-files'], - git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: NOW + } }) const scan = vi.fn().mockResolvedValue({ scannedAt: NOW, @@ -462,7 +482,12 @@ describe('workspace cleanup removal and protection', () => { executionHostId: 'local', tier: 'review', blockers: ['git-status-error'], - git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } }) const nowRevealsUnpushed = makeCandidate({ executionHostId: 'local', diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts index 866b05839e2..49f8814ca62 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts @@ -15,7 +15,9 @@ import { canQueueWorkspaceCleanupCandidate, shouldForceWorkspaceCleanupRemoval, WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT, - type WorkspaceCleanupCandidate + type WorkspaceCleanupCandidate, + type WorkspaceCleanupScanError, + type WorkspaceCleanupUnverifiedRemovalConsent } from '../../../../shared/workspace-cleanup' import { getWorkspaceCleanupCandidateIdentity, @@ -25,6 +27,13 @@ import { import { getWorktreeOperationOwnerHostIds } from '@/lib/worktree-operation-route' import { translate } from '@/i18n/i18n' import type { WorkspaceCleanupFailure } from './workspace-cleanup' +import { + getWorkspaceCleanupGitUnavailableFailure, + getWorkspaceCleanupMissingFailure, + getWorkspaceCleanupRepoScanFailure, + hasValidWorkspaceCleanupUnverifiedConsent, + hasWorkspaceCleanupRiskEscalated +} from './workspace-cleanup-preflight-failures' /** Distinct from every ExecutionHostId, so a hostless row cannot alias one. */ const UNQUALIFIED_HOST_BUCKET = Symbol('unqualified-cleanup-host') @@ -165,12 +174,17 @@ export async function preflightWorkspaceCleanupCandidates( enrich: ( candidates: readonly WorkspaceCleanupCandidate[], state: AppState - ) => Promise + ) => Promise, + options: { + unverifiedRemovalConsent?: WorkspaceCleanupUnverifiedRemovalConsent + getConsentAttemptId?: (identity: string) => string | undefined + } = {} ): Promise { // 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() const identitiesByWorktreeId = new Map>() + const scanErrors: WorkspaceCleanupScanError[] = [] 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) @@ -180,6 +194,7 @@ export async function preflightWorkspaceCleanupCandidates( refreshActivity: true }) const enriched = await enrich(scan.candidates, getState()) + scanErrors.push(...scan.errors) for (const candidate of enriched) { const identity = getWorkspaceCleanupCandidateIdentity(candidate) candidatesByIdentity.set(identity, candidate) @@ -189,7 +204,13 @@ export async function preflightWorkspaceCleanupCandidates( } } return targets.map((target) => - evaluateWorkspaceCleanupPreflight(target, candidatesByIdentity, identitiesByWorktreeId) + evaluateWorkspaceCleanupPreflight( + target, + candidatesByIdentity, + identitiesByWorktreeId, + scanErrors, + options + ) ) } @@ -214,13 +235,21 @@ function resolvePreflightCandidate( return { ok: false } } const identity = identities?.values().next().value - return { ok: true, candidate: identity ? candidatesByIdentity.get(identity) : undefined } + return { + ok: true, + candidate: identity ? candidatesByIdentity.get(identity) : undefined + } } export function evaluateWorkspaceCleanupPreflight( target: WorkspaceCleanupRemovalTarget, candidatesByIdentity: ReadonlyMap, - identitiesByWorktreeId: ReadonlyMap> + identitiesByWorktreeId: ReadonlyMap>, + scanErrors: readonly WorkspaceCleanupScanError[] = [], + options: { + unverifiedRemovalConsent?: WorkspaceCleanupUnverifiedRemovalConsent + getConsentAttemptId?: (identity: string) => string | undefined + } = {} ): WorkspaceCleanupPreflightResult { const resolved = resolvePreflightCandidate(target, candidatesByIdentity, identitiesByWorktreeId) if (!resolved.ok) { @@ -229,20 +258,21 @@ export function evaluateWorkspaceCleanupPreflight( failure: ambiguousHostFailure(target.worktreeId, target.displayName).failure } } - const candidate = resolved.candidate + const repoScanFailure = getWorkspaceCleanupRepoScanFailure(target, scanErrors) + const consentReference = resolved.candidate ?? target.approvedCandidate + const hasUnverifiedRemovalConsent = hasValidWorkspaceCleanupUnverifiedConsent( + consentReference ? getWorkspaceCleanupCandidateIdentity(consentReference) : '', + options.unverifiedRemovalConsent, + options.getConsentAttemptId + ) + if (repoScanFailure && !hasUnverifiedRemovalConsent) { + return { ok: false, failure: repoScanFailure } + } + const candidate = + resolved.candidate ?? + (repoScanFailure && hasUnverifiedRemovalConsent ? target.approvedCandidate : undefined) 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.' - ) - } - } + return { ok: false, failure: getWorkspaceCleanupMissingFailure(target) } } const failure = (message: string): WorkspaceCleanupPreflightResult => ({ ok: false, @@ -260,21 +290,30 @@ export function evaluateWorkspaceCleanupPreflight( : '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 candidateIdentity = getWorkspaceCleanupCandidateIdentity(candidate) + if ( + target.approvedCandidate && + candidateIdentity !== getWorkspaceCleanupCandidateIdentity(target.approvedCandidate) + ) { + return { ok: false, failure: getWorkspaceCleanupMissingFailure(target) } + } + if (candidate.blockers.includes('git-status-error') && !hasUnverifiedRemovalConsent) { + return { + ok: false, + failure: getWorkspaceCleanupGitUnavailableFailure(target, candidate) + } + } + if (!target.approvedCandidate && shouldForceWorkspaceCleanupRemoval(candidate)) { + return failure( + translate( + 'auto.store.slices.workspace.cleanup.forceNeedsApproval', + 'Review and confirm this workspace before force deleting it.' + ) + ) + } 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) { + if (hasWorkspaceCleanupRiskEscalated(candidate, approvedCandidate)) { return failure( translate( 'auto.store.slices.workspace.cleanup.changedSinceConfirmation', @@ -283,9 +322,8 @@ export function evaluateWorkspaceCleanupPreflight( ) } } - const removedIdentity = getWorkspaceCleanupCandidateIdentity(candidate) const sameIdSurvivingHostId = [...(identitiesByWorktreeId.get(target.worktreeId) ?? [])] - .filter((identity) => identity !== removedIdentity) + .filter((identity) => identity !== candidateIdentity) .map((identity) => candidatesByIdentity.get(identity)) .map((otherCandidate) => otherCandidate ? resolveWorkspaceCleanupRemovalHostId(otherCandidate) : null @@ -298,8 +336,3 @@ export function evaluateWorkspaceCleanupPreflight( ...(sameIdSurvivingHostId ? { sameIdSurvivingHostId } : {}) } } - -// 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 diff --git a/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts index 463809f08cf..07f3e660645 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts @@ -1,7 +1,10 @@ import { create } from 'zustand' import { vi } from 'vitest' import type { AppState } from '../types' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + applyWorkspaceCleanupPolicy, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' import { createWorkspaceCleanupSlice } from './workspace-cleanup' export const WORKTREE_ID = 'repo1::/tmp/old-workspace' @@ -10,7 +13,7 @@ export const NOW = 1_700_000_000_000 export function makeCandidate( overrides: Partial = {} ): WorkspaceCleanupCandidate { - return { + return applyWorkspaceCleanupPolicy({ worktreeId: WORKTREE_ID, repoId: 'repo1', repoName: 'Repo 1', @@ -20,8 +23,6 @@ export function makeCandidate( displayName: 'old-workspace', branch: 'old-workspace', path: '/tmp/old-workspace', - tier: 'ready', - selectedByDefault: true, reasons: ['idle-clean'], blockers: [], lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, @@ -41,7 +42,7 @@ export function makeCandidate( }, fingerprint: 'fingerprint-1', ...overrides - } + }) } export function makeState(overrides: Partial = {}): AppState { diff --git a/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts b/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts new file mode 100644 index 00000000000..1cdf9a5388f --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts @@ -0,0 +1,416 @@ +import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' +import { + NOW, + WORKTREE_ID, + createCleanupTestStore, + deferred, + installWorkspaceCleanupApi, + makeCandidate +} from './workspace-cleanup-slice-test-harness' + +describe('workspace cleanup removal verification and consent', () => { + it.each([null, NOW])( + 'fails closed when git evidence is unavailable with checkedAt=%s', + async (checkedAt) => { + const candidate = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt + } + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate] + }) + + expect(result.failures).toEqual([ + expect.objectContaining({ + worktreeId: WORKTREE_ID, + canDeleteAnyway: true, + message: expect.stringContaining("couldn't check this workspace's git status") + }) + ]) + expect(removeWorktree).not.toHaveBeenCalled() + } + ) + + it('scopes repo-level scan failures to their execution host', async () => { + const local = makeCandidate({ executionHostId: 'local' }) + const remote = makeCandidate({ + executionHostId: 'ssh:remote', + displayName: 'remote' + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [local], + errors: [ + { + repoId: local.repoId, + repoName: local.repoName, + executionHostId: 'ssh:remote', + message: 'Git could not list worktrees.' + } + ] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store + .getState() + .removeWorkspaceCleanupCandidates([WORKTREE_ID, WORKTREE_ID], { + approvedCandidates: [local, remote] + }) + + expect(result.removedIdentities).toEqual([getWorkspaceCleanupCandidateIdentity(local)]) + expect(result.failures).toEqual([ + expect.objectContaining({ + executionHostId: 'ssh:remote', + displayName: 'remote' + }) + ]) + expect(removeWorktree).toHaveBeenCalledTimes(1) + }) + + it('fails every same-repo host when an older peer omits the error host', async () => { + const local = makeCandidate({ executionHostId: 'local' }) + const remote = makeCandidate({ + executionHostId: 'ssh:remote', + displayName: 'remote' + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [local, remote], + errors: [ + { + repoId: local.repoId, + repoName: local.repoName, + message: 'Git could not list worktrees.' + } + ] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store + .getState() + .removeWorkspaceCleanupCandidates([WORKTREE_ID, WORKTREE_ID], { + approvedCandidates: [local, remote] + }) + + expect(result.failures).toHaveLength(2) + expect( + result.failures.every((failure) => failure.message.includes('older connected peer')) + ).toBe(true) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('honors one matching unverified-removal consent and rejects its replay', async () => { + const candidate = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity) + expect(attemptId).toEqual(expect.any(String)) + expect(store.getState().beginUnverifiedRemovalConsent(identity)).toBeNull() + + const first = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: { identity, attemptId: attemptId! } + }) + const replay = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: { identity, attemptId: attemptId! } + }) + + expect(first.removedIdentities).toEqual([identity]) + expect(replay.failures).toEqual([expect.objectContaining({ canDeleteAnyway: true })]) + expect(removeWorktree).toHaveBeenCalledTimes(1) + expect(store.getState().beginUnverifiedRemovalConsent(identity)).toEqual(expect.any(String)) + }) + + it("does not let one row's consent authorize a different identity", async () => { + const first = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + const second = makeCandidate({ + worktreeId: 'repo1::/tmp/second', + path: '/tmp/second', + displayName: 'second', + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + installWorkspaceCleanupApi( + vi.fn(async (args: { worktreeIds?: string[] }) => ({ + scannedAt: NOW, + candidates: [first, second].filter((candidate) => + args.worktreeIds?.includes(candidate.worktreeId) + ), + errors: [] + })) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const identity = getWorkspaceCleanupCandidateIdentity(first) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)! + + const result = await store.getState().removeWorkspaceCleanupCandidates([second.worktreeId], { + approvedCandidates: [second], + unverifiedRemovalConsent: { identity, attemptId } + }) + + expect(result.failures).toEqual([expect.objectContaining({ worktreeId: second.worktreeId })]) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('fails closed when the workspace host changes before consent is consumed', async () => { + const approved = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + const moved = makeCandidate({ ...approved, executionHostId: 'ssh:remote' }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ scannedAt: NOW, candidates: [moved], errors: [] }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const identity = getWorkspaceCleanupCandidateIdentity(approved) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)! + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approved], + unverifiedRemovalConsent: { identity, attemptId } + }) + + expect(result.failures).toEqual([ + expect.objectContaining({ message: 'Workspace no longer exists.' }) + ]) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('keeps a live consent bound to its call when dialog state is cleared', async () => { + const candidate = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + const scan = deferred() + installWorkspaceCleanupApi(vi.fn(() => scan.promise)) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)! + const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: { identity, attemptId } + }) + + store.setState({ workspaceCleanupScan: null }) + expect(store.getState().beginUnverifiedRemovalConsent(identity)).toBeNull() + scan.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] }) + + await expect(removal).resolves.toEqual( + expect.objectContaining({ removedIdentities: [identity], failures: [] }) + ) + }) + + it('releases consent when an earlier queue guard refuses the row', async () => { + const candidate = makeCandidate({ + executionHostId: 'local', + blockers: ['main-worktree', 'git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + ) + const store = createCleanupTestStore() + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)! + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: { identity, attemptId } + }) + + expect(result.failures).toHaveLength(1) + expect(store.getState().beginUnverifiedRemovalConsent(identity)).toEqual(expect.any(String)) + }) + + it('allows different identities to hold unverified-removal grants concurrently', async () => { + const candidates = [ + makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }), + makeCandidate({ + worktreeId: 'repo1::/tmp/second', + path: '/tmp/second', + displayName: 'second', + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + ] + installWorkspaceCleanupApi( + vi.fn(async (args: { worktreeIds?: string[] }) => ({ + scannedAt: NOW, + candidates: candidates.filter((candidate) => + args.worktreeIds?.includes(candidate.worktreeId) + ), + errors: [] + })) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const consents = candidates.map((candidate) => { + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + return { + identity, + attemptId: store.getState().beginUnverifiedRemovalConsent(identity)! + } + }) + + const results = await Promise.all( + candidates.map((candidate, index) => + store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: consents[index] + }) + ) + ) + + expect(results.flatMap((result) => result.removedIdentities)).toEqual( + expect.arrayContaining(candidates.map(getWorkspaceCleanupCandidateIdentity)) + ) + expect(removeWorktree).toHaveBeenCalledTimes(2) + }) + + it('requires recorded approval before an id-only force removal', async () => { + const candidate = makeCandidate({ + blockers: ['unknown-base'], + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) + + expect(result.failures).toEqual([ + expect.objectContaining({ + message: 'Review and confirm this workspace before force deleting it.' + }) + ]) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it.each([ + ['dismissed', makeCandidate({ executionHostId: 'local', blockers: ['dismissed'] })], + ['recently active', makeCandidate({ executionHostId: 'local', reasons: [] })] + ])('removes an explicitly selected %s row', async (_label, approvedCandidate) => { + const freshCandidate = makeCandidate({ + ...approvedCandidate, + blockers: approvedCandidate.blockers.filter((blocker) => blocker !== 'dismissed') + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [freshCandidate], + errors: [] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + + expect(result.removedIds).toEqual([WORKTREE_ID]) + expect(removeWorktree).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index b93fa13571e..ac2767cf2c8 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -11,7 +11,6 @@ import { import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION, applyWorkspaceCleanupPolicy, - canSelectWorkspaceCleanupCandidate, shouldForceWorkspaceCleanupRemoval, shouldHideWorkspaceCleanupCandidate, type WorkspaceCleanupBlocker, @@ -19,7 +18,8 @@ import { type WorkspaceCleanupDismissal, type WorkspaceCleanupScanArgs, type WorkspaceCleanupScanProgress, - type WorkspaceCleanupScanResult + type WorkspaceCleanupScanResult, + type WorkspaceCleanupUnverifiedRemovalConsent } from '../../../../shared/workspace-cleanup' import { getWorkspaceCleanupCandidateHostId, @@ -51,6 +51,7 @@ export type WorkspaceCleanupFailure = { executionHostId?: ExecutionHostId displayName: string message: string + canDeleteAnyway?: boolean } export type WorkspaceCleanupRemoveResult = { @@ -65,13 +66,13 @@ export type WorkspaceCleanupRemoveOptions = { // Why: rows are removed long after the confirm click; the confirm-time // candidate records how much git risk the user actually approved. approvedCandidates?: readonly WorkspaceCleanupCandidate[] + unverifiedRemovalConsent?: WorkspaceCleanupUnverifiedRemovalConsent snapshotPruneBatchId?: string } type WorkspaceCleanupViewedCandidate = { viewedAt: number fingerprint: string - wasSuggested: boolean } export type WorkspaceCleanupSlice = { @@ -89,6 +90,7 @@ export type WorkspaceCleanupSlice = { candidates: readonly WorkspaceCleanupCandidate[] ) => Promise resetWorkspaceCleanupDismissals: () => Promise + beginUnverifiedRemovalConsent: (identity: string) => string | null removeWorkspaceCleanupCandidates: ( worktreeIds: readonly string[], options?: WorkspaceCleanupRemoveOptions @@ -162,6 +164,18 @@ const AGENT_PROCESS_NAMES = new Set([ 'opencode' ]) +const unverifiedRemovalConsentByStore = new WeakMap<() => AppState, Map>() + +function getUnverifiedRemovalConsentMap(getState: () => AppState): Map { + const existing = unverifiedRemovalConsentByStore.get(getState) + if (existing) { + return existing + } + const created = new Map() + unverifiedRemovalConsentByStore.set(getState, created) + return created +} + export const createWorkspaceCleanupSlice: StateCreator = ( set, get @@ -227,7 +241,11 @@ export const createWorkspaceCleanupSlice: StateCreator { try { @@ -272,7 +290,10 @@ export const createWorkspaceCleanupSlice: StateCreator { + const unverifiedRemovalConsentByIdentity = getUnverifiedRemovalConsentMap(get) + if (unverifiedRemovalConsentByIdentity.has(identity)) { + return null + } + const attemptId = crypto.randomUUID() + unverifiedRemovalConsentByIdentity.set(identity, attemptId) + return attemptId + }, + removeWorkspaceCleanupCandidates: async (worktreeIds, options) => { - const removedIds: string[] = [] - const removedIdentities = new Set() - const failures: WorkspaceCleanupFailure[] = [] - const preservedBranches: PreservedBranchCleanup[] = [] + const unverifiedRemovalConsentByIdentity = getUnverifiedRemovalConsentMap(get) + const consent = options?.unverifiedRemovalConsent + try { + const removedIds: string[] = [] + const removedIdentities = new Set() + const failures: WorkspaceCleanupFailure[] = [] + const preservedBranches: PreservedBranchCleanup[] = [] - // 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 + // 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) } - removableTargets.push(target) - } - const preflights = await preflightWorkspaceCleanupCandidates( - removableTargets, - get, - (candidates, state) => - enrichWorkspaceCleanupCandidates(candidates, state, { applyDismissals: false }) - ) - const targetsToRemove: { - target: WorkspaceCleanupRemovalTarget - candidate: WorkspaceCleanupCandidate - sameIdSurvivingHostId?: ExecutionHostId - ignoreWorkspaceCleanupScanSurvivors?: boolean - }[] = [] - - for (const preflight of preflights) { - if (!preflight.ok) { - failures.push(preflight.failure) - continue - } - targetsToRemove.push({ - target: preflight.target, - candidate: preflight.candidate, - ...(preflight.sameIdSurvivingHostId - ? { sameIdSurvivingHostId: preflight.sameIdSurvivingHostId } - : {}) - }) - } - const scheduledRemovalIdentities = new Set( - targetsToRemove.map(({ candidate }) => getWorkspaceCleanupCandidateIdentity(candidate)) - ) - for (const pendingRemoval of targetsToRemove) { - if ( - pendingRemoval.sameIdSurvivingHostId && - scheduledRemovalIdentities.has( - getWorkspaceCleanupHostIdentity( - pendingRemoval.sameIdSurvivingHostId, - pendingRemoval.candidate.worktreeId - ) - ) - ) { - delete pendingRemoval.sameIdSurvivingHostId - pendingRemoval.ignoreWorkspaceCleanupScanSurvivors = true - } - } - - // Why: nested workspaces can belong to different repos; parent removal must - // not race child cleanup hooks, PTY teardown, or metadata deletion. - for (const { - target, - candidate, - sameIdSurvivingHostId, - ignoreWorkspaceCleanupScanSurvivors - } of [...targetsToRemove].sort((a, b) => b.candidate.path.length - a.candidate.path.length)) { - const result = await get().removeWorktree( - // The resolved target names the host whose row the user confirmed; the - // removal is routed there instead of to the active workspace's host. - { id: candidate.worktreeId, executionHostId: target.executionHostId }, - shouldForceWorkspaceCleanupRemoval(candidate), - // Why: cleanup reports outcomes in its own summary toasts; per-row - // preserved-branch warnings would stack one toast per removed row. + const preflights = await preflightWorkspaceCleanupCandidates( + removableTargets, + get, + (candidates, state) => + enrichWorkspaceCleanupCandidates(candidates, state, { + applyDismissals: false + }), { - suppressPreservedBranchToast: true, - ...(sameIdSurvivingHostId ? { sameIdSurvivingHostId } : {}), - ...(ignoreWorkspaceCleanupScanSurvivors - ? { ignoreWorkspaceCleanupScanSurvivors: true } - : {}), - ...(options?.snapshotPruneBatchId - ? { snapshotPruneBatchId: options.snapshotPruneBatchId } - : {}) + ...(consent ? { unverifiedRemovalConsent: consent } : {}), + getConsentAttemptId: (identity) => unverifiedRemovalConsentByIdentity.get(identity) } ) - 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, - ...(result.preservedBranch.hostId ? { hostId: result.preservedBranch.hostId } : {}), - ...(result.preservedBranch.runtimeEnvironmentId - ? { runtimeEnvironmentId: result.preservedBranch.runtimeEnvironmentId } - : {}) - }) + const targetsToRemove: { + target: WorkspaceCleanupRemovalTarget + candidate: WorkspaceCleanupCandidate + sameIdSurvivingHostId?: ExecutionHostId + ignoreWorkspaceCleanupScanSurvivors?: boolean + }[] = [] + + for (const preflight of preflights) { + if (!preflight.ok) { + failures.push(preflight.failure) + continue } - } else { - failures.push({ - worktreeId: candidate.worktreeId, - ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), - displayName: candidate.displayName, - message: result.error + targetsToRemove.push({ + target: preflight.target, + candidate: preflight.candidate, + ...(preflight.sameIdSurvivingHostId + ? { sameIdSurvivingHostId: preflight.sameIdSurvivingHostId } + : {}) }) } - } - - if (removedIds.length > 0) { - invalidateWorkspaceCleanupScanProgress() - let prunableWorktreeIds = new Set(removedIds) - set((state) => { - const remainingCandidates = state.workspaceCleanupScan?.candidates.filter( - (candidate) => !removedIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate)) - ) - // 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 + const scheduledRemovalIdentities = new Set( + targetsToRemove.map(({ candidate }) => getWorkspaceCleanupCandidateIdentity(candidate)) + ) + for (const pendingRemoval of targetsToRemove) { + if ( + pendingRemoval.sameIdSurvivingHostId && + scheduledRemovalIdentities.has( + getWorkspaceCleanupHostIdentity( + pendingRemoval.sameIdSurvivingHostId, + pendingRemoval.candidate.worktreeId + ) ) + ) { + delete pendingRemoval.sameIdSurvivingHostId + pendingRemoval.ignoreWorkspaceCleanupScanSurvivors = true } - }) - 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 } : {}) + // Why: nested workspaces can belong to different repos; parent removal must + // not race child cleanup hooks, PTY teardown, or metadata deletion. + for (const { + target, + candidate, + sameIdSurvivingHostId, + ignoreWorkspaceCleanupScanSurvivors + } of [...targetsToRemove].sort((a, b) => b.candidate.path.length - a.candidate.path.length)) { + const result = await get().removeWorktree( + // The resolved target names the host whose row the user confirmed; the + // removal is routed there instead of to the active workspace's host. + { + id: candidate.worktreeId, + executionHostId: target.executionHostId + }, + shouldForceWorkspaceCleanupRemoval(candidate), + // Why: cleanup reports outcomes in its own summary toasts; per-row + // preserved-branch warnings would stack one toast per removed row. + { + suppressPreservedBranchToast: true, + ...(sameIdSurvivingHostId ? { sameIdSurvivingHostId } : {}), + ...(ignoreWorkspaceCleanupScanSurvivors + ? { ignoreWorkspaceCleanupScanSurvivors: true } + : {}), + ...(options?.snapshotPruneBatchId + ? { snapshotPruneBatchId: options.snapshotPruneBatchId } + : {}) + } + ) + 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, + ...(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 + }) + } + } + + if (removedIds.length > 0) { + invalidateWorkspaceCleanupScanProgress() + let prunableWorktreeIds = new Set(removedIds) + set((state) => { + const remainingCandidates = state.workspaceCleanupScan?.candidates.filter( + (candidate) => !removedIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate)) + ) + // 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 } : {}) + } + } finally { + if ( + consent && + unverifiedRemovalConsentByIdentity.get(consent.identity) === consent.attemptId + ) { + unverifiedRemovalConsentByIdentity.delete(consent.identity) + } } } }) @@ -682,7 +740,11 @@ async function enrichWorkspaceCleanupCandidatesForScan( scanToken: number ): Promise { if (workspaceCleanupEnrichmentCache?.scanToken !== scanToken) { - workspaceCleanupEnrichmentCache = { scanToken, localToken: null, entries: new Map() } + workspaceCleanupEnrichmentCache = { + scanToken, + localToken: null, + entries: new Map() + } } const cache = workspaceCleanupEnrichmentCache const localToken = buildWorkspaceCleanupLocalStateToken(state) @@ -1069,7 +1131,7 @@ function shouldPreserveCleanupInspection( state: AppState ): boolean { const viewed = state.workspaceCleanupViewedCandidates[candidate.worktreeId] - if (!viewed?.wasSuggested || viewed.fingerprint !== candidate.fingerprint) { + if (!viewed || viewed.fingerprint !== candidate.fingerprint) { return false } // Why: View is part of cleanup review. It should not make the same diff --git a/src/shared/workspace-cleanup-browse-state-persistence.test.ts b/src/shared/workspace-cleanup-browse-state-persistence.test.ts index d0fad16e1c1..3d48dd1e806 100644 --- a/src/shared/workspace-cleanup-browse-state-persistence.test.ts +++ b/src/shared/workspace-cleanup-browse-state-persistence.test.ts @@ -19,9 +19,17 @@ describe('workspace cleanup browse state persistence', () => { it('round-trips an edited state', () => { const state = createDefaultWorkspaceCleanupBrowseState() state.filters.query = 'checkout' - state.filters.activity = { idleSignal: 'created', idleMinDays: 45, neverVisited: true } - state.filters.safety.tiers = ['review', 'protected'] - state.filters.review = { presence: 'some', states: ['merged'], providers: ['gitlab'] } + state.filters.activity = { + idleSignal: 'created', + idleMinDays: 45, + neverVisited: true + } + state.filters.safety.blockers = ['dirty-files', 'pinned'] + state.filters.review = { + presence: 'some', + states: ['merged'], + providers: ['gitlab'] + } state.sort = { field: 'size', direction: 'desc' } expect(normalizeWorkspaceCleanupBrowseState(throughDisk(state))).toEqual(state) @@ -34,7 +42,11 @@ describe('workspace cleanup browse state persistence', () => { unknownTopLevelField: { anything: true }, filters: { query: 'keep me', - activity: { idleSignal: 'from-the-future', idleMinDays: 30, unknownFacet: 'ignored' }, + activity: { + idleSignal: 'from-the-future', + idleMinDays: 30, + unknownFacet: 'ignored' + }, safety: { tiers: ['ready', 'not-a-tier'] }, unknownGroup: { enabled: true } }, @@ -48,8 +60,13 @@ describe('workspace cleanup browse state persistence', () => { expect(normalized.filters.query).toBe('keep me') expect(normalized.filters.activity.idleMinDays).toBe(30) expect(normalized.filters.activity.idleSignal).toBe('last-visited') - expect(normalized.filters.safety.tiers).toEqual(['ready']) - expect(normalized.sort).toEqual({ field: 'last-activity', direction: 'desc' }) + expect(normalized.filters.safety).toEqual( + createDefaultWorkspaceCleanupBrowseState().filters.safety + ) + expect(normalized.sort).toEqual({ + field: 'last-activity', + direction: 'desc' + }) expect(normalized).not.toHaveProperty('activePresetId') expect(normalized).not.toHaveProperty('customPresets') expect(normalized).not.toHaveProperty('unknownTopLevelField') diff --git a/src/shared/workspace-cleanup-facet-rankings.ts b/src/shared/workspace-cleanup-facet-rankings.ts index 61ebea7e5fa..2455ebd6249 100644 --- a/src/shared/workspace-cleanup-facet-rankings.ts +++ b/src/shared/workspace-cleanup-facet-rankings.ts @@ -10,7 +10,7 @@ import type { WorkspaceCleanupTicketSource, WorkspaceCleanupTriState } from './workspace-cleanup-filter-model' -import type { WorkspaceCleanupBlocker, WorkspaceCleanupTier } from './workspace-cleanup' +import type { WorkspaceCleanupBlocker } from './workspace-cleanup' // Records (not bare arrays) so tsc fails when a union gains a member. const BLOCKER_SEVERITY: Record = { @@ -32,8 +32,6 @@ const BLOCKER_SEVERITY: Record = { dismissed: 10 } -const TIER_RANK: Record = { ready: 0, review: 1, protected: 2 } - const AGENT_RANK: Record = { idle: 0, working: 2, @@ -58,7 +56,6 @@ const REVIEW_RANK: Record = { export const WORKSPACE_CLEANUP_BLOCKER_VALUES = Object.keys( BLOCKER_SEVERITY ) as WorkspaceCleanupBlocker[] -export const WORKSPACE_CLEANUP_TIER_VALUES = Object.keys(TIER_RANK) as WorkspaceCleanupTier[] export const WORKSPACE_CLEANUP_AGENT_STATE_VALUES = Object.keys( AGENT_RANK ) as WorkspaceCleanupAgentState[] @@ -110,7 +107,6 @@ export const WORKSPACE_CLEANUP_SORT_FIELD_VALUES: WorkspaceCleanupSortField[] = 'review', 'ticket', 'local-context', - 'tier', 'blocker-count' ] @@ -118,10 +114,6 @@ export function getWorkspaceCleanupBlockerSeverity(blocker: WorkspaceCleanupBloc return BLOCKER_SEVERITY[blocker] ?? 0 } -export function getWorkspaceCleanupTierRank(tier: WorkspaceCleanupTier): number { - return TIER_RANK[tier] ?? 0 -} - export function getWorkspaceCleanupAgentRank(state: WorkspaceCleanupAgentState): number { return AGENT_RANK[state] ?? 0 } diff --git a/src/shared/workspace-cleanup-filter-model.ts b/src/shared/workspace-cleanup-filter-model.ts index e4cb4af9f89..72d43f96c2d 100644 --- a/src/shared/workspace-cleanup-filter-model.ts +++ b/src/shared/workspace-cleanup-filter-model.ts @@ -1,6 +1,6 @@ import type { ExecutionHostId } from './execution-host' import type { HostedReviewProvider } from './hosted-review' -import type { WorkspaceCleanupBlocker, WorkspaceCleanupTier } from './workspace-cleanup' +import type { WorkspaceCleanupBlocker } from './workspace-cleanup' /** `any` leaves the facet unconstrained; `only`/`exclude` narrow to rows with/without the trait. */ export type WorkspaceCleanupTriState = 'any' | 'only' | 'exclude' @@ -40,7 +40,6 @@ export type WorkspaceCleanupSortField = | 'review' | 'ticket' | 'local-context' - | 'tier' | 'blocker-count' export type WorkspaceCleanupSortDirectionState = 'asc' | 'desc' @@ -117,10 +116,7 @@ export type WorkspaceCleanupLocationFilter = { export type WorkspaceCleanupSafetyFilter = { blockers: WorkspaceCleanupBlocker[] blockerMode: WorkspaceCleanupBlockerMode - tiers: WorkspaceCleanupTier[] dismissed: WorkspaceCleanupTriState - /** Only rows the cleanup policy would actually let the user delete. */ - selectableOnly: boolean } export type WorkspaceCleanupFilterState = { @@ -141,7 +137,11 @@ export type WorkspaceCleanupFilterState = { export function createDefaultWorkspaceCleanupFilterState(): WorkspaceCleanupFilterState { return { query: '', - activity: { idleSignal: 'last-visited', idleMinDays: null, neverVisited: false }, + activity: { + idleSignal: 'last-visited', + idleMinDays: null, + neverVisited: false + }, size: { minBytes: null, maxBytes: null, includeUnsized: true }, status: { workspaceStatuses: [], @@ -167,9 +167,7 @@ export function createDefaultWorkspaceCleanupFilterState(): WorkspaceCleanupFilt safety: { blockers: [], blockerMode: 'none-of', - tiers: [], - dismissed: 'any', - selectableOnly: false + dismissed: 'any' } } } diff --git a/src/shared/workspace-cleanup-filter-state-codec.ts b/src/shared/workspace-cleanup-filter-state-codec.ts index 5af505a281e..f6bc206a005 100644 --- a/src/shared/workspace-cleanup-filter-state-codec.ts +++ b/src/shared/workspace-cleanup-filter-state-codec.ts @@ -11,7 +11,6 @@ import { WORKSPACE_CLEANUP_SORT_DIRECTION_VALUES, WORKSPACE_CLEANUP_SORT_FIELD_VALUES, WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES, - WORKSPACE_CLEANUP_TIER_VALUES, WORKSPACE_CLEANUP_TRI_STATE_VALUES } from './workspace-cleanup-facet-rankings' import { @@ -110,9 +109,7 @@ export function normalizeWorkspaceCleanupFilterState(value: unknown): WorkspaceC WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES, base.safety.blockerMode ), - tiers: asEnumList(safety.tiers, WORKSPACE_CLEANUP_TIER_VALUES), - dismissed: asTriState(safety.dismissed, base.safety.dismissed), - selectableOnly: asBoolean(safety.selectableOnly, base.safety.selectableOnly) + dismissed: asTriState(safety.dismissed, base.safety.dismissed) } } } diff --git a/src/shared/workspace-cleanup.test.ts b/src/shared/workspace-cleanup.test.ts index e1576bf5f86..268c57ca36a 100644 --- a/src/shared/workspace-cleanup.test.ts +++ b/src/shared/workspace-cleanup.test.ts @@ -3,7 +3,6 @@ import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION, applyWorkspaceCleanupPolicy, canQueueWorkspaceCleanupCandidate, - canSelectWorkspaceCleanupCandidate, createWorkspaceCleanupFingerprint, shouldForceWorkspaceCleanupRemoval, shouldHideWorkspaceCleanupCandidate, @@ -60,44 +59,47 @@ describe('workspace cleanup policy', () => { expect(candidate.tier).toBe('ready') expect(candidate.selectedByDefault).toBe(true) - expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(true) }) - it('requires an inactivity reason before selecting a workspace', () => { + it('keeps legacy verdict fields stable for older clients', () => { const candidate = applyWorkspaceCleanupPolicy(makeCandidate({ reasons: [] })) - expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(false) expect(candidate.tier).toBe('review') expect(candidate.selectedByDefault).toBe(false) }) - it('keeps not-suggested candidates queueable when git evidence is clean', () => { + it('queues risk-labelled and recently active candidates', () => { const candidate = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['unpushed-commits'] })) + const recent = applyWorkspaceCleanupPolicy(makeCandidate({ reasons: [] })) expect(candidate.tier).toBe('protected') expect(candidate.selectedByDefault).toBe(false) - expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(false) expect(canQueueWorkspaceCleanupCandidate(candidate)).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(recent)).toBe(true) expect(shouldForceWorkspaceCleanupRemoval(candidate)).toBe(true) }) - it('does not queue main worktrees or folder projects for cleanup removal', () => { + it('keeps active and live-agent rows manually queueable', () => { + expect( + canQueueWorkspaceCleanupCandidate(makeCandidate({ blockers: ['active-workspace'] })) + ).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(makeCandidate({ blockers: ['live-agent'] }))).toBe( + true + ) + }) + + it('refuses only operations that cannot be routed', () => { const mainWorktree = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['main-worktree'] })) const folderProject = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['folder-repo'] })) + const disconnected = applyWorkspaceCleanupPolicy( + makeCandidate({ blockers: ['ssh-disconnected'] }) + ) + const dismissed = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['dismissed'] })) expect(canQueueWorkspaceCleanupCandidate(mainWorktree)).toBe(false) expect(canQueueWorkspaceCleanupCandidate(folderProject)).toBe(false) - }) - - it('requires current git status before selecting a workspace', () => { - const candidate = applyWorkspaceCleanupPolicy( - makeCandidate({ - git: { clean: null, checkedAt: null } - }) - ) - - expect(candidate.tier).toBe('review') - expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(false) + expect(canQueueWorkspaceCleanupCandidate(disconnected)).toBe(false) + expect(canQueueWorkspaceCleanupCandidate(dismissed)).toBe(true) }) it('matches dismissals only for the current classifier fingerprint', () => { diff --git a/src/shared/workspace-cleanup.ts b/src/shared/workspace-cleanup.ts index a1fd9293d8c..a2bedc2d9ed 100644 --- a/src/shared/workspace-cleanup.ts +++ b/src/shared/workspace-cleanup.ts @@ -60,7 +60,9 @@ export type WorkspaceCleanupCandidate = { displayName: string branch: string path: string + /** @deprecated Current renderers ignore this legacy verdict; retained for older paired clients. */ tier: WorkspaceCleanupTier + /** @deprecated Current renderers ignore this legacy verdict; retained for older paired clients. */ selectedByDefault: boolean reasons: WorkspaceCleanupReason[] blockers: WorkspaceCleanupBlocker[] @@ -117,6 +119,8 @@ export type WorkspaceCleanupScanError = { repoId: string repoName: string message: string + /** Optional for compatibility with older hosts that did not qualify scan errors. */ + executionHostId?: ExecutionHostId } export type WorkspaceCleanupScanResult = { @@ -132,6 +136,14 @@ export type WorkspaceCleanupScanProgress = WorkspaceCleanupScanResult & { candidateMode?: 'append' | 'snapshot' } +/** One-shot consent to remove a workspace whose git status could not be read. */ +export type WorkspaceCleanupUnverifiedRemovalConsent = { + /** Host-qualified candidate identity, never a bare worktree id. */ + identity: string + /** Minted for one removal call and released when that call settles. */ + attemptId: string +} + export type WorkspaceCleanupLocalProcessResult = { hasKillableProcesses: boolean | null } @@ -142,7 +154,7 @@ export type WorkspaceCleanupDismissArgs = { removedWorktreeIds?: string[] } -export const WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet = new Set([ +const LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet = new Set([ 'main-worktree', 'folder-repo', 'pinned', @@ -164,23 +176,19 @@ export const WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet = new Set([ 'main-worktree', 'folder-repo', - 'dismissed' + 'ssh-disconnected' ]) export const WORKSPACE_CLEANUP_FORCE_REMOVE_BLOCKERS: ReadonlySet = - new Set(['dirty-files', 'unpushed-commits', 'unknown-base', 'git-status-error']) + new Set(['dirty-files', 'unpushed-commits', 'unknown-base']) -export function isWorkspaceCleanupHardBlocker(blocker: WorkspaceCleanupBlocker): boolean { - return WORKSPACE_CLEANUP_HARD_BLOCKERS.has(blocker) -} +export const WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS: ReadonlySet = + new Set(['active-workspace', 'live-agent', 'dismissed']) export function canQueueWorkspaceCleanupCandidate( - candidate: Pick + candidate: Pick ): boolean { - return ( - candidate.reasons.length > 0 && - !candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_QUEUE_BLOCKERS.has(blocker)) - ) + return !candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_QUEUE_BLOCKERS.has(blocker)) } export function shouldForceWorkspaceCleanupRemoval( @@ -193,22 +201,27 @@ export function shouldForceWorkspaceCleanupRemoval( ) } -export function canSelectWorkspaceCleanupCandidate( +function canSelectWorkspaceCleanupCandidate( candidate: Pick ): boolean { return ( candidate.reasons.length > 0 && candidate.git.clean === true && candidate.git.checkedAt !== null && - !candidate.blockers.some(isWorkspaceCleanupHardBlocker) + !candidate.blockers.some((blocker) => LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS.has(blocker)) ) } +type WorkspaceCleanupPolicyInput = Omit & + Partial> + export function applyWorkspaceCleanupPolicy( - candidate: WorkspaceCleanupCandidate + candidate: WorkspaceCleanupPolicyInput ): WorkspaceCleanupCandidate { const canSelect = canSelectWorkspaceCleanupCandidate(candidate) - const hasHardBlocker = candidate.blockers.some(isWorkspaceCleanupHardBlocker) + const hasHardBlocker = candidate.blockers.some((blocker) => + LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS.has(blocker) + ) const tier: WorkspaceCleanupTier = hasHardBlocker ? 'protected' : canSelect ? 'ready' : 'review' return {