Files
orca/src/main/ipc/workspace-cleanup-candidate.ts
T
mirror 34fa2cd5ca
Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
source snapshot 2026-09-02
2026-09-02 02:03:16 +08:00

224 lines
7.4 KiB
TypeScript

import type { IGitProvider } from '../providers/types'
import { isFolderRepo } from '../../shared/repo-kind'
import type { Repo } from '../../shared/repo-types'
import type { Worktree } from '../../shared/worktree/types'
import { getWorktreeExecutionHostId } from '../../shared/execution-host'
import {
applyWorkspaceCleanupPolicy,
createWorkspaceCleanupFingerprint,
getWorkspaceCleanupInactivityReasons,
isWorkspaceOldForCleanup,
type WorkspaceCleanupBlocker,
type WorkspaceCleanupCandidate,
type WorkspaceCleanupReason
} from '../../shared/workspace-cleanup'
import {
createEmptyWorkspaceCleanupGitEvidence,
readWorkspaceCleanupGitEvidence
} from './workspace-cleanup-git-evidence'
import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives'
export async function buildWorkspaceCleanupCandidate(args: {
repo: Repo
worktree: Worktree
scannedAt: number
provider: IGitProvider | null
skipGit: boolean
forceGitCheck: boolean
signal?: AbortSignal
}): Promise<WorkspaceCleanupCandidate> {
const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck, signal } = args
const blockers: WorkspaceCleanupBlocker[] = []
const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt)
const repoIsFolder = isFolderRepo(repo)
if (worktree.isMainWorktree) {
blockers.push('main-worktree')
}
if (repoIsFolder) {
blockers.push('folder-repo')
}
if (worktree.isPinned) {
blockers.push('pinned')
}
const localContext = buildWorkspaceCleanupLocalContext(worktree)
const shouldReadGit = shouldReadWorkspaceCleanupGitEvidence({
repoIsFolder,
blockers,
worktree,
skipGit,
forceGitCheck
})
const gitEvidence = !shouldReadGit
? createEmptyWorkspaceCleanupGitEvidence()
: await readWorkspaceCleanupGitEvidence(worktree, repo, provider, signal)
appendWorkspaceCleanupItems(blockers, gitEvidence.blockers)
const candidateWithoutFingerprint: WorkspaceCleanupCandidate = {
worktreeId: worktree.id,
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
executionHostId: getWorktreeExecutionHostId(worktree, repo),
displayName: worktree.displayName,
branch: shortWorkspaceCleanupBranchName(worktree.branch),
path: worktree.path,
tier: 'review',
selectedByDefault: false,
reasons,
blockers: uniqueWorkspaceCleanupBlockers(blockers),
lastActivityAt: worktree.lastActivityAt,
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
localContext,
git: {
clean: gitEvidence.clean,
upstreamAhead: gitEvidence.upstreamAhead,
upstreamBehind: gitEvidence.upstreamBehind,
checkedAt: gitEvidence.checkedAt
},
fingerprint: ''
}
const fingerprint = createWorkspaceCleanupFingerprint({
branch: candidateWithoutFingerprint.branch,
head: worktree.head,
gitClean: gitEvidence.clean,
lastActivityAt: worktree.lastActivityAt
})
return applyWorkspaceCleanupPolicy({
...candidateWithoutFingerprint,
reasons: uniqueWorkspaceCleanupReasons(reasons),
blockers: uniqueWorkspaceCleanupBlockers(blockers),
fingerprint
})
}
export function buildWorkspaceCleanupCandidateFromError(
repo: Repo,
worktree: Worktree,
scannedAt: number
): WorkspaceCleanupCandidate {
return applyWorkspaceCleanupPolicy({
worktreeId: worktree.id,
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
executionHostId: getWorktreeExecutionHostId(worktree, repo),
displayName: worktree.displayName,
branch: shortWorkspaceCleanupBranchName(worktree.branch),
path: worktree.path,
tier: 'protected',
selectedByDefault: false,
reasons: getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt),
blockers: ['git-status-error'],
lastActivityAt: worktree.lastActivityAt,
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
localContext: buildWorkspaceCleanupLocalContext(worktree),
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: scannedAt
},
fingerprint: createWorkspaceCleanupFingerprint({
branch: shortWorkspaceCleanupBranchName(worktree.branch),
head: worktree.head,
gitClean: null,
lastActivityAt: worktree.lastActivityAt
})
})
}
export function buildWorkspaceCleanupLocalContext(
worktree: Pick<Worktree, 'diffComments'>
): WorkspaceCleanupCandidate['localContext'] {
return {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: worktree.diffComments?.length ?? 0,
newestDiffCommentAt: getNewestWorkspaceCleanupDiffCommentAt(worktree.diffComments),
retainedDoneAgentCount: 0
}
}
export function getNewestWorkspaceCleanupDiffCommentAt(
diffComments: Worktree['diffComments'] | undefined
): number | null {
if (!diffComments || diffComments.length === 0) {
return null
}
// Why: persisted diff notes can grow large enough for spread-based Math.max
// to exceed the JavaScript argument limit during cleanup scans.
let newest = diffComments[0]?.createdAt ?? null
for (let index = 1; index < diffComments.length; index += 1) {
const createdAt = diffComments[index]?.createdAt
if (createdAt !== undefined && (newest === null || createdAt > newest)) {
newest = createdAt
}
}
return newest
}
export function isWorkspaceInactiveForCleanup(
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
scannedAt: number
): boolean {
return isWorkspaceOldForCleanup(workspace, scannedAt)
}
export function getWorkspaceCleanupInactivityReasonsForWorkspace(
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
scannedAt: number
): WorkspaceCleanupReason[] {
return getWorkspaceCleanupInactivityReasons(workspace, scannedAt)
}
export function shortWorkspaceCleanupBranchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '') || 'HEAD'
}
function shouldReadWorkspaceCleanupGitEvidence(args: {
repoIsFolder: boolean
blockers: WorkspaceCleanupBlocker[]
worktree: Worktree
skipGit: boolean
forceGitCheck: boolean
}): boolean {
const { repoIsFolder, blockers, worktree, skipGit, forceGitCheck } = args
if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) {
return false
}
// Why pinned sits with the cost skips and not the refusals: a pinned workspace is
// still queueable (`pinned` is not a queue blocker), so it can reach removal -- and
// removal forces whenever git is unknown. Skipping its git read on a broad scan is a
// fair saving; skipping it on the confirm-time forced read meant deleting with no
// evidence ever obtained. main-worktree and folder-repo stay unconditional: those are
// refused before removal, so reading git for them is pure cost.
if (blockers.includes('pinned') && !forceGitCheck) {
return false
}
if (blockers.includes('main-worktree') || blockers.includes('folder-repo')) {
return false
}
// Why: inactivity is the only recommendation signal now. Git is read only
// to keep the destructive path from deleting dirty or local-only branch work.
return true
}
function uniqueWorkspaceCleanupBlockers(
blockers: WorkspaceCleanupBlocker[]
): WorkspaceCleanupBlocker[] {
return [...new Set(blockers)]
}
function uniqueWorkspaceCleanupReasons(
reasons: WorkspaceCleanupReason[]
): WorkspaceCleanupReason[] {
return [...new Set(reasons)]
}