Stream workspace cleanup scan progress

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-26 11:05:08 -07:00
co-authored by Orca
parent 0c9192959b
commit 3e0ff551d7
8 changed files with 305 additions and 58 deletions
+43
View File
@@ -171,6 +171,49 @@ describe('workspace cleanup scan', () => {
})
})
it('reports cleanup scan progress as inactive workspaces are checked', async () => {
listRepoWorktreesMock.mockResolvedValue([
{
path: '/repo-feature-a',
head: 'abc123',
branch: 'refs/heads/feature-a',
isBare: false,
isMainWorktree: false
},
{
path: '/repo-feature-b',
head: 'def456',
branch: 'refs/heads/feature-b',
isBare: false,
isMainWorktree: false
}
])
const progress: unknown[] = []
const result = await scanWorkspaceCleanup(
makeStore(),
{ scanId: 'scan-1' },
{ onProgress: (event) => progress.push(event) }
)
expect(result.candidates).toHaveLength(2)
expect(progress[0]).toMatchObject({
scanId: 'scan-1',
scannedWorktreeCount: 0,
totalWorktreeCount: 2,
candidates: []
})
expect(progress.at(-1)).toMatchObject({
scanId: 'scan-1',
scannedWorktreeCount: 2,
totalWorktreeCount: 2,
candidates: expect.arrayContaining([
expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-a' }),
expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-b' })
])
})
})
it('keeps raw scan errors out of renderer-facing results', async () => {
listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed'))
+98 -27
View File
@@ -36,6 +36,7 @@ import {
type WorkspaceCleanupReason,
type WorkspaceCleanupScanError,
type WorkspaceCleanupScanArgs,
type WorkspaceCleanupScanProgress,
type WorkspaceCleanupScanResult
} from '../../shared/workspace-cleanup'
@@ -55,6 +56,16 @@ type WorkspaceCleanupHandlerDeps = {
getLocalPtyProvider?: () => IPtyProvider
}
type WorkspaceCleanupScanOptions = {
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
}
type WorkspaceCleanupScanRepoProgress = {
onWorktreesDiscovered?: (count: number) => void
onCandidateScanned?: (candidate: WorkspaceCleanupCandidate) => void
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
}
export function registerWorkspaceCleanupHandlers(
store: Store,
deps: WorkspaceCleanupHandlerDeps = {}
@@ -66,8 +77,12 @@ export function registerWorkspaceCleanupHandlers(
ipcMain.handle(
'workspaceCleanup:scan',
(_event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> =>
scanWorkspaceCleanup(store, args ?? {})
(event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> =>
scanWorkspaceCleanup(store, args ?? {}, {
onProgress: args?.scanId
? (progress) => event.sender.send('workspaceCleanup:scanProgress', progress)
: undefined
})
)
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
@@ -192,7 +207,8 @@ function isPathWithin(candidatePath: string, parentPath: string): boolean {
export async function scanWorkspaceCleanup(
store: Store,
args: WorkspaceCleanupScanArgs = {}
args: WorkspaceCleanupScanArgs = {},
options: WorkspaceCleanupScanOptions = {}
): Promise<WorkspaceCleanupScanResult> {
const scannedAt = Date.now()
const parsedTarget = args.worktreeId ? splitWorktreeId(args.worktreeId) : null
@@ -204,6 +220,23 @@ export async function scanWorkspaceCleanup(
: store.getRepos()
const errors: WorkspaceCleanupScanResult['errors'] = []
const candidates: WorkspaceCleanupCandidate[] = []
const progressCandidates: WorkspaceCleanupCandidate[] = []
const progressErrors: WorkspaceCleanupScanError[] = []
let totalWorktreeCount = 0
let scannedWorktreeCount = 0
const emitProgress = (): void => {
if (!args.scanId) {
return
}
options.onProgress?.({
scanId: args.scanId,
scannedAt,
totalWorktreeCount,
scannedWorktreeCount,
candidates: [...progressCandidates],
errors: [...progressErrors]
})
}
for (const repo of repos) {
const result = await scanRepoWorkspaces({
@@ -211,7 +244,20 @@ export async function scanWorkspaceCleanup(
repo,
scannedAt,
targetWorktreeId: args.worktreeId,
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? [])
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
onWorktreesDiscovered: (count) => {
totalWorktreeCount += count
emitProgress()
},
onCandidateScanned: (candidate) => {
progressCandidates.push(candidate)
scannedWorktreeCount += 1
emitProgress()
},
onErrors: (repoErrors) => {
appendListItems(progressErrors, repoErrors)
emitProgress()
}
})
appendListItems(candidates, result.candidates)
appendListItems(errors, result.errors)
@@ -220,14 +266,25 @@ export async function scanWorkspaceCleanup(
return { scannedAt, candidates, errors }
}
async function scanRepoWorkspaces(args: {
store: Store
repo: Repo
scannedAt: number
targetWorktreeId?: string
skipGitWorktreeIds: Set<string>
}): Promise<WorkspaceCleanupScanResult> {
const { store, repo, scannedAt, targetWorktreeId, skipGitWorktreeIds } = args
async function scanRepoWorkspaces(
args: {
store: Store
repo: Repo
scannedAt: number
targetWorktreeId?: string
skipGitWorktreeIds: Set<string>
} & WorkspaceCleanupScanRepoProgress
): Promise<WorkspaceCleanupScanResult> {
const {
store,
repo,
scannedAt,
targetWorktreeId,
skipGitWorktreeIds,
onWorktreesDiscovered,
onCandidateScanned,
onErrors
} = args
const errors: WorkspaceCleanupScanResult['errors'] = []
let provider: IGitProvider | null = null
let gitWorktrees: GitWorktreeInfo[] = []
@@ -241,11 +298,16 @@ async function scanRepoWorkspaces(args: {
if (!provider) {
// Why: cleanup should reflect only workspaces Orca can currently
// inspect. Disconnected SSH repos are skipped in broad scans.
const candidates = targetWorktreeId
? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId)
: []
onWorktreesDiscovered?.(candidates.length)
for (const candidate of candidates) {
onCandidateScanned?.(candidate)
}
return {
scannedAt,
candidates: targetWorktreeId
? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId)
: [],
candidates,
errors: []
}
}
@@ -269,6 +331,7 @@ async function scanRepoWorkspaces(args: {
return { scannedAt, candidates: [], errors: [] }
}
errors.push(createScanError(repo, toSafeRepoScanError(error)))
onErrors?.(errors)
return { scannedAt, candidates: [], errors }
}
@@ -289,18 +352,26 @@ async function scanRepoWorkspaces(args: {
)
})
const candidates = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, (worktree) =>
buildCandidate({
repo,
worktree,
scannedAt,
provider,
skipGit: skipGitWorktreeIds.has(worktree.id),
forceGitCheck: Boolean(targetWorktreeId)
}).catch((error) => {
console.error('Workspace cleanup candidate scan failed', error)
return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error))
})
onWorktreesDiscovered?.(worktrees.length)
const candidates = await mapWithConcurrency(
worktrees,
WORKTREE_SCAN_CONCURRENCY,
async (worktree) => {
const candidate = await buildCandidate({
repo,
worktree,
scannedAt,
provider,
skipGit: skipGitWorktreeIds.has(worktree.id),
forceGitCheck: Boolean(targetWorktreeId)
}).catch((error) => {
console.error('Workspace cleanup candidate scan failed', error)
return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error))
})
onCandidateScanned?.(candidate)
return candidate
}
)
return { scannedAt, candidates, errors }
+5 -1
View File
@@ -391,6 +391,7 @@ import type {
WorkspaceCleanupLocalProcessArgs,
WorkspaceCleanupLocalProcessResult,
WorkspaceCleanupScanArgs,
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../shared/workspace-cleanup'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
@@ -989,7 +990,10 @@ export type PreloadApi = {
) => () => void
}
workspaceCleanup: {
scan: (args?: WorkspaceCleanupScanArgs) => Promise<WorkspaceCleanupScanResult>
scan: (
args?: WorkspaceCleanupScanArgs,
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
) => Promise<WorkspaceCleanupScanResult>
dismiss: (args: WorkspaceCleanupDismissArgs) => Promise<void>
clearDismissals: () => Promise<void>
hasKillableLocalProcesses: (
+19 -1
View File
@@ -79,6 +79,7 @@ import type {
RateLimitState
} from '../shared/rate-limit-types'
import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types'
import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup'
import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type { TaskSourceContext } from '../shared/task-source-context'
@@ -662,7 +663,24 @@ const api = {
} satisfies PreloadApi['worktrees'],
workspaceCleanup: {
scan: (args) => ipcRenderer.invoke('workspaceCleanup:scan', args),
scan: (args, onProgress) => {
if (!onProgress) {
return ipcRenderer.invoke('workspaceCleanup:scan', args)
}
const scanId = args?.scanId ?? crypto.randomUUID()
const listener = (
_event: Electron.IpcRendererEvent,
progress: WorkspaceCleanupScanProgress
): void => {
if (progress.scanId === scanId) {
onProgress(progress)
}
}
ipcRenderer.on('workspaceCleanup:scanProgress', listener)
return ipcRenderer
.invoke('workspaceCleanup:scan', { ...args, scanId })
.finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener))
},
dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args),
clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'),
hasKillableLocalProcesses: (args) =>
@@ -27,7 +27,6 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import RepoMultiCombobox from '@/components/ui/repo-multi-combobox'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
@@ -48,6 +47,7 @@ import {
type WorkspaceCleanupBlocker,
type WorkspaceCleanupCandidate,
type WorkspaceCleanupScanError,
type WorkspaceCleanupScanProgress,
type WorkspaceCleanupTier
} from '../../../../shared/workspace-cleanup'
import {
@@ -192,6 +192,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
const openModal = useAppStore((s) => s.openModal)
const closeModal = useAppStore((s) => s.closeModal)
const scan = useAppStore((s) => s.workspaceCleanupScan)
const scanProgress = useAppStore((s) => s.workspaceCleanupProgress)
const loading = useAppStore((s) => s.workspaceCleanupLoading)
const error = useAppStore((s) => s.workspaceCleanupError)
const repos = useAppStore((s) => s.repos)
@@ -407,7 +408,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
const protectedCount = groups.protected.length
const inactiveCount = filteredCandidates.length
const hasAnyCandidates = candidates.length > 0
const initialLoading = loading && !scan
const initialLoading = loading && !hasAnyCandidates
const activeBaseRows =
resolvedActiveView === 'hidden' ? hiddenCandidates : groups[resolvedActiveView]
const activeRows = useMemo(
@@ -670,7 +671,9 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
'Scanning worktrees and git state, then combining open tab, terminal, live agent, and remote availability signals before suggesting deletions.'
)}
</div>
<ScanProgress value={35} className="mt-2" />
<div className="mt-1 text-xs font-medium text-muted-foreground">
{formatWorkspaceCleanupProgress(scanProgress)}
</div>
</div>
</div>
) : hasAnyCandidates ? (
@@ -721,7 +724,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
<StatusPill>
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.946e8b0eb1',
'Refreshing'
'Scanning'
)}
</StatusPill>
) : null}
@@ -754,18 +757,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
</div>
) : null}
{loading && scan ? (
{loading && scan && hasAnyCandidates ? (
<div className="border-b border-border bg-muted/25 px-5 py-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4dc32d56c',
'Refreshing inactive workspaces. You can leave this modal; the last result stays visible.'
'Scanning inactive workspaces. New rows appear here as they finish.'
)}
</span>
<span className="font-medium text-foreground">
{formatWorkspaceCleanupProgress(scanProgress)}
</span>
</div>
<ScanProgress value={70} className="mt-2" />
</div>
) : null}
@@ -1002,22 +1007,6 @@ function StatusPill({
)
}
function ScanProgress({
value,
className
}: {
value: number
className?: string
}): React.JSX.Element {
return (
<Progress
value={value}
aria-label="Workspace cleanup scan progress"
className={cn('h-1 bg-muted', className)}
/>
)
}
function WorkspaceCleanupFilterToolbar({
filters,
sortKey,
@@ -1763,6 +1752,25 @@ function formatWorkspaceCleanupReadyToastDescription(
return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
}
function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | null): string {
if (!progress || progress.totalWorktreeCount === 0) {
return translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe',
'Finding inactive workspaces...'
)
}
const noun = progress.totalWorktreeCount === 1 ? 'workspace' : 'workspaces'
return translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.5bf2e88480',
'{{value0}}/{{value1}} {{value2}} scanned',
{
value0: progress.scannedWorktreeCount,
value1: progress.totalWorktreeCount,
value2: noun
}
)
}
function SkeletonRows(): React.JSX.Element {
return (
<div className="space-y-2">
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type {
WorkspaceCleanupCandidate,
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
import { createWorkspaceCleanupSlice, enrichWorkspaceCleanupCandidates } from './workspace-cleanup'
@@ -135,6 +136,55 @@ describe('workspace cleanup viewed rows', () => {
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('shows scanned cleanup candidates before the final broad scan resolves', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' })
const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [partialCandidate],
errors: []
})
expect(store.getState().workspaceCleanupLoading).toBe(true)
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress).toMatchObject({
scannedWorktreeCount: 1,
totalWorktreeCount: 2
})
})
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate])
pending.resolve({
scannedAt: NOW,
candidates: [partialCandidate, finalCandidate],
errors: []
})
await expect(scanPromise).resolves.toEqual({
scannedAt: NOW,
candidates: [partialCandidate, finalCandidate],
errors: []
})
expect(store.getState().workspaceCleanupLoading).toBe(false)
expect(store.getState().workspaceCleanupProgress).toMatchObject({
scannedWorktreeCount: 2,
totalWorktreeCount: 2
})
})
it('does not join broad cleanup scans with different explicit args', async () => {
const firstPending = deferred<WorkspaceCleanupScanResult>()
const secondPending = deferred<WorkspaceCleanupScanResult>()
@@ -436,9 +486,12 @@ describe('workspace cleanup viewed rows', () => {
await store.getState().scanWorkspaceCleanup()
expect(scan).toHaveBeenCalledWith({
skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace'])
})
expect(scan).toHaveBeenCalledWith(
{
skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace'])
},
expect.any(Function)
)
})
it('does not defer git checks for focused remove preflights', async () => {
@@ -18,6 +18,7 @@ import {
type WorkspaceCleanupCandidate,
type WorkspaceCleanupDismissal,
type WorkspaceCleanupScanArgs,
type WorkspaceCleanupScanProgress,
type WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
@@ -42,6 +43,7 @@ type WorkspaceCleanupViewedCandidate = {
export type WorkspaceCleanupSlice = {
workspaceCleanupScan: WorkspaceCleanupScanResult | null
workspaceCleanupProgress: WorkspaceCleanupScanProgress | null
workspaceCleanupLoading: boolean
workspaceCleanupError: string | null
workspaceCleanupDismissals: Record<string, WorkspaceCleanupDismissal>
@@ -105,6 +107,7 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
get
) => ({
workspaceCleanupScan: null,
workspaceCleanupProgress: null,
workspaceCleanupLoading: false,
workspaceCleanupError: null,
workspaceCleanupDismissals: {},
@@ -135,14 +138,31 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
return inFlightWorkspaceCleanupScan.promise
}
set({ workspaceCleanupLoading: true, workspaceCleanupError: null })
set({
workspaceCleanupLoading: true,
workspaceCleanupProgress: null,
workspaceCleanupError: null
})
const scanToken = ++latestWorkspaceCleanupScanToken
const promise = (async () => {
const scan = await window.api.workspaceCleanup.scan(scanArgs)
const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => {
void applyWorkspaceCleanupProgress(progress, scanToken, get, set)
})
const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, get())
const result = { ...scan, candidates: enriched }
if (scanToken === latestWorkspaceCleanupScanToken) {
set({ workspaceCleanupScan: result, workspaceCleanupLoading: false })
set({
workspaceCleanupScan: result,
workspaceCleanupProgress: {
scanId: get().workspaceCleanupProgress?.scanId ?? '',
scannedAt: result.scannedAt,
scannedWorktreeCount: result.candidates.length,
totalWorktreeCount: result.candidates.length,
candidates: result.candidates,
errors: result.errors
},
workspaceCleanupLoading: false
})
}
return result
})()
@@ -286,6 +306,29 @@ function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string {
})
}
async function applyWorkspaceCleanupProgress(
progress: WorkspaceCleanupScanProgress,
scanToken: number,
getState: () => AppState,
setState: (
partial: Partial<AppState> | ((state: AppState) => Partial<AppState>),
replace?: false
) => void
): Promise<void> {
const enriched = await enrichWorkspaceCleanupCandidates(progress.candidates, getState())
if (scanToken !== latestWorkspaceCleanupScanToken) {
return
}
setState({
workspaceCleanupScan: {
scannedAt: progress.scannedAt,
candidates: enriched,
errors: progress.errors
},
workspaceCleanupProgress: { ...progress, candidates: enriched }
})
}
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
+7
View File
@@ -74,6 +74,7 @@ export type WorkspaceCleanupCandidate = {
export type WorkspaceCleanupScanArgs = {
worktreeId?: string
skipGitWorktreeIds?: string[]
scanId?: string
}
export type WorkspaceCleanupLocalProcessArgs = {
@@ -94,6 +95,12 @@ export type WorkspaceCleanupScanResult = {
errors: WorkspaceCleanupScanError[]
}
export type WorkspaceCleanupScanProgress = WorkspaceCleanupScanResult & {
scanId: string
scannedWorktreeCount: number
totalWorktreeCount: number
}
export type WorkspaceCleanupLocalProcessResult = {
hasKillableProcesses: boolean | null
}