mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+146
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
+5
-3
@@ -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 }
|
||||
: {})
|
||||
|
||||
+64
-17
@@ -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)
|
||||
|
||||
+56
-46
@@ -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(' · ')
|
||||
}
|
||||
|
||||
+41
@@ -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}
|
||||
|
||||
+100
-17
@@ -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
|
||||
}
|
||||
|
||||
+40
-16
@@ -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
|
||||
]
|
||||
|
||||
+12
-12
@@ -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')
|
||||
)
|
||||
})
|
||||
|
||||
+43
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
+6
-5
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
+18
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user