mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
perf(workspace-cleanup): bound scan and snapshot work
This commit is contained in:
@@ -26,6 +26,12 @@ vi.mock('../workspace-cleanup-scan-snapshot', () => ({
|
||||
readWorkspaceCleanupScanSnapshot: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(),
|
||||
finishWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(async () => undefined),
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerWorkspaceCleanupHandlers } from './workspace-cleanup'
|
||||
|
||||
function makeEmptyStore(): Store {
|
||||
|
||||
@@ -7,6 +7,12 @@ const { persistScanResultMock, readScanSnapshotMock } = vi.hoisted(() => ({
|
||||
readScanSnapshotMock: vi.fn()
|
||||
}))
|
||||
|
||||
const { beginPruneBatchMock, finishPruneBatchMock, recordPruneMock } = vi.hoisted(() => ({
|
||||
beginPruneBatchMock: vi.fn(),
|
||||
finishPruneBatchMock: vi.fn().mockResolvedValue(undefined),
|
||||
recordPruneMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
@@ -19,6 +25,12 @@ vi.mock('../workspace-cleanup-scan-snapshot', () => ({
|
||||
readWorkspaceCleanupScanSnapshot: readScanSnapshotMock
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch: beginPruneBatchMock,
|
||||
finishWorkspaceCleanupRemovalSnapshotPruneBatch: finishPruneBatchMock,
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune: recordPruneMock
|
||||
}))
|
||||
|
||||
import { registerWorkspaceCleanupHandlers } from './workspace-cleanup'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
@@ -38,6 +50,9 @@ describe('workspace cleanup snapshot IPC', () => {
|
||||
vi.mocked(ipcMain.handle).mockClear()
|
||||
persistScanResultMock.mockReset().mockResolvedValue(undefined)
|
||||
readScanSnapshotMock.mockReset()
|
||||
beginPruneBatchMock.mockReset()
|
||||
finishPruneBatchMock.mockReset().mockResolvedValue(undefined)
|
||||
recordPruneMock.mockReset()
|
||||
})
|
||||
|
||||
it('persists the completed scan result after replying', async () => {
|
||||
@@ -75,4 +90,23 @@ describe('workspace cleanup snapshot IPC', () => {
|
||||
expect(readScanSnapshotMock).toHaveBeenCalledWith('/profile-a')
|
||||
expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan')
|
||||
})
|
||||
|
||||
it('routes a validated removal snapshot prune batch through its explicit boundary', async () => {
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls)
|
||||
const batch = { batchId: 'batch-1' }
|
||||
const target = {
|
||||
...batch,
|
||||
worktreeId: 'repo-ssh::/remote/feature',
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}
|
||||
|
||||
await handlers['workspaceCleanup:beginRemovalSnapshotPruneBatch']?.({} as never, batch)
|
||||
await handlers['workspaceCleanup:recordRemovalSnapshotPrune']?.({} as never, target)
|
||||
await handlers['workspaceCleanup:finishRemovalSnapshotPruneBatch']?.({} as never, batch)
|
||||
|
||||
expect(beginPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch)
|
||||
expect(recordPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', target)
|
||||
expect(finishPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,13 +10,21 @@ import {
|
||||
type WorkspaceCleanupLocalProcessArgs,
|
||||
type WorkspaceCleanupLocalProcessResult,
|
||||
type WorkspaceCleanupScanArgs,
|
||||
type WorkspaceCleanupScanResult
|
||||
type WorkspaceCleanupScanResult,
|
||||
type WorkspaceCleanupSnapshotPruneBatchArgs,
|
||||
type WorkspaceCleanupSnapshotPruneRecordArgs
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { parseExecutionHostId } from '../../shared/execution-host'
|
||||
import { scanWorkspaceCleanup } from './workspace-cleanup-scan'
|
||||
import {
|
||||
persistWorkspaceCleanupScanResult,
|
||||
readWorkspaceCleanupScanSnapshot
|
||||
} from '../workspace-cleanup-scan-snapshot'
|
||||
import {
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch,
|
||||
finishWorkspaceCleanupRemovalSnapshotPruneBatch,
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune
|
||||
} from '../workspace-cleanup-removal-snapshot-prune'
|
||||
|
||||
export { scanWorkspaceCleanup }
|
||||
|
||||
@@ -35,6 +43,9 @@ export function registerWorkspaceCleanupHandlers(
|
||||
ipcMain.removeHandler('workspaceCleanup:dismiss')
|
||||
ipcMain.removeHandler('workspaceCleanup:clearDismissals')
|
||||
ipcMain.removeHandler('workspaceCleanup:hasKillableLocalProcesses')
|
||||
ipcMain.removeHandler('workspaceCleanup:beginRemovalSnapshotPruneBatch')
|
||||
ipcMain.removeHandler('workspaceCleanup:recordRemovalSnapshotPrune')
|
||||
ipcMain.removeHandler('workspaceCleanup:finishRemovalSnapshotPruneBatch')
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:scan',
|
||||
@@ -87,6 +98,43 @@ export function registerWorkspaceCleanupHandlers(
|
||||
hasKillableProcesses: await hasKillableProcesses(args, deps)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:beginRemovalSnapshotPruneBatch',
|
||||
(_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => {
|
||||
if (isSnapshotPruneBatchId(args?.batchId)) {
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:recordRemovalSnapshotPrune',
|
||||
(_event, args: WorkspaceCleanupSnapshotPruneRecordArgs) => {
|
||||
if (
|
||||
isSnapshotPruneBatchId(args?.batchId) &&
|
||||
typeof args?.worktreeId === 'string' &&
|
||||
args.worktreeId.length > 0 &&
|
||||
(args.executionHostId === undefined || parseExecutionHostId(args.executionHostId))
|
||||
) {
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, args)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:finishRemovalSnapshotPruneBatch',
|
||||
(_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => {
|
||||
if (isSnapshotPruneBatchId(args?.batchId)) {
|
||||
return finishWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function isSnapshotPruneBatchId(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= 128
|
||||
}
|
||||
|
||||
async function hasKillableProcesses(
|
||||
|
||||
@@ -234,17 +234,34 @@ vi.mock('../ports/advertised-url-watcher', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const { pruneCleanupScanSnapshotMock, pruneSpaceAnalysisSnapshotMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
pruneCleanupScanSnapshotMock,
|
||||
pruneCleanupScanSnapshotsMock,
|
||||
pruneSpaceAnalysisSnapshotMock,
|
||||
pruneSpaceAnalysisSnapshotsMock
|
||||
} = vi.hoisted(() => ({
|
||||
pruneCleanupScanSnapshotMock: vi.fn().mockResolvedValue(undefined),
|
||||
pruneSpaceAnalysisSnapshotMock: vi.fn().mockResolvedValue(undefined)
|
||||
pruneCleanupScanSnapshotsMock: vi.fn().mockResolvedValue(undefined),
|
||||
pruneSpaceAnalysisSnapshotMock: vi.fn().mockResolvedValue(undefined),
|
||||
pruneSpaceAnalysisSnapshotsMock: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-scan-snapshot', () => ({
|
||||
pruneWorkspaceCleanupScanSnapshot: pruneCleanupScanSnapshotMock
|
||||
pruneWorkspaceCleanupScanSnapshot: pruneCleanupScanSnapshotMock,
|
||||
pruneWorkspaceCleanupScanSnapshots: pruneCleanupScanSnapshotsMock
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-space-analysis-snapshot', () => ({
|
||||
pruneWorkspaceSpaceAnalysisSnapshot: pruneSpaceAnalysisSnapshotMock
|
||||
pruneWorkspaceSpaceAnalysisSnapshot: pruneSpaceAnalysisSnapshotMock,
|
||||
pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceAnalysisSnapshotsMock
|
||||
}))
|
||||
|
||||
const { recordRemovalSnapshotPruneMock } = vi.hoisted(() => ({
|
||||
recordRemovalSnapshotPruneMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune: recordRemovalSnapshotPruneMock
|
||||
}))
|
||||
|
||||
const {
|
||||
@@ -420,6 +437,11 @@ describe('registerWorktreeHandlers', () => {
|
||||
getSshPtyProviderMock,
|
||||
deleteWorktreeHistoryDirMock,
|
||||
advertisedUrlWatcherForgetWorktreeMock,
|
||||
pruneCleanupScanSnapshotMock,
|
||||
pruneCleanupScanSnapshotsMock,
|
||||
pruneSpaceAnalysisSnapshotMock,
|
||||
pruneSpaceAnalysisSnapshotsMock,
|
||||
recordRemovalSnapshotPruneMock,
|
||||
findExistingWorktreeSymlinkPathsMock,
|
||||
removeWorktreeLinkedPathsMock
|
||||
]) {
|
||||
@@ -2721,6 +2743,10 @@ describe('registerWorktreeHandlers', () => {
|
||||
const metaById: Record<string, Record<string, unknown>> = {
|
||||
'repo-1::/remote/repo': makeWorktreeMeta({ displayName: 'main', hostId: sshHostId }),
|
||||
'repo-1::/remote/deleted': makeWorktreeMeta({ displayName: 'deleted', hostId: sshHostId }),
|
||||
'repo-1::/remote/deleted-too': makeWorktreeMeta({
|
||||
displayName: 'deleted too',
|
||||
hostId: sshHostId
|
||||
}),
|
||||
'repo-1::/remote/other-host': makeWorktreeMeta({
|
||||
displayName: 'other host',
|
||||
hostId: toSshExecutionHostId('target-b')
|
||||
@@ -2738,18 +2764,33 @@ describe('registerWorktreeHandlers', () => {
|
||||
const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, {
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId,
|
||||
// Only the first is this host's row; the rest must survive an over-broad request.
|
||||
// Only the first two are this host's rows; the rest must survive an over-broad request.
|
||||
worktreeIds: [
|
||||
'repo-1::/remote/deleted',
|
||||
'repo-1::/remote/deleted-too',
|
||||
'repo-1::/remote/other-host',
|
||||
'repo-2::/remote/other-repo',
|
||||
'repo-1::/remote/never-persisted'
|
||||
]
|
||||
})
|
||||
|
||||
expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/remote/deleted'] })
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1)
|
||||
expect(forgotten).toEqual({
|
||||
forgottenWorktreeIds: ['repo-1::/remote/deleted', 'repo-1::/remote/deleted-too']
|
||||
})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(2)
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/remote/deleted', sshHostId)
|
||||
const pruneTargets = [
|
||||
{ worktreeId: 'repo-1::/remote/deleted', executionHostId: sshHostId },
|
||||
{ worktreeId: 'repo-1::/remote/deleted-too', executionHostId: sshHostId }
|
||||
]
|
||||
expect(pruneCleanupScanSnapshotsMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'/profile-a',
|
||||
pruneTargets
|
||||
)
|
||||
expect(pruneSpaceAnalysisSnapshotsMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'/profile-a',
|
||||
pruneTargets
|
||||
)
|
||||
|
||||
// Why: the renderer's suppression memory is session-scoped, so the next launch must not re-list the row.
|
||||
const relisted = (await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
@@ -8327,6 +8368,25 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
snapshotPruneBatchId: 'cleanup-batch-1'
|
||||
})
|
||||
|
||||
expect(recordRemovalSnapshotPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', {
|
||||
batchId: 'cleanup-batch-1',
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
expect(pruneCleanupScanSnapshotMock).not.toHaveBeenCalled()
|
||||
expect(pruneSpaceAnalysisSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('traces the removal as worktree.remove with a stage sub-span tree', async () => {
|
||||
const records: RedactableSpan[] = []
|
||||
setActiveSink({
|
||||
|
||||
+84
-13
@@ -17,8 +17,15 @@ import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
|
||||
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
|
||||
import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { deleteWorktreeHistoryDir } from '../terminal-history-deletion'
|
||||
import { pruneWorkspaceCleanupScanSnapshot } from '../workspace-cleanup-scan-snapshot'
|
||||
import { pruneWorkspaceSpaceAnalysisSnapshot } from '../workspace-space-analysis-snapshot'
|
||||
import {
|
||||
pruneWorkspaceCleanupScanSnapshot,
|
||||
pruneWorkspaceCleanupScanSnapshots
|
||||
} from '../workspace-cleanup-scan-snapshot'
|
||||
import {
|
||||
pruneWorkspaceSpaceAnalysisSnapshot,
|
||||
pruneWorkspaceSpaceAnalysisSnapshots
|
||||
} from '../workspace-space-analysis-snapshot'
|
||||
import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../workspace-cleanup-removal-snapshot-prune'
|
||||
import type {
|
||||
AutomationWorkspaceProvenance,
|
||||
CliWorkspaceProvenance,
|
||||
@@ -158,6 +165,7 @@ type RemoveWorktreeArgs = {
|
||||
/** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
skipArchive?: boolean
|
||||
snapshotPruneBatchId?: string
|
||||
}
|
||||
|
||||
type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs
|
||||
@@ -322,7 +330,8 @@ function resolveWorktreeRemovalOwnerHostId(
|
||||
function removeWorktreeMetadataAndTransientState(
|
||||
store: Store,
|
||||
worktreeId: string,
|
||||
hostId?: ExecutionHostId
|
||||
hostId?: ExecutionHostId,
|
||||
snapshotPruneBatchId?: string
|
||||
): void {
|
||||
// Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace.
|
||||
if (hostId) {
|
||||
@@ -339,6 +348,14 @@ function removeWorktreeMetadataAndTransientState(
|
||||
pruneWorktreePRRefreshAliases(worktreeId)
|
||||
// Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots.
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
if (snapshotPruneBatchId) {
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, {
|
||||
batchId: snapshotPruneBatchId,
|
||||
worktreeId,
|
||||
executionHostId: hostId
|
||||
})
|
||||
return
|
||||
}
|
||||
void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId)
|
||||
void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId)
|
||||
}
|
||||
@@ -2065,6 +2082,15 @@ export function registerWorktreeHandlers(
|
||||
store.removeWorktreeMeta(worktreeId, requestedExecutionHostId)
|
||||
forgottenWorktreeIds.push(worktreeId)
|
||||
}
|
||||
if (forgottenWorktreeIds.length > 0) {
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
const targets = forgottenWorktreeIds.map((worktreeId) => ({
|
||||
worktreeId,
|
||||
executionHostId: requestedExecutionHostId
|
||||
}))
|
||||
void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets)
|
||||
void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets)
|
||||
}
|
||||
return { forgottenWorktreeIds }
|
||||
}
|
||||
)
|
||||
@@ -2423,7 +2449,12 @@ export function registerWorktreeHandlers(
|
||||
})
|
||||
})
|
||||
await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => {
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
})
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId })
|
||||
@@ -2535,7 +2566,12 @@ export function registerWorktreeHandlers(
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({
|
||||
worktreeId: args.worktreeId,
|
||||
@@ -2585,7 +2621,12 @@ export function registerWorktreeHandlers(
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({
|
||||
worktreeId: args.worktreeId,
|
||||
@@ -2622,7 +2663,12 @@ export function registerWorktreeHandlers(
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({
|
||||
worktreeId: args.worktreeId,
|
||||
@@ -2682,7 +2728,12 @@ export function registerWorktreeHandlers(
|
||||
removedPushTarget
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return removalResult ?? {}
|
||||
@@ -2781,7 +2832,12 @@ export function registerWorktreeHandlers(
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => {
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
})
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return removalResult ?? {}
|
||||
@@ -2927,7 +2983,12 @@ export function registerWorktreeHandlers(
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({
|
||||
worktreeId: args.worktreeId,
|
||||
@@ -2964,7 +3025,12 @@ export function registerWorktreeHandlers(
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => {
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
})
|
||||
await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => {
|
||||
invalidateAuthorizedRootsCache()
|
||||
@@ -2996,7 +3062,7 @@ export function registerWorktreeHandlers(
|
||||
'worktrees:forgetLocal',
|
||||
async (
|
||||
_event,
|
||||
args: Pick<RemoveWorktreeArgs, 'worktreeId' | 'hostId'>
|
||||
args: Pick<RemoveWorktreeArgs, 'worktreeId' | 'hostId' | 'snapshotPruneBatchId'>
|
||||
): Promise<RemoveWorktreeResult> => {
|
||||
const { repoId } = parseWorktreeId(args.worktreeId)
|
||||
const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId)
|
||||
@@ -3058,7 +3124,12 @@ export function registerWorktreeHandlers(
|
||||
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
// The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition.
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
ownerHost?.id,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
// Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild.
|
||||
invalidateAuthorizedRootsCache()
|
||||
if (ownerHost?.id) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
finalizeCleanupSnapshotsMock,
|
||||
finalizeSpaceSnapshotsMock,
|
||||
pruneCleanupSnapshotsMock,
|
||||
pruneSpaceSnapshotsMock,
|
||||
registerCleanupTombstonesMock,
|
||||
registerSpaceTombstonesMock
|
||||
} = vi.hoisted(() => ({
|
||||
finalizeCleanupSnapshotsMock: vi.fn().mockResolvedValue(undefined),
|
||||
finalizeSpaceSnapshotsMock: vi.fn().mockResolvedValue(undefined),
|
||||
pruneCleanupSnapshotsMock: vi.fn().mockResolvedValue(undefined),
|
||||
pruneSpaceSnapshotsMock: vi.fn().mockResolvedValue(undefined),
|
||||
registerCleanupTombstonesMock: vi.fn(),
|
||||
registerSpaceTombstonesMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./workspace-cleanup-scan-snapshot', () => ({
|
||||
finalizeWorkspaceCleanupScanSnapshotPrunes: finalizeCleanupSnapshotsMock,
|
||||
pruneWorkspaceCleanupScanSnapshots: pruneCleanupSnapshotsMock,
|
||||
registerWorkspaceCleanupScanSnapshotPruneTombstones: registerCleanupTombstonesMock
|
||||
}))
|
||||
|
||||
vi.mock('./workspace-space-analysis-snapshot', () => ({
|
||||
finalizeWorkspaceSpaceAnalysisSnapshotPrunes: finalizeSpaceSnapshotsMock,
|
||||
pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceSnapshotsMock,
|
||||
registerWorkspaceSpaceAnalysisSnapshotPruneTombstones: registerSpaceTombstonesMock
|
||||
}))
|
||||
|
||||
import {
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch,
|
||||
finishWorkspaceCleanupRemovalSnapshotPruneBatch,
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune,
|
||||
resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests
|
||||
} from './workspace-cleanup-removal-snapshot-prune'
|
||||
|
||||
describe('workspace cleanup removal snapshot prune batch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('registers every sequential tombstone immediately and flushes once per sidecar', async () => {
|
||||
const snapshotDirectory = '/profile-a'
|
||||
const batch = { batchId: 'batch-1' }
|
||||
const targets = Array.from({ length: 100 }, (_, index) => ({
|
||||
worktreeId: `repo-${index % 10}::/workspace-${index}`,
|
||||
executionHostId: index % 2 === 0 ? ('local' as const) : (`ssh:ssh-${index % 3}` as const)
|
||||
}))
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, batch)
|
||||
|
||||
for (const target of targets) {
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { ...batch, ...target })
|
||||
}
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { ...batch, ...targets[0] })
|
||||
|
||||
expect(registerCleanupTombstonesMock).toHaveBeenCalledTimes(100)
|
||||
expect(registerSpaceTombstonesMock).toHaveBeenCalledTimes(100)
|
||||
expect(finalizeCleanupSnapshotsMock).not.toHaveBeenCalled()
|
||||
expect(finalizeSpaceSnapshotsMock).not.toHaveBeenCalled()
|
||||
|
||||
await finishWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, batch)
|
||||
|
||||
expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith(snapshotDirectory, targets)
|
||||
expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith(snapshotDirectory, targets)
|
||||
})
|
||||
|
||||
it('falls back to immediate best-effort pruning when no batch is active', () => {
|
||||
const target = { worktreeId: 'repo-1::/workspace', executionHostId: 'runtime:vm-1' as const }
|
||||
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', {
|
||||
batchId: 'missing-batch',
|
||||
...target
|
||||
})
|
||||
|
||||
expect(pruneCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target])
|
||||
expect(pruneSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target])
|
||||
})
|
||||
|
||||
it('finalizes only the matching profile and batch scope', async () => {
|
||||
const batch = { batchId: 'shared-id' }
|
||||
const profileATarget = { worktreeId: 'repo-a::/workspace', executionHostId: 'local' as const }
|
||||
const profileBTarget = {
|
||||
worktreeId: 'repo-b::/workspace',
|
||||
executionHostId: 'ssh:ssh-1' as const
|
||||
}
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch)
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-b', batch)
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { ...batch, ...profileATarget })
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune('/profile-b', { ...batch, ...profileBTarget })
|
||||
|
||||
await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch)
|
||||
|
||||
expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [
|
||||
profileATarget
|
||||
])
|
||||
expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [
|
||||
profileATarget
|
||||
])
|
||||
|
||||
await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-b', batch)
|
||||
expect(finalizeCleanupSnapshotsMock).toHaveBeenNthCalledWith(2, '/profile-b', [profileBTarget])
|
||||
expect(finalizeSpaceSnapshotsMock).toHaveBeenNthCalledWith(2, '/profile-b', [profileBTarget])
|
||||
})
|
||||
|
||||
it('expires and finalizes an unfinished idle batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const batch = { batchId: 'abandoned-batch' }
|
||||
const target = { worktreeId: 'repo-1::/workspace', executionHostId: 'local' as const }
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch)
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { ...batch, ...target })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5 * 60_000)
|
||||
|
||||
expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target])
|
||||
expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target])
|
||||
await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch)
|
||||
expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledTimes(1)
|
||||
expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import type {
|
||||
WorkspaceCleanupSnapshotPruneBatchArgs,
|
||||
WorkspaceCleanupSnapshotPruneRecordArgs
|
||||
} from '../shared/workspace-cleanup'
|
||||
import {
|
||||
finalizeWorkspaceCleanupScanSnapshotPrunes,
|
||||
pruneWorkspaceCleanupScanSnapshots,
|
||||
registerWorkspaceCleanupScanSnapshotPruneTombstones
|
||||
} from './workspace-cleanup-scan-snapshot'
|
||||
import {
|
||||
finalizeWorkspaceSpaceAnalysisSnapshotPrunes,
|
||||
pruneWorkspaceSpaceAnalysisSnapshots,
|
||||
registerWorkspaceSpaceAnalysisSnapshotPruneTombstones
|
||||
} from './workspace-space-analysis-snapshot'
|
||||
|
||||
type SnapshotPruneTarget = Omit<WorkspaceCleanupSnapshotPruneRecordArgs, 'batchId'>
|
||||
|
||||
type SnapshotPruneBatch = {
|
||||
targets: Map<string, SnapshotPruneTarget>
|
||||
expirationTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
const batches = new Map<string, SnapshotPruneBatch>()
|
||||
const SNAPSHOT_PRUNE_BATCH_IDLE_TIMEOUT_MS = 5 * 60_000
|
||||
|
||||
function batchKey(snapshotDirectory: string, batchId: string): string {
|
||||
return `${snapshotDirectory}\0${batchId}`
|
||||
}
|
||||
|
||||
function targetKey(target: SnapshotPruneTarget): string {
|
||||
return `${target.executionHostId ?? '*'}\0${target.worktreeId}`
|
||||
}
|
||||
|
||||
function scheduleBatchExpiration(
|
||||
snapshotDirectory: string,
|
||||
batchId: string,
|
||||
batch: SnapshotPruneBatch
|
||||
): ReturnType<typeof setTimeout> {
|
||||
const timer = setTimeout(() => {
|
||||
const expired = takeBatch(snapshotDirectory, batchId, batch)
|
||||
if (expired) {
|
||||
void finalizeBatch(snapshotDirectory, expired)
|
||||
}
|
||||
}, SNAPSHOT_PRUNE_BATCH_IDLE_TIMEOUT_MS)
|
||||
timer.unref()
|
||||
return timer
|
||||
}
|
||||
|
||||
function takeBatch(
|
||||
snapshotDirectory: string,
|
||||
batchId: string,
|
||||
expected?: SnapshotPruneBatch
|
||||
): SnapshotPruneBatch | undefined {
|
||||
const key = batchKey(snapshotDirectory, batchId)
|
||||
const batch = batches.get(key)
|
||||
if (!batch || (expected && batch !== expected)) {
|
||||
return undefined
|
||||
}
|
||||
batches.delete(key)
|
||||
if (batch.expirationTimer) {
|
||||
clearTimeout(batch.expirationTimer)
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
async function finalizeBatch(snapshotDirectory: string, batch: SnapshotPruneBatch): Promise<void> {
|
||||
const targets = [...batch.targets.values()]
|
||||
await Promise.all([
|
||||
finalizeWorkspaceCleanupScanSnapshotPrunes(snapshotDirectory, targets),
|
||||
finalizeWorkspaceSpaceAnalysisSnapshotPrunes(snapshotDirectory, targets)
|
||||
])
|
||||
}
|
||||
|
||||
export function beginWorkspaceCleanupRemovalSnapshotPruneBatch(
|
||||
snapshotDirectory: string,
|
||||
args: WorkspaceCleanupSnapshotPruneBatchArgs
|
||||
): void {
|
||||
const key = batchKey(snapshotDirectory, args.batchId)
|
||||
const existing = batches.get(key)
|
||||
if (existing) {
|
||||
if (existing.expirationTimer) {
|
||||
clearTimeout(existing.expirationTimer)
|
||||
}
|
||||
existing.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, existing)
|
||||
return
|
||||
}
|
||||
const batch: SnapshotPruneBatch = { targets: new Map() }
|
||||
batch.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, batch)
|
||||
batches.set(key, batch)
|
||||
}
|
||||
|
||||
/** Tombstone now; defer the two durable sidecar rewrites until the batch closes. */
|
||||
export function recordWorkspaceCleanupRemovalSnapshotPrune(
|
||||
snapshotDirectory: string,
|
||||
args: WorkspaceCleanupSnapshotPruneRecordArgs
|
||||
): void {
|
||||
const target = {
|
||||
worktreeId: args.worktreeId,
|
||||
...(args.executionHostId ? { executionHostId: args.executionHostId } : {})
|
||||
}
|
||||
const batch = batches.get(batchKey(snapshotDirectory, args.batchId))
|
||||
if (!batch) {
|
||||
void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, [target])
|
||||
void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, [target])
|
||||
return
|
||||
}
|
||||
|
||||
const key = targetKey(target)
|
||||
if (!batch.targets.has(key)) {
|
||||
registerWorkspaceCleanupScanSnapshotPruneTombstones(snapshotDirectory, [target])
|
||||
registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(snapshotDirectory, [target])
|
||||
batch.targets.set(key, target)
|
||||
}
|
||||
if (batch.expirationTimer) {
|
||||
clearTimeout(batch.expirationTimer)
|
||||
}
|
||||
batch.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, batch)
|
||||
}
|
||||
|
||||
export async function finishWorkspaceCleanupRemovalSnapshotPruneBatch(
|
||||
snapshotDirectory: string,
|
||||
args: WorkspaceCleanupSnapshotPruneBatchArgs
|
||||
): Promise<void> {
|
||||
const batch = takeBatch(snapshotDirectory, args.batchId)
|
||||
if (!batch) {
|
||||
return
|
||||
}
|
||||
await finalizeBatch(snapshotDirectory, batch)
|
||||
}
|
||||
|
||||
export function resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests(): void {
|
||||
for (const batch of batches.values()) {
|
||||
if (batch.expirationTimer) {
|
||||
clearTimeout(batch.expirationTimer)
|
||||
}
|
||||
}
|
||||
batches.clear()
|
||||
}
|
||||
@@ -7,12 +7,33 @@ import type {
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../shared/workspace-cleanup'
|
||||
|
||||
const { userDataDirHolder } = vi.hoisted(() => ({ userDataDirHolder: { dir: '' } }))
|
||||
const { snapshotWriteSpy, userDataDirHolder } = vi.hoisted(() => ({
|
||||
snapshotWriteSpy: vi.fn(),
|
||||
userDataDirHolder: { dir: '' }
|
||||
}))
|
||||
|
||||
vi.mock('./sidecar-snapshot-file', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
const writeSidecarSnapshot = actual.writeSidecarSnapshot as (
|
||||
file: string,
|
||||
payload: unknown
|
||||
) => Promise<void>
|
||||
return {
|
||||
...actual,
|
||||
writeSidecarSnapshot: async (file: string, payload: unknown) => {
|
||||
snapshotWriteSpy(file, payload)
|
||||
await writeSidecarSnapshot(file, payload)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
finalizeWorkspaceCleanupScanSnapshotPrunes,
|
||||
persistWorkspaceCleanupScanResult,
|
||||
pruneWorkspaceCleanupScanSnapshot,
|
||||
pruneWorkspaceCleanupScanSnapshots,
|
||||
readWorkspaceCleanupScanSnapshot,
|
||||
registerWorkspaceCleanupScanSnapshotPruneTombstones,
|
||||
workspaceCleanupScanSnapshotFingerprint
|
||||
} from './workspace-cleanup-scan-snapshot'
|
||||
|
||||
@@ -56,6 +77,7 @@ function makeBroadResult(candidates: WorkspaceCleanupCandidate[]): WorkspaceClea
|
||||
|
||||
describe('workspace cleanup scan snapshot', () => {
|
||||
beforeEach(async () => {
|
||||
snapshotWriteSpy.mockClear()
|
||||
userDataDirHolder.dir = await mkdtemp(join(tmpdir(), 'orca-cleanup-snapshot-'))
|
||||
})
|
||||
|
||||
@@ -242,14 +264,67 @@ describe('workspace cleanup scan snapshot', () => {
|
||||
makeBroadResult([makeCandidate(), kept])
|
||||
)
|
||||
|
||||
snapshotWriteSpy.mockClear()
|
||||
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local')
|
||||
|
||||
const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)
|
||||
expect(snapshot?.candidates).toEqual([kept])
|
||||
expect(snapshotWriteSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Unknown ids are a no-op.
|
||||
snapshotWriteSpy.mockClear()
|
||||
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed')
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(snapshot)
|
||||
expect(snapshotWriteSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('coalesces host-scoped local and remote prunes into one write', async () => {
|
||||
const localCollision = makeCandidate()
|
||||
const remoteCollision = makeCandidate({
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
})
|
||||
const localRemoved = makeCandidate({
|
||||
worktreeId: 'repo-1::/local-removed',
|
||||
path: '/local-removed'
|
||||
})
|
||||
const remoteRemoved = makeCandidate({
|
||||
worktreeId: 'repo-1::/remote-removed',
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
path: '/remote-removed'
|
||||
})
|
||||
const kept = makeCandidate({ worktreeId: 'repo-1::/kept', path: '/kept' })
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
makeBroadResult([localCollision, remoteCollision, localRemoved, remoteRemoved, kept])
|
||||
)
|
||||
|
||||
snapshotWriteSpy.mockClear()
|
||||
await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [
|
||||
{ worktreeId: localCollision.worktreeId, executionHostId: 'local' },
|
||||
{ worktreeId: localRemoved.worktreeId, executionHostId: 'local' },
|
||||
{ worktreeId: remoteRemoved.worktreeId, executionHostId: 'ssh:ssh-1' }
|
||||
])
|
||||
|
||||
expect(snapshotWriteSpy).toHaveBeenCalledTimes(1)
|
||||
expect(snapshotWriteSpy).toHaveBeenCalledWith(
|
||||
join(userDataDirHolder.dir, SNAPSHOT_FILE),
|
||||
expect.objectContaining({
|
||||
result: expect.objectContaining({ candidates: [remoteCollision, kept] })
|
||||
})
|
||||
)
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toMatchObject({
|
||||
candidates: [remoteCollision, kept]
|
||||
})
|
||||
|
||||
snapshotWriteSpy.mockClear()
|
||||
await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [
|
||||
{ worktreeId: 'repo-1::/missing-local', executionHostId: 'local' },
|
||||
{ worktreeId: 'repo-1::/missing-remote', executionHostId: 'ssh:ssh-1' }
|
||||
])
|
||||
expect(snapshotWriteSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps profile snapshots isolated', async () => {
|
||||
@@ -267,12 +342,22 @@ describe('workspace cleanup scan snapshot', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let a scan started before removal restore the pruned row', async () => {
|
||||
it('does not let a scan started before a bulk removal restore pruned rows', async () => {
|
||||
const local = makeCandidate()
|
||||
const remote = makeCandidate({
|
||||
worktreeId: 'repo-1::/remote-feature',
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
path: '/remote-feature'
|
||||
})
|
||||
const staleResult = {
|
||||
...makeBroadResult([makeCandidate()]),
|
||||
...makeBroadResult([local, remote]),
|
||||
scannedAt: Date.now() - 1
|
||||
}
|
||||
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local')
|
||||
await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [
|
||||
{ worktreeId: local.worktreeId, executionHostId: 'local' },
|
||||
{ worktreeId: remote.worktreeId, executionHostId: 'ssh:ssh-1' }
|
||||
])
|
||||
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
@@ -288,10 +373,47 @@ describe('workspace cleanup scan snapshot', () => {
|
||||
recreated
|
||||
)
|
||||
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([
|
||||
makeCandidate()
|
||||
local,
|
||||
remote
|
||||
])
|
||||
})
|
||||
|
||||
it('registers a tombstone immediately without rewriting the sidecar', async () => {
|
||||
const staleResult = { ...makeBroadResult([makeCandidate()]), scannedAt: Date.now() - 1 }
|
||||
|
||||
registerWorkspaceCleanupScanSnapshotPruneTombstones(userDataDirHolder.dir, [
|
||||
{ worktreeId: 'repo-1::/repo-feature', executionHostId: 'local' }
|
||||
])
|
||||
|
||||
expect(snapshotWriteSpy).not.toHaveBeenCalled()
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
staleResult
|
||||
)
|
||||
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the original tombstone time when a removal batch flushes', async () => {
|
||||
const candidate = makeCandidate()
|
||||
const target = { worktreeId: candidate.worktreeId, executionHostId: 'local' as const }
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(100)
|
||||
registerWorkspaceCleanupScanSnapshotPruneTombstones(userDataDirHolder.dir, [target])
|
||||
now.mockReturnValue(200)
|
||||
|
||||
await finalizeWorkspaceCleanupScanSnapshotPrunes(userDataDirHolder.dir, [target])
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
{ ...makeBroadResult([candidate]), scannedAt: 150 }
|
||||
)
|
||||
|
||||
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([
|
||||
candidate
|
||||
])
|
||||
now.mockRestore()
|
||||
})
|
||||
|
||||
it('patches and prunes host-colliding workspace ids independently', async () => {
|
||||
const local = makeCandidate()
|
||||
const remote = makeCandidate({
|
||||
|
||||
@@ -11,17 +11,21 @@ import {
|
||||
writeSidecarSnapshot
|
||||
} from './sidecar-snapshot-file'
|
||||
import type { ExecutionHostId } from '../shared/execution-host'
|
||||
import {
|
||||
activeWorkspaceSnapshotPruneKeys,
|
||||
registerWorkspaceSnapshotPrunesForFile,
|
||||
workspaceSnapshotPruneKey,
|
||||
workspaceSnapshotPruneTargetKeys,
|
||||
type WorkspaceSnapshotPruneTarget,
|
||||
type WorkspaceSnapshotPruneTombstone
|
||||
} from './workspace-snapshot-prune-index'
|
||||
|
||||
const SNAPSHOT_FILE_NAME = 'orca-workspace-cleanup-scan.json'
|
||||
const SNAPSHOT_VERSION = 2
|
||||
|
||||
type PrunedWorkspace = {
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
prunedAt: number
|
||||
}
|
||||
export type WorkspaceCleanupScanSnapshotPruneTarget = WorkspaceSnapshotPruneTarget
|
||||
|
||||
const prunedWorkspacesByFile = new Map<string, Map<string, PrunedWorkspace>>()
|
||||
const prunedWorkspacesByFile = new Map<string, Map<string, WorkspaceSnapshotPruneTombstone>>()
|
||||
|
||||
type PersistedWorkspaceCleanupScanSnapshot = {
|
||||
version: number
|
||||
@@ -123,17 +127,18 @@ function candidateSnapshotKey(
|
||||
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)
|
||||
/** Register anti-resurrection tombstones without scheduling a sidecar rewrite. */
|
||||
export function registerWorkspaceCleanupScanSnapshotPruneTombstones(
|
||||
snapshotDirectory: string,
|
||||
targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[]
|
||||
): void {
|
||||
if (targets.length === 0) {
|
||||
return
|
||||
}
|
||||
registerWorkspaceSnapshotPrunesForFile(
|
||||
prunedWorkspacesByFile,
|
||||
sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME),
|
||||
targets
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,15 +146,17 @@ function excludeRowsPrunedDuringScan(
|
||||
file: string,
|
||||
result: WorkspaceCleanupScanResult
|
||||
): WorkspaceCleanupScanResult {
|
||||
const pruned = [...(prunedWorkspacesByFile.get(file)?.values() ?? [])]
|
||||
if (pruned.length === 0) {
|
||||
const prunedKeys = activeWorkspaceSnapshotPruneKeys(
|
||||
prunedWorkspacesByFile.get(file),
|
||||
result.scannedAt
|
||||
)
|
||||
if (prunedKeys.size === 0) {
|
||||
return result
|
||||
}
|
||||
const candidates = result.candidates.filter(
|
||||
(candidate) =>
|
||||
!pruned.some(
|
||||
(entry) => entry.prunedAt >= result.scannedAt && matchesPrunedWorkspace(candidate, entry)
|
||||
)
|
||||
!prunedKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId)) &&
|
||||
!prunedKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId))
|
||||
)
|
||||
return candidates.length === result.candidates.length ? result : { ...result, candidates }
|
||||
}
|
||||
@@ -163,11 +170,16 @@ function clearSupersededPrunes(
|
||||
if (!pruned) {
|
||||
return
|
||||
}
|
||||
const candidateKeys = broad
|
||||
? undefined
|
||||
: new Set(
|
||||
result.candidates.flatMap((candidate) => [
|
||||
workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId),
|
||||
workspaceSnapshotPruneKey(candidate.worktreeId)
|
||||
])
|
||||
)
|
||||
for (const [key, entry] of pruned) {
|
||||
if (
|
||||
entry.prunedAt < result.scannedAt &&
|
||||
(broad || result.candidates.some((candidate) => matchesPrunedWorkspace(candidate, entry)))
|
||||
) {
|
||||
if (entry.prunedAt < result.scannedAt && (broad || candidateKeys?.has(key))) {
|
||||
pruned.delete(key)
|
||||
}
|
||||
}
|
||||
@@ -215,30 +227,37 @@ export async function persistWorkspaceCleanupScanResult(
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a removed workspace so it never resurrects from cache. Never throws. */
|
||||
export async function pruneWorkspaceCleanupScanSnapshot(
|
||||
async function pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones(
|
||||
snapshotDirectory: string,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[],
|
||||
registerTombstones: boolean
|
||||
): Promise<void> {
|
||||
if (targets.length === 0) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
const targetKeys = workspaceSnapshotPruneTargetKeys(targets)
|
||||
if (registerTombstones) {
|
||||
registerWorkspaceSnapshotPrunesForFile(prunedWorkspacesByFile, file, targets)
|
||||
}
|
||||
try {
|
||||
await withSidecarSnapshotQueue(file, async () => {
|
||||
const registered = prunedWorkspacesByFile.get(file)
|
||||
const coalescedTargetKeys = registerTombstones
|
||||
? targetKeys
|
||||
: new Set([...targetKeys].filter((key) => registered?.has(key)))
|
||||
if (coalescedTargetKeys.size === 0) {
|
||||
return
|
||||
}
|
||||
const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
const candidates = existing.candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.worktreeId !== worktreeId ||
|
||||
(executionHostId !== undefined && candidate.executionHostId !== executionHostId)
|
||||
!coalescedTargetKeys.has(
|
||||
workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId)
|
||||
) && !coalescedTargetKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId))
|
||||
)
|
||||
if (candidates.length === existing.candidates.length) {
|
||||
return
|
||||
@@ -249,3 +268,32 @@ export async function pruneWorkspaceCleanupScanSnapshot(
|
||||
console.warn('[workspace-cleanup] failed to prune scan snapshot:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop removed workspaces in one sidecar transaction. Never throws. */
|
||||
export async function pruneWorkspaceCleanupScanSnapshots(
|
||||
snapshotDirectory: string,
|
||||
targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[]
|
||||
): Promise<void> {
|
||||
await pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones(snapshotDirectory, targets, true)
|
||||
}
|
||||
|
||||
/** Flush only tombstones still active for this batch, preserving their original prune time. */
|
||||
export async function finalizeWorkspaceCleanupScanSnapshotPrunes(
|
||||
snapshotDirectory: string,
|
||||
targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[]
|
||||
): Promise<void> {
|
||||
await pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones(
|
||||
snapshotDirectory,
|
||||
targets,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop a removed workspace so it never resurrects from cache. Never throws. */
|
||||
export async function pruneWorkspaceCleanupScanSnapshot(
|
||||
snapshotDirectory: string,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): Promise<void> {
|
||||
await pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ExecutionHostId } from '../shared/execution-host'
|
||||
|
||||
export type WorkspaceSnapshotPruneTarget = {
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type WorkspaceSnapshotPruneTombstone = WorkspaceSnapshotPruneTarget & {
|
||||
prunedAt: number
|
||||
}
|
||||
|
||||
export function workspaceSnapshotPruneKey(
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): string {
|
||||
return `${executionHostId ?? '*'}\0${worktreeId}`
|
||||
}
|
||||
|
||||
export function workspaceSnapshotPruneTargetKeys(
|
||||
targets: readonly WorkspaceSnapshotPruneTarget[]
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
targets.map(({ worktreeId, executionHostId }) =>
|
||||
workspaceSnapshotPruneKey(worktreeId, executionHostId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function activeWorkspaceSnapshotPruneKeys(
|
||||
tombstones: ReadonlyMap<string, WorkspaceSnapshotPruneTombstone> | undefined,
|
||||
scannedAt: number
|
||||
): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const [key, entry] of tombstones ?? []) {
|
||||
if (entry.prunedAt >= scannedAt) {
|
||||
keys.add(key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
export function registerWorkspaceSnapshotPrunesForFile(
|
||||
tombstonesByFile: Map<string, Map<string, WorkspaceSnapshotPruneTombstone>>,
|
||||
file: string,
|
||||
targets: readonly WorkspaceSnapshotPruneTarget[]
|
||||
): void {
|
||||
const tombstones = tombstonesByFile.get(file) ?? new Map()
|
||||
const prunedAt = Date.now()
|
||||
for (const { worktreeId, executionHostId } of targets) {
|
||||
tombstones.set(workspaceSnapshotPruneKey(worktreeId, executionHostId), {
|
||||
worktreeId,
|
||||
...(executionHostId ? { executionHostId } : {}),
|
||||
prunedAt
|
||||
})
|
||||
}
|
||||
tombstonesByFile.set(file, tombstones)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Opt in: ORCA_WORKSPACE_SNAPSHOT_PRUNE_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/workspace-snapshot-pruning.bench.test.ts
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceCleanupCandidate } from '../shared/workspace-cleanup'
|
||||
import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types'
|
||||
import {
|
||||
persistWorkspaceCleanupScanResult,
|
||||
pruneWorkspaceCleanupScanSnapshots
|
||||
} from './workspace-cleanup-scan-snapshot'
|
||||
import {
|
||||
persistWorkspaceSpaceAnalysisSnapshot,
|
||||
pruneWorkspaceSpaceAnalysisSnapshots
|
||||
} from './workspace-space-analysis-snapshot'
|
||||
|
||||
const describeBench = process.env.ORCA_WORKSPACE_SNAPSHOT_PRUNE_BENCH ? describe : describe.skip
|
||||
const ROW_COUNT = 610
|
||||
const DELETE_COUNT = 100
|
||||
|
||||
describeBench('workspace snapshot bulk pruning', () => {
|
||||
let snapshotDirectory = ''
|
||||
|
||||
beforeAll(async () => {
|
||||
snapshotDirectory = await mkdtemp(join(tmpdir(), 'orca-snapshot-prune-bench-'))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(snapshotDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('prunes the measured 610-row/100-delete shape with one write per sidecar', async () => {
|
||||
const candidates = Array.from({ length: ROW_COUNT }, (_, index) => makeCandidate(index))
|
||||
const worktrees = Array.from({ length: ROW_COUNT }, (_, index) => makeWorktree(index))
|
||||
const targets = candidates.slice(0, DELETE_COUNT).map((candidate) => ({
|
||||
worktreeId: candidate.worktreeId,
|
||||
executionHostId: candidate.executionHostId
|
||||
}))
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
snapshotDirectory,
|
||||
{ includeAllWorkspaces: true },
|
||||
{ scannedAt: 1, candidates, errors: [] }
|
||||
)
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, {
|
||||
scannedAt: 1,
|
||||
totalSizeBytes: ROW_COUNT * 1_000,
|
||||
reclaimableBytes: ROW_COUNT * 1_000,
|
||||
worktreeCount: ROW_COUNT,
|
||||
scannedWorktreeCount: ROW_COUNT,
|
||||
unavailableWorktreeCount: 0,
|
||||
repos: makeRepoSummaries(worktrees),
|
||||
worktrees
|
||||
})
|
||||
|
||||
const startedAt = performance.now()
|
||||
await Promise.all([
|
||||
pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets),
|
||||
pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets)
|
||||
])
|
||||
const pruneMs = performance.now() - startedAt
|
||||
const cleanupPayload = JSON.parse(
|
||||
await readFile(join(snapshotDirectory, 'orca-workspace-cleanup-scan.json'), 'utf-8')
|
||||
) as { result: { candidates: unknown[] } }
|
||||
const spacePayload = JSON.parse(
|
||||
await readFile(join(snapshotDirectory, 'orca-workspace-space-analysis.json'), 'utf-8')
|
||||
) as { analysis: { worktrees: unknown[] } }
|
||||
|
||||
console.log(
|
||||
`[bench] rows=${ROW_COUNT} deleted=${DELETE_COUNT} bulkPrune=${pruneMs.toFixed(2)}ms`
|
||||
)
|
||||
expect(cleanupPayload.result.candidates).toHaveLength(ROW_COUNT - DELETE_COUNT)
|
||||
expect(spacePayload.analysis.worktrees).toHaveLength(ROW_COUNT - DELETE_COUNT)
|
||||
})
|
||||
})
|
||||
|
||||
function executionHostId(index: number): 'local' | `ssh:${string}` {
|
||||
return index % 3 === 0 ? `ssh:ssh-${index % 7}` : 'local'
|
||||
}
|
||||
|
||||
function makeCandidate(index: number): WorkspaceCleanupCandidate {
|
||||
const hostId = executionHostId(index)
|
||||
return {
|
||||
worktreeId: `repo-${index % 20}::/workspace-${index}`,
|
||||
repoId: `repo-${index % 20}`,
|
||||
repoName: `Repo ${index % 20}`,
|
||||
connectionId: hostId === 'local' ? null : hostId.slice('ssh:'.length),
|
||||
executionHostId: hostId,
|
||||
displayName: `Workspace ${index}`,
|
||||
branch: `branch-${index}`,
|
||||
path: `/workspace-${index}`,
|
||||
tier: 'ready',
|
||||
selectedByDefault: true,
|
||||
reasons: ['idle-clean'],
|
||||
blockers: [],
|
||||
lastActivityAt: 0,
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
},
|
||||
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 0 },
|
||||
fingerprint: `fingerprint-${index}`
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(index: number): WorkspaceSpaceWorktree {
|
||||
const candidate = makeCandidate(index)
|
||||
return {
|
||||
worktreeId: candidate.worktreeId,
|
||||
repoId: candidate.repoId,
|
||||
executionHostId: candidate.executionHostId,
|
||||
repoDisplayName: candidate.repoName,
|
||||
repoPath: `/repo-${index % 20}`,
|
||||
displayName: candidate.displayName,
|
||||
path: candidate.path,
|
||||
branch: candidate.branch,
|
||||
isMainWorktree: false,
|
||||
isRemote: candidate.executionHostId !== 'local',
|
||||
isSparse: false,
|
||||
canDelete: true,
|
||||
lastActivityAt: 0,
|
||||
status: 'ok',
|
||||
error: null,
|
||||
scannedAt: 1,
|
||||
sizeBytes: 1_000,
|
||||
reclaimableBytes: 1_000,
|
||||
skippedEntryCount: 0,
|
||||
topLevelItems: [],
|
||||
omittedTopLevelItemCount: 0,
|
||||
omittedTopLevelSizeBytes: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeRepoSummaries(worktrees: WorkspaceSpaceWorktree[]) {
|
||||
const groups = Map.groupBy(worktrees, (row) => `${row.executionHostId ?? 'local'}\0${row.repoId}`)
|
||||
return [...groups.values()].map((rows) => ({
|
||||
repoId: rows[0].repoId,
|
||||
executionHostId: rows[0].executionHostId,
|
||||
displayName: rows[0].repoDisplayName,
|
||||
path: rows[0].repoPath,
|
||||
isRemote: rows[0].isRemote,
|
||||
worktreeCount: rows.length,
|
||||
scannedWorktreeCount: rows.length,
|
||||
unavailableWorktreeCount: 0,
|
||||
totalSizeBytes: rows.length * 1_000,
|
||||
reclaimableBytes: rows.length * 1_000,
|
||||
error: null
|
||||
}))
|
||||
}
|
||||
@@ -7,12 +7,33 @@ import type {
|
||||
WorkspaceSpaceWorktree
|
||||
} from '../shared/workspace-space-types'
|
||||
|
||||
const { userDataDirHolder } = vi.hoisted(() => ({ userDataDirHolder: { dir: '' } }))
|
||||
const { snapshotWriteSpy, userDataDirHolder } = vi.hoisted(() => ({
|
||||
snapshotWriteSpy: vi.fn(),
|
||||
userDataDirHolder: { dir: '' }
|
||||
}))
|
||||
|
||||
vi.mock('./sidecar-snapshot-file', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
const writeSidecarSnapshot = actual.writeSidecarSnapshot as (
|
||||
file: string,
|
||||
payload: unknown
|
||||
) => Promise<void>
|
||||
return {
|
||||
...actual,
|
||||
writeSidecarSnapshot: async (file: string, payload: unknown) => {
|
||||
snapshotWriteSpy(file, payload)
|
||||
await writeSidecarSnapshot(file, payload)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
finalizeWorkspaceSpaceAnalysisSnapshotPrunes,
|
||||
persistWorkspaceSpaceAnalysisSnapshot,
|
||||
pruneWorkspaceSpaceAnalysisSnapshot,
|
||||
readWorkspaceSpaceAnalysisSnapshot
|
||||
pruneWorkspaceSpaceAnalysisSnapshots,
|
||||
readWorkspaceSpaceAnalysisSnapshot,
|
||||
registerWorkspaceSpaceAnalysisSnapshotPruneTombstones
|
||||
} from './workspace-space-analysis-snapshot'
|
||||
|
||||
const SNAPSHOT_FILE = 'orca-workspace-space-analysis.json'
|
||||
@@ -78,6 +99,7 @@ function makeAnalysis(worktrees: WorkspaceSpaceWorktree[]): WorkspaceSpaceAnalys
|
||||
|
||||
describe('workspace space analysis snapshot', () => {
|
||||
beforeEach(async () => {
|
||||
snapshotWriteSpy.mockClear()
|
||||
userDataDirHolder.dir = await mkdtemp(join(tmpdir(), 'orca-space-snapshot-'))
|
||||
})
|
||||
|
||||
@@ -178,6 +200,7 @@ describe('workspace space analysis snapshot', () => {
|
||||
makeAnalysis([removed, kept])
|
||||
)
|
||||
|
||||
snapshotWriteSpy.mockClear()
|
||||
await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, removed.worktreeId, 'local')
|
||||
|
||||
const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)
|
||||
@@ -196,10 +219,86 @@ describe('workspace space analysis snapshot', () => {
|
||||
totalSizeBytes: 1000,
|
||||
reclaimableBytes: 0
|
||||
})
|
||||
expect(snapshotWriteSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Unknown ids are a no-op.
|
||||
snapshotWriteSpy.mockClear()
|
||||
await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed')
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual(cached)
|
||||
expect(snapshotWriteSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('coalesces local and remote row rebalancing into one write', async () => {
|
||||
const localCollision = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 })
|
||||
const remoteCollision = makeWorktreeRow({
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
isRemote: true,
|
||||
sizeBytes: 2000,
|
||||
reclaimableBytes: 2000
|
||||
})
|
||||
const localRemoved = makeWorktreeRow({
|
||||
worktreeId: 'repo-1::/local-removed',
|
||||
path: '/local-removed',
|
||||
status: 'missing',
|
||||
sizeBytes: 3000,
|
||||
reclaimableBytes: 0
|
||||
})
|
||||
const remoteRemoved = makeWorktreeRow({
|
||||
worktreeId: 'repo-1::/remote-removed',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
isRemote: true,
|
||||
path: '/remote-removed',
|
||||
sizeBytes: 4000,
|
||||
reclaimableBytes: 4000
|
||||
})
|
||||
const kept = makeWorktreeRow({
|
||||
worktreeId: 'repo-1::/kept',
|
||||
path: '/kept',
|
||||
sizeBytes: 500,
|
||||
reclaimableBytes: 500
|
||||
})
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(
|
||||
userDataDirHolder.dir,
|
||||
makeAnalysis([localCollision, remoteCollision, localRemoved, remoteRemoved, kept])
|
||||
)
|
||||
|
||||
snapshotWriteSpy.mockClear()
|
||||
await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [
|
||||
{ worktreeId: localCollision.worktreeId, executionHostId: 'local' },
|
||||
{ worktreeId: localRemoved.worktreeId, executionHostId: 'local' },
|
||||
{ worktreeId: remoteRemoved.worktreeId, executionHostId: 'ssh:ssh-1' }
|
||||
])
|
||||
|
||||
expect(snapshotWriteSpy).toHaveBeenCalledTimes(1)
|
||||
expect(snapshotWriteSpy).toHaveBeenCalledWith(
|
||||
join(userDataDirHolder.dir, SNAPSHOT_FILE),
|
||||
expect.objectContaining({
|
||||
analysis: expect.objectContaining({
|
||||
worktrees: [remoteCollision, kept],
|
||||
worktreeCount: 2,
|
||||
scannedWorktreeCount: 2,
|
||||
unavailableWorktreeCount: 0,
|
||||
totalSizeBytes: 2500,
|
||||
reclaimableBytes: 2500
|
||||
})
|
||||
})
|
||||
)
|
||||
const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)
|
||||
expect(cached?.repos).toEqual([
|
||||
expect.objectContaining({ executionHostId: 'local', worktreeCount: 1, totalSizeBytes: 500 }),
|
||||
expect.objectContaining({
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
worktreeCount: 1,
|
||||
totalSizeBytes: 2000
|
||||
})
|
||||
])
|
||||
|
||||
snapshotWriteSpy.mockClear()
|
||||
await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [
|
||||
{ worktreeId: 'repo-1::/missing-local', executionHostId: 'local' },
|
||||
{ worktreeId: 'repo-1::/missing-remote', executionHostId: 'ssh:ssh-1' }
|
||||
])
|
||||
expect(snapshotWriteSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps profile snapshots isolated', async () => {
|
||||
@@ -216,16 +315,22 @@ describe('workspace space analysis snapshot', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let an analysis started before removal restore the pruned row', async () => {
|
||||
it('does not let an analysis started before bulk removal restore pruned rows', async () => {
|
||||
const local = makeWorktreeRow()
|
||||
const remote = makeWorktreeRow({
|
||||
worktreeId: 'repo-1::/remote-feature',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
isRemote: true,
|
||||
path: '/remote-feature'
|
||||
})
|
||||
const staleAnalysis = {
|
||||
...makeAnalysis([makeWorktreeRow()]),
|
||||
...makeAnalysis([local, remote]),
|
||||
scannedAt: Date.now() - 1
|
||||
}
|
||||
await pruneWorkspaceSpaceAnalysisSnapshot(
|
||||
userDataDirHolder.dir,
|
||||
'repo-1::/repo-feature',
|
||||
'local'
|
||||
)
|
||||
await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [
|
||||
{ worktreeId: local.worktreeId, executionHostId: 'local' },
|
||||
{ worktreeId: remote.worktreeId, executionHostId: 'ssh:ssh-1' }
|
||||
])
|
||||
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis)
|
||||
expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([])
|
||||
@@ -233,10 +338,42 @@ describe('workspace space analysis snapshot', () => {
|
||||
const recreated = { ...staleAnalysis, scannedAt: Date.now() + 1 }
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, recreated)
|
||||
expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([
|
||||
makeWorktreeRow()
|
||||
local,
|
||||
remote
|
||||
])
|
||||
})
|
||||
|
||||
it('registers a tombstone immediately without rewriting the sidecar', async () => {
|
||||
const staleAnalysis = { ...makeAnalysis([makeWorktreeRow()]), scannedAt: Date.now() - 1 }
|
||||
|
||||
registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(userDataDirHolder.dir, [
|
||||
{ worktreeId: 'repo-1::/repo-feature', executionHostId: 'local' }
|
||||
])
|
||||
|
||||
expect(snapshotWriteSpy).not.toHaveBeenCalled()
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis)
|
||||
expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the original tombstone time when a removal batch flushes', async () => {
|
||||
const row = makeWorktreeRow()
|
||||
const target = { worktreeId: row.worktreeId, executionHostId: 'local' as const }
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(100)
|
||||
registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(userDataDirHolder.dir, [target])
|
||||
now.mockReturnValue(200)
|
||||
|
||||
await finalizeWorkspaceSpaceAnalysisSnapshotPrunes(userDataDirHolder.dir, [target])
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, {
|
||||
...makeAnalysis([row]),
|
||||
scannedAt: 150
|
||||
})
|
||||
|
||||
expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([
|
||||
{ ...row, topLevelItems: [] }
|
||||
])
|
||||
now.mockRestore()
|
||||
})
|
||||
|
||||
it('prunes host-colliding workspace ids without corrupting surviving totals', async () => {
|
||||
const local = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 })
|
||||
const remote = makeWorktreeRow({
|
||||
|
||||
@@ -9,17 +9,21 @@ import {
|
||||
writeSidecarSnapshot
|
||||
} from './sidecar-snapshot-file'
|
||||
import type { ExecutionHostId } from '../shared/execution-host'
|
||||
import {
|
||||
activeWorkspaceSnapshotPruneKeys,
|
||||
registerWorkspaceSnapshotPrunesForFile,
|
||||
workspaceSnapshotPruneKey,
|
||||
workspaceSnapshotPruneTargetKeys,
|
||||
type WorkspaceSnapshotPruneTarget,
|
||||
type WorkspaceSnapshotPruneTombstone
|
||||
} from './workspace-snapshot-prune-index'
|
||||
|
||||
const SNAPSHOT_FILE_NAME = 'orca-workspace-space-analysis.json'
|
||||
const SNAPSHOT_VERSION = 2
|
||||
|
||||
type PrunedWorkspace = {
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
prunedAt: number
|
||||
}
|
||||
export type WorkspaceSpaceAnalysisSnapshotPruneTarget = WorkspaceSnapshotPruneTarget
|
||||
|
||||
const prunedWorkspacesByFile = new Map<string, Map<string, PrunedWorkspace>>()
|
||||
const prunedWorkspacesByFile = new Map<string, Map<string, WorkspaceSnapshotPruneTombstone>>()
|
||||
|
||||
type PersistedWorkspaceSpaceAnalysisSnapshot = {
|
||||
version: number
|
||||
@@ -117,50 +121,103 @@ export async function persistWorkspaceSpaceAnalysisSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
function withoutWorktreeRow(
|
||||
function withoutWorktreeRows(
|
||||
analysis: WorkspaceSpaceAnalysis,
|
||||
removed: WorkspaceSpaceWorktree
|
||||
shouldRemove: (row: WorkspaceSpaceWorktree) => boolean
|
||||
): WorkspaceSpaceAnalysis {
|
||||
const scannedDelta = removed.status === 'ok' ? 1 : 0
|
||||
const unavailableDelta = removed.status === 'ok' ? 0 : 1
|
||||
const worktrees: WorkspaceSpaceWorktree[] = []
|
||||
const removedByRepo = new Map<
|
||||
string,
|
||||
{
|
||||
worktreeCount: number
|
||||
scannedWorktreeCount: number
|
||||
unavailableWorktreeCount: number
|
||||
totalSizeBytes: number
|
||||
reclaimableBytes: number
|
||||
}
|
||||
>()
|
||||
let removedCount = 0
|
||||
let scannedDelta = 0
|
||||
let unavailableDelta = 0
|
||||
let totalSizeDelta = 0
|
||||
let reclaimableDelta = 0
|
||||
|
||||
for (const row of analysis.worktrees) {
|
||||
if (!shouldRemove(row)) {
|
||||
worktrees.push(row)
|
||||
continue
|
||||
}
|
||||
const scanned = row.status === 'ok' ? 1 : 0
|
||||
const unavailable = row.status === 'ok' ? 0 : 1
|
||||
removedCount += 1
|
||||
scannedDelta += scanned
|
||||
unavailableDelta += unavailable
|
||||
totalSizeDelta += row.sizeBytes
|
||||
reclaimableDelta += row.reclaimableBytes
|
||||
const key = analysisRepoKey(row)
|
||||
const delta = removedByRepo.get(key) ?? {
|
||||
worktreeCount: 0,
|
||||
scannedWorktreeCount: 0,
|
||||
unavailableWorktreeCount: 0,
|
||||
totalSizeBytes: 0,
|
||||
reclaimableBytes: 0
|
||||
}
|
||||
delta.worktreeCount += 1
|
||||
delta.scannedWorktreeCount += scanned
|
||||
delta.unavailableWorktreeCount += unavailable
|
||||
delta.totalSizeBytes += row.sizeBytes
|
||||
delta.reclaimableBytes += row.reclaimableBytes
|
||||
removedByRepo.set(key, delta)
|
||||
}
|
||||
if (removedCount === 0) {
|
||||
return analysis
|
||||
}
|
||||
return {
|
||||
...analysis,
|
||||
worktrees: analysis.worktrees.filter((row) => !isSameWorkspace(row, removed)),
|
||||
worktreeCount: Math.max(0, analysis.worktreeCount - 1),
|
||||
worktrees,
|
||||
worktreeCount: Math.max(0, analysis.worktreeCount - removedCount),
|
||||
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.executionHostId === removed.executionHostId
|
||||
totalSizeBytes: Math.max(0, analysis.totalSizeBytes - totalSizeDelta),
|
||||
reclaimableBytes: Math.max(0, analysis.reclaimableBytes - reclaimableDelta),
|
||||
repos: analysis.repos.map((repo) => {
|
||||
const delta = removedByRepo.get(analysisRepoKey(repo))
|
||||
return delta
|
||||
? {
|
||||
...repo,
|
||||
worktreeCount: Math.max(0, repo.worktreeCount - 1),
|
||||
scannedWorktreeCount: Math.max(0, repo.scannedWorktreeCount - scannedDelta),
|
||||
unavailableWorktreeCount: Math.max(0, repo.unavailableWorktreeCount - unavailableDelta),
|
||||
totalSizeBytes: Math.max(0, repo.totalSizeBytes - removed.sizeBytes),
|
||||
reclaimableBytes: Math.max(0, repo.reclaimableBytes - removed.reclaimableBytes)
|
||||
worktreeCount: Math.max(0, repo.worktreeCount - delta.worktreeCount),
|
||||
scannedWorktreeCount: Math.max(
|
||||
0,
|
||||
repo.scannedWorktreeCount - delta.scannedWorktreeCount
|
||||
),
|
||||
unavailableWorktreeCount: Math.max(
|
||||
0,
|
||||
repo.unavailableWorktreeCount - delta.unavailableWorktreeCount
|
||||
),
|
||||
totalSizeBytes: Math.max(0, repo.totalSizeBytes - delta.totalSizeBytes),
|
||||
reclaimableBytes: Math.max(0, repo.reclaimableBytes - delta.reclaimableBytes)
|
||||
}
|
||||
: repo
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isSameWorkspace(
|
||||
left: Pick<WorkspaceSpaceWorktree, 'executionHostId' | 'worktreeId'>,
|
||||
right: Pick<WorkspaceSpaceWorktree, 'executionHostId' | 'worktreeId'>
|
||||
): boolean {
|
||||
return left.worktreeId === right.worktreeId && left.executionHostId === right.executionHostId
|
||||
function analysisRepoKey(entry: { repoId: string; executionHostId?: ExecutionHostId }): string {
|
||||
return JSON.stringify([entry.executionHostId, entry.repoId])
|
||||
}
|
||||
|
||||
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)
|
||||
/** Register anti-resurrection tombstones without scheduling a sidecar rewrite. */
|
||||
export function registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(
|
||||
snapshotDirectory: string,
|
||||
targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[]
|
||||
): void {
|
||||
if (targets.length === 0) {
|
||||
return
|
||||
}
|
||||
registerWorkspaceSnapshotPrunesForFile(
|
||||
prunedWorkspacesByFile,
|
||||
sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME),
|
||||
targets
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,15 +225,15 @@ 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
|
||||
const prunedKeys = activeWorkspaceSnapshotPruneKeys(
|
||||
prunedWorkspacesByFile.get(file),
|
||||
analysis.scannedAt
|
||||
)
|
||||
return withoutWorktreeRows(
|
||||
analysis,
|
||||
(row) =>
|
||||
prunedKeys.has(workspaceSnapshotPruneKey(row.worktreeId, row.executionHostId)) ||
|
||||
prunedKeys.has(workspaceSnapshotPruneKey(row.worktreeId))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,37 +252,77 @@ function clearSupersededPrunes(file: string, analysis: WorkspaceSpaceAnalysis):
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a removed workspace's row and rebalance the totals it contributed. Never throws. */
|
||||
export async function pruneWorkspaceSpaceAnalysisSnapshot(
|
||||
async function pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones(
|
||||
snapshotDirectory: string,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[],
|
||||
registerTombstones: boolean
|
||||
): Promise<void> {
|
||||
if (targets.length === 0) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
const targetKeys = workspaceSnapshotPruneTargetKeys(targets)
|
||||
if (registerTombstones) {
|
||||
registerWorkspaceSnapshotPrunesForFile(prunedWorkspacesByFile, file, targets)
|
||||
}
|
||||
try {
|
||||
await withSidecarSnapshotQueue(file, async () => {
|
||||
const existing = await readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
|
||||
const removed = existing?.worktrees.filter(
|
||||
(row) =>
|
||||
row.worktreeId === worktreeId &&
|
||||
(executionHostId === undefined || row.executionHostId === executionHostId)
|
||||
)
|
||||
if (!existing || !removed || removed.length === 0) {
|
||||
const registered = prunedWorkspacesByFile.get(file)
|
||||
const coalescedTargetKeys = registerTombstones
|
||||
? targetKeys
|
||||
: new Set([...targetKeys].filter((key) => registered?.has(key)))
|
||||
if (coalescedTargetKeys.size === 0) {
|
||||
return
|
||||
}
|
||||
await writeSnapshot(
|
||||
file,
|
||||
removed.reduce((analysis, row) => withoutWorktreeRow(analysis, row), existing)
|
||||
const existing = await readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
const next = withoutWorktreeRows(
|
||||
existing,
|
||||
(row) =>
|
||||
coalescedTargetKeys.has(workspaceSnapshotPruneKey(row.worktreeId, row.executionHostId)) ||
|
||||
coalescedTargetKeys.has(workspaceSnapshotPruneKey(row.worktreeId))
|
||||
)
|
||||
if (next === existing) {
|
||||
return
|
||||
}
|
||||
await writeSnapshot(file, next)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[workspace-space] failed to prune analysis snapshot:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop removed workspace rows and rebalance their totals in one sidecar transaction. Never throws. */
|
||||
export async function pruneWorkspaceSpaceAnalysisSnapshots(
|
||||
snapshotDirectory: string,
|
||||
targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[]
|
||||
): Promise<void> {
|
||||
await pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones(
|
||||
snapshotDirectory,
|
||||
targets,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/** Flush only tombstones still active for this batch, preserving their original prune time. */
|
||||
export async function finalizeWorkspaceSpaceAnalysisSnapshotPrunes(
|
||||
snapshotDirectory: string,
|
||||
targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[]
|
||||
): Promise<void> {
|
||||
await pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones(
|
||||
snapshotDirectory,
|
||||
targets,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop one removed workspace row and rebalance the totals it contributed. Never throws. */
|
||||
export async function pruneWorkspaceSpaceAnalysisSnapshot(
|
||||
snapshotDirectory: string,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): Promise<void> {
|
||||
await pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }])
|
||||
}
|
||||
|
||||
@@ -536,7 +536,9 @@ import type {
|
||||
WorkspaceCleanupLocalProcessResult,
|
||||
WorkspaceCleanupScanArgs,
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
WorkspaceCleanupScanResult,
|
||||
WorkspaceCleanupSnapshotPruneBatchArgs,
|
||||
WorkspaceCleanupSnapshotPruneRecordArgs
|
||||
} from '../shared/workspace-cleanup'
|
||||
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
|
||||
|
||||
@@ -1404,11 +1406,13 @@ export type PreloadApi = {
|
||||
// may waive the proof that every PTY stopped.
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
skipArchive?: boolean
|
||||
snapshotPruneBatchId?: string
|
||||
}) => Promise<RemoveWorktreeResult>
|
||||
// Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host.
|
||||
forgetLocal: (args: {
|
||||
worktreeId: string
|
||||
hostId?: ExecutionHostId
|
||||
snapshotPruneBatchId?: string
|
||||
}) => Promise<RemoveWorktreeResult>
|
||||
forceDeletePreservedBranch: (args: {
|
||||
worktreeId: string
|
||||
@@ -1453,6 +1457,11 @@ export type PreloadApi = {
|
||||
hasKillableLocalProcesses: (
|
||||
args: WorkspaceCleanupLocalProcessArgs
|
||||
) => Promise<WorkspaceCleanupLocalProcessResult>
|
||||
beginRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise<void>
|
||||
recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise<void>
|
||||
finishRemovalSnapshotPruneBatch?: (
|
||||
args: WorkspaceCleanupSnapshotPruneBatchArgs
|
||||
) => Promise<void>
|
||||
}
|
||||
workspaceSpace: {
|
||||
analyze: () => Promise<WorkspaceSpaceAnalyzeResult>
|
||||
|
||||
@@ -909,7 +909,13 @@ const api = {
|
||||
dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args),
|
||||
clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'),
|
||||
hasKillableLocalProcesses: (args) =>
|
||||
ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args)
|
||||
ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args),
|
||||
beginRemovalSnapshotPruneBatch: (args) =>
|
||||
ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args),
|
||||
recordRemovalSnapshotPrune: (args) =>
|
||||
ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args),
|
||||
finishRemovalSnapshotPruneBatch: (args) =>
|
||||
ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args)
|
||||
} satisfies PreloadApi['workspaceCleanup'],
|
||||
|
||||
workspaceSpace: {
|
||||
|
||||
@@ -146,9 +146,26 @@ export function useWorkspaceCleanupRemoval({
|
||||
removalInFlightRef.current = false
|
||||
}
|
||||
try {
|
||||
const beginSnapshotPruneBatch = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch
|
||||
const recordSnapshotPrune = window.api.workspaceCleanup.recordRemovalSnapshotPrune
|
||||
const finishSnapshotPruneBatch = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch
|
||||
const snapshotPruneBatch =
|
||||
typeof beginSnapshotPruneBatch === 'function' &&
|
||||
typeof recordSnapshotPrune === 'function' &&
|
||||
typeof finishSnapshotPruneBatch === 'function'
|
||||
? (() => {
|
||||
const batchId = crypto.randomUUID()
|
||||
return {
|
||||
batchId,
|
||||
begin: () => beginSnapshotPruneBatch({ batchId }),
|
||||
finish: () => finishSnapshotPruneBatch({ batchId })
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: removableCandidates,
|
||||
removeCandidates,
|
||||
snapshotPruneBatch,
|
||||
onProgress: (progress) => {
|
||||
if (mountedRef.current) {
|
||||
setRemovalProgress(progress)
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal'
|
||||
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() }
|
||||
}))
|
||||
|
||||
async function settleBackgroundRemoval(): Promise<void> {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
it('holds one snapshot prune batch across the sequential cleanup removals', async () => {
|
||||
const first = makeCandidate()
|
||||
const second = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/beta',
|
||||
displayName: 'beta',
|
||||
branch: 'beta',
|
||||
path: '/repo/beta'
|
||||
})
|
||||
const events: string[] = []
|
||||
const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => {
|
||||
events.push(`remove:${worktreeIds[0]}`)
|
||||
return { removedIds: [...worktreeIds], failures: [] }
|
||||
})
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [first, second],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
snapshotPruneBatch: {
|
||||
batchId: 'batch-1',
|
||||
begin: async () => {
|
||||
events.push('begin')
|
||||
},
|
||||
finish: async () => {
|
||||
events.push('finish')
|
||||
}
|
||||
}
|
||||
})
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(1, [first.worktreeId], {
|
||||
approvedCandidates: [first],
|
||||
snapshotPruneBatchId: 'batch-1'
|
||||
})
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(2, [second.worktreeId], {
|
||||
approvedCandidates: [second],
|
||||
snapshotPruneBatchId: 'batch-1'
|
||||
})
|
||||
expect(events).toEqual([
|
||||
'begin',
|
||||
`remove:${first.worktreeId}`,
|
||||
`remove:${second.worktreeId}`,
|
||||
'finish'
|
||||
])
|
||||
})
|
||||
+91
-61
@@ -50,6 +50,11 @@ export type WorkspaceCleanupBackgroundRemovalArgs = {
|
||||
onRowFailed?: (failure: WorkspaceCleanupFailure) => void
|
||||
removalTimeoutMs?: number
|
||||
removalSettlementGraceMs?: number
|
||||
snapshotPruneBatch?: {
|
||||
batchId: string
|
||||
begin: () => Promise<void>
|
||||
finish: () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export function startWorkspaceCleanupBackgroundRemoval({
|
||||
@@ -61,7 +66,8 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
onError,
|
||||
onRowFailed,
|
||||
removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS,
|
||||
removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS
|
||||
removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS,
|
||||
snapshotPruneBatch
|
||||
}: WorkspaceCleanupBackgroundRemovalArgs): void {
|
||||
if (candidates.length === 0) {
|
||||
try {
|
||||
@@ -166,71 +172,95 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
while (queue.length > 0) {
|
||||
const candidate = queue.shift()
|
||||
if (!candidate) {
|
||||
break
|
||||
}
|
||||
const blockers = findBlockingDescendants(candidate)
|
||||
if (blockers.length > 0) {
|
||||
skipBlockedAncestor(candidate, blockers)
|
||||
continue
|
||||
}
|
||||
let snapshotPruneBatchActive = false
|
||||
if (snapshotPruneBatch) {
|
||||
try {
|
||||
const outcome = await waitForWorkspaceCleanupRemovalWithTimeout(
|
||||
removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate] }),
|
||||
removalTimeoutMs,
|
||||
removalSettlementGraceMs
|
||||
)
|
||||
if (outcome.status === 'rejected') {
|
||||
throw outcome.error
|
||||
await snapshotPruneBatch.begin()
|
||||
snapshotPruneBatchActive = true
|
||||
} catch (error) {
|
||||
console.warn('Failed to begin workspace cleanup snapshot prune batch:', error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (queue.length > 0) {
|
||||
const candidate = queue.shift()
|
||||
if (!candidate) {
|
||||
break
|
||||
}
|
||||
if (outcome.status === 'unresolved') {
|
||||
const timeoutFailure = getWorkspaceCleanupTimeoutFailure(candidate)
|
||||
failedCandidates.push(candidate)
|
||||
provisionallyBlocked.add(candidate)
|
||||
pendingSettlementFailures.add(timeoutFailure)
|
||||
reportFailures([timeoutFailure])
|
||||
// Why: renderer IPC cannot be cancelled at the timeout boundary; keep
|
||||
// its authoritative settlement without unblocking ancestors early.
|
||||
// After the batch ends, detach switches to the post-batch path which
|
||||
// still holds skip state so ancestors can reclassify.
|
||||
lateSettlementTrackers.push(
|
||||
trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => {
|
||||
removeArrayEntry(failures, timeoutFailure)
|
||||
pendingSettlementFailures.delete(timeoutFailure)
|
||||
provisionallyBlocked.delete(candidate)
|
||||
removedIds.push(...lateResult.removedIds)
|
||||
preservedBranches.push(...(lateResult.preservedBranches ?? []))
|
||||
reportFailures(lateResult.failures)
|
||||
if (lateResult.failures.length === 0) {
|
||||
removeArrayEntry(failedCandidates, candidate)
|
||||
}
|
||||
resettleSkippedAncestors()
|
||||
emitProgress()
|
||||
})
|
||||
)
|
||||
const blockers = findBlockingDescendants(candidate)
|
||||
if (blockers.length > 0) {
|
||||
skipBlockedAncestor(candidate, blockers)
|
||||
continue
|
||||
}
|
||||
const result = outcome.result
|
||||
removedIds.push(...result.removedIds)
|
||||
preservedBranches.push(...(result.preservedBranches ?? []))
|
||||
reportFailures(result.failures)
|
||||
if (result.failures.length > 0) {
|
||||
failedCandidates.push(candidate)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
failedCandidates.push(candidate)
|
||||
reportFailures([
|
||||
{
|
||||
worktreeId: candidate.worktreeId,
|
||||
displayName: candidate.displayName,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
const outcome = await waitForWorkspaceCleanupRemovalWithTimeout(
|
||||
removeCandidates([candidate.worktreeId], {
|
||||
approvedCandidates: [candidate],
|
||||
...(snapshotPruneBatchActive && snapshotPruneBatch
|
||||
? { snapshotPruneBatchId: snapshotPruneBatch.batchId }
|
||||
: {})
|
||||
}),
|
||||
removalTimeoutMs,
|
||||
removalSettlementGraceMs
|
||||
)
|
||||
if (outcome.status === 'rejected') {
|
||||
throw outcome.error
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
processedCount += 1
|
||||
emitProgress()
|
||||
if (outcome.status === 'unresolved') {
|
||||
const timeoutFailure = getWorkspaceCleanupTimeoutFailure(candidate)
|
||||
failedCandidates.push(candidate)
|
||||
provisionallyBlocked.add(candidate)
|
||||
pendingSettlementFailures.add(timeoutFailure)
|
||||
reportFailures([timeoutFailure])
|
||||
// Why: renderer IPC cannot be cancelled at the timeout boundary; keep
|
||||
// its authoritative settlement without unblocking ancestors early.
|
||||
// After the batch ends, detach switches to the post-batch path which
|
||||
// still holds skip state so ancestors can reclassify.
|
||||
lateSettlementTrackers.push(
|
||||
trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => {
|
||||
removeArrayEntry(failures, timeoutFailure)
|
||||
pendingSettlementFailures.delete(timeoutFailure)
|
||||
provisionallyBlocked.delete(candidate)
|
||||
removedIds.push(...lateResult.removedIds)
|
||||
preservedBranches.push(...(lateResult.preservedBranches ?? []))
|
||||
reportFailures(lateResult.failures)
|
||||
if (lateResult.failures.length === 0) {
|
||||
removeArrayEntry(failedCandidates, candidate)
|
||||
}
|
||||
resettleSkippedAncestors()
|
||||
emitProgress()
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
const result = outcome.result
|
||||
removedIds.push(...result.removedIds)
|
||||
preservedBranches.push(...(result.preservedBranches ?? []))
|
||||
reportFailures(result.failures)
|
||||
if (result.failures.length > 0) {
|
||||
failedCandidates.push(candidate)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
failedCandidates.push(candidate)
|
||||
reportFailures([
|
||||
{
|
||||
worktreeId: candidate.worktreeId,
|
||||
displayName: candidate.displayName,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
processedCount += 1
|
||||
emitProgress()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (snapshotPruneBatchActive && snapshotPruneBatch) {
|
||||
try {
|
||||
await snapshotPruneBatch.finish()
|
||||
} catch (error) {
|
||||
console.warn('Failed to finish workspace cleanup snapshot prune batch:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type {
|
||||
WorkspaceCleanupScanArgs,
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY } from './workspace-cleanup'
|
||||
import {
|
||||
NOW,
|
||||
createCleanupTestStore,
|
||||
deferred,
|
||||
installWorkspaceCleanupApi,
|
||||
makeCandidate
|
||||
} from './workspace-cleanup-slice-test-harness'
|
||||
|
||||
type GlobalCollectionScanCounts = {
|
||||
openFiles: number
|
||||
retainedAgents: number
|
||||
agentStatuses: number
|
||||
}
|
||||
|
||||
const ALPHA_ID = 'repo-alpha::/workspace/alpha'
|
||||
const BETA_ID = 'repo-beta::/workspace/beta'
|
||||
const PROJECTION_CANDIDATE_COUNT = 1_000
|
||||
|
||||
function countOpenFileScans(
|
||||
openFiles: AppState['openFiles'],
|
||||
counts: GlobalCollectionScanCounts
|
||||
): AppState['openFiles'] {
|
||||
return new Proxy(openFiles, {
|
||||
get(target, property, receiver) {
|
||||
if (property === Symbol.iterator) {
|
||||
return () => {
|
||||
counts.openFiles += 1
|
||||
return target[Symbol.iterator]()
|
||||
}
|
||||
}
|
||||
if (property === 'filter') {
|
||||
return (predicate: (file: AppState['openFiles'][number]) => boolean) => {
|
||||
counts.openFiles += 1
|
||||
return target.filter(predicate)
|
||||
}
|
||||
}
|
||||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function countRecordScans<T extends object>(record: T, onScan: () => void): T {
|
||||
return new Proxy(record, {
|
||||
ownKeys(target) {
|
||||
onScan()
|
||||
return Reflect.ownKeys(target)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resetGlobalCollectionScanCounts(counts: GlobalCollectionScanCounts): void {
|
||||
counts.openFiles = 0
|
||||
counts.retainedAgents = 0
|
||||
counts.agentStatuses = 0
|
||||
}
|
||||
|
||||
function makePerformanceCandidate(index: number, fingerprintSuffix = 'stream') {
|
||||
return makeCandidate({
|
||||
worktreeId: `repo-${index}::/workspace/${index}`,
|
||||
repoId: `repo-${index}`,
|
||||
displayName: `workspace-${index}`,
|
||||
path: `/workspace/${index}`,
|
||||
fingerprint: `fingerprint-${index}-${fingerprintSuffix}`
|
||||
})
|
||||
}
|
||||
|
||||
describe('workspace cleanup enrichment performance', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('scans global collections once per progress and final pass without changing results', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW)
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const scan = vi.fn(
|
||||
(
|
||||
_args?: WorkspaceCleanupScanArgs,
|
||||
progressCallback?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
}
|
||||
)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
|
||||
const alphaTab = { id: 'tab-alpha', title: 'shell' }
|
||||
const betaTab = { id: 'tab-beta', title: 'shell' }
|
||||
const alphaWorking = {
|
||||
paneKey: 'tab-alpha:leaf-alpha',
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: NOW,
|
||||
stateStartedAt: NOW,
|
||||
stateHistory: []
|
||||
}
|
||||
const betaDone = {
|
||||
paneKey: 'tab-beta:leaf-beta',
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
updatedAt: NOW,
|
||||
stateStartedAt: NOW,
|
||||
stateHistory: []
|
||||
}
|
||||
const counts: GlobalCollectionScanCounts = {
|
||||
openFiles: 0,
|
||||
retainedAgents: 0,
|
||||
agentStatuses: 0
|
||||
}
|
||||
const openFiles = countOpenFileScans(
|
||||
[
|
||||
{
|
||||
id: 'alpha-dirty',
|
||||
worktreeId: ALPHA_ID,
|
||||
filePath: '/workspace/alpha/dirty.ts',
|
||||
relativePath: 'dirty.ts',
|
||||
language: 'typescript',
|
||||
isDirty: true
|
||||
},
|
||||
{
|
||||
id: 'alpha-clean',
|
||||
worktreeId: ALPHA_ID,
|
||||
filePath: '/workspace/alpha/clean.ts',
|
||||
relativePath: 'clean.ts',
|
||||
language: 'typescript',
|
||||
isDirty: false
|
||||
},
|
||||
{
|
||||
id: 'beta-clean',
|
||||
worktreeId: BETA_ID,
|
||||
filePath: '/workspace/beta/clean.ts',
|
||||
relativePath: 'clean.ts',
|
||||
language: 'typescript',
|
||||
isDirty: false
|
||||
}
|
||||
] as AppState['openFiles'],
|
||||
counts
|
||||
)
|
||||
const retainedAgentsByPaneKey = countRecordScans(
|
||||
{
|
||||
'tab-alpha:retained': {
|
||||
entry: { ...alphaWorking, paneKey: 'tab-alpha:retained', state: 'done' },
|
||||
worktreeId: ALPHA_ID,
|
||||
tab: alphaTab,
|
||||
agentType: 'codex',
|
||||
startedAt: NOW
|
||||
},
|
||||
'tab-beta:retained': {
|
||||
entry: { ...betaDone, paneKey: 'tab-beta:retained', state: 'working' },
|
||||
worktreeId: BETA_ID,
|
||||
tab: betaTab,
|
||||
agentType: 'codex',
|
||||
startedAt: NOW
|
||||
}
|
||||
} as unknown as AppState['retainedAgentsByPaneKey'],
|
||||
() => {
|
||||
counts.retainedAgents += 1
|
||||
}
|
||||
)
|
||||
const agentStatusByPaneKey = countRecordScans(
|
||||
{
|
||||
[alphaWorking.paneKey]: alphaWorking,
|
||||
[betaDone.paneKey]: betaDone
|
||||
} as AppState['agentStatusByPaneKey'],
|
||||
() => {
|
||||
counts.agentStatuses += 1
|
||||
}
|
||||
)
|
||||
const candidates = [
|
||||
makeCandidate({
|
||||
worktreeId: ALPHA_ID,
|
||||
repoId: 'repo-alpha',
|
||||
displayName: 'alpha',
|
||||
path: '/workspace/alpha',
|
||||
blockers: ['dismissed'],
|
||||
fingerprint: 'alpha-fingerprint'
|
||||
}),
|
||||
makeCandidate({
|
||||
worktreeId: BETA_ID,
|
||||
repoId: 'repo-beta',
|
||||
displayName: 'beta',
|
||||
path: '/workspace/beta',
|
||||
fingerprint: 'beta-fingerprint'
|
||||
}),
|
||||
...Array.from({ length: PROJECTION_CANDIDATE_COUNT - 2 }, (_, index) =>
|
||||
makePerformanceCandidate(index + 2, 'projection')
|
||||
)
|
||||
]
|
||||
const tabsByWorktree = Object.fromEntries(
|
||||
candidates.map((candidate, index) => {
|
||||
if (index === 0) {
|
||||
return [candidate.worktreeId, [alphaTab]]
|
||||
}
|
||||
if (index === 1) {
|
||||
return [candidate.worktreeId, [betaTab]]
|
||||
}
|
||||
return [candidate.worktreeId, [{ id: `tab-projection-${index}`, title: 'shell' }]]
|
||||
})
|
||||
) as unknown as AppState['tabsByWorktree']
|
||||
const store = createCleanupTestStore()
|
||||
store.setState({
|
||||
activeWorktreeId: BETA_ID,
|
||||
tabsByWorktree,
|
||||
openFiles,
|
||||
browserTabsByWorktree: {
|
||||
[ALPHA_ID]: [{}]
|
||||
} as unknown as AppState['browserTabsByWorktree'],
|
||||
retainedAgentsByPaneKey,
|
||||
agentStatusByPaneKey,
|
||||
lastVisitedAtByWorktreeId: { [ALPHA_ID]: NOW - 1 }
|
||||
} as Partial<AppState>)
|
||||
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
expect(counts).toEqual({ openFiles: 1, retainedAgents: 0, agentStatuses: 1 })
|
||||
resetGlobalCollectionScanCounts(counts)
|
||||
onProgress?.({
|
||||
scanId: 'performance-scan',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: candidates.length,
|
||||
totalWorktreeCount: candidates.length,
|
||||
candidates,
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(
|
||||
candidates.length
|
||||
)
|
||||
})
|
||||
expect(counts).toEqual({ openFiles: 1, retainedAgents: 1, agentStatuses: 1 })
|
||||
const progressCandidates = store.getState().workspaceCleanupScan?.candidates
|
||||
expect(progressCandidates?.[0]).toMatchObject({
|
||||
blockers: ['dirty-editor-buffer', 'live-agent', 'recent-visible-context'],
|
||||
localContext: {
|
||||
terminalTabCount: 1,
|
||||
cleanEditorTabCount: 1,
|
||||
browserTabCount: 1,
|
||||
retainedDoneAgentCount: 1
|
||||
}
|
||||
})
|
||||
expect(progressCandidates?.[1]).toMatchObject({
|
||||
blockers: ['active-workspace'],
|
||||
localContext: {
|
||||
terminalTabCount: 1,
|
||||
cleanEditorTabCount: 1,
|
||||
browserTabCount: 0,
|
||||
retainedDoneAgentCount: 0
|
||||
}
|
||||
})
|
||||
|
||||
resetGlobalCollectionScanCounts(counts)
|
||||
pending.resolve({ scannedAt: NOW, candidates, errors: [] })
|
||||
const finalScan = await scanPromise
|
||||
|
||||
expect(counts).toEqual({ openFiles: 1, retainedAgents: 1, agentStatuses: 1 })
|
||||
expect(finalScan.candidates).toEqual(progressCandidates)
|
||||
})
|
||||
|
||||
it('caps terminal candidate probes for streamed and final enrichment', async () => {
|
||||
const candidateCount = WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY + 3
|
||||
const streamCandidates = Array.from({ length: candidateCount }, (_, index) =>
|
||||
makePerformanceCandidate(index)
|
||||
)
|
||||
const finalCandidates = streamCandidates.map((candidate, index) => ({
|
||||
...candidate,
|
||||
fingerprint: `fingerprint-${index}-final`
|
||||
}))
|
||||
const tabsByWorktree = Object.fromEntries(
|
||||
streamCandidates.map((candidate, index) => [
|
||||
candidate.worktreeId,
|
||||
[{ id: `tab-${index}`, title: 'shell' }]
|
||||
])
|
||||
) as AppState['tabsByWorktree']
|
||||
const ptyIdsByTabId = Object.fromEntries(
|
||||
streamCandidates.map((_, index) => [`tab-${index}`, [`pty-${index}`]])
|
||||
)
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const scan = vi.fn(
|
||||
(
|
||||
_args?: WorkspaceCleanupScanArgs,
|
||||
progressCallback?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
}
|
||||
)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
|
||||
type ProbePhase = 'stream' | 'final'
|
||||
let phase: ProbePhase = 'stream'
|
||||
let probeGate = deferred<void>()
|
||||
const active: Record<ProbePhase, number> = { stream: 0, final: 0 }
|
||||
const peak: Record<ProbePhase, number> = { stream: 0, final: 0 }
|
||||
const hasChildProcesses = vi.fn(async () => {
|
||||
const callPhase = phase
|
||||
const callGate = probeGate
|
||||
active[callPhase] += 1
|
||||
peak[callPhase] = Math.max(peak[callPhase], active[callPhase])
|
||||
await callGate.promise
|
||||
active[callPhase] -= 1
|
||||
return false
|
||||
})
|
||||
;(
|
||||
globalThis.window as unknown as {
|
||||
api: {
|
||||
pty: {
|
||||
hasChildProcesses: typeof hasChildProcesses
|
||||
getForegroundProcess: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
}
|
||||
).api.pty = {
|
||||
hasChildProcesses,
|
||||
getForegroundProcess: vi.fn().mockResolvedValue('zsh')
|
||||
}
|
||||
const store = createCleanupTestStore()
|
||||
store.setState({ tabsByWorktree, ptyIdsByTabId } as Partial<AppState>)
|
||||
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
onProgress?.({
|
||||
scanId: 'bounded-scan',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: candidateCount,
|
||||
totalWorktreeCount: candidateCount,
|
||||
candidates: streamCandidates,
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(active.stream).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY)
|
||||
})
|
||||
probeGate.resolve()
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(candidateCount)
|
||||
})
|
||||
expect(peak.stream).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY)
|
||||
|
||||
phase = 'final'
|
||||
probeGate = deferred<void>()
|
||||
pending.resolve({ scannedAt: NOW, candidates: finalCandidates, errors: [] })
|
||||
await vi.waitFor(() => {
|
||||
expect(active.final).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY)
|
||||
})
|
||||
probeGate.resolve()
|
||||
const finalScan = await scanPromise
|
||||
|
||||
expect(peak.final).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY)
|
||||
expect(hasChildProcesses).toHaveBeenCalledTimes(candidateCount * 2)
|
||||
expect(finalScan.candidates.map((candidate) => candidate.worktreeId)).toEqual(
|
||||
finalCandidates.map((candidate) => candidate.worktreeId)
|
||||
)
|
||||
})
|
||||
|
||||
it('rebuilds focused removal enrichment from state changed after the broad scan', async () => {
|
||||
const candidate = makeCandidate()
|
||||
const focusedScan = deferred<WorkspaceCleanupScanResult>()
|
||||
const scan = vi.fn((args?: WorkspaceCleanupScanArgs) =>
|
||||
args?.worktreeId
|
||||
? focusedScan.promise
|
||||
: Promise.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] })
|
||||
)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const hasChildProcesses = vi.fn().mockResolvedValue(false)
|
||||
;(
|
||||
globalThis.window as unknown as {
|
||||
api: {
|
||||
pty: {
|
||||
hasChildProcesses: typeof hasChildProcesses
|
||||
getForegroundProcess: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
}
|
||||
).api.pty = {
|
||||
hasChildProcesses,
|
||||
getForegroundProcess: vi.fn().mockResolvedValue('zsh')
|
||||
}
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
await store.getState().scanWorkspaceCleanup()
|
||||
const removal = store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId])
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
[candidate.worktreeId]: [
|
||||
{ id: 'post-scan-tab', title: 'shell' }
|
||||
] as AppState['tabsByWorktree'][string]
|
||||
},
|
||||
ptyIdsByTabId: { 'post-scan-tab': ['post-scan-pty'] }
|
||||
} as Partial<AppState>)
|
||||
focusedScan.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] })
|
||||
|
||||
await expect(removal).resolves.toMatchObject({ removedIds: [candidate.worktreeId] })
|
||||
expect(hasChildProcesses).toHaveBeenCalledWith('post-scan-pty')
|
||||
})
|
||||
})
|
||||
@@ -121,6 +121,24 @@ describe('workspace cleanup removal and protection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards the snapshot batch through each successful removal', async () => {
|
||||
const candidate = makeCandidate({ executionHostId: 'ssh:ssh-1' })
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn(async () => ({ scannedAt: NOW, candidates: [candidate], errors: [] }))
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
await store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId], {
|
||||
snapshotPruneBatchId: 'batch-1'
|
||||
})
|
||||
|
||||
expect(removeWorktree).toHaveBeenCalledWith(candidate.worktreeId, false, {
|
||||
suppressPreservedBranchToast: true,
|
||||
snapshotPruneBatchId: 'batch-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('demotes an active suggested workspace when it was not viewed from cleanup', async () => {
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate()],
|
||||
|
||||
@@ -43,6 +43,7 @@ export type WorkspaceCleanupRemoveOptions = {
|
||||
// Why: rows are removed long after the confirm click; the confirm-time
|
||||
// candidate records how much git risk the user actually approved.
|
||||
approvedCandidates?: readonly WorkspaceCleanupCandidate[]
|
||||
snapshotPruneBatchId?: string
|
||||
}
|
||||
|
||||
type WorkspaceCleanupViewedCandidate = {
|
||||
@@ -82,8 +83,15 @@ type WorkspaceCleanupEnrichmentCacheEntry = {
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
}
|
||||
|
||||
type WorkspaceCleanupEnrichmentProjection = {
|
||||
openFilesByWorktreeId: Map<string, AppState['openFiles']>
|
||||
retainedDoneAgentPaneKeysByWorktreeId: Map<string, string[]>
|
||||
agentStatusesByTabId: Map<string, AgentStatusEntry[]>
|
||||
}
|
||||
|
||||
const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000
|
||||
const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000
|
||||
export const WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY = 8
|
||||
const WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY = 4
|
||||
// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base
|
||||
// and git-status-error only mean "couldn't verify". A row approved while
|
||||
@@ -348,7 +356,12 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
shouldForceWorkspaceCleanupRemoval(candidate),
|
||||
// Why: cleanup reports outcomes in its own summary toasts; per-row
|
||||
// preserved-branch warnings would stack one toast per removed row.
|
||||
{ suppressPreservedBranchToast: true }
|
||||
{
|
||||
suppressPreservedBranchToast: true,
|
||||
...(options?.snapshotPruneBatchId
|
||||
? { snapshotPruneBatchId: options.snapshotPruneBatchId }
|
||||
: {})
|
||||
}
|
||||
)
|
||||
if (result.ok) {
|
||||
removedIds.push(candidate.worktreeId)
|
||||
@@ -597,12 +610,16 @@ function getInitialWorkspaceCleanupGitDeferrals(state: AppState): string[] {
|
||||
}
|
||||
|
||||
const openEditorWorktreeIds = new Set(state.openFiles.map((file) => file.worktreeId))
|
||||
const agentStatusesByTabId = buildWorkspaceCleanupAgentStatusIndex(state)
|
||||
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
|
||||
const tabIds = new Set(tabs.map((tab) => tab.id))
|
||||
if (tabs.some((tab) => (state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0)) {
|
||||
ids.add(worktreeId)
|
||||
}
|
||||
if (hasFreshLiveAgent(state, tabIds) || hasWorkingTitleAgent(state, tabs)) {
|
||||
if (
|
||||
hasFreshIndexedLiveAgent(agentStatusesByTabId, tabIds) ||
|
||||
hasWorkingTitleAgent(state, tabs)
|
||||
) {
|
||||
ids.add(worktreeId)
|
||||
}
|
||||
}
|
||||
@@ -634,8 +651,9 @@ export async function enrichWorkspaceCleanupCandidates(
|
||||
state: AppState,
|
||||
options: EnrichOptions = {}
|
||||
): Promise<WorkspaceCleanupCandidate[]> {
|
||||
return Promise.all(
|
||||
candidates.map((candidate) => enrichWorkspaceCleanupCandidate(candidate, state, options))
|
||||
const projection = buildWorkspaceCleanupEnrichmentProjection(candidates, state)
|
||||
return mapWithConcurrency(candidates, WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY, (candidate) =>
|
||||
enrichWorkspaceCleanupCandidate(candidate, state, projection, options)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -645,12 +663,16 @@ async function enrichWorkspaceCleanupCandidatesWithCache(
|
||||
cache: Map<string, WorkspaceCleanupEnrichmentCacheEntry>,
|
||||
options: EnrichOptions = {}
|
||||
): Promise<WorkspaceCleanupCandidate[]> {
|
||||
return Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
const projection = buildWorkspaceCleanupEnrichmentProjection(candidates, state)
|
||||
return mapWithConcurrency(
|
||||
candidates,
|
||||
WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY,
|
||||
async (candidate) => {
|
||||
const inputSignature = getWorkspaceCleanupCandidateInputSignature(candidate)
|
||||
const localSignature = getWorkspaceCleanupLocalStateSignature(
|
||||
candidate.worktreeId,
|
||||
state,
|
||||
projection,
|
||||
options
|
||||
)
|
||||
const cached = cache.get(candidate.worktreeId)
|
||||
@@ -658,17 +680,58 @@ async function enrichWorkspaceCleanupCandidatesWithCache(
|
||||
return cached.candidate
|
||||
}
|
||||
|
||||
const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, options)
|
||||
const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, projection, options)
|
||||
cache.set(candidate.worktreeId, {
|
||||
inputSignature,
|
||||
localSignature,
|
||||
candidate: enriched
|
||||
})
|
||||
return enriched
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function buildWorkspaceCleanupEnrichmentProjection(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
state: AppState
|
||||
): WorkspaceCleanupEnrichmentProjection {
|
||||
const worktreeIds = new Set(candidates.map((candidate) => candidate.worktreeId))
|
||||
const tabIds = new Set<string>()
|
||||
for (const worktreeId of worktreeIds) {
|
||||
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
|
||||
tabIds.add(tab.id)
|
||||
}
|
||||
}
|
||||
|
||||
const openFilesByWorktreeId = new Map<string, AppState['openFiles']>()
|
||||
for (const file of state.openFiles) {
|
||||
if (!worktreeIds.has(file.worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const files = openFilesByWorktreeId.get(file.worktreeId) ?? []
|
||||
files.push(file)
|
||||
openFilesByWorktreeId.set(file.worktreeId, files)
|
||||
}
|
||||
|
||||
const retainedDoneAgentPaneKeysByWorktreeId = new Map<string, string[]>()
|
||||
for (const [paneKey, retained] of Object.entries(state.retainedAgentsByPaneKey)) {
|
||||
if (!worktreeIds.has(retained.worktreeId) || retained.entry.state !== 'done') {
|
||||
continue
|
||||
}
|
||||
const paneKeys = retainedDoneAgentPaneKeysByWorktreeId.get(retained.worktreeId) ?? []
|
||||
paneKeys.push(paneKey)
|
||||
retainedDoneAgentPaneKeysByWorktreeId.set(retained.worktreeId, paneKeys)
|
||||
}
|
||||
|
||||
const agentStatusesByTabId = buildWorkspaceCleanupAgentStatusIndex(state, tabIds)
|
||||
|
||||
return {
|
||||
openFilesByWorktreeId,
|
||||
retainedDoneAgentPaneKeysByWorktreeId,
|
||||
agentStatusesByTabId
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupCandidate): string {
|
||||
return JSON.stringify({
|
||||
fingerprint: candidate.fingerprint,
|
||||
@@ -685,24 +748,22 @@ function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupC
|
||||
function getWorkspaceCleanupLocalStateSignature(
|
||||
worktreeId: string,
|
||||
state: AppState,
|
||||
projection: WorkspaceCleanupEnrichmentProjection,
|
||||
options: EnrichOptions
|
||||
): string {
|
||||
const tabs = state.tabsByWorktree[worktreeId] ?? []
|
||||
const tabIds = tabs.map((tab) => tab.id)
|
||||
const tabIdSet = new Set(tabIds)
|
||||
const openFiles = state.openFiles
|
||||
.filter((file) => file.worktreeId === worktreeId)
|
||||
.map((file) => ({
|
||||
id: file.id,
|
||||
isDirty: file.isDirty,
|
||||
hasDraft: state.editorDrafts[file.id] !== undefined
|
||||
}))
|
||||
const retainedDoneAgentPaneKeys = Object.entries(state.retainedAgentsByPaneKey)
|
||||
.filter(([, entry]) => entry.worktreeId === worktreeId && entry.entry.state === 'done')
|
||||
.map(([paneKey]) => paneKey)
|
||||
.sort()
|
||||
const agentStatuses = Object.values(state.agentStatusByPaneKey)
|
||||
.filter((entry) => tabIdSet.has(getPaneKeyTabId(entry.paneKey)))
|
||||
const openFiles = (projection.openFilesByWorktreeId.get(worktreeId) ?? []).map((file) => ({
|
||||
id: file.id,
|
||||
isDirty: file.isDirty,
|
||||
hasDraft: state.editorDrafts[file.id] !== undefined
|
||||
}))
|
||||
const retainedDoneAgentPaneKeys = [
|
||||
...(projection.retainedDoneAgentPaneKeysByWorktreeId.get(worktreeId) ?? [])
|
||||
].sort()
|
||||
const agentStatuses = [...tabIdSet]
|
||||
.flatMap((tabId) => projection.agentStatusesByTabId.get(tabId) ?? [])
|
||||
.map((entry) => ({
|
||||
paneKey: entry.paneKey,
|
||||
state: entry.state,
|
||||
@@ -742,19 +803,19 @@ function getWorkspaceCleanupLocalStateSignature(
|
||||
async function enrichWorkspaceCleanupCandidate(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
state: AppState,
|
||||
projection: WorkspaceCleanupEnrichmentProjection,
|
||||
options: EnrichOptions
|
||||
): Promise<WorkspaceCleanupCandidate> {
|
||||
const tabs = state.tabsByWorktree[candidate.worktreeId] ?? []
|
||||
const tabIds = new Set(tabs.map((tab) => tab.id))
|
||||
const openFiles = state.openFiles.filter((file) => file.worktreeId === candidate.worktreeId)
|
||||
const openFiles = projection.openFilesByWorktreeId.get(candidate.worktreeId) ?? []
|
||||
const dirtyEditorBuffers = openFiles.filter(
|
||||
(file) => file.isDirty || state.editorDrafts[file.id] !== undefined
|
||||
)
|
||||
const cleanEditorTabCount = openFiles.length - dirtyEditorBuffers.length
|
||||
const browserTabCount = (state.browserTabsByWorktree[candidate.worktreeId] ?? []).length
|
||||
const retainedDoneAgentCount = Object.values(state.retainedAgentsByPaneKey).filter(
|
||||
(entry) => entry.worktreeId === candidate.worktreeId && entry.entry.state === 'done'
|
||||
).length
|
||||
const retainedDoneAgentCount =
|
||||
projection.retainedDoneAgentPaneKeysByWorktreeId.get(candidate.worktreeId)?.length ?? 0
|
||||
const blockers = candidate.blockers.filter((blocker) => blocker !== 'dismissed')
|
||||
const preserveCleanupInspection = shouldPreserveCleanupInspection(candidate, state)
|
||||
|
||||
@@ -764,7 +825,7 @@ async function enrichWorkspaceCleanupCandidate(
|
||||
if (dirtyEditorBuffers.length > 0) {
|
||||
blockers.push('dirty-editor-buffer')
|
||||
}
|
||||
if (hasFreshLiveAgent(state, tabIds)) {
|
||||
if (hasFreshIndexedLiveAgent(projection.agentStatusesByTabId, tabIds)) {
|
||||
blockers.push('live-agent')
|
||||
}
|
||||
if (hasWorkingTitleAgent(state, tabs)) {
|
||||
@@ -841,7 +902,8 @@ async function preflightWorkspaceCleanupCandidate(
|
||||
| { ok: false; failure: WorkspaceCleanupFailure }
|
||||
> {
|
||||
const scan = await window.api.workspaceCleanup.scan({ worktreeId })
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(scan.candidates, getState(), {
|
||||
const currentState = getState()
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(scan.candidates, currentState, {
|
||||
applyDismissals: false
|
||||
})
|
||||
if (!candidate) {
|
||||
@@ -899,14 +961,39 @@ async function preflightWorkspaceCleanupCandidate(
|
||||
return { ok: true, candidate }
|
||||
}
|
||||
|
||||
function hasFreshLiveAgent(state: AppState, tabIds: Set<string>): boolean {
|
||||
function buildWorkspaceCleanupAgentStatusIndex(
|
||||
state: AppState,
|
||||
includedTabIds?: ReadonlySet<string>
|
||||
): Map<string, AgentStatusEntry[]> {
|
||||
const agentStatusesByTabId = new Map<string, AgentStatusEntry[]>()
|
||||
for (const entry of Object.values(state.agentStatusByPaneKey)) {
|
||||
const tabId = getPaneKeyTabId(entry.paneKey)
|
||||
if (includedTabIds && !includedTabIds.has(tabId)) {
|
||||
continue
|
||||
}
|
||||
const entries = agentStatusesByTabId.get(tabId) ?? []
|
||||
entries.push(entry)
|
||||
agentStatusesByTabId.set(tabId, entries)
|
||||
}
|
||||
return agentStatusesByTabId
|
||||
}
|
||||
|
||||
function hasFreshIndexedLiveAgent(
|
||||
agentStatusesByTabId: ReadonlyMap<string, readonly AgentStatusEntry[]>,
|
||||
tabIds: Set<string>
|
||||
): boolean {
|
||||
const now = Date.now()
|
||||
return Object.values(state.agentStatusByPaneKey).some(
|
||||
(entry) =>
|
||||
tabIds.has(getPaneKeyTabId(entry.paneKey)) &&
|
||||
isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) &&
|
||||
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
|
||||
)
|
||||
for (const tabId of tabIds) {
|
||||
for (const entry of agentStatusesByTabId.get(tabId) ?? []) {
|
||||
if (
|
||||
isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) &&
|
||||
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function hasWorkingTitleAgent(state: AppState, tabs: { id: string; title: string }[]): boolean {
|
||||
|
||||
@@ -244,6 +244,7 @@ export type WorktreeSlice = {
|
||||
// Why (#11960): only an explicit Force Delete waives the proof that every
|
||||
// PTY stopped; `force` alone is set by the ordinary delete confirmation.
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
snapshotPruneBatchId?: string
|
||||
}
|
||||
) => Promise<({ ok: true } & RendererRemoveWorktreeResult) | { ok: false; error: string }>
|
||||
markWorktreesDeleting: (worktreeIds: readonly string[]) => void
|
||||
|
||||
@@ -143,6 +143,9 @@ const mockApi = {
|
||||
hooks: {
|
||||
check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false })
|
||||
},
|
||||
workspaceCleanup: {
|
||||
recordRemovalSnapshotPrune: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
runtimeEnvironments: {
|
||||
call: runtimeEnvironmentTransportCall
|
||||
},
|
||||
@@ -6428,7 +6431,9 @@ describe('worktree remote runtime mutations', () => {
|
||||
worktreesByRepo: { repo1: [wt] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
const result = await store.getState().removeWorktree(wt.id)
|
||||
const result = await store.getState().removeWorktree(wt.id, false, {
|
||||
snapshotPruneBatchId: 'batch-1'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
@@ -6437,14 +6442,20 @@ describe('worktree remote runtime mutations', () => {
|
||||
params: {
|
||||
worktree: `id:${wt.id}`,
|
||||
hostId: 'runtime:env-1',
|
||||
force: undefined,
|
||||
force: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
runHooks: true
|
||||
},
|
||||
timeoutMs: 60_000,
|
||||
expectedEnvironmentPairingRevision: undefined
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
expectedRuntimeId: undefined
|
||||
})
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
expect(mockApi.workspaceCleanup.recordRemovalSnapshotPrune).toHaveBeenCalledExactlyOnceWith({
|
||||
batchId: 'batch-1',
|
||||
worktreeId: wt.id,
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
expect(store.getState().shutdownWorktreeTerminals).toHaveBeenCalledWith(wt.id, {
|
||||
shutdownReason: 'remove-worktree',
|
||||
backendOwnsPtyTeardown: true
|
||||
|
||||
@@ -4254,9 +4254,16 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
: { activeRuntimeEnvironmentId: null }
|
||||
)
|
||||
let removalResult: RemoveWorktreeResult
|
||||
let snapshotPruneHandledByLocalMain = forgetLocalOnly || target.kind === 'local'
|
||||
try {
|
||||
removalResult = await (forgetLocalOnly
|
||||
? window.api.worktrees.forgetLocal({ worktreeId, hostId })
|
||||
? window.api.worktrees.forgetLocal({
|
||||
worktreeId,
|
||||
hostId,
|
||||
...(options?.snapshotPruneBatchId
|
||||
? { snapshotPruneBatchId: options.snapshotPruneBatchId }
|
||||
: {})
|
||||
})
|
||||
: target.kind === 'local'
|
||||
? (removalGenerationGuard?.assertCurrent(),
|
||||
window.api.worktrees.remove({
|
||||
@@ -4264,7 +4271,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
hostId,
|
||||
force,
|
||||
allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true,
|
||||
skipArchive
|
||||
skipArchive,
|
||||
...(options?.snapshotPruneBatchId
|
||||
? { snapshotPruneBatchId: options.snapshotPruneBatchId }
|
||||
: {})
|
||||
}))
|
||||
: (removalGenerationGuard?.assertCurrent(),
|
||||
callRuntimeRpc<RemoveWorktreeResult>(
|
||||
@@ -4294,7 +4304,14 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
removalGenerationGuard?.assertCurrent()
|
||||
}
|
||||
try {
|
||||
removalResult = await window.api.worktrees.forgetLocal({ worktreeId, hostId })
|
||||
removalResult = await window.api.worktrees.forgetLocal({
|
||||
worktreeId,
|
||||
hostId,
|
||||
...(options?.snapshotPruneBatchId
|
||||
? { snapshotPruneBatchId: options.snapshotPruneBatchId }
|
||||
: {})
|
||||
})
|
||||
snapshotPruneHandledByLocalMain = true
|
||||
} catch (fallbackError) {
|
||||
// Preserve the remote verdict as fallback failure context.
|
||||
throw new Error(
|
||||
@@ -4307,6 +4324,18 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.snapshotPruneBatchId && !snapshotPruneHandledByLocalMain) {
|
||||
try {
|
||||
await window.api.workspaceCleanup?.recordRemovalSnapshotPrune?.({
|
||||
batchId: options.snapshotPruneBatchId,
|
||||
worktreeId,
|
||||
...(hostId ? { executionHostId: hostId } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to record workspace cleanup snapshot prune:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement.
|
||||
forgetHugeRepoWarningDismissalsForWorktrees([worktreeId])
|
||||
// Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an
|
||||
|
||||
@@ -93,6 +93,15 @@ export type WorkspaceCleanupLocalProcessArgs = {
|
||||
worktreePath?: string
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupSnapshotPruneBatchArgs = {
|
||||
batchId: string
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupSnapshotPruneRecordArgs = WorkspaceCleanupSnapshotPruneBatchArgs & {
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupScanError = {
|
||||
repoId: string
|
||||
repoName: string
|
||||
|
||||
Reference in New Issue
Block a user