mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Harden workspace cleanup scan performance
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
export type GitRuntimeOptions = {
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export function gitOptionsForWorktree(
|
||||
cwd: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): { cwd: string; wslDistro?: string } {
|
||||
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
|
||||
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
|
||||
return {
|
||||
cwd,
|
||||
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,6 +836,7 @@ type GitStreamOptions = {
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
/** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */
|
||||
maxBuffer?: number
|
||||
/**
|
||||
@@ -863,6 +864,10 @@ export async function gitStreamStdout(
|
||||
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
|
||||
return withGitSpan({ args, cwd: options.cwd }, async () => {
|
||||
return new Promise<GitStreamResult>((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(createAbortError())
|
||||
return
|
||||
}
|
||||
const child = gitSpawn(args, {
|
||||
cwd: options.cwd,
|
||||
env: nonInteractiveGitEnv(options.env),
|
||||
@@ -887,6 +892,7 @@ export async function gitStreamStdout(
|
||||
child.stderr?.off('data', onStderrData)
|
||||
child.off('error', onError)
|
||||
child.off('close', onClose)
|
||||
options.signal?.removeEventListener('abort', onAbort)
|
||||
// Flush any bytes the decoders were holding for an incomplete sequence.
|
||||
stdoutDecoder.end()
|
||||
stderrDecoder.end()
|
||||
@@ -953,11 +959,19 @@ export async function gitStreamStdout(
|
||||
}
|
||||
finish(new Error(`git exited with ${code}: ${stderr}`))
|
||||
}
|
||||
function onAbort(): void {
|
||||
killSpawnedCommandTree(child)
|
||||
finish(createAbortError())
|
||||
}
|
||||
|
||||
child.stdout?.on('data', onStdoutData)
|
||||
child.stderr?.on('data', onStderrData)
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (options.signal?.aborted) {
|
||||
onAbort()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ export async function getStatus(
|
||||
options: GetStatusOptions = {}
|
||||
): Promise<GitStatusResult> {
|
||||
gitDiffReadDedupe.clear()
|
||||
if (options.signal) {
|
||||
return runGetStatus(worktreePath, options)
|
||||
}
|
||||
// Why: dedupe only concurrent identical reads; after settle, callers must
|
||||
// execute a fresh status read rather than observing a cached result.
|
||||
const cacheKey = getStatusReadKey(worktreePath, options)
|
||||
@@ -190,6 +193,7 @@ async function runGetStatus(
|
||||
// Why: status polling is read-like; avoid refreshing the index and racing
|
||||
// terminal Git commands on `.git/worktrees/*/index.lock`.
|
||||
env: gitOptionalLocksDisabledEnv(),
|
||||
signal: options.signal,
|
||||
onStdout: (chunk) => parser.update(chunk, limit)
|
||||
})
|
||||
if (!stoppedEarly) {
|
||||
|
||||
@@ -29,6 +29,7 @@ type SparseWorktreeCreateError = Error & {
|
||||
|
||||
export type GitWorktreeExecOptions = {
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type AddWorktreeOptions = GitWorktreeExecOptions & {
|
||||
@@ -69,8 +70,12 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
|
||||
function gitExecOptions(
|
||||
cwd: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): { cwd: string; wslDistro?: string } {
|
||||
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
|
||||
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
|
||||
return {
|
||||
cwd,
|
||||
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
|
||||
@@ -37,7 +37,10 @@ export async function readWorkspaceCleanupGitEvidence(
|
||||
|
||||
try {
|
||||
status = await withWorkspaceCleanupTimeout(
|
||||
repo.connectionId ? provider!.getStatus(worktree.path) : getStatus(worktree.path),
|
||||
(signal) =>
|
||||
repo.connectionId
|
||||
? provider!.getStatus(worktree.path, { signal })
|
||||
: getStatus(worktree.path, { signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out reading git status.'
|
||||
)
|
||||
@@ -90,11 +93,15 @@ async function readUnpushedCommitCount(
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const result = await withWorkspaceCleanupTimeout(
|
||||
repo.connectionId
|
||||
? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path)
|
||||
: gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], {
|
||||
cwd: worktree.path
|
||||
}),
|
||||
(signal) =>
|
||||
repo.connectionId
|
||||
? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path, {
|
||||
signal
|
||||
})
|
||||
: gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], {
|
||||
cwd: worktree.path,
|
||||
signal
|
||||
}),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out checking unpushed commits.'
|
||||
)
|
||||
|
||||
@@ -33,16 +33,20 @@ export async function mapWorkspaceCleanupWithConcurrency<T, R>(
|
||||
}
|
||||
|
||||
export async function withWorkspaceCleanupTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
run: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
let timeoutId: NodeJS.Timeout | undefined
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(new Error(message))
|
||||
}, timeoutMs)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise])
|
||||
return await Promise.race([run(controller.signal), timeoutPromise])
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
@@ -182,7 +182,7 @@ async function listCleanupGitWorktrees(
|
||||
return {
|
||||
provider,
|
||||
gitWorktrees: await withWorkspaceCleanupTimeout(
|
||||
provider.listWorktrees(repo.path),
|
||||
(signal) => provider.listWorktrees(repo.path, { signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing SSH worktrees.'
|
||||
)
|
||||
@@ -191,7 +191,7 @@ async function listCleanupGitWorktrees(
|
||||
return {
|
||||
provider: null,
|
||||
gitWorktrees: await withWorkspaceCleanupTimeout(
|
||||
listRepoWorktrees(repo),
|
||||
(signal) => listRepoWorktrees(repo, { signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing worktrees.'
|
||||
)
|
||||
@@ -222,11 +222,13 @@ function createProgressEmitter(
|
||||
scannedAt: number,
|
||||
options: WorkspaceCleanupScanOptions
|
||||
): WorkspaceCleanupProgressEmitter {
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
const errors: WorkspaceCleanupScanError[] = []
|
||||
let totalWorktreeCount = 0
|
||||
let scannedWorktreeCount = 0
|
||||
const emit = (): void => {
|
||||
const emit = (
|
||||
candidates: WorkspaceCleanupCandidate[],
|
||||
candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot'
|
||||
): void => {
|
||||
if (!scanId) {
|
||||
return
|
||||
}
|
||||
@@ -235,23 +237,23 @@ function createProgressEmitter(
|
||||
scannedAt,
|
||||
totalWorktreeCount,
|
||||
scannedWorktreeCount,
|
||||
candidates: [...candidates],
|
||||
errors: [...errors]
|
||||
candidates,
|
||||
errors: [...errors],
|
||||
candidateMode
|
||||
})
|
||||
}
|
||||
return {
|
||||
addDiscovered: (count) => {
|
||||
totalWorktreeCount += count
|
||||
emit()
|
||||
emit([], 'append')
|
||||
},
|
||||
addCandidate: (candidate) => {
|
||||
candidates.push(candidate)
|
||||
scannedWorktreeCount += 1
|
||||
emit()
|
||||
emit([candidate], 'append')
|
||||
},
|
||||
addErrors: (newErrors) => {
|
||||
appendWorkspaceCleanupItems(errors, newErrors)
|
||||
emit()
|
||||
emit([], 'append')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
Repo,
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup'
|
||||
|
||||
const {
|
||||
listRepoWorktreesMock,
|
||||
@@ -203,14 +204,23 @@ describe('workspace cleanup scan', () => {
|
||||
totalWorktreeCount: 2,
|
||||
candidates: []
|
||||
})
|
||||
const candidateProgress = progress.filter(
|
||||
(event): event is WorkspaceCleanupScanProgress =>
|
||||
(event as WorkspaceCleanupScanProgress).candidateMode === 'append' &&
|
||||
(event as WorkspaceCleanupScanProgress).candidates.length > 0
|
||||
)
|
||||
expect(candidateProgress).toHaveLength(2)
|
||||
expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true)
|
||||
const progressWorktreeIds = candidateProgress.flatMap((event) =>
|
||||
event.candidates.map((candidate) => candidate.worktreeId)
|
||||
)
|
||||
expect(progressWorktreeIds).toEqual(
|
||||
expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b'])
|
||||
)
|
||||
expect(progress.at(-1)).toMatchObject({
|
||||
scanId: 'scan-1',
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: expect.arrayContaining([
|
||||
expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-a' }),
|
||||
expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-b' })
|
||||
])
|
||||
totalWorktreeCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
@@ -228,6 +238,30 @@ describe('workspace cleanup scan', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('aborts a hung worktree list when the cleanup timeout fires', async () => {
|
||||
let signal: AbortSignal | undefined
|
||||
listRepoWorktreesMock.mockImplementation((_repo: Repo, options?: { signal?: AbortSignal }) => {
|
||||
signal = options?.signal
|
||||
return new Promise<GitWorktreeInfo[]>(() => {})
|
||||
})
|
||||
|
||||
const scan = scanWorkspaceCleanup(makeStore())
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
|
||||
await expect(scan).resolves.toEqual({
|
||||
scannedAt: NOW,
|
||||
candidates: [],
|
||||
errors: [
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
message: 'Timed out listing worktrees.'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('skips disconnected remote workspaces without a scan warning', async () => {
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore({
|
||||
@@ -306,8 +340,12 @@ describe('workspace cleanup scan', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo')
|
||||
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature')
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
connectionId: 'ssh-1',
|
||||
@@ -330,7 +368,9 @@ describe('workspace cleanup scan', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo')
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.candidates).toEqual([])
|
||||
})
|
||||
@@ -389,7 +429,9 @@ describe('workspace cleanup scan', () => {
|
||||
)
|
||||
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledWith(repoTwo)
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledWith(repoTwo, {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(result.candidates).toHaveLength(1)
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
worktreeId: 'repo-2::/repo-two-feature',
|
||||
@@ -440,7 +482,7 @@ describe('workspace cleanup scan', () => {
|
||||
expect(getStatusMock).toHaveBeenCalledTimes(1)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['rev-list', '--count', 'HEAD', '--not', '--remotes'],
|
||||
{ cwd: '/repo-feature' }
|
||||
{ cwd: '/repo-feature', signal: expect.any(AbortSignal) }
|
||||
)
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
tier: 'ready',
|
||||
|
||||
@@ -103,11 +103,15 @@ export class SshGitProvider implements IGitProvider {
|
||||
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}
|
||||
return (await this.mux.request('git.status', {
|
||||
worktreePath,
|
||||
...includeIgnoredArgs,
|
||||
...upstreamCacheBypassArgs
|
||||
})) as GitStatusResult
|
||||
return (await this.mux.request(
|
||||
'git.status',
|
||||
{
|
||||
worktreePath,
|
||||
...includeIgnoredArgs,
|
||||
...upstreamCacheBypassArgs
|
||||
},
|
||||
{ signal: options?.signal }
|
||||
)) as GitStatusResult
|
||||
}
|
||||
|
||||
async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]> {
|
||||
|
||||
@@ -168,6 +168,7 @@ export type IFilesystemProvider = {
|
||||
export type GitProviderStatusOptions = {
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type IGitProvider = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { areWorktreePathsEqual } from './ipc/worktree-logic'
|
||||
|
||||
type LocalRepoWorktreeListOptions = {
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean {
|
||||
|
||||
@@ -185,6 +185,83 @@ describe('workspace cleanup viewed rows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not re-probe previously enriched rows during cumulative progress updates', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' })
|
||||
const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' })
|
||||
const scan = vi.fn((_args, progressCallback) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
})
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const hasChildProcesses = vi.fn().mockResolvedValue(false)
|
||||
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
|
||||
;(
|
||||
globalThis.window as unknown as {
|
||||
api: {
|
||||
pty?: {
|
||||
hasChildProcesses: typeof hasChildProcesses
|
||||
getForegroundProcess: typeof getForegroundProcess
|
||||
}
|
||||
}
|
||||
}
|
||||
).api.pty = { hasChildProcesses, getForegroundProcess }
|
||||
const store = createCleanupTestStore()
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
'repo1::/tmp/terminal': [
|
||||
{ id: 'tab-1', title: 'zsh' }
|
||||
] as AppState['tabsByWorktree'][string]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 1,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [terminalCandidate],
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
|
||||
})
|
||||
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [laterCandidate],
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
|
||||
})
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2)
|
||||
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
|
||||
pending.resolve({
|
||||
scannedAt: NOW,
|
||||
candidates: [terminalCandidate, laterCandidate],
|
||||
errors: []
|
||||
})
|
||||
await scanPromise
|
||||
|
||||
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not join broad cleanup scans with different explicit args', async () => {
|
||||
const firstPending = deferred<WorkspaceCleanupScanResult>()
|
||||
const secondPending = deferred<WorkspaceCleanupScanResult>()
|
||||
|
||||
@@ -63,6 +63,12 @@ type EnrichOptions = {
|
||||
applyDismissals?: boolean
|
||||
}
|
||||
|
||||
type WorkspaceCleanupEnrichmentCacheEntry = {
|
||||
inputSignature: string
|
||||
localSignature: string
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
}
|
||||
|
||||
const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000
|
||||
const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000
|
||||
const WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY = 4
|
||||
@@ -72,6 +78,10 @@ let inFlightWorkspaceCleanupScan: {
|
||||
promise: Promise<WorkspaceCleanupScanResult>
|
||||
} | null = null
|
||||
let latestWorkspaceCleanupScanToken = 0
|
||||
let workspaceCleanupEnrichmentCache: {
|
||||
scanToken: number
|
||||
entries: Map<string, WorkspaceCleanupEnrichmentCacheEntry>
|
||||
} | null = null
|
||||
|
||||
const SHELL_PROCESS_NAMES = new Set([
|
||||
'bash',
|
||||
@@ -144,11 +154,16 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
workspaceCleanupError: null
|
||||
})
|
||||
const scanToken = ++latestWorkspaceCleanupScanToken
|
||||
workspaceCleanupEnrichmentCache = { scanToken, entries: new Map() }
|
||||
const promise = (async () => {
|
||||
const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => {
|
||||
void applyWorkspaceCleanupProgress(progress, scanToken, get, set)
|
||||
})
|
||||
const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, get())
|
||||
const enriched = await enrichWorkspaceCleanupCandidatesForScan(
|
||||
scan.candidates,
|
||||
get(),
|
||||
scanToken
|
||||
)
|
||||
const result = { ...scan, candidates: enriched }
|
||||
if (scanToken === latestWorkspaceCleanupScanToken) {
|
||||
set({
|
||||
@@ -315,20 +330,81 @@ async function applyWorkspaceCleanupProgress(
|
||||
replace?: false
|
||||
) => void
|
||||
): Promise<void> {
|
||||
const enriched = await enrichWorkspaceCleanupCandidates(progress.candidates, getState())
|
||||
const state = getState()
|
||||
const previousCandidates =
|
||||
progress.candidateMode === 'append' &&
|
||||
state.workspaceCleanupProgress?.scanId === progress.scanId
|
||||
? state.workspaceCleanupProgress.candidates
|
||||
: []
|
||||
const enrichedProgressCandidates = await enrichWorkspaceCleanupCandidatesForScan(
|
||||
progress.candidates,
|
||||
state,
|
||||
scanToken
|
||||
)
|
||||
const candidates =
|
||||
progress.candidateMode === 'append'
|
||||
? appendWorkspaceCleanupProgressCandidates(previousCandidates, enrichedProgressCandidates)
|
||||
: enrichedProgressCandidates
|
||||
if (scanToken !== latestWorkspaceCleanupScanToken) {
|
||||
return
|
||||
}
|
||||
setState({
|
||||
workspaceCleanupScan: {
|
||||
scannedAt: progress.scannedAt,
|
||||
candidates: enriched,
|
||||
errors: progress.errors
|
||||
},
|
||||
workspaceCleanupProgress: { ...progress, candidates: enriched }
|
||||
setState((state) => {
|
||||
if (
|
||||
state.workspaceCleanupProgress?.scanId === progress.scanId &&
|
||||
state.workspaceCleanupProgress.scannedWorktreeCount > progress.scannedWorktreeCount
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
workspaceCleanupScan: {
|
||||
scannedAt: progress.scannedAt,
|
||||
candidates,
|
||||
errors: progress.errors
|
||||
},
|
||||
workspaceCleanupProgress: { ...progress, candidates }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function enrichWorkspaceCleanupCandidatesForScan(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
state: AppState,
|
||||
scanToken: number
|
||||
): Promise<WorkspaceCleanupCandidate[]> {
|
||||
if (workspaceCleanupEnrichmentCache?.scanToken !== scanToken) {
|
||||
workspaceCleanupEnrichmentCache = { scanToken, entries: new Map() }
|
||||
}
|
||||
return enrichWorkspaceCleanupCandidatesWithCache(
|
||||
candidates,
|
||||
state,
|
||||
workspaceCleanupEnrichmentCache.entries
|
||||
)
|
||||
}
|
||||
|
||||
function appendWorkspaceCleanupProgressCandidates(
|
||||
previousCandidates: readonly WorkspaceCleanupCandidate[],
|
||||
nextCandidates: readonly WorkspaceCleanupCandidate[]
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
if (nextCandidates.length === 0) {
|
||||
return [...previousCandidates]
|
||||
}
|
||||
|
||||
const indexesByWorktreeId = new Map(
|
||||
previousCandidates.map((candidate, index) => [candidate.worktreeId, index])
|
||||
)
|
||||
const merged = [...previousCandidates]
|
||||
for (const candidate of nextCandidates) {
|
||||
const existingIndex = indexesByWorktreeId.get(candidate.worktreeId)
|
||||
if (existingIndex === undefined) {
|
||||
indexesByWorktreeId.set(candidate.worktreeId, merged.length)
|
||||
merged.push(candidate)
|
||||
continue
|
||||
}
|
||||
merged[existingIndex] = candidate
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
@@ -404,6 +480,106 @@ export async function enrichWorkspaceCleanupCandidates(
|
||||
)
|
||||
}
|
||||
|
||||
async function enrichWorkspaceCleanupCandidatesWithCache(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
state: AppState,
|
||||
cache: Map<string, WorkspaceCleanupEnrichmentCacheEntry>,
|
||||
options: EnrichOptions = {}
|
||||
): Promise<WorkspaceCleanupCandidate[]> {
|
||||
return Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
const inputSignature = getWorkspaceCleanupCandidateInputSignature(candidate)
|
||||
const localSignature = getWorkspaceCleanupLocalStateSignature(
|
||||
candidate.worktreeId,
|
||||
state,
|
||||
options
|
||||
)
|
||||
const cached = cache.get(candidate.worktreeId)
|
||||
if (cached?.inputSignature === inputSignature && cached.localSignature === localSignature) {
|
||||
return cached.candidate
|
||||
}
|
||||
|
||||
const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, options)
|
||||
cache.set(candidate.worktreeId, {
|
||||
inputSignature,
|
||||
localSignature,
|
||||
candidate: enriched
|
||||
})
|
||||
return enriched
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupCandidate): string {
|
||||
return JSON.stringify({
|
||||
fingerprint: candidate.fingerprint,
|
||||
blockers: candidate.blockers,
|
||||
reasons: candidate.reasons,
|
||||
git: candidate.git,
|
||||
lastActivityAt: candidate.lastActivityAt,
|
||||
createdAt: candidate.createdAt,
|
||||
path: candidate.path,
|
||||
branch: candidate.branch
|
||||
})
|
||||
}
|
||||
|
||||
function getWorkspaceCleanupLocalStateSignature(
|
||||
worktreeId: string,
|
||||
state: AppState,
|
||||
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)))
|
||||
.map((entry) => ({
|
||||
paneKey: entry.paneKey,
|
||||
state: entry.state,
|
||||
updatedAt: entry.updatedAt
|
||||
}))
|
||||
.sort((a, b) => a.paneKey.localeCompare(b.paneKey))
|
||||
const ptyIdsByTabId = Object.fromEntries(
|
||||
tabIds.map((tabId) => [tabId, state.ptyIdsByTabId[tabId] ?? []])
|
||||
)
|
||||
const runtimePaneTitlesByTabId = Object.fromEntries(
|
||||
tabIds.map((tabId) => [tabId, state.runtimePaneTitlesByTabId[tabId] ?? {}])
|
||||
)
|
||||
const terminalLayoutsByTabId = Object.fromEntries(
|
||||
tabIds.map((tabId) => [tabId, state.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId ?? {}])
|
||||
)
|
||||
const dismissal =
|
||||
options.applyDismissals === false
|
||||
? null
|
||||
: (state.workspaceCleanupDismissals[worktreeId] ?? null)
|
||||
|
||||
return JSON.stringify({
|
||||
active: state.activeWorktreeId === worktreeId,
|
||||
tabs: tabs.map((tab) => ({ id: tab.id, title: tab.title })),
|
||||
ptyIdsByTabId,
|
||||
runtimePaneTitlesByTabId,
|
||||
terminalLayoutsByTabId,
|
||||
openFiles,
|
||||
browserTabCount: (state.browserTabsByWorktree[worktreeId] ?? []).length,
|
||||
retainedDoneAgentPaneKeys,
|
||||
agentStatuses,
|
||||
lastVisitedAt: state.lastVisitedAtByWorktreeId[worktreeId] ?? 0,
|
||||
viewed: state.workspaceCleanupViewedCandidates[worktreeId] ?? null,
|
||||
dismissal
|
||||
})
|
||||
}
|
||||
|
||||
async function enrichWorkspaceCleanupCandidate(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
state: AppState,
|
||||
|
||||
@@ -99,6 +99,7 @@ export type WorkspaceCleanupScanProgress = WorkspaceCleanupScanResult & {
|
||||
scanId: string
|
||||
scannedWorktreeCount: number
|
||||
totalWorktreeCount: number
|
||||
candidateMode?: 'append' | 'snapshot'
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupLocalProcessResult = {
|
||||
|
||||
Reference in New Issue
Block a user