mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(workspaces): scope snapshot persistence
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { IGitProvider } from '../providers/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type { Repo, Worktree } from '../../shared/types'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
applyWorkspaceCleanupPolicy,
|
||||
createWorkspaceCleanupFingerprint,
|
||||
@@ -58,6 +59,7 @@ export async function buildWorkspaceCleanupCandidate(args: {
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
executionHostId: getRepoExecutionHostId(repo),
|
||||
displayName: worktree.displayName,
|
||||
branch: shortWorkspaceCleanupBranchName(worktree.branch),
|
||||
path: worktree.path,
|
||||
@@ -102,6 +104,7 @@ export function buildWorkspaceCleanupCandidateFromError(
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
executionHostId: getRepoExecutionHostId(repo),
|
||||
displayName: worktree.displayName,
|
||||
branch: shortWorkspaceCleanupBranchName(worktree.branch),
|
||||
path: worktree.path,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { basename } from 'node:path'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo, WorktreeMeta } from '../../shared/types'
|
||||
import {
|
||||
@@ -60,6 +61,7 @@ function createDisconnectedSshCandidate(
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
executionHostId: getRepoExecutionHostId(repo),
|
||||
displayName: meta.displayName || basename(path),
|
||||
branch: basename(path),
|
||||
path,
|
||||
|
||||
@@ -30,6 +30,7 @@ import { registerWorkspaceCleanupHandlers } from './workspace-cleanup'
|
||||
|
||||
function makeEmptyStore(): Store {
|
||||
return {
|
||||
getProfileStorageDirectory: () => '/profile-a',
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => ({}),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
|
||||
@@ -25,6 +25,7 @@ const NOW = 1_700_000_000_000
|
||||
|
||||
function makeEmptyStore(): Store {
|
||||
return {
|
||||
getProfileStorageDirectory: () => '/profile-a',
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => ({}),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
@@ -48,7 +49,7 @@ describe('workspace cleanup snapshot IPC', () => {
|
||||
const args = { includeAllWorkspaces: true }
|
||||
const result = await handler?.({ sender: { send: vi.fn() } } as never, args)
|
||||
|
||||
expect(persistScanResultMock).toHaveBeenCalledWith(args, result)
|
||||
expect(persistScanResultMock).toHaveBeenCalledWith('/profile-a', args, result)
|
||||
})
|
||||
|
||||
it('serves the cached scan snapshot through getCachedScan', async () => {
|
||||
@@ -60,6 +61,7 @@ describe('workspace cleanup snapshot IPC', () => {
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:getCachedScan')?.[1]
|
||||
|
||||
await expect(handler?.({} as never)).resolves.toBe(snapshot)
|
||||
expect(readScanSnapshotMock).toHaveBeenCalledWith('/profile-a')
|
||||
expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ export function registerWorkspaceCleanupHandlers(
|
||||
store: Store,
|
||||
deps: WorkspaceCleanupHandlerDeps = {}
|
||||
): void {
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
ipcMain.removeHandler('workspaceCleanup:scan')
|
||||
ipcMain.removeHandler('workspaceCleanup:getCachedScan')
|
||||
ipcMain.removeHandler('workspaceCleanup:dismiss')
|
||||
@@ -44,14 +45,15 @@ export function registerWorkspaceCleanupHandlers(
|
||||
: undefined
|
||||
})
|
||||
// Fire-and-forget: snapshot persistence must never delay or fail the scan reply.
|
||||
void persistWorkspaceCleanupScanResult(args ?? {}, result)
|
||||
void persistWorkspaceCleanupScanResult(snapshotDirectory, args ?? {}, result)
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:getCachedScan',
|
||||
(): Promise<WorkspaceCleanupScanResult | null> => readWorkspaceCleanupScanSnapshot()
|
||||
(): Promise<WorkspaceCleanupScanResult | null> =>
|
||||
readWorkspaceCleanupScanSnapshot(snapshotDirectory)
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
|
||||
|
||||
@@ -74,6 +74,10 @@ function createEvent() {
|
||||
}
|
||||
}
|
||||
|
||||
function createStore(): Store {
|
||||
return { getProfileStorageDirectory: () => '/profile-a' } as Store
|
||||
}
|
||||
|
||||
describe('registerWorkspaceSpaceHandlers', () => {
|
||||
beforeEach(() => {
|
||||
analyzeWorkspaceSpaceMock.mockReset()
|
||||
@@ -82,7 +86,7 @@ describe('registerWorkspaceSpaceHandlers', () => {
|
||||
})
|
||||
|
||||
it('shares an in-flight analysis request', async () => {
|
||||
const store = {} as Store
|
||||
const store = createStore()
|
||||
let resolveFirstScan: (analysis: WorkspaceSpaceAnalysis) => void = () => {}
|
||||
const firstScan = new Promise<WorkspaceSpaceAnalysis>((resolve) => {
|
||||
resolveFirstScan = resolve
|
||||
@@ -120,7 +124,7 @@ describe('registerWorkspaceSpaceHandlers', () => {
|
||||
})
|
||||
|
||||
it('forwards scan progress to the requesting renderer', async () => {
|
||||
const store = {} as Store
|
||||
const store = createStore()
|
||||
let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined
|
||||
analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => {
|
||||
onProgress = options.onProgress
|
||||
@@ -150,7 +154,7 @@ describe('registerWorkspaceSpaceHandlers', () => {
|
||||
})
|
||||
|
||||
it('cancels the in-flight scan', async () => {
|
||||
const store = {} as Store
|
||||
const store = createStore()
|
||||
let signal: AbortSignal | undefined
|
||||
analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => {
|
||||
signal = options.signal
|
||||
@@ -168,7 +172,7 @@ describe('registerWorkspaceSpaceHandlers', () => {
|
||||
})
|
||||
|
||||
it('returns a normal cancelled result instead of rejecting expected cancellation', async () => {
|
||||
const store = {} as Store
|
||||
const store = createStore()
|
||||
analyzeWorkspaceSpaceMock.mockRejectedValueOnce(new WorkspaceSpaceScanCancelledErrorMock())
|
||||
|
||||
registerWorkspaceSpaceHandlers(store)
|
||||
@@ -182,21 +186,22 @@ describe('registerWorkspaceSpaceHandlers', () => {
|
||||
const analysis = createAnalysis(7)
|
||||
analyzeWorkspaceSpaceMock.mockResolvedValueOnce(analysis)
|
||||
|
||||
registerWorkspaceSpaceHandlers({} as Store)
|
||||
registerWorkspaceSpaceHandlers(createStore())
|
||||
const analyzeHandler = handlers.get('workspaceSpace:analyze')
|
||||
|
||||
await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: true, analysis })
|
||||
expect(persistAnalysisSnapshotMock).toHaveBeenCalledWith(analysis)
|
||||
expect(persistAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a', analysis)
|
||||
})
|
||||
|
||||
it('serves the cached analysis through getCachedAnalysis', async () => {
|
||||
const cached = createAnalysis(3)
|
||||
readAnalysisSnapshotMock.mockResolvedValue(cached)
|
||||
|
||||
registerWorkspaceSpaceHandlers({} as Store)
|
||||
registerWorkspaceSpaceHandlers(createStore())
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('workspaceSpace:getCachedAnalysis')
|
||||
const cachedHandler = handlers.get('workspaceSpace:getCachedAnalysis')
|
||||
|
||||
await expect(cachedHandler!()).resolves.toBe(cached)
|
||||
expect(readAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ type InFlightWorkspaceSpaceScan = {
|
||||
}
|
||||
|
||||
export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
let inFlightScan: InFlightWorkspaceSpaceScan | null = null
|
||||
ipcMain.removeHandler('workspaceSpace:cancel')
|
||||
ipcMain.removeHandler('workspaceSpace:analyze')
|
||||
@@ -87,7 +88,7 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
.then((analysis): WorkspaceSpaceAnalyzeResult => {
|
||||
// Fire-and-forget: a ~6-minute cold analysis must survive reload/restart, but
|
||||
// persistence must never delay or fail the reply.
|
||||
void persistWorkspaceSpaceAnalysisSnapshot(analysis)
|
||||
void persistWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, analysis)
|
||||
return { ok: true, analysis }
|
||||
})
|
||||
.catch((error: unknown): WorkspaceSpaceAnalyzeResult => {
|
||||
@@ -105,7 +106,8 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceSpace:getCachedAnalysis',
|
||||
(): Promise<WorkspaceSpaceAnalysis | null> => readWorkspaceSpaceAnalysisSnapshot()
|
||||
(): Promise<WorkspaceSpaceAnalysis | null> =>
|
||||
readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaceSpace:cancel', async (): Promise<boolean> => {
|
||||
|
||||
@@ -314,6 +314,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
}
|
||||
const ipcEvent = { sender: { id: 1 } }
|
||||
const store = {
|
||||
getProfileStorageDirectory: vi.fn(() => '/profile-a'),
|
||||
getRepos: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
getProjects: vi.fn(),
|
||||
@@ -8302,8 +8303,16 @@ describe('registerWorktreeHandlers', () => {
|
||||
await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
|
||||
|
||||
// A removed workspace must never resurrect from the cached scan snapshots.
|
||||
expect(pruneCleanupScanSnapshotMock).toHaveBeenCalledWith('repo-1::/workspace/feature-wt')
|
||||
expect(pruneSpaceAnalysisSnapshotMock).toHaveBeenCalledWith('repo-1::/workspace/feature-wt')
|
||||
expect(pruneCleanupScanSnapshotMock).toHaveBeenCalledWith(
|
||||
'/profile-a',
|
||||
'repo-1::/workspace/feature-wt',
|
||||
'local'
|
||||
)
|
||||
expect(pruneSpaceAnalysisSnapshotMock).toHaveBeenCalledWith(
|
||||
'/profile-a',
|
||||
'repo-1::/workspace/feature-wt',
|
||||
'local'
|
||||
)
|
||||
})
|
||||
|
||||
it('traces the removal as worktree.remove with a stage sub-span tree', async () => {
|
||||
|
||||
@@ -339,8 +339,9 @@ function removeWorktreeMetadataAndTransientState(
|
||||
// Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep).
|
||||
pruneWorktreePRRefreshAliases(worktreeId)
|
||||
// Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots.
|
||||
void pruneWorkspaceCleanupScanSnapshot(worktreeId)
|
||||
void pruneWorkspaceSpaceAnalysisSnapshot(worktreeId)
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId)
|
||||
void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId)
|
||||
}
|
||||
|
||||
function getProjectHostSetupMetaUpdates(
|
||||
|
||||
@@ -4179,6 +4179,10 @@ export class Store {
|
||||
|
||||
// ── Repos ──────────────────────────────────────────────────────────
|
||||
|
||||
getProfileStorageDirectory(): string {
|
||||
return dirname(this.dataFile)
|
||||
}
|
||||
|
||||
getRepos(): Repo[] {
|
||||
return this.state.repos.map((repo) => this.hydrateRepo(repo))
|
||||
}
|
||||
|
||||
@@ -3,21 +3,26 @@
|
||||
// githubCache sidecar precedent). Best-effort by design: a lost snapshot only costs a rescan.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { durableWriteTempPath, writeFileDurable } from './durable-file-write'
|
||||
import { getCanonicalUserDataPath } from './persistence'
|
||||
import {
|
||||
durableWriteTempPath,
|
||||
removeStaleDurableWriteTempFiles,
|
||||
writeFileDurable
|
||||
} from './durable-file-write'
|
||||
|
||||
const queues = new Map<string, Promise<unknown>>()
|
||||
const staleTempCleanups = new Map<string, Promise<void>>()
|
||||
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export function sidecarSnapshotFile(fileName: string): string {
|
||||
return join(getCanonicalUserDataPath(), fileName)
|
||||
export function sidecarSnapshotFile(snapshotDirectory: string, fileName: string): string {
|
||||
return join(snapshotDirectory, fileName)
|
||||
}
|
||||
|
||||
/** Serialize tasks per sidecar so read-modify-write patch/prune cycles cannot interleave. */
|
||||
export function withSidecarSnapshotQueue<T>(fileName: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = queues.get(fileName) ?? Promise.resolve()
|
||||
export function withSidecarSnapshotQueue<T>(file: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = queues.get(file) ?? Promise.resolve()
|
||||
const run = previous.then(task, task)
|
||||
queues.set(
|
||||
fileName,
|
||||
file,
|
||||
run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
@@ -27,15 +32,20 @@ export function withSidecarSnapshotQueue<T>(fileName: string, task: () => Promis
|
||||
}
|
||||
|
||||
/** Parsed sidecar contents, or null when the file is missing or not valid JSON. */
|
||||
export async function readSidecarSnapshot(fileName: string): Promise<unknown> {
|
||||
export async function readSidecarSnapshot(file: string): Promise<unknown> {
|
||||
try {
|
||||
return JSON.parse(await readFile(sidecarSnapshotFile(fileName), 'utf-8')) as unknown
|
||||
return JSON.parse(await readFile(file, 'utf-8')) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSidecarSnapshot(fileName: string, payload: unknown): Promise<void> {
|
||||
const file = sidecarSnapshotFile(fileName)
|
||||
export async function writeSidecarSnapshot(file: string, payload: unknown): Promise<void> {
|
||||
let cleanup = staleTempCleanups.get(file)
|
||||
if (!cleanup) {
|
||||
cleanup = removeStaleDurableWriteTempFiles(file, { minimumAgeMs: STALE_TEMP_AGE_MS })
|
||||
staleTempCleanups.set(file, cleanup)
|
||||
}
|
||||
await cleanup
|
||||
await writeFileDurable(durableWriteTempPath(file), file, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ import type {
|
||||
|
||||
const { userDataDirHolder } = vi.hoisted(() => ({ userDataDirHolder: { dir: '' } }))
|
||||
|
||||
vi.mock('./persistence', () => ({
|
||||
getCanonicalUserDataPath: () => userDataDirHolder.dir
|
||||
}))
|
||||
|
||||
import {
|
||||
persistWorkspaceCleanupScanResult,
|
||||
pruneWorkspaceCleanupScanSnapshot,
|
||||
@@ -31,6 +27,7 @@ function makeCandidate(
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
connectionId: null,
|
||||
executionHostId: 'local',
|
||||
displayName: 'Feature',
|
||||
branch: 'feature',
|
||||
path: '/repo-feature',
|
||||
@@ -71,62 +68,67 @@ describe('workspace cleanup scan snapshot', () => {
|
||||
worktreeId: 'repo-ssh::/remote/repo-feature',
|
||||
repoId: 'repo-ssh',
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
path: '/remote/repo-feature',
|
||||
blockers: ['ssh-disconnected'],
|
||||
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }
|
||||
})
|
||||
const result = makeBroadResult([makeCandidate(), sshCandidate])
|
||||
|
||||
await persistWorkspaceCleanupScanResult({ includeAllWorkspaces: true }, result)
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
result
|
||||
)
|
||||
|
||||
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toEqual(result)
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(result)
|
||||
})
|
||||
|
||||
it('returns null when no snapshot has been persisted', async () => {
|
||||
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('degrades corrupt persisted blobs to null instead of throwing', async () => {
|
||||
const file = join(userDataDirHolder.dir, SNAPSHOT_FILE)
|
||||
|
||||
await writeFile(file, 'not json{', 'utf-8')
|
||||
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
version: 2,
|
||||
argsFingerprint: workspaceCleanupScanSnapshotFingerprint(),
|
||||
result: { scannedAt: NOW, candidates: 'nope', errors: [] }
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
version: 2,
|
||||
argsFingerprint: workspaceCleanupScanSnapshotFingerprint(),
|
||||
result: { scannedAt: NOW, candidates: [{ worktreeId: 42 }], errors: [] }
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('treats a snapshot from another classifier version as absent', async () => {
|
||||
await writeFile(
|
||||
join(userDataDirHolder.dir, SNAPSHOT_FILE),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
version: 2,
|
||||
argsFingerprint: 'classifier:1|includeAllWorkspaces',
|
||||
result: makeBroadResult([makeCandidate()])
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('patches targeted rescans into the snapshot without touching other rows', async () => {
|
||||
@@ -137,17 +139,19 @@ describe('workspace cleanup scan snapshot', () => {
|
||||
branch: 'other'
|
||||
})
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
makeBroadResult([stale, other])
|
||||
)
|
||||
|
||||
const rescanned = makeCandidate({ tier: 'protected', blockers: ['dirty-files'] })
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ worktreeId: stale.worktreeId },
|
||||
{ scannedAt: NOW + 60_000, candidates: [rescanned], errors: [] }
|
||||
)
|
||||
|
||||
const snapshot = await readWorkspaceCleanupScanSnapshot()
|
||||
const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)
|
||||
expect(snapshot?.candidates).toEqual([rescanned, other])
|
||||
// Snapshot freshness stays anchored to the last FULL scan.
|
||||
expect(snapshot?.scannedAt).toBe(NOW)
|
||||
@@ -155,17 +159,19 @@ describe('workspace cleanup scan snapshot', () => {
|
||||
|
||||
it('appends targeted rows the snapshot has not seen yet', async () => {
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
makeBroadResult([makeCandidate()])
|
||||
)
|
||||
|
||||
const created = makeCandidate({ worktreeId: 'repo-1::/repo-new', path: '/repo-new' })
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ worktreeId: created.worktreeId },
|
||||
{ scannedAt: NOW + 1, candidates: [created], errors: [] }
|
||||
)
|
||||
|
||||
const snapshot = await readWorkspaceCleanupScanSnapshot()
|
||||
const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)
|
||||
expect(snapshot?.candidates.map((candidate) => candidate.worktreeId)).toEqual([
|
||||
'repo-1::/repo-feature',
|
||||
'repo-1::/repo-new'
|
||||
@@ -174,11 +180,12 @@ describe('workspace cleanup scan snapshot', () => {
|
||||
|
||||
it('does not create a snapshot from a targeted scan alone', async () => {
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ worktreeId: 'repo-1::/repo-feature' },
|
||||
makeBroadResult([makeCandidate()])
|
||||
)
|
||||
|
||||
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
await expect(readFile(join(userDataDirHolder.dir, SNAPSHOT_FILE))).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
@@ -189,15 +196,20 @@ describe('workspace cleanup scan snapshot', () => {
|
||||
makeCandidate(),
|
||||
makeCandidate({ worktreeId: 'repo-1::/b', path: '/b' })
|
||||
])
|
||||
await persistWorkspaceCleanupScanResult({ includeAllWorkspaces: true }, broad)
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
broad
|
||||
)
|
||||
|
||||
const legacyRow = makeCandidate({ tier: 'review', selectedByDefault: false })
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{},
|
||||
{ scannedAt: NOW + 1, candidates: [legacyRow], errors: [] }
|
||||
)
|
||||
|
||||
const snapshot = await readWorkspaceCleanupScanSnapshot()
|
||||
const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)
|
||||
expect(snapshot?.candidates).toHaveLength(2)
|
||||
expect(snapshot?.candidates[0]).toEqual(legacyRow)
|
||||
})
|
||||
@@ -205,17 +217,92 @@ describe('workspace cleanup scan snapshot', () => {
|
||||
it('prunes a removed worktree so it cannot resurrect from cache', async () => {
|
||||
const kept = makeCandidate({ worktreeId: 'repo-1::/repo-kept', path: '/repo-kept' })
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
makeBroadResult([makeCandidate(), kept])
|
||||
)
|
||||
|
||||
await pruneWorkspaceCleanupScanSnapshot('repo-1::/repo-feature')
|
||||
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local')
|
||||
|
||||
const snapshot = await readWorkspaceCleanupScanSnapshot()
|
||||
const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)
|
||||
expect(snapshot?.candidates).toEqual([kept])
|
||||
|
||||
// Unknown ids are a no-op.
|
||||
await pruneWorkspaceCleanupScanSnapshot('repo-1::/never-existed')
|
||||
await expect(readWorkspaceCleanupScanSnapshot()).resolves.toEqual(snapshot)
|
||||
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed')
|
||||
await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(snapshot)
|
||||
})
|
||||
|
||||
it('keeps profile snapshots isolated', async () => {
|
||||
const otherProfile = await mkdtemp(join(tmpdir(), 'orca-cleanup-snapshot-other-'))
|
||||
try {
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
makeBroadResult([makeCandidate()])
|
||||
)
|
||||
|
||||
await expect(readWorkspaceCleanupScanSnapshot(otherProfile)).resolves.toBeNull()
|
||||
} finally {
|
||||
await rm(otherProfile, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let a scan started before removal restore the pruned row', async () => {
|
||||
const staleResult = {
|
||||
...makeBroadResult([makeCandidate()]),
|
||||
scannedAt: Date.now() - 1
|
||||
}
|
||||
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local')
|
||||
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
staleResult
|
||||
)
|
||||
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([])
|
||||
|
||||
const recreated = { ...staleResult, scannedAt: Date.now() + 1 }
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
recreated
|
||||
)
|
||||
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([
|
||||
makeCandidate()
|
||||
])
|
||||
})
|
||||
|
||||
it('patches and prunes host-colliding workspace ids independently', async () => {
|
||||
const local = makeCandidate()
|
||||
const remote = makeCandidate({
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
repoName: 'Remote repo'
|
||||
})
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ includeAllWorkspaces: true },
|
||||
makeBroadResult([local, remote])
|
||||
)
|
||||
|
||||
const rescannedRemote = makeCandidate({
|
||||
...remote,
|
||||
tier: 'protected',
|
||||
blockers: ['dirty-files']
|
||||
})
|
||||
await persistWorkspaceCleanupScanResult(
|
||||
userDataDirHolder.dir,
|
||||
{ worktreeId: remote.worktreeId },
|
||||
{ scannedAt: NOW + 1, candidates: [rescannedRemote], errors: [] }
|
||||
)
|
||||
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([
|
||||
local,
|
||||
rescannedRemote
|
||||
])
|
||||
|
||||
await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, remote.worktreeId, 'ssh:ssh-1')
|
||||
expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([
|
||||
local
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,22 @@ import {
|
||||
} from '../shared/workspace-cleanup'
|
||||
import {
|
||||
readSidecarSnapshot,
|
||||
sidecarSnapshotFile,
|
||||
withSidecarSnapshotQueue,
|
||||
writeSidecarSnapshot
|
||||
} from './sidecar-snapshot-file'
|
||||
import type { ExecutionHostId } from '../shared/execution-host'
|
||||
|
||||
const SNAPSHOT_FILE_NAME = 'orca-workspace-cleanup-scan.json'
|
||||
const SNAPSHOT_VERSION = 1
|
||||
const SNAPSHOT_VERSION = 2
|
||||
|
||||
type PrunedWorkspace = {
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
prunedAt: number
|
||||
}
|
||||
|
||||
const prunedWorkspacesByFile = new Map<string, Map<string, PrunedWorkspace>>()
|
||||
|
||||
type PersistedWorkspaceCleanupScanSnapshot = {
|
||||
version: number
|
||||
@@ -37,6 +47,7 @@ function isPersistableCandidate(value: unknown): value is WorkspaceCleanupCandid
|
||||
typeof value.repoId === 'string' &&
|
||||
typeof value.fingerprint === 'string' &&
|
||||
(value.connectionId === null || typeof value.connectionId === 'string') &&
|
||||
typeof value.executionHostId === 'string' &&
|
||||
Array.isArray(value.reasons) &&
|
||||
Array.isArray(value.blockers) &&
|
||||
isRecord(value.git) &&
|
||||
@@ -70,16 +81,20 @@ function parseSnapshot(parsed: unknown): WorkspaceCleanupScanResult | null {
|
||||
return result as unknown as WorkspaceCleanupScanResult
|
||||
}
|
||||
|
||||
export async function readWorkspaceCleanupScanSnapshot(): Promise<WorkspaceCleanupScanResult | null> {
|
||||
export async function readWorkspaceCleanupScanSnapshot(
|
||||
snapshotDirectory: string
|
||||
): Promise<WorkspaceCleanupScanResult | null> {
|
||||
try {
|
||||
return parseSnapshot(await readSidecarSnapshot(SNAPSHOT_FILE_NAME))
|
||||
return parseSnapshot(
|
||||
await readSidecarSnapshot(sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME))
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSnapshot(result: WorkspaceCleanupScanResult): Promise<void> {
|
||||
await writeSidecarSnapshot(SNAPSHOT_FILE_NAME, {
|
||||
async function writeSnapshot(file: string, result: WorkspaceCleanupScanResult): Promise<void> {
|
||||
await writeSidecarSnapshot(file, {
|
||||
version: SNAPSHOT_VERSION,
|
||||
argsFingerprint: workspaceCleanupScanSnapshotFingerprint(),
|
||||
result
|
||||
@@ -90,10 +105,11 @@ function patchCandidates(
|
||||
existing: WorkspaceCleanupScanResult,
|
||||
fresh: WorkspaceCleanupCandidate[]
|
||||
): WorkspaceCleanupScanResult {
|
||||
const freshById = new Map(fresh.map((candidate) => [candidate.worktreeId, candidate]))
|
||||
const freshById = new Map(fresh.map((candidate) => [candidateSnapshotKey(candidate), candidate]))
|
||||
const candidates = existing.candidates.map((candidate) => {
|
||||
const replacement = freshById.get(candidate.worktreeId)
|
||||
freshById.delete(candidate.worktreeId)
|
||||
const key = candidateSnapshotKey(candidate)
|
||||
const replacement = freshById.get(key)
|
||||
freshById.delete(key)
|
||||
return replacement ?? candidate
|
||||
})
|
||||
candidates.push(...freshById.values())
|
||||
@@ -101,29 +117,94 @@ function patchCandidates(
|
||||
return { ...existing, candidates }
|
||||
}
|
||||
|
||||
function candidateSnapshotKey(
|
||||
candidate: Pick<WorkspaceCleanupCandidate, 'executionHostId' | 'worktreeId'>
|
||||
): string {
|
||||
return `${candidate.executionHostId ?? 'local'}\0${candidate.worktreeId}`
|
||||
}
|
||||
|
||||
function prunedWorkspaceKey(worktreeId: string, executionHostId?: ExecutionHostId): string {
|
||||
return `${executionHostId ?? '*'}\0${worktreeId}`
|
||||
}
|
||||
|
||||
function matchesPrunedWorkspace(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
pruned: PrunedWorkspace
|
||||
): boolean {
|
||||
return (
|
||||
candidate.worktreeId === pruned.worktreeId &&
|
||||
(!pruned.executionHostId || candidate.executionHostId === pruned.executionHostId)
|
||||
)
|
||||
}
|
||||
|
||||
function excludeRowsPrunedDuringScan(
|
||||
file: string,
|
||||
result: WorkspaceCleanupScanResult
|
||||
): WorkspaceCleanupScanResult {
|
||||
const pruned = [...(prunedWorkspacesByFile.get(file)?.values() ?? [])]
|
||||
if (pruned.length === 0) {
|
||||
return result
|
||||
}
|
||||
const candidates = result.candidates.filter(
|
||||
(candidate) =>
|
||||
!pruned.some(
|
||||
(entry) => entry.prunedAt >= result.scannedAt && matchesPrunedWorkspace(candidate, entry)
|
||||
)
|
||||
)
|
||||
return candidates.length === result.candidates.length ? result : { ...result, candidates }
|
||||
}
|
||||
|
||||
function clearSupersededPrunes(
|
||||
file: string,
|
||||
result: WorkspaceCleanupScanResult,
|
||||
broad: boolean
|
||||
): void {
|
||||
const pruned = prunedWorkspacesByFile.get(file)
|
||||
if (!pruned) {
|
||||
return
|
||||
}
|
||||
for (const [key, entry] of pruned) {
|
||||
if (
|
||||
entry.prunedAt < result.scannedAt &&
|
||||
(broad || result.candidates.some((candidate) => matchesPrunedWorkspace(candidate, entry)))
|
||||
) {
|
||||
pruned.delete(key)
|
||||
}
|
||||
}
|
||||
if (pruned.size === 0) {
|
||||
prunedWorkspacesByFile.delete(file)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a completed scan: a broad (includeAllWorkspaces) scan replaces the snapshot, anything
|
||||
* narrower patches matching rows into it. Never throws — the snapshot is a refetchable cache.
|
||||
*/
|
||||
export async function persistWorkspaceCleanupScanResult(
|
||||
snapshotDirectory: string,
|
||||
args: WorkspaceCleanupScanArgs,
|
||||
result: WorkspaceCleanupScanResult
|
||||
): Promise<void> {
|
||||
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
|
||||
try {
|
||||
await withSidecarSnapshotQueue(SNAPSHOT_FILE_NAME, async () => {
|
||||
if (!args.worktreeId && args.includeAllWorkspaces === true) {
|
||||
await writeSnapshot(result)
|
||||
await withSidecarSnapshotQueue(file, async () => {
|
||||
const filteredResult = excludeRowsPrunedDuringScan(file, result)
|
||||
const broad = !args.worktreeId && args.includeAllWorkspaces === true
|
||||
if (broad) {
|
||||
await writeSnapshot(file, filteredResult)
|
||||
clearSupersededPrunes(file, result, true)
|
||||
return
|
||||
}
|
||||
if (result.candidates.length === 0) {
|
||||
if (filteredResult.candidates.length === 0) {
|
||||
return
|
||||
}
|
||||
const existing = await readWorkspaceCleanupScanSnapshot()
|
||||
const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory)
|
||||
// Why: a focused/legacy scan is a subset; without a broad baseline it is not a fleet snapshot.
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
await writeSnapshot(patchCandidates(existing, result.candidates))
|
||||
await writeSnapshot(file, patchCandidates(existing, filteredResult.candidates))
|
||||
clearSupersededPrunes(file, result, false)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[workspace-cleanup] failed to persist scan snapshot:', error)
|
||||
@@ -131,20 +212,34 @@ export async function persistWorkspaceCleanupScanResult(
|
||||
}
|
||||
|
||||
/** Drop a removed workspace so it never resurrects from cache. Never throws. */
|
||||
export async function pruneWorkspaceCleanupScanSnapshot(worktreeId: string): Promise<void> {
|
||||
export async function pruneWorkspaceCleanupScanSnapshot(
|
||||
snapshotDirectory: string,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): Promise<void> {
|
||||
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
|
||||
const pruned = prunedWorkspacesByFile.get(file) ?? new Map<string, PrunedWorkspace>()
|
||||
pruned.set(prunedWorkspaceKey(worktreeId, executionHostId), {
|
||||
worktreeId,
|
||||
...(executionHostId ? { executionHostId } : {}),
|
||||
prunedAt: Date.now()
|
||||
})
|
||||
prunedWorkspacesByFile.set(file, pruned)
|
||||
try {
|
||||
await withSidecarSnapshotQueue(SNAPSHOT_FILE_NAME, async () => {
|
||||
const existing = await readWorkspaceCleanupScanSnapshot()
|
||||
await withSidecarSnapshotQueue(file, async () => {
|
||||
const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
const candidates = existing.candidates.filter(
|
||||
(candidate) => candidate.worktreeId !== worktreeId
|
||||
(candidate) =>
|
||||
candidate.worktreeId !== worktreeId ||
|
||||
(executionHostId !== undefined && candidate.executionHostId !== executionHostId)
|
||||
)
|
||||
if (candidates.length === existing.candidates.length) {
|
||||
return
|
||||
}
|
||||
await writeSnapshot({ ...existing, candidates })
|
||||
await writeSnapshot(file, { ...existing, candidates })
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[workspace-cleanup] failed to prune scan snapshot:', error)
|
||||
|
||||
@@ -9,10 +9,6 @@ import type {
|
||||
|
||||
const { userDataDirHolder } = vi.hoisted(() => ({ userDataDirHolder: { dir: '' } }))
|
||||
|
||||
vi.mock('./persistence', () => ({
|
||||
getCanonicalUserDataPath: () => userDataDirHolder.dir
|
||||
}))
|
||||
|
||||
import {
|
||||
persistWorkspaceSpaceAnalysisSnapshot,
|
||||
pruneWorkspaceSpaceAnalysisSnapshot,
|
||||
@@ -26,6 +22,7 @@ function makeWorktreeRow(overrides: Partial<WorkspaceSpaceWorktree> = {}): Works
|
||||
return {
|
||||
worktreeId: 'repo-1::/repo-feature',
|
||||
repoId: 'repo-1',
|
||||
executionHostId: 'local',
|
||||
repoDisplayName: 'Repo',
|
||||
repoPath: '/repo',
|
||||
displayName: 'Feature',
|
||||
@@ -51,6 +48,7 @@ function makeWorktreeRow(overrides: Partial<WorkspaceSpaceWorktree> = {}): Works
|
||||
|
||||
function makeAnalysis(worktrees: WorkspaceSpaceWorktree[]): WorkspaceSpaceAnalysis {
|
||||
const okRows = worktrees.filter((row) => row.status === 'ok')
|
||||
const rowsByHost = Map.groupBy(worktrees, (row) => row.executionHostId ?? 'local')
|
||||
return {
|
||||
scannedAt: NOW,
|
||||
totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0),
|
||||
@@ -58,20 +56,22 @@ function makeAnalysis(worktrees: WorkspaceSpaceWorktree[]): WorkspaceSpaceAnalys
|
||||
worktreeCount: worktrees.length,
|
||||
scannedWorktreeCount: okRows.length,
|
||||
unavailableWorktreeCount: worktrees.length - okRows.length,
|
||||
repos: [
|
||||
{
|
||||
repos: [...rowsByHost.entries()].map(([executionHostId, rows]) => {
|
||||
const scanned = rows.filter((row) => row.status === 'ok')
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
executionHostId,
|
||||
displayName: 'Repo',
|
||||
path: '/repo',
|
||||
isRemote: false,
|
||||
worktreeCount: worktrees.length,
|
||||
scannedWorktreeCount: okRows.length,
|
||||
unavailableWorktreeCount: worktrees.length - okRows.length,
|
||||
totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0),
|
||||
reclaimableBytes: worktrees.reduce((sum, row) => sum + row.reclaimableBytes, 0),
|
||||
isRemote: executionHostId !== 'local',
|
||||
worktreeCount: rows.length,
|
||||
scannedWorktreeCount: scanned.length,
|
||||
unavailableWorktreeCount: rows.length - scanned.length,
|
||||
totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0),
|
||||
reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0),
|
||||
error: null
|
||||
}
|
||||
],
|
||||
}),
|
||||
worktrees
|
||||
}
|
||||
}
|
||||
@@ -88,9 +88,11 @@ describe('workspace space analysis snapshot', () => {
|
||||
it('round-trips a completed analysis', async () => {
|
||||
const analysis = makeAnalysis([makeWorktreeRow()])
|
||||
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(analysis)
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, analysis)
|
||||
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toEqual(analysis)
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual(
|
||||
analysis
|
||||
)
|
||||
})
|
||||
|
||||
it('prunes topLevelItems into the omitted counters to bound the payload', async () => {
|
||||
@@ -109,9 +111,9 @@ describe('workspace space analysis snapshot', () => {
|
||||
omittedTopLevelSizeBytes: 500
|
||||
})
|
||||
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(makeAnalysis([row]))
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, makeAnalysis([row]))
|
||||
|
||||
const cached = await readWorkspaceSpaceAnalysisSnapshot()
|
||||
const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)
|
||||
expect(cached?.worktrees[0]).toMatchObject({
|
||||
sizeBytes: 5000,
|
||||
topLevelItems: [],
|
||||
@@ -121,28 +123,28 @@ describe('workspace space analysis snapshot', () => {
|
||||
})
|
||||
|
||||
it('returns null when missing or corrupt instead of throwing', async () => {
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
|
||||
const file = join(userDataDirHolder.dir, SNAPSHOT_FILE)
|
||||
await writeFile(file, '{"version":1,"analysis":', 'utf-8')
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
version: 2,
|
||||
analysis: { scannedAt: 'yesterday', repos: [], worktrees: [] }
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({ version: 99, analysis: makeAnalysis([makeWorktreeRow()]) }),
|
||||
'utf-8'
|
||||
)
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toBeNull()
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('prunes a removed worktree row and rebalances totals', async () => {
|
||||
@@ -154,11 +156,14 @@ describe('workspace space analysis snapshot', () => {
|
||||
sizeBytes: 1000,
|
||||
reclaimableBytes: 0
|
||||
})
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(makeAnalysis([removed, kept]))
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(
|
||||
userDataDirHolder.dir,
|
||||
makeAnalysis([removed, kept])
|
||||
)
|
||||
|
||||
await pruneWorkspaceSpaceAnalysisSnapshot(removed.worktreeId)
|
||||
await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, removed.worktreeId, 'local')
|
||||
|
||||
const cached = await readWorkspaceSpaceAnalysisSnapshot()
|
||||
const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)
|
||||
expect(cached?.worktrees.map((row) => row.worktreeId)).toEqual(['repo-1::/repo-kept'])
|
||||
expect(cached).toMatchObject({
|
||||
worktreeCount: 1,
|
||||
@@ -176,7 +181,79 @@ describe('workspace space analysis snapshot', () => {
|
||||
})
|
||||
|
||||
// Unknown ids are a no-op.
|
||||
await pruneWorkspaceSpaceAnalysisSnapshot('repo-1::/never-existed')
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot()).resolves.toEqual(cached)
|
||||
await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed')
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual(cached)
|
||||
})
|
||||
|
||||
it('keeps profile snapshots isolated', async () => {
|
||||
const otherProfile = await mkdtemp(join(tmpdir(), 'orca-space-snapshot-other-'))
|
||||
try {
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(
|
||||
userDataDirHolder.dir,
|
||||
makeAnalysis([makeWorktreeRow()])
|
||||
)
|
||||
|
||||
await expect(readWorkspaceSpaceAnalysisSnapshot(otherProfile)).resolves.toBeNull()
|
||||
} finally {
|
||||
await rm(otherProfile, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let an analysis started before removal restore the pruned row', async () => {
|
||||
const staleAnalysis = {
|
||||
...makeAnalysis([makeWorktreeRow()]),
|
||||
scannedAt: Date.now() - 1
|
||||
}
|
||||
await pruneWorkspaceSpaceAnalysisSnapshot(
|
||||
userDataDirHolder.dir,
|
||||
'repo-1::/repo-feature',
|
||||
'local'
|
||||
)
|
||||
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis)
|
||||
expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([])
|
||||
|
||||
const recreated = { ...staleAnalysis, scannedAt: Date.now() + 1 }
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, recreated)
|
||||
expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([
|
||||
makeWorktreeRow()
|
||||
])
|
||||
})
|
||||
|
||||
it('prunes host-colliding workspace ids without corrupting surviving totals', async () => {
|
||||
const local = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 })
|
||||
const remote = makeWorktreeRow({
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
isRemote: true,
|
||||
sizeBytes: 3000,
|
||||
reclaimableBytes: 3000
|
||||
})
|
||||
await persistWorkspaceSpaceAnalysisSnapshot(
|
||||
userDataDirHolder.dir,
|
||||
makeAnalysis([local, remote])
|
||||
)
|
||||
|
||||
await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, remote.worktreeId, 'ssh:ssh-1')
|
||||
|
||||
const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)
|
||||
expect(cached?.worktrees).toEqual([local])
|
||||
expect(cached).toMatchObject({
|
||||
worktreeCount: 1,
|
||||
scannedWorktreeCount: 1,
|
||||
totalSizeBytes: 1000,
|
||||
reclaimableBytes: 1000
|
||||
})
|
||||
expect(cached?.repos).toEqual([
|
||||
expect.objectContaining({
|
||||
executionHostId: 'local',
|
||||
worktreeCount: 1,
|
||||
totalSizeBytes: 1000
|
||||
}),
|
||||
expect.objectContaining({
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
worktreeCount: 0,
|
||||
totalSizeBytes: 0
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,12 +4,22 @@ import type {
|
||||
} from '../shared/workspace-space-types'
|
||||
import {
|
||||
readSidecarSnapshot,
|
||||
sidecarSnapshotFile,
|
||||
withSidecarSnapshotQueue,
|
||||
writeSidecarSnapshot
|
||||
} from './sidecar-snapshot-file'
|
||||
import type { ExecutionHostId } from '../shared/execution-host'
|
||||
|
||||
const SNAPSHOT_FILE_NAME = 'orca-workspace-space-analysis.json'
|
||||
const SNAPSHOT_VERSION = 1
|
||||
const SNAPSHOT_VERSION = 2
|
||||
|
||||
type PrunedWorkspace = {
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
prunedAt: number
|
||||
}
|
||||
|
||||
const prunedWorkspacesByFile = new Map<string, Map<string, PrunedWorkspace>>()
|
||||
|
||||
type PersistedWorkspaceSpaceAnalysisSnapshot = {
|
||||
version: number
|
||||
@@ -27,6 +37,7 @@ function isPersistableWorktreeRow(value: unknown): value is WorkspaceSpaceWorktr
|
||||
return (
|
||||
typeof value.worktreeId === 'string' &&
|
||||
typeof value.repoId === 'string' &&
|
||||
typeof value.executionHostId === 'string' &&
|
||||
typeof value.status === 'string' &&
|
||||
typeof value.sizeBytes === 'number' &&
|
||||
typeof value.reclaimableBytes === 'number' &&
|
||||
@@ -71,16 +82,20 @@ function stripTopLevelItems(analysis: WorkspaceSpaceAnalysis): WorkspaceSpaceAna
|
||||
}
|
||||
}
|
||||
|
||||
export async function readWorkspaceSpaceAnalysisSnapshot(): Promise<WorkspaceSpaceAnalysis | null> {
|
||||
export async function readWorkspaceSpaceAnalysisSnapshot(
|
||||
snapshotDirectory: string
|
||||
): Promise<WorkspaceSpaceAnalysis | null> {
|
||||
try {
|
||||
return parseSnapshot(await readSidecarSnapshot(SNAPSHOT_FILE_NAME))
|
||||
return parseSnapshot(
|
||||
await readSidecarSnapshot(sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME))
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSnapshot(analysis: WorkspaceSpaceAnalysis): Promise<void> {
|
||||
await writeSidecarSnapshot(SNAPSHOT_FILE_NAME, {
|
||||
async function writeSnapshot(file: string, analysis: WorkspaceSpaceAnalysis): Promise<void> {
|
||||
await writeSidecarSnapshot(file, {
|
||||
version: SNAPSHOT_VERSION,
|
||||
analysis
|
||||
} satisfies PersistedWorkspaceSpaceAnalysisSnapshot)
|
||||
@@ -88,12 +103,15 @@ async function writeSnapshot(analysis: WorkspaceSpaceAnalysis): Promise<void> {
|
||||
|
||||
/** Persist a completed analysis. Never throws — the snapshot is a refetchable cache. */
|
||||
export async function persistWorkspaceSpaceAnalysisSnapshot(
|
||||
snapshotDirectory: string,
|
||||
analysis: WorkspaceSpaceAnalysis
|
||||
): Promise<void> {
|
||||
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
|
||||
try {
|
||||
await withSidecarSnapshotQueue(SNAPSHOT_FILE_NAME, () =>
|
||||
writeSnapshot(stripTopLevelItems(analysis))
|
||||
)
|
||||
await withSidecarSnapshotQueue(file, async () => {
|
||||
await writeSnapshot(file, stripTopLevelItems(excludeRowsPrunedDuringScan(file, analysis)))
|
||||
clearSupersededPrunes(file, analysis)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[workspace-space] failed to persist analysis snapshot:', error)
|
||||
}
|
||||
@@ -107,14 +125,14 @@ function withoutWorktreeRow(
|
||||
const unavailableDelta = removed.status === 'ok' ? 0 : 1
|
||||
return {
|
||||
...analysis,
|
||||
worktrees: analysis.worktrees.filter((row) => row.worktreeId !== removed.worktreeId),
|
||||
worktrees: analysis.worktrees.filter((row) => !isSameWorkspace(row, removed)),
|
||||
worktreeCount: Math.max(0, analysis.worktreeCount - 1),
|
||||
scannedWorktreeCount: Math.max(0, analysis.scannedWorktreeCount - scannedDelta),
|
||||
unavailableWorktreeCount: Math.max(0, analysis.unavailableWorktreeCount - unavailableDelta),
|
||||
totalSizeBytes: Math.max(0, analysis.totalSizeBytes - removed.sizeBytes),
|
||||
reclaimableBytes: Math.max(0, analysis.reclaimableBytes - removed.reclaimableBytes),
|
||||
repos: analysis.repos.map((repo) =>
|
||||
repo.repoId === removed.repoId
|
||||
repo.repoId === removed.repoId && repo.executionHostId === removed.executionHostId
|
||||
? {
|
||||
...repo,
|
||||
worktreeCount: Math.max(0, repo.worktreeCount - 1),
|
||||
@@ -128,16 +146,81 @@ function withoutWorktreeRow(
|
||||
}
|
||||
}
|
||||
|
||||
function isSameWorkspace(
|
||||
left: Pick<WorkspaceSpaceWorktree, 'executionHostId' | 'worktreeId'>,
|
||||
right: Pick<WorkspaceSpaceWorktree, 'executionHostId' | 'worktreeId'>
|
||||
): boolean {
|
||||
return left.worktreeId === right.worktreeId && left.executionHostId === right.executionHostId
|
||||
}
|
||||
|
||||
function prunedWorkspaceKey(worktreeId: string, executionHostId?: ExecutionHostId): string {
|
||||
return `${executionHostId ?? '*'}\0${worktreeId}`
|
||||
}
|
||||
|
||||
function matchesPrunedWorkspace(row: WorkspaceSpaceWorktree, pruned: PrunedWorkspace): boolean {
|
||||
return (
|
||||
row.worktreeId === pruned.worktreeId &&
|
||||
(!pruned.executionHostId || row.executionHostId === pruned.executionHostId)
|
||||
)
|
||||
}
|
||||
|
||||
function excludeRowsPrunedDuringScan(
|
||||
file: string,
|
||||
analysis: WorkspaceSpaceAnalysis
|
||||
): WorkspaceSpaceAnalysis {
|
||||
const pruned = [...(prunedWorkspacesByFile.get(file)?.values() ?? [])]
|
||||
return analysis.worktrees.reduce(
|
||||
(current, row) =>
|
||||
pruned.some(
|
||||
(entry) => entry.prunedAt >= analysis.scannedAt && matchesPrunedWorkspace(row, entry)
|
||||
)
|
||||
? withoutWorktreeRow(current, row)
|
||||
: current,
|
||||
analysis
|
||||
)
|
||||
}
|
||||
|
||||
function clearSupersededPrunes(file: string, analysis: WorkspaceSpaceAnalysis): void {
|
||||
const pruned = prunedWorkspacesByFile.get(file)
|
||||
if (!pruned) {
|
||||
return
|
||||
}
|
||||
for (const [key, entry] of pruned) {
|
||||
if (entry.prunedAt < analysis.scannedAt) {
|
||||
pruned.delete(key)
|
||||
}
|
||||
}
|
||||
if (pruned.size === 0) {
|
||||
prunedWorkspacesByFile.delete(file)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a removed workspace's row and rebalance the totals it contributed. Never throws. */
|
||||
export async function pruneWorkspaceSpaceAnalysisSnapshot(worktreeId: string): Promise<void> {
|
||||
export async function pruneWorkspaceSpaceAnalysisSnapshot(
|
||||
snapshotDirectory: string,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): Promise<void> {
|
||||
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
|
||||
const pruned = prunedWorkspacesByFile.get(file) ?? new Map<string, PrunedWorkspace>()
|
||||
pruned.set(prunedWorkspaceKey(worktreeId, executionHostId), {
|
||||
worktreeId,
|
||||
...(executionHostId ? { executionHostId } : {}),
|
||||
prunedAt: Date.now()
|
||||
})
|
||||
prunedWorkspacesByFile.set(file, pruned)
|
||||
try {
|
||||
await withSidecarSnapshotQueue(SNAPSHOT_FILE_NAME, async () => {
|
||||
const existing = await readWorkspaceSpaceAnalysisSnapshot()
|
||||
const removed = existing?.worktrees.find((row) => row.worktreeId === worktreeId)
|
||||
await withSidecarSnapshotQueue(file, async () => {
|
||||
const existing = await readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
|
||||
const removed = existing?.worktrees.find(
|
||||
(row) =>
|
||||
row.worktreeId === worktreeId &&
|
||||
(executionHostId === undefined || row.executionHostId === executionHostId)
|
||||
)
|
||||
if (!existing || !removed) {
|
||||
return
|
||||
}
|
||||
await writeSnapshot(withoutWorktreeRow(existing, removed))
|
||||
await writeSnapshot(file, withoutWorktreeRow(existing, removed))
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[workspace-space] failed to prune analysis snapshot:', error)
|
||||
|
||||
@@ -34,6 +34,7 @@ import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
|
||||
import { mergeWorktree } from './ipc/worktree-logic'
|
||||
import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options'
|
||||
import { getRepoExecutionHostId } from '../shared/execution-host'
|
||||
|
||||
const REPO_SCAN_CONCURRENCY = 2
|
||||
const WORKTREE_SCAN_CONCURRENCY = 3
|
||||
@@ -309,6 +310,7 @@ function createBaseWorktreeRow(
|
||||
return {
|
||||
worktreeId: worktree.id,
|
||||
repoId: repo.id,
|
||||
executionHostId: getRepoExecutionHostId(repo),
|
||||
repoDisplayName: repo.displayName,
|
||||
repoPath: repo.path,
|
||||
displayName: worktree.displayName,
|
||||
@@ -733,6 +735,7 @@ async function scanRepo(
|
||||
worktrees: [],
|
||||
summary: {
|
||||
repoId: repo.id,
|
||||
executionHostId: getRepoExecutionHostId(repo),
|
||||
displayName: repo.displayName,
|
||||
path: repo.path,
|
||||
isRemote: Boolean(repo.connectionId),
|
||||
@@ -809,6 +812,7 @@ async function scanRepo(
|
||||
worktrees: rows,
|
||||
summary: {
|
||||
repoId: repo.id,
|
||||
executionHostId: getRepoExecutionHostId(repo),
|
||||
displayName: repo.displayName,
|
||||
path: repo.path,
|
||||
isRemote: Boolean(repo.connectionId),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
|
||||
export const WORKSPACE_CLEANUP_CLASSIFIER_VERSION = 2
|
||||
export const WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
export const WORKSPACE_CLEANUP_IDLE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
@@ -45,6 +47,7 @@ export type WorkspaceCleanupCandidate = {
|
||||
repoId: string
|
||||
repoName: string
|
||||
connectionId: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
displayName: string
|
||||
branch: string
|
||||
path: string
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
|
||||
export type WorkspaceSpaceScanStatus =
|
||||
| 'ok'
|
||||
| 'missing'
|
||||
@@ -17,6 +19,7 @@ export type WorkspaceSpaceItem = {
|
||||
export type WorkspaceSpaceWorktree = {
|
||||
worktreeId: string
|
||||
repoId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
repoDisplayName: string
|
||||
repoPath: string
|
||||
displayName: string
|
||||
@@ -40,6 +43,7 @@ export type WorkspaceSpaceWorktree = {
|
||||
|
||||
export type WorkspaceSpaceRepoSummary = {
|
||||
repoId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
displayName: string
|
||||
path: string
|
||||
isRemote: boolean
|
||||
|
||||
Reference in New Issue
Block a user