Merge remote-tracking branch 'origin/brennanb2025/workspace-cleanup-filter-model' into brennanb2025/review-pr-13413-dialog-ui

This commit is contained in:
Brennan Benson
2026-08-10 18:10:04 -07:00
31 changed files with 757 additions and 138 deletions
@@ -193,19 +193,48 @@ describe('workspace cleanup broad scan opt-in', () => {
})
it('reports folder workspaces with a folder-repo blocker', async () => {
const result = await scanWorkspaceCleanup(makeStore([FOLDER_REPO]), {
const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555`
const instanceMeta = makeWorktreeMeta({
displayName: 'Folder session',
lastActivityAt: NOW - 2 * DAY_MS
})
const result = await scanWorkspaceCleanup(
makeStore([FOLDER_REPO], { [instanceId]: instanceMeta }),
{ includeAllWorkspaces: true }
)
expect(result.candidates).toEqual([
expect.objectContaining({
worktreeId: 'repo-folder::/folder-workspace',
tier: 'protected',
selectedByDefault: false,
blockers: expect.arrayContaining(['folder-repo', 'main-worktree'])
}),
expect.objectContaining({
worktreeId: instanceId,
displayName: 'Folder session',
path: FOLDER_REPO.path,
tier: 'protected',
selectedByDefault: false,
blockers: ['folder-repo']
})
])
})
it('does not mark connected SSH folder workspaces as disconnected', async () => {
const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
getSshGitProviderMock.mockReturnValue({})
const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo]), {
includeAllWorkspaces: true
})
expect(result.candidates).toHaveLength(1)
expect(result.candidates[0]).toMatchObject({
worktreeId: 'repo-folder::/folder-workspace',
tier: 'protected',
selectedByDefault: false
connectionId: 'ssh-1',
blockers: ['main-worktree', 'folder-repo']
})
expect(result.candidates[0]?.blockers).toEqual(
expect.arrayContaining(['folder-repo', 'main-worktree'])
)
})
it('defers git evidence for rows that no cleanup decision can use', async () => {
@@ -258,6 +287,27 @@ describe('workspace cleanup broad scan opt-in', () => {
})
})
it('uses the backing path for disconnected SSH folder instances', async () => {
const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
const instanceId = `${sshFolderRepo.id}::${sshFolderRepo.path}::workspace:11111111-2222-4333-8444-555555555555`
const allMeta = {
[instanceId]: makeWorktreeMeta({ displayName: 'Remote folder session' })
}
const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo], allMeta), {
includeAllWorkspaces: true
})
expect(result.candidates).toEqual([
expect.objectContaining({
worktreeId: instanceId,
path: sshFolderRepo.path,
branch: 'folder-workspace',
blockers: ['ssh-disconnected']
})
])
})
it('streams every row through scan progress on an opt-in scan', async () => {
const progress: { scannedWorktreeCount: number; totalWorktreeCount: number }[] = []
@@ -1,6 +1,7 @@
import type { IGitProvider } from '../providers/types'
import { isFolderRepo } from '../../shared/repo-kind'
import type { Repo, Worktree } from '../../shared/types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import {
applyWorkspaceCleanupPolicy,
createWorkspaceCleanupFingerprint,
@@ -58,6 +59,7 @@ export async function buildWorkspaceCleanupCandidate(args: {
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
executionHostId: getRepoExecutionHostId(repo),
displayName: worktree.displayName,
branch: shortWorkspaceCleanupBranchName(worktree.branch),
path: worktree.path,
@@ -102,6 +104,7 @@ export function buildWorkspaceCleanupCandidateFromError(
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
executionHostId: getRepoExecutionHostId(repo),
displayName: worktree.displayName,
branch: shortWorkspaceCleanupBranchName(worktree.branch),
path: worktree.path,
@@ -1,4 +1,5 @@
import { basename } from 'node:path'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { Store } from '../persistence'
import type { Repo, WorktreeMeta } from '../../shared/types'
import {
@@ -6,7 +7,7 @@ import {
createWorkspaceCleanupFingerprint,
type WorkspaceCleanupCandidate
} from '../../shared/workspace-cleanup'
import { splitWorktreeId } from '../../shared/worktree-id'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import {
getNewestWorkspaceCleanupDiffCommentAt,
getWorkspaceCleanupInactivityReasonsForWorkspace,
@@ -52,7 +53,7 @@ function createDisconnectedSshCandidate(
worktreeId: string,
meta: WorktreeMeta
): WorkspaceCleanupCandidate {
const parsed = splitWorktreeId(worktreeId)
const parsed = splitWorktreeIdForFilesystem(worktreeId)
const path = parsed?.worktreePath ?? worktreeId
const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(meta, scannedAt)
return applyWorkspaceCleanupPolicy({
@@ -60,6 +61,7 @@ function createDisconnectedSshCandidate(
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
executionHostId: getRepoExecutionHostId(repo),
displayName: meta.displayName || basename(path),
branch: basename(path),
path,
@@ -0,0 +1,22 @@
import type { Store } from '../persistence'
import { createFolderWorktree } from '../repo-worktrees'
import type { Repo, Worktree } from '../../shared/types'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
import { mergeWorktree } from './worktree-logic'
export function listWorkspaceCleanupFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
const rootId = `${repo.id}::${repo.path}`
const instancePrefix = `${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
const allMeta = store.getAllWorktreeMeta()
const worktreeIds = [
rootId,
...Object.keys(allMeta).filter((worktreeId) => worktreeId.startsWith(instancePrefix))
]
const folderWorktree = createFolderWorktree(repo)
return worktreeIds.map((worktreeId) => ({
...mergeWorktree(repo.id, folderWorktree, store.getWorktreeMeta(worktreeId), repo.displayName),
id: worktreeId,
isMainWorktree: worktreeId === rootId
}))
}
@@ -30,6 +30,7 @@ import { registerWorkspaceCleanupHandlers } from './workspace-cleanup'
function makeEmptyStore(): Store {
return {
getProfileStorageDirectory: () => '/profile-a',
getRepos: () => [],
getWorktreeMeta: () => ({}),
getAllWorktreeMeta: () => ({}),
+13 -6
View File
@@ -33,6 +33,7 @@ import {
withWorkspaceCleanupTimeout
} from './workspace-cleanup-scan-primitives'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import { listWorkspaceCleanupFolderWorkspaces } from './workspace-cleanup-folder-workspaces'
const WORKTREE_SCAN_CONCURRENCY = 3
@@ -142,11 +143,14 @@ async function scanRepoWorkspaces(
return { scannedAt, candidates, errors: [] }
}
const mergedWorktrees = gitWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = store.getWorktreeMeta(worktreeId)
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
})
const mergedWorktrees =
repoIsFolder && includeAllWorkspaces
? listWorkspaceCleanupFolderWorkspaces(store, repo)
: gitWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = store.getWorktreeMeta(worktreeId)
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
})
// Why: with includeAllWorkspaces the browser shows every workspace and lets
// filters narrow it; an age threshold here would hide rows from all views.
const candidateWorktrees = targetWorktreeId
@@ -227,7 +231,10 @@ async function listCleanupGitWorktrees(
repoIsFolder: boolean
): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> {
if (repoIsFolder) {
return { provider: null, gitWorktrees: [createFolderWorktree(repo)] }
return {
provider: repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null,
gitWorktrees: [createFolderWorktree(repo)]
}
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId) ?? null
@@ -25,6 +25,7 @@ const NOW = 1_700_000_000_000
function makeEmptyStore(): Store {
return {
getProfileStorageDirectory: () => '/profile-a',
getRepos: () => [],
getWorktreeMeta: () => ({}),
getAllWorktreeMeta: () => ({}),
@@ -48,7 +49,7 @@ describe('workspace cleanup snapshot IPC', () => {
const args = { includeAllWorkspaces: true }
const result = await handler?.({ sender: { send: vi.fn() } } as never, args)
expect(persistScanResultMock).toHaveBeenCalledWith(args, result)
expect(persistScanResultMock).toHaveBeenCalledWith('/profile-a', args, result)
})
it('serves the cached scan snapshot through getCachedScan', async () => {
@@ -60,6 +61,7 @@ describe('workspace cleanup snapshot IPC', () => {
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:getCachedScan')?.[1]
await expect(handler?.({} as never)).resolves.toBe(snapshot)
expect(readScanSnapshotMock).toHaveBeenCalledWith('/profile-a')
expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan')
})
})
+7 -10
View File
@@ -29,6 +29,7 @@ export function registerWorkspaceCleanupHandlers(
store: Store,
deps: WorkspaceCleanupHandlerDeps = {}
): void {
const snapshotDirectory = store.getProfileStorageDirectory()
ipcMain.removeHandler('workspaceCleanup:scan')
ipcMain.removeHandler('workspaceCleanup:getCachedScan')
ipcMain.removeHandler('workspaceCleanup:dismiss')
@@ -44,21 +45,19 @@ export function registerWorkspaceCleanupHandlers(
: undefined
})
// Fire-and-forget: snapshot persistence must never delay or fail the scan reply.
void persistWorkspaceCleanupScanResult(args ?? {}, result)
void persistWorkspaceCleanupScanResult(snapshotDirectory, args ?? {}, result)
return result
}
)
ipcMain.handle(
'workspaceCleanup:getCachedScan',
(): Promise<WorkspaceCleanupScanResult | null> => readWorkspaceCleanupScanSnapshot()
(): Promise<WorkspaceCleanupScanResult | null> =>
readWorkspaceCleanupScanSnapshot(snapshotDirectory)
)
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
// Why spread the whole record: updateUI replaces `workspaceCleanup` wholesale,
// so writing dismissals alone would erase the persisted browse state.
const currentState = store.getUI().workspaceCleanup
const next = { ...currentState?.dismissals }
const next = { ...store.getUI().workspaceCleanup?.dismissals }
for (const dismissal of args.dismissals ?? []) {
if (
dismissal &&
@@ -69,13 +68,11 @@ export function registerWorkspaceCleanupHandlers(
next[dismissal.worktreeId] = dismissal
}
}
store.updateUI({ workspaceCleanup: { ...currentState, dismissals: next } })
store.updateUI({ workspaceCleanup: { dismissals: next } })
})
ipcMain.handle('workspaceCleanup:clearDismissals', () => {
store.updateUI({
workspaceCleanup: { ...store.getUI().workspaceCleanup, dismissals: {} }
})
store.updateUI({ workspaceCleanup: { dismissals: {} } })
})
ipcMain.handle(
+12 -7
View File
@@ -74,6 +74,10 @@ function createEvent() {
}
}
function createStore(): Store {
return { getProfileStorageDirectory: () => '/profile-a' } as Store
}
describe('registerWorkspaceSpaceHandlers', () => {
beforeEach(() => {
analyzeWorkspaceSpaceMock.mockReset()
@@ -82,7 +86,7 @@ describe('registerWorkspaceSpaceHandlers', () => {
})
it('shares an in-flight analysis request', async () => {
const store = {} as Store
const store = createStore()
let resolveFirstScan: (analysis: WorkspaceSpaceAnalysis) => void = () => {}
const firstScan = new Promise<WorkspaceSpaceAnalysis>((resolve) => {
resolveFirstScan = resolve
@@ -120,7 +124,7 @@ describe('registerWorkspaceSpaceHandlers', () => {
})
it('forwards scan progress to the requesting renderer', async () => {
const store = {} as Store
const store = createStore()
let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined
analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => {
onProgress = options.onProgress
@@ -150,7 +154,7 @@ describe('registerWorkspaceSpaceHandlers', () => {
})
it('cancels the in-flight scan', async () => {
const store = {} as Store
const store = createStore()
let signal: AbortSignal | undefined
analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => {
signal = options.signal
@@ -168,7 +172,7 @@ describe('registerWorkspaceSpaceHandlers', () => {
})
it('returns a normal cancelled result instead of rejecting expected cancellation', async () => {
const store = {} as Store
const store = createStore()
analyzeWorkspaceSpaceMock.mockRejectedValueOnce(new WorkspaceSpaceScanCancelledErrorMock())
registerWorkspaceSpaceHandlers(store)
@@ -182,21 +186,22 @@ describe('registerWorkspaceSpaceHandlers', () => {
const analysis = createAnalysis(7)
analyzeWorkspaceSpaceMock.mockResolvedValueOnce(analysis)
registerWorkspaceSpaceHandlers({} as Store)
registerWorkspaceSpaceHandlers(createStore())
const analyzeHandler = handlers.get('workspaceSpace:analyze')
await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: true, analysis })
expect(persistAnalysisSnapshotMock).toHaveBeenCalledWith(analysis)
expect(persistAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a', analysis)
})
it('serves the cached analysis through getCachedAnalysis', async () => {
const cached = createAnalysis(3)
readAnalysisSnapshotMock.mockResolvedValue(cached)
registerWorkspaceSpaceHandlers({} as Store)
registerWorkspaceSpaceHandlers(createStore())
expect(removeHandlerMock).toHaveBeenCalledWith('workspaceSpace:getCachedAnalysis')
const cachedHandler = handlers.get('workspaceSpace:getCachedAnalysis')
await expect(cachedHandler!()).resolves.toBe(cached)
expect(readAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a')
})
})
+4 -2
View File
@@ -24,6 +24,7 @@ type InFlightWorkspaceSpaceScan = {
}
export function registerWorkspaceSpaceHandlers(store: Store): void {
const snapshotDirectory = store.getProfileStorageDirectory()
let inFlightScan: InFlightWorkspaceSpaceScan | null = null
ipcMain.removeHandler('workspaceSpace:cancel')
ipcMain.removeHandler('workspaceSpace:analyze')
@@ -87,7 +88,7 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
.then((analysis): WorkspaceSpaceAnalyzeResult => {
// Fire-and-forget: a ~6-minute cold analysis must survive reload/restart, but
// persistence must never delay or fail the reply.
void persistWorkspaceSpaceAnalysisSnapshot(analysis)
void persistWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, analysis)
return { ok: true, analysis }
})
.catch((error: unknown): WorkspaceSpaceAnalyzeResult => {
@@ -105,7 +106,8 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
ipcMain.handle(
'workspaceSpace:getCachedAnalysis',
(): Promise<WorkspaceSpaceAnalysis | null> => readWorkspaceSpaceAnalysisSnapshot()
(): Promise<WorkspaceSpaceAnalysis | null> =>
readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
)
ipcMain.handle('workspaceSpace:cancel', async (): Promise<boolean> => {
+1
View File
@@ -151,6 +151,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
}
}
const store = {
getProfileStorageDirectory: vi.fn(() => '/profile-a'),
getRepos: vi.fn(),
getRepo: vi.fn(),
getProjects: vi.fn(),
+11 -2
View File
@@ -314,6 +314,7 @@ describe('registerWorktreeHandlers', () => {
}
const ipcEvent = { sender: { id: 1 } }
const store = {
getProfileStorageDirectory: vi.fn(() => '/profile-a'),
getRepos: vi.fn(),
getRepo: vi.fn(),
getProjects: vi.fn(),
@@ -8302,8 +8303,16 @@ describe('registerWorktreeHandlers', () => {
await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
// A removed workspace must never resurrect from the cached scan snapshots.
expect(pruneCleanupScanSnapshotMock).toHaveBeenCalledWith('repo-1::/workspace/feature-wt')
expect(pruneSpaceAnalysisSnapshotMock).toHaveBeenCalledWith('repo-1::/workspace/feature-wt')
expect(pruneCleanupScanSnapshotMock).toHaveBeenCalledWith(
'/profile-a',
'repo-1::/workspace/feature-wt',
'local'
)
expect(pruneSpaceAnalysisSnapshotMock).toHaveBeenCalledWith(
'/profile-a',
'repo-1::/workspace/feature-wt',
'local'
)
})
it('traces the removal as worktree.remove with a stage sub-span tree', async () => {
+3 -2
View File
@@ -339,8 +339,9 @@ function removeWorktreeMetadataAndTransientState(
// Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep).
pruneWorktreePRRefreshAliases(worktreeId)
// Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots.
void pruneWorkspaceCleanupScanSnapshot(worktreeId)
void pruneWorkspaceSpaceAnalysisSnapshot(worktreeId)
const snapshotDirectory = store.getProfileStorageDirectory()
void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId)
void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId)
}
function getProjectHostSetupMetaUpdates(
+24
View File
@@ -41,6 +41,7 @@ import { SshConnectionStore } from './ssh/ssh-connection-store'
import { setSourceControlActionDefault } from '../shared/source-control-ai-actions'
import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close'
import { createDefaultWorkspaceCleanupBrowseState } from '../shared/workspace-cleanup-browse-state'
// Shared mutable state so the electron mock can reference a per-test directory
const testState = { dir: '' }
@@ -6495,6 +6496,29 @@ describe('Store', () => {
})
})
it('updateUI preserves browse state when a legacy peer publishes dismissals only', async () => {
const store = await createStore()
const browse = createDefaultWorkspaceCleanupBrowseState()
browse.filters.query = 'stale'
store.updateUI({ workspaceCleanup: { dismissals: {}, browse } })
store.updateUI({
workspaceCleanup: {
dismissals: {
'wt-1': {
worktreeId: 'wt-1',
dismissedAt: 1700000000000,
fingerprint: 'fp-1',
classifierVersion: 2
}
}
}
})
expect(store.getUI().workspaceCleanup?.browse).toEqual(browse)
expect(store.getUI().workspaceCleanup?.dismissals).toHaveProperty('wt-1')
})
it('updateUI merges contextual tour seen ids instead of replacing stale snapshots', async () => {
const store = await createStore()
+9
View File
@@ -176,6 +176,7 @@ import {
} from '../shared/cross-platform-path'
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
import { normalizeTaskProviderSettings } from '../shared/task-providers'
import { mergeWorkspaceCleanupUIState } from '../shared/workspace-cleanup-ui-state'
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings'
import {
addMobilePairingCustomAddress,
@@ -4179,6 +4180,10 @@ export class Store {
// ── Repos ──────────────────────────────────────────────────────────
getProfileStorageDirectory(): string {
return dirname(this.dataFile)
}
getRepos(): Repo[] {
return this.state.repos.map((repo) => this.hydrateRepo(repo))
}
@@ -6037,6 +6042,10 @@ export class Store {
const nextUI = {
...currentUI,
...durableUpdates,
workspaceCleanup: mergeWorkspaceCleanupUIState(
currentUI.workspaceCleanup,
durableUpdates.workspaceCleanup
),
groupBy: durableUpdates.groupBy
? normalizeGroupBy(durableUpdates.groupBy)
: normalizeGroupBy(this.state.ui?.groupBy),
+21 -11
View File
@@ -3,21 +3,26 @@
// githubCache sidecar precedent). Best-effort by design: a lost snapshot only costs a rescan.
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { durableWriteTempPath, writeFileDurable } from './durable-file-write'
import { getCanonicalUserDataPath } from './persistence'
import {
durableWriteTempPath,
removeStaleDurableWriteTempFiles,
writeFileDurable
} from './durable-file-write'
const queues = new Map<string, Promise<unknown>>()
const staleTempCleanups = new Map<string, Promise<void>>()
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000
export function sidecarSnapshotFile(fileName: string): string {
return join(getCanonicalUserDataPath(), fileName)
export function sidecarSnapshotFile(snapshotDirectory: string, fileName: string): string {
return join(snapshotDirectory, fileName)
}
/** Serialize tasks per sidecar so read-modify-write patch/prune cycles cannot interleave. */
export function withSidecarSnapshotQueue<T>(fileName: string, task: () => Promise<T>): Promise<T> {
const previous = queues.get(fileName) ?? Promise.resolve()
export function withSidecarSnapshotQueue<T>(file: string, task: () => Promise<T>): Promise<T> {
const previous = queues.get(file) ?? Promise.resolve()
const run = previous.then(task, task)
queues.set(
fileName,
file,
run.then(
() => undefined,
() => undefined
@@ -27,15 +32,20 @@ export function withSidecarSnapshotQueue<T>(fileName: string, task: () => Promis
}
/** Parsed sidecar contents, or null when the file is missing or not valid JSON. */
export async function readSidecarSnapshot(fileName: string): Promise<unknown> {
export async function readSidecarSnapshot(file: string): Promise<unknown> {
try {
return JSON.parse(await readFile(sidecarSnapshotFile(fileName), 'utf-8')) as unknown
return JSON.parse(await readFile(file, 'utf-8')) as unknown
} catch {
return null
}
}
export async function writeSidecarSnapshot(fileName: string, payload: unknown): Promise<void> {
const file = sidecarSnapshotFile(fileName)
export async function writeSidecarSnapshot(file: string, payload: unknown): Promise<void> {
let cleanup = staleTempCleanups.get(file)
if (!cleanup) {
cleanup = removeStaleDurableWriteTempFiles(file, { minimumAgeMs: STALE_TEMP_AGE_MS })
staleTempCleanups.set(file, cleanup)
}
await cleanup
await writeFileDurable(durableWriteTempPath(file), file, JSON.stringify(payload))
}
@@ -165,8 +165,9 @@ function createMainWindow(
function createStore(): Store & { flushPendingAsync: MockFn } {
return {
getProfileStorageDirectory: vi.fn(() => '/profile-a'),
flushPendingAsync: vi.fn(() => Promise.resolve())
} as Store & { flushPendingAsync: MockFn }
} as unknown as Store & { flushPendingAsync: MockFn }
}
function createRuntime(): RuntimeStub {
+110 -23
View File
@@ -9,10 +9,6 @@ import type {
const { userDataDirHolder } = vi.hoisted(() => ({ userDataDirHolder: { dir: '' } }))
vi.mock('./persistence', () => ({
getCanonicalUserDataPath: () => userDataDirHolder.dir
}))
import {
persistWorkspaceCleanupScanResult,
pruneWorkspaceCleanupScanSnapshot,
@@ -31,6 +27,7 @@ function makeCandidate(
repoId: 'repo-1',
repoName: 'Repo',
connectionId: null,
executionHostId: 'local',
displayName: 'Feature',
branch: 'feature',
path: '/repo-feature',
@@ -71,62 +68,67 @@ describe('workspace cleanup scan snapshot', () => {
worktreeId: 'repo-ssh::/remote/repo-feature',
repoId: 'repo-ssh',
connectionId: 'ssh-1',
executionHostId: 'ssh:ssh-1',
path: '/remote/repo-feature',
blockers: ['ssh-disconnected'],
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }
})
const result = makeBroadResult([makeCandidate(), sshCandidate])
await persistWorkspaceCleanupScanResult({ includeAllWorkspaces: true }, result)
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ includeAllWorkspaces: true },
result
)
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toEqual(result)
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(result)
})
it('returns null when no snapshot has been persisted', async () => {
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
})
it('degrades corrupt persisted blobs to null instead of throwing', async () => {
const file = join(userDataDirHolder.dir, SNAPSHOT_FILE)
await writeFile(file, 'not json{', 'utf-8')
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
await writeFile(
file,
JSON.stringify({
version: 1,
version: 2,
argsFingerprint: workspaceCleanupScanSnapshotFingerprint(),
result: { scannedAt: NOW, candidates: 'nope', errors: [] }
}),
'utf-8'
)
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
await writeFile(
file,
JSON.stringify({
version: 1,
version: 2,
argsFingerprint: workspaceCleanupScanSnapshotFingerprint(),
result: { scannedAt: NOW, candidates: [{ worktreeId: 42 }], errors: [] }
}),
'utf-8'
)
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
})
it('treats a snapshot from another classifier version as absent', async () => {
await writeFile(
join(userDataDirHolder.dir, SNAPSHOT_FILE),
JSON.stringify({
version: 1,
version: 2,
argsFingerprint: 'classifier:1|includeAllWorkspaces',
result: makeBroadResult([makeCandidate()])
}),
'utf-8'
)
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
})
it('patches targeted rescans into the snapshot without touching other rows', async () => {
@@ -137,17 +139,19 @@ describe('workspace cleanup scan snapshot', () => {
branch: 'other'
})
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ includeAllWorkspaces: true },
makeBroadResult([stale, other])
)
const rescanned = makeCandidate({ tier: 'protected', blockers: ['dirty-files'] })
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ worktreeId: stale.worktreeId },
{ scannedAt: NOW + 60_000, candidates: [rescanned], errors: [] }
)
const snapshot = await readWorkspaceCleanupScanSnapshot()
const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)
expect(snapshot?.candidates).toEqual([rescanned, other])
// Snapshot freshness stays anchored to the last FULL scan.
expect(snapshot?.scannedAt).toBe(NOW)
@@ -155,17 +159,19 @@ describe('workspace cleanup scan snapshot', () => {
it('appends targeted rows the snapshot has not seen yet', async () => {
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ includeAllWorkspaces: true },
makeBroadResult([makeCandidate()])
)
const created = makeCandidate({ worktreeId: 'repo-1::/repo-new', path: '/repo-new' })
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ worktreeId: created.worktreeId },
{ scannedAt: NOW + 1, candidates: [created], errors: [] }
)
const snapshot = await readWorkspaceCleanupScanSnapshot()
const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)
expect(snapshot?.candidates.map((candidate) => candidate.worktreeId)).toEqual([
'repo-1::/repo-feature',
'repo-1::/repo-new'
@@ -174,11 +180,12 @@ describe('workspace cleanup scan snapshot', () => {
it('does not create a snapshot from a targeted scan alone', async () => {
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ worktreeId: 'repo-1::/repo-feature' },
makeBroadResult([makeCandidate()])
)
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
await expect(readFile(join(userDataDirHolder.dir, SNAPSHOT_FILE))).rejects.toMatchObject({
code: 'ENOENT'
})
@@ -189,15 +196,20 @@ describe('workspace cleanup scan snapshot', () => {
makeCandidate(),
makeCandidate({ worktreeId: 'repo-1::/b', path: '/b' })
])
await persistWorkspaceCleanupScanResult({ includeAllWorkspaces: true }, broad)
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ includeAllWorkspaces: true },
broad
)
const legacyRow = makeCandidate({ tier: 'review', selectedByDefault: false })
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{},
{ scannedAt: NOW + 1, candidates: [legacyRow], errors: [] }
)
const snapshot = await readWorkspaceCleanupScanSnapshot()
const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)
expect(snapshot?.candidates).toHaveLength(2)
expect(snapshot?.candidates[0]).toEqual(legacyRow)
})
@@ -205,17 +217,92 @@ describe('workspace cleanup scan snapshot', () => {
it('prunes a removed worktree so it cannot resurrect from cache', async () => {
const kept = makeCandidate({ worktreeId: 'repo-1::/repo-kept', path: '/repo-kept' })
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ includeAllWorkspaces: true },
makeBroadResult([makeCandidate(), kept])
)
await pruneWorkspaceCleanupScanSnapshot('repo-1::/repo-feature')
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local')
const snapshot = await readWorkspaceCleanupScanSnapshot()
const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)
expect(snapshot?.candidates).toEqual([kept])
// Unknown ids are a no-op.
await pruneWorkspaceCleanupScanSnapshot('repo-1::/never-existed')
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toEqual(snapshot)
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed')
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(snapshot)
})
it('keeps profile snapshots isolated', async () => {
const otherProfile = await mkdtemp(join(tmpdir(), 'orca-cleanup-snapshot-other-'))
try {
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ includeAllWorkspaces: true },
makeBroadResult([makeCandidate()])
)
await expect(readWorkspaceCleanupScanSnapshot(otherProfile)).resolves.toBeNull()
} finally {
await rm(otherProfile, { recursive: true, force: true })
}
})
it('does not let a scan started before removal restore the pruned row', async () => {
const staleResult = {
...makeBroadResult([makeCandidate()]),
scannedAt: Date.now() - 1
}
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local')
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ includeAllWorkspaces: true },
staleResult
)
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([])
const recreated = { ...staleResult, scannedAt: Date.now() + 1 }
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ includeAllWorkspaces: true },
recreated
)
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([
makeCandidate()
])
})
it('patches and prunes host-colliding workspace ids independently', async () => {
const local = makeCandidate()
const remote = makeCandidate({
connectionId: 'ssh-1',
executionHostId: 'ssh:ssh-1',
repoName: 'Remote repo'
})
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ includeAllWorkspaces: true },
makeBroadResult([local, remote])
)
const rescannedRemote = makeCandidate({
...remote,
tier: 'protected',
blockers: ['dirty-files']
})
await persistWorkspaceCleanupScanResult(
userDataDirHolder.dir,
{ worktreeId: remote.worktreeId },
{ scannedAt: NOW + 1, candidates: [rescannedRemote], errors: [] }
)
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([
local,
rescannedRemote
])
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, remote.worktreeId, 'ssh:ssh-1')
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([
local
])
})
})
+114 -19
View File
@@ -6,12 +6,22 @@ import {
} from '../shared/workspace-cleanup'
import {
readSidecarSnapshot,
sidecarSnapshotFile,
withSidecarSnapshotQueue,
writeSidecarSnapshot
} from './sidecar-snapshot-file'
import type { ExecutionHostId } from '../shared/execution-host'
const SNAPSHOT_FILE_NAME = 'orca-workspace-cleanup-scan.json'
const SNAPSHOT_VERSION = 1
const SNAPSHOT_VERSION = 2
type PrunedWorkspace = {
worktreeId: string
executionHostId?: ExecutionHostId
prunedAt: number
}
const prunedWorkspacesByFile = new Map<string, Map<string, PrunedWorkspace>>()
type PersistedWorkspaceCleanupScanSnapshot = {
version: number
@@ -37,6 +47,7 @@ function isPersistableCandidate(value: unknown): value is WorkspaceCleanupCandid
typeof value.repoId === 'string' &&
typeof value.fingerprint === 'string' &&
(value.connectionId === null || typeof value.connectionId === 'string') &&
typeof value.executionHostId === 'string' &&
Array.isArray(value.reasons) &&
Array.isArray(value.blockers) &&
isRecord(value.git) &&
@@ -70,16 +81,20 @@ function parseSnapshot(parsed: unknown): WorkspaceCleanupScanResult | null {
return result as unknown as WorkspaceCleanupScanResult
}
export async function readWorkspaceCleanupScanSnapshot(): Promise<WorkspaceCleanupScanResult | null> {
export async function readWorkspaceCleanupScanSnapshot(
snapshotDirectory: string
): Promise<WorkspaceCleanupScanResult | null> {
try {
return parseSnapshot(await readSidecarSnapshot(SNAPSHOT_FILE_NAME))
return parseSnapshot(
await readSidecarSnapshot(sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME))
)
} catch {
return null
}
}
async function writeSnapshot(result: WorkspaceCleanupScanResult): Promise<void> {
await writeSidecarSnapshot(SNAPSHOT_FILE_NAME, {
async function writeSnapshot(file: string, result: WorkspaceCleanupScanResult): Promise<void> {
await writeSidecarSnapshot(file, {
version: SNAPSHOT_VERSION,
argsFingerprint: workspaceCleanupScanSnapshotFingerprint(),
result
@@ -90,10 +105,11 @@ function patchCandidates(
existing: WorkspaceCleanupScanResult,
fresh: WorkspaceCleanupCandidate[]
): WorkspaceCleanupScanResult {
const freshById = new Map(fresh.map((candidate) => [candidate.worktreeId, candidate]))
const freshById = new Map(fresh.map((candidate) => [candidateSnapshotKey(candidate), candidate]))
const candidates = existing.candidates.map((candidate) => {
const replacement = freshById.get(candidate.worktreeId)
freshById.delete(candidate.worktreeId)
const key = candidateSnapshotKey(candidate)
const replacement = freshById.get(key)
freshById.delete(key)
return replacement ?? candidate
})
candidates.push(...freshById.values())
@@ -101,29 +117,94 @@ function patchCandidates(
return { ...existing, candidates }
}
function candidateSnapshotKey(
candidate: Pick<WorkspaceCleanupCandidate, 'executionHostId' | 'worktreeId'>
): string {
return `${candidate.executionHostId ?? 'local'}\0${candidate.worktreeId}`
}
function prunedWorkspaceKey(worktreeId: string, executionHostId?: ExecutionHostId): string {
return `${executionHostId ?? '*'}\0${worktreeId}`
}
function matchesPrunedWorkspace(
candidate: WorkspaceCleanupCandidate,
pruned: PrunedWorkspace
): boolean {
return (
candidate.worktreeId === pruned.worktreeId &&
(!pruned.executionHostId || candidate.executionHostId === pruned.executionHostId)
)
}
function excludeRowsPrunedDuringScan(
file: string,
result: WorkspaceCleanupScanResult
): WorkspaceCleanupScanResult {
const pruned = [...(prunedWorkspacesByFile.get(file)?.values() ?? [])]
if (pruned.length === 0) {
return result
}
const candidates = result.candidates.filter(
(candidate) =>
!pruned.some(
(entry) => entry.prunedAt >= result.scannedAt && matchesPrunedWorkspace(candidate, entry)
)
)
return candidates.length === result.candidates.length ? result : { ...result, candidates }
}
function clearSupersededPrunes(
file: string,
result: WorkspaceCleanupScanResult,
broad: boolean
): void {
const pruned = prunedWorkspacesByFile.get(file)
if (!pruned) {
return
}
for (const [key, entry] of pruned) {
if (
entry.prunedAt < result.scannedAt &&
(broad || result.candidates.some((candidate) => matchesPrunedWorkspace(candidate, entry)))
) {
pruned.delete(key)
}
}
if (pruned.size === 0) {
prunedWorkspacesByFile.delete(file)
}
}
/**
* Persist a completed scan: a broad (includeAllWorkspaces) scan replaces the snapshot, anything
* narrower patches matching rows into it. Never throws — the snapshot is a refetchable cache.
*/
export async function persistWorkspaceCleanupScanResult(
snapshotDirectory: string,
args: WorkspaceCleanupScanArgs,
result: WorkspaceCleanupScanResult
): Promise<void> {
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
try {
await withSidecarSnapshotQueue(SNAPSHOT_FILE_NAME, async () => {
if (!args.worktreeId && args.includeAllWorkspaces === true) {
await writeSnapshot(result)
await withSidecarSnapshotQueue(file, async () => {
const filteredResult = excludeRowsPrunedDuringScan(file, result)
const broad = !args.worktreeId && args.includeAllWorkspaces === true
if (broad) {
await writeSnapshot(file, filteredResult)
clearSupersededPrunes(file, result, true)
return
}
if (result.candidates.length === 0) {
if (filteredResult.candidates.length === 0) {
return
}
const existing = await readWorkspaceCleanupScanSnapshot()
const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory)
// Why: a focused/legacy scan is a subset; without a broad baseline it is not a fleet snapshot.
if (!existing) {
return
}
await writeSnapshot(patchCandidates(existing, result.candidates))
await writeSnapshot(file, patchCandidates(existing, filteredResult.candidates))
clearSupersededPrunes(file, result, false)
})
} catch (error) {
console.warn('[workspace-cleanup] failed to persist scan snapshot:', error)
@@ -131,20 +212,34 @@ export async function persistWorkspaceCleanupScanResult(
}
/** Drop a removed workspace so it never resurrects from cache. Never throws. */
export async function pruneWorkspaceCleanupScanSnapshot(worktreeId: string): Promise<void> {
export async function pruneWorkspaceCleanupScanSnapshot(
snapshotDirectory: string,
worktreeId: string,
executionHostId?: ExecutionHostId
): Promise<void> {
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
const pruned = prunedWorkspacesByFile.get(file) ?? new Map<string, PrunedWorkspace>()
pruned.set(prunedWorkspaceKey(worktreeId, executionHostId), {
worktreeId,
...(executionHostId ? { executionHostId } : {}),
prunedAt: Date.now()
})
prunedWorkspacesByFile.set(file, pruned)
try {
await withSidecarSnapshotQueue(SNAPSHOT_FILE_NAME, async () => {
const existing = await readWorkspaceCleanupScanSnapshot()
await withSidecarSnapshotQueue(file, async () => {
const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory)
if (!existing) {
return
}
const candidates = existing.candidates.filter(
(candidate) => candidate.worktreeId !== worktreeId
(candidate) =>
candidate.worktreeId !== worktreeId ||
(executionHostId !== undefined && candidate.executionHostId !== executionHostId)
)
if (candidates.length === existing.candidates.length) {
return
}
await writeSnapshot({ ...existing, candidates })
await writeSnapshot(file, { ...existing, candidates })
})
} catch (error) {
console.warn('[workspace-cleanup] failed to prune scan snapshot:', error)
@@ -9,10 +9,6 @@ import type {
const { userDataDirHolder } = vi.hoisted(() => ({ userDataDirHolder: { dir: '' } }))
vi.mock('./persistence', () => ({
getCanonicalUserDataPath: () => userDataDirHolder.dir
}))
import {
persistWorkspaceSpaceAnalysisSnapshot,
pruneWorkspaceSpaceAnalysisSnapshot,
@@ -26,6 +22,7 @@ function makeWorktreeRow(overrides: Partial<WorkspaceSpaceWorktree> = {}): Works
return {
worktreeId: 'repo-1::/repo-feature',
repoId: 'repo-1',
executionHostId: 'local',
repoDisplayName: 'Repo',
repoPath: '/repo',
displayName: 'Feature',
@@ -51,6 +48,7 @@ function makeWorktreeRow(overrides: Partial<WorkspaceSpaceWorktree> = {}): Works
function makeAnalysis(worktrees: WorkspaceSpaceWorktree[]): WorkspaceSpaceAnalysis {
const okRows = worktrees.filter((row) => row.status === 'ok')
const rowsByHost = Map.groupBy(worktrees, (row) => row.executionHostId ?? 'local')
return {
scannedAt: NOW,
totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0),
@@ -58,20 +56,22 @@ function makeAnalysis(worktrees: WorkspaceSpaceWorktree[]): WorkspaceSpaceAnalys
worktreeCount: worktrees.length,
scannedWorktreeCount: okRows.length,
unavailableWorktreeCount: worktrees.length - okRows.length,
repos: [
{
repos: [...rowsByHost.entries()].map(([executionHostId, rows]) => {
const scanned = rows.filter((row) => row.status === 'ok')
return {
repoId: 'repo-1',
executionHostId,
displayName: 'Repo',
path: '/repo',
isRemote: false,
worktreeCount: worktrees.length,
scannedWorktreeCount: okRows.length,
unavailableWorktreeCount: worktrees.length - okRows.length,
totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0),
reclaimableBytes: worktrees.reduce((sum, row) => sum + row.reclaimableBytes, 0),
isRemote: executionHostId !== 'local',
worktreeCount: rows.length,
scannedWorktreeCount: scanned.length,
unavailableWorktreeCount: rows.length - scanned.length,
totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0),
reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0),
error: null
}
],
}),
worktrees
}
}
@@ -88,9 +88,11 @@ describe('workspace space analysis snapshot', () => {
it('round-trips a completed analysis', async () => {
const analysis = makeAnalysis([makeWorktreeRow()])
await persistWorkspaceSpaceAnalysisSnapshot(analysis)
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, analysis)
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toEqual(analysis)
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual(
analysis
)
})
it('prunes topLevelItems into the omitted counters to bound the payload', async () => {
@@ -109,9 +111,9 @@ describe('workspace space analysis snapshot', () => {
omittedTopLevelSizeBytes: 500
})
await persistWorkspaceSpaceAnalysisSnapshot(makeAnalysis([row]))
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, makeAnalysis([row]))
const cached = await readWorkspaceSpaceAnalysisSnapshot()
const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)
expect(cached?.worktrees[0]).toMatchObject({
sizeBytes: 5000,
topLevelItems: [],
@@ -121,28 +123,28 @@ describe('workspace space analysis snapshot', () => {
})
it('returns null when missing or corrupt instead of throwing', async () => {
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toBeNull()
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
const file = join(userDataDirHolder.dir, SNAPSHOT_FILE)
await writeFile(file, '{"version":1,"analysis":', 'utf-8')
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toBeNull()
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
await writeFile(
file,
JSON.stringify({
version: 1,
version: 2,
analysis: { scannedAt: 'yesterday', repos: [], worktrees: [] }
}),
'utf-8'
)
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toBeNull()
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
await writeFile(
file,
JSON.stringify({ version: 99, analysis: makeAnalysis([makeWorktreeRow()]) }),
'utf-8'
)
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toBeNull()
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
})
it('prunes a removed worktree row and rebalances totals', async () => {
@@ -154,11 +156,14 @@ describe('workspace space analysis snapshot', () => {
sizeBytes: 1000,
reclaimableBytes: 0
})
await persistWorkspaceSpaceAnalysisSnapshot(makeAnalysis([removed, kept]))
await persistWorkspaceSpaceAnalysisSnapshot(
userDataDirHolder.dir,
makeAnalysis([removed, kept])
)
await pruneWorkspaceSpaceAnalysisSnapshot(removed.worktreeId)
await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, removed.worktreeId, 'local')
const cached = await readWorkspaceSpaceAnalysisSnapshot()
const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)
expect(cached?.worktrees.map((row) => row.worktreeId)).toEqual(['repo-1::/repo-kept'])
expect(cached).toMatchObject({
worktreeCount: 1,
@@ -176,7 +181,79 @@ describe('workspace space analysis snapshot', () => {
})
// Unknown ids are a no-op.
await pruneWorkspaceSpaceAnalysisSnapshot('repo-1::/never-existed')
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toEqual(cached)
await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed')
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual(cached)
})
it('keeps profile snapshots isolated', async () => {
const otherProfile = await mkdtemp(join(tmpdir(), 'orca-space-snapshot-other-'))
try {
await persistWorkspaceSpaceAnalysisSnapshot(
userDataDirHolder.dir,
makeAnalysis([makeWorktreeRow()])
)
await expect(readWorkspaceSpaceAnalysisSnapshot(otherProfile)).resolves.toBeNull()
} finally {
await rm(otherProfile, { recursive: true, force: true })
}
})
it('does not let an analysis started before removal restore the pruned row', async () => {
const staleAnalysis = {
...makeAnalysis([makeWorktreeRow()]),
scannedAt: Date.now() - 1
}
await pruneWorkspaceSpaceAnalysisSnapshot(
userDataDirHolder.dir,
'repo-1::/repo-feature',
'local'
)
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis)
expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([])
const recreated = { ...staleAnalysis, scannedAt: Date.now() + 1 }
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, recreated)
expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([
makeWorktreeRow()
])
})
it('prunes host-colliding workspace ids without corrupting surviving totals', async () => {
const local = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 })
const remote = makeWorktreeRow({
executionHostId: 'ssh:ssh-1',
isRemote: true,
sizeBytes: 3000,
reclaimableBytes: 3000
})
await persistWorkspaceSpaceAnalysisSnapshot(
userDataDirHolder.dir,
makeAnalysis([local, remote])
)
await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, remote.worktreeId, 'ssh:ssh-1')
const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)
expect(cached?.worktrees).toEqual([local])
expect(cached).toMatchObject({
worktreeCount: 1,
scannedWorktreeCount: 1,
totalSizeBytes: 1000,
reclaimableBytes: 1000
})
expect(cached?.repos).toEqual([
expect.objectContaining({
executionHostId: 'local',
worktreeCount: 1,
totalSizeBytes: 1000
}),
expect.objectContaining({
executionHostId: 'ssh:ssh-1',
worktreeCount: 0,
totalSizeBytes: 0
})
])
})
})
+98 -15
View File
@@ -4,12 +4,22 @@ import type {
} from '../shared/workspace-space-types'
import {
readSidecarSnapshot,
sidecarSnapshotFile,
withSidecarSnapshotQueue,
writeSidecarSnapshot
} from './sidecar-snapshot-file'
import type { ExecutionHostId } from '../shared/execution-host'
const SNAPSHOT_FILE_NAME = 'orca-workspace-space-analysis.json'
const SNAPSHOT_VERSION = 1
const SNAPSHOT_VERSION = 2
type PrunedWorkspace = {
worktreeId: string
executionHostId?: ExecutionHostId
prunedAt: number
}
const prunedWorkspacesByFile = new Map<string, Map<string, PrunedWorkspace>>()
type PersistedWorkspaceSpaceAnalysisSnapshot = {
version: number
@@ -27,6 +37,7 @@ function isPersistableWorktreeRow(value: unknown): value is WorkspaceSpaceWorktr
return (
typeof value.worktreeId === 'string' &&
typeof value.repoId === 'string' &&
typeof value.executionHostId === 'string' &&
typeof value.status === 'string' &&
typeof value.sizeBytes === 'number' &&
typeof value.reclaimableBytes === 'number' &&
@@ -71,16 +82,20 @@ function stripTopLevelItems(analysis: WorkspaceSpaceAnalysis): WorkspaceSpaceAna
}
}
export async function readWorkspaceSpaceAnalysisSnapshot(): Promise<WorkspaceSpaceAnalysis | null> {
export async function readWorkspaceSpaceAnalysisSnapshot(
snapshotDirectory: string
): Promise<WorkspaceSpaceAnalysis | null> {
try {
return parseSnapshot(await readSidecarSnapshot(SNAPSHOT_FILE_NAME))
return parseSnapshot(
await readSidecarSnapshot(sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME))
)
} catch {
return null
}
}
async function writeSnapshot(analysis: WorkspaceSpaceAnalysis): Promise<void> {
await writeSidecarSnapshot(SNAPSHOT_FILE_NAME, {
async function writeSnapshot(file: string, analysis: WorkspaceSpaceAnalysis): Promise<void> {
await writeSidecarSnapshot(file, {
version: SNAPSHOT_VERSION,
analysis
} satisfies PersistedWorkspaceSpaceAnalysisSnapshot)
@@ -88,12 +103,15 @@ async function writeSnapshot(analysis: WorkspaceSpaceAnalysis): Promise<void> {
/** Persist a completed analysis. Never throws — the snapshot is a refetchable cache. */
export async function persistWorkspaceSpaceAnalysisSnapshot(
snapshotDirectory: string,
analysis: WorkspaceSpaceAnalysis
): Promise<void> {
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
try {
await withSidecarSnapshotQueue(SNAPSHOT_FILE_NAME, () =>
writeSnapshot(stripTopLevelItems(analysis))
)
await withSidecarSnapshotQueue(file, async () => {
await writeSnapshot(file, stripTopLevelItems(excludeRowsPrunedDuringScan(file, analysis)))
clearSupersededPrunes(file, analysis)
})
} catch (error) {
console.warn('[workspace-space] failed to persist analysis snapshot:', error)
}
@@ -107,14 +125,14 @@ function withoutWorktreeRow(
const unavailableDelta = removed.status === 'ok' ? 0 : 1
return {
...analysis,
worktrees: analysis.worktrees.filter((row) => row.worktreeId !== removed.worktreeId),
worktrees: analysis.worktrees.filter((row) => !isSameWorkspace(row, removed)),
worktreeCount: Math.max(0, analysis.worktreeCount - 1),
scannedWorktreeCount: Math.max(0, analysis.scannedWorktreeCount - scannedDelta),
unavailableWorktreeCount: Math.max(0, analysis.unavailableWorktreeCount - unavailableDelta),
totalSizeBytes: Math.max(0, analysis.totalSizeBytes - removed.sizeBytes),
reclaimableBytes: Math.max(0, analysis.reclaimableBytes - removed.reclaimableBytes),
repos: analysis.repos.map((repo) =>
repo.repoId === removed.repoId
repo.repoId === removed.repoId && repo.executionHostId === removed.executionHostId
? {
...repo,
worktreeCount: Math.max(0, repo.worktreeCount - 1),
@@ -128,16 +146,81 @@ function withoutWorktreeRow(
}
}
function isSameWorkspace(
left: Pick<WorkspaceSpaceWorktree, 'executionHostId' | 'worktreeId'>,
right: Pick<WorkspaceSpaceWorktree, 'executionHostId' | 'worktreeId'>
): boolean {
return left.worktreeId === right.worktreeId && left.executionHostId === right.executionHostId
}
function prunedWorkspaceKey(worktreeId: string, executionHostId?: ExecutionHostId): string {
return `${executionHostId ?? '*'}\0${worktreeId}`
}
function matchesPrunedWorkspace(row: WorkspaceSpaceWorktree, pruned: PrunedWorkspace): boolean {
return (
row.worktreeId === pruned.worktreeId &&
(!pruned.executionHostId || row.executionHostId === pruned.executionHostId)
)
}
function excludeRowsPrunedDuringScan(
file: string,
analysis: WorkspaceSpaceAnalysis
): WorkspaceSpaceAnalysis {
const pruned = [...(prunedWorkspacesByFile.get(file)?.values() ?? [])]
return analysis.worktrees.reduce(
(current, row) =>
pruned.some(
(entry) => entry.prunedAt >= analysis.scannedAt && matchesPrunedWorkspace(row, entry)
)
? withoutWorktreeRow(current, row)
: current,
analysis
)
}
function clearSupersededPrunes(file: string, analysis: WorkspaceSpaceAnalysis): void {
const pruned = prunedWorkspacesByFile.get(file)
if (!pruned) {
return
}
for (const [key, entry] of pruned) {
if (entry.prunedAt < analysis.scannedAt) {
pruned.delete(key)
}
}
if (pruned.size === 0) {
prunedWorkspacesByFile.delete(file)
}
}
/** Drop a removed workspace's row and rebalance the totals it contributed. Never throws. */
export async function pruneWorkspaceSpaceAnalysisSnapshot(worktreeId: string): Promise<void> {
export async function pruneWorkspaceSpaceAnalysisSnapshot(
snapshotDirectory: string,
worktreeId: string,
executionHostId?: ExecutionHostId
): Promise<void> {
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
const pruned = prunedWorkspacesByFile.get(file) ?? new Map<string, PrunedWorkspace>()
pruned.set(prunedWorkspaceKey(worktreeId, executionHostId), {
worktreeId,
...(executionHostId ? { executionHostId } : {}),
prunedAt: Date.now()
})
prunedWorkspacesByFile.set(file, pruned)
try {
await withSidecarSnapshotQueue(SNAPSHOT_FILE_NAME, async () => {
const existing = await readWorkspaceSpaceAnalysisSnapshot()
const removed = existing?.worktrees.find((row) => row.worktreeId === worktreeId)
await withSidecarSnapshotQueue(file, async () => {
const existing = await readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
const removed = existing?.worktrees.find(
(row) =>
row.worktreeId === worktreeId &&
(executionHostId === undefined || row.executionHostId === executionHostId)
)
if (!existing || !removed) {
return
}
await writeSnapshot(withoutWorktreeRow(existing, removed))
await writeSnapshot(file, withoutWorktreeRow(existing, removed))
})
} catch (error) {
console.warn('[workspace-space] failed to prune analysis snapshot:', error)
+4
View File
@@ -34,6 +34,7 @@ import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
import { mergeWorktree } from './ipc/worktree-logic'
import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options'
import { getRepoExecutionHostId } from '../shared/execution-host'
const REPO_SCAN_CONCURRENCY = 2
const WORKTREE_SCAN_CONCURRENCY = 3
@@ -309,6 +310,7 @@ function createBaseWorktreeRow(
return {
worktreeId: worktree.id,
repoId: repo.id,
executionHostId: getRepoExecutionHostId(repo),
repoDisplayName: repo.displayName,
repoPath: repo.path,
displayName: worktree.displayName,
@@ -733,6 +735,7 @@ async function scanRepo(
worktrees: [],
summary: {
repoId: repo.id,
executionHostId: getRepoExecutionHostId(repo),
displayName: repo.displayName,
path: repo.path,
isRemote: Boolean(repo.connectionId),
@@ -809,6 +812,7 @@ async function scanRepo(
worktrees: rows,
summary: {
repoId: repo.id,
executionHostId: getRepoExecutionHostId(repo),
displayName: repo.displayName,
path: repo.path,
isRemote: Boolean(repo.connectionId),
@@ -31,8 +31,8 @@ export const createWorkspaceCleanupBrowseSlice: StateCreator<
}
persistTimer = setTimeout(() => {
persistTimer = null
// Why dismissals ride along: main replaces `workspaceCleanup` wholesale on
// ui.set, so a browse-only payload would erase every dismissal.
// Why dismissals ride along: the legacy wire schema requires the field,
// while `browse` stays optional for older clients.
window.api.ui
.set({
workspaceCleanup: {
@@ -552,6 +552,24 @@ describe('workspace cleanup scan progress', () => {
expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult)
})
it('does not join legacy and full-workspace broad scans', async () => {
const legacyPending = deferred<WorkspaceCleanupScanResult>()
const fullPending = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn((args?: { includeAllWorkspaces?: boolean }) =>
args?.includeAllWorkspaces ? fullPending.promise : legacyPending.promise
)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const legacy = store.getState().scanWorkspaceCleanup()
const full = store.getState().scanWorkspaceCleanup({ includeAllWorkspaces: true })
expect(scan).toHaveBeenCalledTimes(2)
fullPending.resolve({ scannedAt: NOW, candidates: [], errors: [] })
legacyPending.resolve({ scannedAt: NOW - 1, candidates: [], errors: [] })
await expect(Promise.all([legacy, full])).resolves.toHaveLength(2)
})
it('keeps stale cleanup results visible after a broad refresh failure', async () => {
const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
const scan = vi
+5
View File
@@ -146,6 +146,7 @@ import {
parseRuntimeNativeChatTurnLifecycle
} from '@/components/native-chat/native-chat-runtime-contract'
import { createWebFileMutationMethods } from './web-file-mutation-methods'
import { mergeWorkspaceCleanupUIState } from '../../../shared/workspace-cleanup-ui-state'
const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1'
const UI_STORAGE_KEY = 'orca.web.ui.v1'
@@ -3916,6 +3917,10 @@ function mergeWebUIState(
return {
...base,
...safeUpdates,
workspaceCleanup: mergeWorkspaceCleanupUIState(
base.workspaceCleanup,
safeUpdates.workspaceCleanup
),
worktreeCardProperties: normalizeWorktreeCardProperties(
safeUpdates.worktreeCardProperties ?? base.worktreeCardProperties
),
@@ -55,6 +55,22 @@ describe('workspace cleanup browse state persistence', () => {
expect(normalized).not.toHaveProperty('unknownTopLevelField')
})
it('drops invalid persisted host ids without losing valid SSH and runtime hosts', () => {
const normalized = normalizeWorkspaceCleanupBrowseState({
filters: {
location: {
hostIds: ['local', 'ssh:build%20box', 'not-a-host', 'runtime:linux', 'ssh:']
}
}
})
expect(normalized.filters.location.hostIds).toEqual([
'local',
'ssh:build%20box',
'runtime:linux'
])
})
it.each([null, undefined, 'corrupt', 42, [], { filters: 'nonsense' }])(
'never throws on a corrupt blob (%p)',
(value) => {
@@ -1,4 +1,4 @@
import type { ExecutionHostId } from './execution-host'
import { normalizeExecutionHostId, type ExecutionHostId } from './execution-host'
import type { HostedReviewProvider } from './hosted-review'
import {
WORKSPACE_CLEANUP_AGENT_STATE_VALUES,
@@ -99,7 +99,7 @@ export function normalizeWorkspaceCleanupFilterState(value: unknown): WorkspaceC
completelyEmpty: asBoolean(context.completelyEmpty, base.context.completelyEmpty)
},
location: {
hostIds: asStringList(location.hostIds) as ExecutionHostId[],
hostIds: asExecutionHostIdList(location.hostIds),
repoIds: asStringList(location.repoIds),
pathPrefix: asString(location.pathPrefix, base.location.pathPrefix)
},
@@ -199,6 +199,23 @@ export function asStringList(value: unknown): string[] {
return [...seen]
}
function asExecutionHostIdList(value: unknown): ExecutionHostId[] {
if (!Array.isArray(value)) {
return []
}
const result = new Set<ExecutionHostId>()
for (const entry of value) {
if (typeof entry !== 'string') {
continue
}
const hostId = normalizeExecutionHostId(entry)
if (hostId) {
result.add(hostId)
}
}
return [...result]
}
function asTriState(
value: unknown,
fallback: WorkspaceCleanupFilterState['status']['archived']
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { createDefaultWorkspaceCleanupBrowseState } from './workspace-cleanup-browse-state'
import { mergeWorkspaceCleanupUIState } from './workspace-cleanup-ui-state'
describe('workspace cleanup UI state', () => {
it('keeps browse state when a legacy peer publishes dismissals only', () => {
const browse = createDefaultWorkspaceCleanupBrowseState()
browse.filters.query = 'stale'
expect(
mergeWorkspaceCleanupUIState(
{ dismissals: {}, browse },
{
dismissals: {
'wt-1': {
worktreeId: 'wt-1',
dismissedAt: 1700000000000,
fingerprint: 'fp-1',
classifierVersion: 2
}
}
}
)
).toEqual({
dismissals: {
'wt-1': {
worktreeId: 'wt-1',
dismissedAt: 1700000000000,
fingerprint: 'fp-1',
classifierVersion: 2
}
},
browse
})
})
it('accepts browse state published by a compatible peer', () => {
const current = createDefaultWorkspaceCleanupBrowseState()
current.filters.query = 'local'
const incoming = createDefaultWorkspaceCleanupBrowseState()
incoming.filters.query = 'host'
expect(
mergeWorkspaceCleanupUIState(
{ dismissals: {}, browse: current },
{ dismissals: {}, browse: incoming }
)?.browse
).toBe(incoming)
})
})
+15
View File
@@ -0,0 +1,15 @@
import type { WorkspaceCleanupUIState } from './workspace-cleanup'
/** Keep browse state when an older client or host publishes dismissals only. */
export function mergeWorkspaceCleanupUIState(
current: WorkspaceCleanupUIState | undefined,
incoming: WorkspaceCleanupUIState | undefined
): WorkspaceCleanupUIState | undefined {
if (!incoming) {
return current
}
return {
...current,
...incoming
}
}
+2
View File
@@ -1,5 +1,6 @@
// Type-only, so the cycle back through workspace-cleanup-filter-model erases at build.
import type { WorkspaceCleanupBrowseState } from './workspace-cleanup-browse-state'
import type { ExecutionHostId } from './execution-host'
export const WORKSPACE_CLEANUP_CLASSIFIER_VERSION = 2
export const WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS = 7 * 24 * 60 * 60 * 1000
@@ -50,6 +51,7 @@ export type WorkspaceCleanupCandidate = {
repoId: string
repoName: string
connectionId: string | null
executionHostId?: ExecutionHostId
displayName: string
branch: string
path: string
+4
View File
@@ -1,3 +1,5 @@
import type { ExecutionHostId } from './execution-host'
export type WorkspaceSpaceScanStatus =
| 'ok'
| 'missing'
@@ -17,6 +19,7 @@ export type WorkspaceSpaceItem = {
export type WorkspaceSpaceWorktree = {
worktreeId: string
repoId: string
executionHostId?: ExecutionHostId
repoDisplayName: string
repoPath: string
displayName: string
@@ -40,6 +43,7 @@ export type WorkspaceSpaceWorktree = {
export type WorkspaceSpaceRepoSummary = {
repoId: string
executionHostId?: ExecutionHostId
displayName: string
path: string
isRemote: boolean