wip(workspace-cleanup): PR 3 blockers-become-labels, recovered from a dead worker

Uncommitted work recovered from a worker that died with 'Agent process stop was
requested but never confirmed'. Committed as-is to preserve it; NOT verified yet.
This commit is contained in:
Brennan Benson
2026-08-24 10:38:41 -07:00
parent 3d74f5fe03
commit 5fd98fd99f
45 changed files with 1709 additions and 538 deletions
@@ -119,8 +119,12 @@ function makeWorktreeMeta(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
const META_BY_WORKTREE_ID: Record<string, WorktreeMeta> = {
'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<string, WorktreeMeta> = {}): 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<string, WorktreeMeta> = {
'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(),
+2 -6
View File
@@ -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')
) {
@@ -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,
@@ -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)
}
}
+2 -2
View File
@@ -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
+2
View File
@@ -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.'
}
]
@@ -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<Set<string>>(() => new Set())
const [rowsScrollElement, setRowsScrollElement] = useState<HTMLDivElement | null>(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}
/>
</div>
</ScrollArea>
@@ -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<string, string>
rowFailures: Record<string, WorkspaceCleanupFailure>
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<string, 'queued' | 'deleting'>
>({})
const [rowFailures, setRowFailures] = useState<Record<string, string>>({})
const [rowFailures, setRowFailures] = useState<Record<string, WorkspaceCleanupFailure>>({})
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<string, string> = {}
const nextFailures: Record<string, WorkspaceCleanupFailure> = {}
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
}
@@ -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<SetStateAction<Record<string, WorkspaceCleanupFailure>>>
setDeletionPhaseByIdentity: Dispatch<
SetStateAction<Record<string, WorkspaceCleanupDeletionPhase>>
>
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<string, WorkspaceCleanupFailure>,
failures: readonly WorkspaceCleanupFailure[]
): Record<string, WorkspaceCleanupFailure> {
const next = { ...current }
for (const failure of failures) {
next[getWorkspaceCleanupFailureIdentity(failure)] = failure
}
return next
}
function withoutIdentity<T>(current: Record<string, T>, identity: string): Record<string, T> {
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 })
}
@@ -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' })
})
})
@@ -56,6 +56,7 @@ export type WorkspaceCleanupBackgroundRemovalArgs = {
begin: () => Promise<void>
finish: () => Promise<void>
}
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 }
: {})
@@ -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)
@@ -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
@@ -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…'
)}
</StatusPill>
) : factStatus ? (
<StatusPill tone={factStatus.tone}>{factStatus.label}</StatusPill>
) : null}
) : (
factStatuses.map((status) => (
<StatusPill key={status.label} tone={status.tone}>
{status.label}
</StatusPill>
))
)}
{workspaceStatusLabel ? (
<WorkspaceCleanupMetadataChip
icon={CircleDot}
@@ -255,7 +266,17 @@ export const CandidateRow = React.memo(function CandidateRow({
{failure ? (
<div className="mt-2 flex items-center gap-1.5 text-xs text-destructive">
<AlertTriangle className="size-3.5" />
{failure}
<span>{failure.message}</span>
{failure.canDeleteAnyway && onDeleteAnyway ? (
<Button
variant="link"
size="xs"
className="h-auto px-1 text-destructive"
onClick={() => onDeleteAnyway(candidate)}
>
{translate('components.workspace.cleanup.browse.deleteAnyway', 'Delete anyway')}
</Button>
) : null}
</div>
) : null}
@@ -377,26 +398,11 @@ export const CandidateRow = React.memo(function CandidateRow({
</TooltipContent>
</Tooltip>
) : null}
{candidate.blockers.includes('ssh-disconnected') && onForgetLocally ? (
<WorkspaceCleanupForgetLocallyButton candidate={candidate} onForget={onForgetLocally} />
) : null}
</div>
</div>
</div>
)
})
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(' · ')
}
@@ -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(
<WorkspaceCleanupConfirmRemove
candidates={[failed, unknownBase]}
now={Date.now()}
reviewInfoByWorktreeId={new Map()}
progress={null}
onBack={vi.fn()}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>
)
})
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'])
)
})
})
@@ -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<HTMLDivElement | null>(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 }
)}
</div>
{riskCount > 0 ? (
<div className="text-xs text-destructive">
{translate(
'components.workspace.cleanup.browse.forceDeleteProjection',
'{{value0}} currently show risk and may need a force delete',
{ value0: riskCount }
)}
</div>
) : null}
</div>
<ScrollArea className="min-h-0 flex-1" viewportRef={setScrollElement}>
<WorkspaceCleanupCandidateList
@@ -206,7 +219,7 @@ function ConfirmRemoveRow({
const branchDiffersFromName = candidate.branch !== candidate.displayName
const contextPillLabel = getContextPillLabel(candidate)
const showGitMetadataChip = shouldShowGitMetadataChip(candidate)
const factStatus = getCandidateFactStatus(candidate)
const factStatuses = getCandidateFactStatuses(candidate)
const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate)
return (
<div
@@ -230,7 +243,11 @@ function ConfirmRemoveRow({
)}{' '}
{formatWorkspaceCleanupRelativeTime(candidate.lastActivityAt, now)}
</span>
{factStatus ? <StatusPill tone={factStatus.tone}>{factStatus.label}</StatusPill> : null}
{factStatuses.map((status) => (
<StatusPill key={status.label} tone={status.tone}>
{status.label}
</StatusPill>
))}
{reviewInfo.label ? (
<StatusPill tone={getReviewPillTone(reviewInfo)}>{reviewInfo.label}</StatusPill>
) : null}
@@ -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(
@@ -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':
@@ -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
}
@@ -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'))
})
@@ -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 ||
@@ -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<WorkspaceCleanupFacets, 'searchText'>): st
facets.review.title,
facets.review.provider,
facets.gitState,
facets.tier,
...facets.ticketSources,
...facets.blockers
]
@@ -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')
)
})
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label={label}
onClick={() => onForget(candidate)}
>
<Trash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -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}
>
<FacetToggleList
label={translate('components.workspace.cleanup.browse.tierField', 'Cleanup tier')}
values={WORKSPACE_CLEANUP_TIER_VALUES}
selected={filters.safety.tiers}
getLabel={getWorkspaceCleanupTierLabel}
onChange={(tiers) => onPatch('safety', { tiers })}
/>
<FacetChoice
label={translate('components.workspace.cleanup.browse.blockerMode', 'Blocker match')}
value={filters.safety.blockerMode}
@@ -194,15 +184,6 @@ export function WorkspaceCleanupGitReviewFacets({
getLabel={getWorkspaceCleanupTriStateLabel}
onChange={(dismissed) => onPatch('safety', { dismissed })}
/>
<FacetCheckbox
id="safety-selectable-only"
label={translate(
'components.workspace.cleanup.browse.selectableOnly',
'Only workspaces I can delete now'
)}
checked={filters.safety.selectableOnly}
onChange={(selectableOnly) => onPatch('safety', { selectableOnly })}
/>
</FacetSection>
</>
)
@@ -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> = {}
): 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> = {}): Worktree {
@@ -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(' · ')
}
@@ -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<string>
gitPendingWorktreeIds: ReadonlySet<string>
/** Keyed by host-qualified identity so a failure marks only its own host's row. */
rowFailures: Record<string, string>
rowFailures: Record<string, WorkspaceCleanupFailure>
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}
/>
)}
/>
@@ -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 })
}
}
+9 -2
View File
@@ -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}})",
+10 -1
View File
@@ -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}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다"
}
}
},
+10 -1
View File
@@ -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}} 个工作区显示风险,可能需要强制删除"
}
}
},
@@ -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)
@@ -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
@@ -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<AppState>)
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<AppState>)
store.setState({
activeWorktreeId: 'repo1::/tmp/other-workspace'
} as Partial<AppState>)
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',
@@ -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<WorkspaceCleanupCandidate[]>
) => Promise<WorkspaceCleanupCandidate[]>,
options: {
unverifiedRemovalConsent?: WorkspaceCleanupUnverifiedRemovalConsent
getConsentAttemptId?: (identity: string) => string | undefined
} = {}
): Promise<WorkspaceCleanupPreflightResult[]> {
// Why: one batched scan per chunk replaces a git worktree-list + activity
// read per row; chunks stay under main's silent target truncation limit.
const candidatesByIdentity = new Map<string, WorkspaceCleanupCandidate>()
const identitiesByWorktreeId = new Map<string, Set<string>>()
const 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<string, WorkspaceCleanupCandidate>,
identitiesByWorktreeId: ReadonlyMap<string, ReadonlySet<string>>
identitiesByWorktreeId: ReadonlyMap<string, ReadonlySet<string>>,
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
@@ -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> = {}
): 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> = {}): AppState {
@@ -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<WorkspaceCleanupScanResult>()
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)
})
})
+222 -160
View File
@@ -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<void>
resetWorkspaceCleanupDismissals: () => Promise<void>
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<string, string>>()
function getUnverifiedRemovalConsentMap(getState: () => AppState): Map<string, string> {
const existing = unverifiedRemovalConsentByStore.get(getState)
if (existing) {
return existing
}
const created = new Map<string, string>()
unverifiedRemovalConsentByStore.set(getState, created)
return created
}
export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], WorkspaceCleanupSlice> = (
set,
get
@@ -227,7 +241,11 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
const scanToken = ++latestWorkspaceCleanupScanToken
finalizedWorkspaceCleanupScanToken = 0
workspaceCleanupProgressQueue = null
workspaceCleanupEnrichmentCache = { scanToken, localToken: null, entries: new Map() }
workspaceCleanupEnrichmentCache = {
scanToken,
localToken: null,
entries: new Map()
}
workspaceCleanupProgressCandidateIndex = null
const promise = (async () => {
try {
@@ -272,7 +290,10 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (scanToken === latestWorkspaceCleanupScanToken) {
set({ workspaceCleanupError: message, workspaceCleanupLoading: false })
set({
workspaceCleanupError: message,
workspaceCleanupLoading: false
})
}
throw error
} finally {
@@ -303,8 +324,7 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
),
[candidate.worktreeId]: {
viewedAt: now,
fingerprint: candidate.fingerprint,
wasSuggested: candidate.tier === 'ready' && canSelectWorkspaceCleanupCandidate(candidate)
fingerprint: candidate.fingerprint
}
}
}))
@@ -367,171 +387,209 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
await window.api.workspaceCleanup.clearDismissals()
},
beginUnverifiedRemovalConsent: (identity) => {
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<string>()
const failures: WorkspaceCleanupFailure[] = []
const preservedBranches: PreservedBranchCleanup[] = []
const unverifiedRemovalConsentByIdentity = getUnverifiedRemovalConsentMap(get)
const consent = options?.unverifiedRemovalConsent
try {
const removedIds: string[] = []
const removedIdentities = new Set<string>()
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<WorkspaceCleanupCandidate[]> {
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
@@ -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')
@@ -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<WorkspaceCleanupBlocker, number> = {
@@ -32,8 +32,6 @@ const BLOCKER_SEVERITY: Record<WorkspaceCleanupBlocker, number> = {
dismissed: 10
}
const TIER_RANK: Record<WorkspaceCleanupTier, number> = { ready: 0, review: 1, protected: 2 }
const AGENT_RANK: Record<WorkspaceCleanupAgentState, number> = {
idle: 0,
working: 2,
@@ -58,7 +56,6 @@ const REVIEW_RANK: Record<WorkspaceCleanupReviewState, number> = {
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
}
+7 -9
View File
@@ -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'
}
}
}
@@ -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)
}
}
}
+20 -18
View File
@@ -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', () => {
+28 -15
View File
@@ -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<WorkspaceCleanupBlocker> = new Set([
const LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> = new Set([
'main-worktree',
'folder-repo',
'pinned',
@@ -164,23 +176,19 @@ export const WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocke
const WORKSPACE_CLEANUP_QUEUE_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> = new Set([
'main-worktree',
'folder-repo',
'dismissed'
'ssh-disconnected'
])
export const WORKSPACE_CLEANUP_FORCE_REMOVE_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> =
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<WorkspaceCleanupBlocker> =
new Set(['active-workspace', 'live-agent', 'dismissed'])
export function canQueueWorkspaceCleanupCandidate(
candidate: Pick<WorkspaceCleanupCandidate, 'blockers' | 'reasons'>
candidate: Pick<WorkspaceCleanupCandidate, 'blockers'>
): 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<WorkspaceCleanupCandidate, 'blockers' | 'git' | 'reasons'>
): 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<WorkspaceCleanupCandidate, 'tier' | 'selectedByDefault'> &
Partial<Pick<WorkspaceCleanupCandidate, 'tier' | 'selectedByDefault'>>
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 {