mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
Merge remote-tracking branch 'origin/brennanb2025/workspace-cleanup-filter-model' into brennanb2025/review-pr-13413-dialog-ui
This commit is contained in:
@@ -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: () => ({}),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user