diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index e7ec27412db..0f663f4c58e 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -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 }[] = [] diff --git a/src/main/ipc/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup-candidate.ts index e25af90964c..78fc4d8bb3f 100644 --- a/src/main/ipc/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -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, diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index 9e2ce2eef47..70f48e0aba3 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -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, diff --git a/src/main/ipc/workspace-cleanup-folder-workspaces.ts b/src/main/ipc/workspace-cleanup-folder-workspaces.ts new file mode 100644 index 00000000000..597c70db44b --- /dev/null +++ b/src/main/ipc/workspace-cleanup-folder-workspaces.ts @@ -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 + })) +} diff --git a/src/main/ipc/workspace-cleanup-process-preflight.test.ts b/src/main/ipc/workspace-cleanup-process-preflight.test.ts index cd8ddafaf2c..dce5abfaf12 100644 --- a/src/main/ipc/workspace-cleanup-process-preflight.test.ts +++ b/src/main/ipc/workspace-cleanup-process-preflight.test.ts @@ -30,6 +30,7 @@ import { registerWorkspaceCleanupHandlers } from './workspace-cleanup' function makeEmptyStore(): Store { return { + getProfileStorageDirectory: () => '/profile-a', getRepos: () => [], getWorktreeMeta: () => ({}), getAllWorktreeMeta: () => ({}), diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index efa5b79fa52..4af4ea36eb4 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -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 diff --git a/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts index c4d45d963d7..e8bcbb59608 100644 --- a/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts +++ b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts @@ -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') }) }) diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts index 106d3e60087..829a1918470 100644 --- a/src/main/ipc/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup.ts @@ -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 => readWorkspaceCleanupScanSnapshot() + (): Promise => + 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( diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts index 006feec805c..2c3d373ccd0 100644 --- a/src/main/ipc/workspace-space.test.ts +++ b/src/main/ipc/workspace-space.test.ts @@ -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((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') }) }) diff --git a/src/main/ipc/workspace-space.ts b/src/main/ipc/workspace-space.ts index 313e8a08aa2..12d2e54c4d7 100644 --- a/src/main/ipc/workspace-space.ts +++ b/src/main/ipc/workspace-space.ts @@ -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 => readWorkspaceSpaceAnalysisSnapshot() + (): Promise => + readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory) ) ipcMain.handle('workspaceSpace:cancel', async (): Promise => { diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index ee9540048cd..692c2ea6d51 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -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(), diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 3790a4c95d6..edbec14ed86 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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 () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 107353b2c97..af01dd1f5b0 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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( diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b59ca6f5986..de1bf8aaff3 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 08697613c31..d6791ebc9be 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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), diff --git a/src/main/sidecar-snapshot-file.ts b/src/main/sidecar-snapshot-file.ts index 3232457d2df..f7c0f23c0e4 100644 --- a/src/main/sidecar-snapshot-file.ts +++ b/src/main/sidecar-snapshot-file.ts @@ -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>() +const staleTempCleanups = new Map>() +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(fileName: string, task: () => Promise): Promise { - const previous = queues.get(fileName) ?? Promise.resolve() +export function withSidecarSnapshotQueue(file: string, task: () => Promise): Promise { + 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(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 { +export async function readSidecarSnapshot(file: string): Promise { 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 { - const file = sidecarSnapshotFile(fileName) +export async function writeSidecarSnapshot(file: string, payload: unknown): Promise { + 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)) } diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 28fbeb4cd3d..ac473733753 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -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 { diff --git a/src/main/workspace-cleanup-scan-snapshot.test.ts b/src/main/workspace-cleanup-scan-snapshot.test.ts index 70ebe097ecc..4a9f051a928 100644 --- a/src/main/workspace-cleanup-scan-snapshot.test.ts +++ b/src/main/workspace-cleanup-scan-snapshot.test.ts @@ -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 + ]) }) }) diff --git a/src/main/workspace-cleanup-scan-snapshot.ts b/src/main/workspace-cleanup-scan-snapshot.ts index 81951a77c86..b415cfa7280 100644 --- a/src/main/workspace-cleanup-scan-snapshot.ts +++ b/src/main/workspace-cleanup-scan-snapshot.ts @@ -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>() 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 { +export async function readWorkspaceCleanupScanSnapshot( + snapshotDirectory: string +): Promise { 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 { - await writeSidecarSnapshot(SNAPSHOT_FILE_NAME, { +async function writeSnapshot(file: string, result: WorkspaceCleanupScanResult): Promise { + 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 +): 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 { + 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 { +export async function pruneWorkspaceCleanupScanSnapshot( + snapshotDirectory: string, + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + const pruned = prunedWorkspacesByFile.get(file) ?? new Map() + 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) diff --git a/src/main/workspace-space-analysis-snapshot.test.ts b/src/main/workspace-space-analysis-snapshot.test.ts index c5b3ae6911d..2ccad601781 100644 --- a/src/main/workspace-space-analysis-snapshot.test.ts +++ b/src/main/workspace-space-analysis-snapshot.test.ts @@ -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 = {}): 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 = {}): 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 + }) + ]) }) }) diff --git a/src/main/workspace-space-analysis-snapshot.ts b/src/main/workspace-space-analysis-snapshot.ts index 2c096212c65..ebdb461959e 100644 --- a/src/main/workspace-space-analysis-snapshot.ts +++ b/src/main/workspace-space-analysis-snapshot.ts @@ -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>() 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 { +export async function readWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string +): Promise { 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 { - await writeSidecarSnapshot(SNAPSHOT_FILE_NAME, { +async function writeSnapshot(file: string, analysis: WorkspaceSpaceAnalysis): Promise { + await writeSidecarSnapshot(file, { version: SNAPSHOT_VERSION, analysis } satisfies PersistedWorkspaceSpaceAnalysisSnapshot) @@ -88,12 +103,15 @@ async function writeSnapshot(analysis: WorkspaceSpaceAnalysis): Promise { /** Persist a completed analysis. Never throws — the snapshot is a refetchable cache. */ export async function persistWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string, analysis: WorkspaceSpaceAnalysis ): Promise { + 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, + right: Pick +): 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 { +export async function pruneWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string, + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + const pruned = prunedWorkspacesByFile.get(file) ?? new Map() + 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) diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index bdb8d953aab..79304980f1f 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -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), diff --git a/src/renderer/src/store/slices/workspace-cleanup-browse.ts b/src/renderer/src/store/slices/workspace-cleanup-browse.ts index 91134db96fa..9e917284912 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-browse.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-browse.ts @@ -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: { diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts index f38a10f86e5..2ac29470f69 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts @@ -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() + const fullPending = deferred() + 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 diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index c90c7cd95ec..31cbdca057c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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 ), diff --git a/src/shared/workspace-cleanup-browse-state-persistence.test.ts b/src/shared/workspace-cleanup-browse-state-persistence.test.ts index 251c5c25db0..d0fad16e1c1 100644 --- a/src/shared/workspace-cleanup-browse-state-persistence.test.ts +++ b/src/shared/workspace-cleanup-browse-state-persistence.test.ts @@ -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) => { diff --git a/src/shared/workspace-cleanup-filter-state-codec.ts b/src/shared/workspace-cleanup-filter-state-codec.ts index 5465adc75d2..5af505a281e 100644 --- a/src/shared/workspace-cleanup-filter-state-codec.ts +++ b/src/shared/workspace-cleanup-filter-state-codec.ts @@ -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() + 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'] diff --git a/src/shared/workspace-cleanup-ui-state.test.ts b/src/shared/workspace-cleanup-ui-state.test.ts new file mode 100644 index 00000000000..12aef389652 --- /dev/null +++ b/src/shared/workspace-cleanup-ui-state.test.ts @@ -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) + }) +}) diff --git a/src/shared/workspace-cleanup-ui-state.ts b/src/shared/workspace-cleanup-ui-state.ts new file mode 100644 index 00000000000..a674810c590 --- /dev/null +++ b/src/shared/workspace-cleanup-ui-state.ts @@ -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 + } +} diff --git a/src/shared/workspace-cleanup.ts b/src/shared/workspace-cleanup.ts index bb1e1fbd9d6..1c3480aa7e6 100644 --- a/src/shared/workspace-cleanup.ts +++ b/src/shared/workspace-cleanup.ts @@ -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 diff --git a/src/shared/workspace-space-types.ts b/src/shared/workspace-space-types.ts index bfd173db4eb..fa77086ad4b 100644 --- a/src/shared/workspace-space-types.ts +++ b/src/shared/workspace-space-types.ts @@ -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