chore: initialize recovered claude workspace
This commit is contained in:
602
src/utils/cleanup.ts
Normal file
602
src/utils/cleanup.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import * as fs from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { logEvent } from '../services/analytics/index.js'
|
||||
import { CACHE_PATHS } from './cachePaths.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { getClaudeConfigHomeDir } from './envUtils.js'
|
||||
import { type FsOperations, getFsImplementation } from './fsOperations.js'
|
||||
import { cleanupOldImageCaches } from './imageStore.js'
|
||||
import * as lockfile from './lockfile.js'
|
||||
import { logError } from './log.js'
|
||||
import { cleanupOldVersions } from './nativeInstaller/index.js'
|
||||
import { cleanupOldPastes } from './pasteStore.js'
|
||||
import { getProjectsDir } from './sessionStorage.js'
|
||||
import { getSettingsWithAllErrors } from './settings/allErrors.js'
|
||||
import {
|
||||
getSettings_DEPRECATED,
|
||||
rawSettingsContainsKey,
|
||||
} from './settings/settings.js'
|
||||
import { TOOL_RESULTS_SUBDIR } from './toolResultStorage.js'
|
||||
import { cleanupStaleAgentWorktrees } from './worktree.js'
|
||||
|
||||
const DEFAULT_CLEANUP_PERIOD_DAYS = 30
|
||||
|
||||
function getCutoffDate(): Date {
|
||||
const settings = getSettings_DEPRECATED() || {}
|
||||
const cleanupPeriodDays =
|
||||
settings.cleanupPeriodDays ?? DEFAULT_CLEANUP_PERIOD_DAYS
|
||||
const cleanupPeriodMs = cleanupPeriodDays * 24 * 60 * 60 * 1000
|
||||
return new Date(Date.now() - cleanupPeriodMs)
|
||||
}
|
||||
|
||||
export type CleanupResult = {
|
||||
messages: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export function addCleanupResults(
|
||||
a: CleanupResult,
|
||||
b: CleanupResult,
|
||||
): CleanupResult {
|
||||
return {
|
||||
messages: a.messages + b.messages,
|
||||
errors: a.errors + b.errors,
|
||||
}
|
||||
}
|
||||
|
||||
export function convertFileNameToDate(filename: string): Date {
|
||||
const isoStr = filename
|
||||
.split('.')[0]!
|
||||
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z')
|
||||
return new Date(isoStr)
|
||||
}
|
||||
|
||||
async function cleanupOldFilesInDirectory(
|
||||
dirPath: string,
|
||||
cutoffDate: Date,
|
||||
isMessagePath: boolean,
|
||||
): Promise<CleanupResult> {
|
||||
const result: CleanupResult = { messages: 0, errors: 0 }
|
||||
|
||||
try {
|
||||
const files = await getFsImplementation().readdir(dirPath)
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Convert filename format where all ':.' were replaced with '-'
|
||||
const timestamp = convertFileNameToDate(file.name)
|
||||
if (timestamp < cutoffDate) {
|
||||
await getFsImplementation().unlink(join(dirPath, file.name))
|
||||
// Increment the appropriate counter
|
||||
if (isMessagePath) {
|
||||
result.messages++
|
||||
} else {
|
||||
result.errors++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but continue processing other files
|
||||
logError(error as Error)
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Ignore if directory doesn't exist
|
||||
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function cleanupOldMessageFiles(): Promise<CleanupResult> {
|
||||
const fsImpl = getFsImplementation()
|
||||
const cutoffDate = getCutoffDate()
|
||||
const errorPath = CACHE_PATHS.errors()
|
||||
const baseCachePath = CACHE_PATHS.baseLogs()
|
||||
|
||||
// Clean up message and error logs
|
||||
let result = await cleanupOldFilesInDirectory(errorPath, cutoffDate, false)
|
||||
|
||||
// Clean up MCP logs
|
||||
try {
|
||||
let dirents
|
||||
try {
|
||||
dirents = await fsImpl.readdir(baseCachePath)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
|
||||
const mcpLogDirs = dirents
|
||||
.filter(
|
||||
dirent => dirent.isDirectory() && dirent.name.startsWith('mcp-logs-'),
|
||||
)
|
||||
.map(dirent => join(baseCachePath, dirent.name))
|
||||
|
||||
for (const mcpLogDir of mcpLogDirs) {
|
||||
// Clean up files in MCP log directory
|
||||
result = addCleanupResults(
|
||||
result,
|
||||
await cleanupOldFilesInDirectory(mcpLogDir, cutoffDate, true),
|
||||
)
|
||||
await tryRmdir(mcpLogDir, fsImpl)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function unlinkIfOld(
|
||||
filePath: string,
|
||||
cutoffDate: Date,
|
||||
fsImpl: FsOperations,
|
||||
): Promise<boolean> {
|
||||
const stats = await fsImpl.stat(filePath)
|
||||
if (stats.mtime < cutoffDate) {
|
||||
await fsImpl.unlink(filePath)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function tryRmdir(dirPath: string, fsImpl: FsOperations): Promise<void> {
|
||||
try {
|
||||
await fsImpl.rmdir(dirPath)
|
||||
} catch {
|
||||
// not empty / doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupOldSessionFiles(): Promise<CleanupResult> {
|
||||
const cutoffDate = getCutoffDate()
|
||||
const result: CleanupResult = { messages: 0, errors: 0 }
|
||||
const projectsDir = getProjectsDir()
|
||||
const fsImpl = getFsImplementation()
|
||||
|
||||
let projectDirents
|
||||
try {
|
||||
projectDirents = await fsImpl.readdir(projectsDir)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
|
||||
for (const projectDirent of projectDirents) {
|
||||
if (!projectDirent.isDirectory()) continue
|
||||
const projectDir = join(projectsDir, projectDirent.name)
|
||||
|
||||
// Single readdir per project directory — partition into files and session dirs
|
||||
let entries
|
||||
try {
|
||||
entries = await fsImpl.readdir(projectDir)
|
||||
} catch {
|
||||
result.errors++
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) {
|
||||
if (!entry.name.endsWith('.jsonl') && !entry.name.endsWith('.cast')) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (
|
||||
await unlinkIfOld(join(projectDir, entry.name), cutoffDate, fsImpl)
|
||||
) {
|
||||
result.messages++
|
||||
}
|
||||
} catch {
|
||||
result.errors++
|
||||
}
|
||||
} else if (entry.isDirectory()) {
|
||||
// Session directory — clean up tool-results/<toolDir>/* beneath it
|
||||
const sessionDir = join(projectDir, entry.name)
|
||||
const toolResultsDir = join(sessionDir, TOOL_RESULTS_SUBDIR)
|
||||
let toolDirs
|
||||
try {
|
||||
toolDirs = await fsImpl.readdir(toolResultsDir)
|
||||
} catch {
|
||||
// No tool-results dir — still try to remove an empty session dir
|
||||
await tryRmdir(sessionDir, fsImpl)
|
||||
continue
|
||||
}
|
||||
for (const toolEntry of toolDirs) {
|
||||
if (toolEntry.isFile()) {
|
||||
try {
|
||||
if (
|
||||
await unlinkIfOld(
|
||||
join(toolResultsDir, toolEntry.name),
|
||||
cutoffDate,
|
||||
fsImpl,
|
||||
)
|
||||
) {
|
||||
result.messages++
|
||||
}
|
||||
} catch {
|
||||
result.errors++
|
||||
}
|
||||
} else if (toolEntry.isDirectory()) {
|
||||
const toolDirPath = join(toolResultsDir, toolEntry.name)
|
||||
let toolFiles
|
||||
try {
|
||||
toolFiles = await fsImpl.readdir(toolDirPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const tf of toolFiles) {
|
||||
if (!tf.isFile()) continue
|
||||
try {
|
||||
if (
|
||||
await unlinkIfOld(
|
||||
join(toolDirPath, tf.name),
|
||||
cutoffDate,
|
||||
fsImpl,
|
||||
)
|
||||
) {
|
||||
result.messages++
|
||||
}
|
||||
} catch {
|
||||
result.errors++
|
||||
}
|
||||
}
|
||||
await tryRmdir(toolDirPath, fsImpl)
|
||||
}
|
||||
}
|
||||
await tryRmdir(toolResultsDir, fsImpl)
|
||||
await tryRmdir(sessionDir, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
await tryRmdir(projectDir, fsImpl)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper for cleaning up old files in a single directory
|
||||
* @param dirPath Path to the directory to clean
|
||||
* @param extension File extension to filter (e.g., '.md', '.jsonl')
|
||||
* @param removeEmptyDir Whether to remove the directory if empty after cleanup
|
||||
*/
|
||||
async function cleanupSingleDirectory(
|
||||
dirPath: string,
|
||||
extension: string,
|
||||
removeEmptyDir: boolean = true,
|
||||
): Promise<CleanupResult> {
|
||||
const cutoffDate = getCutoffDate()
|
||||
const result: CleanupResult = { messages: 0, errors: 0 }
|
||||
const fsImpl = getFsImplementation()
|
||||
|
||||
let dirents
|
||||
try {
|
||||
dirents = await fsImpl.readdir(dirPath)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
if (!dirent.isFile() || !dirent.name.endsWith(extension)) continue
|
||||
try {
|
||||
if (await unlinkIfOld(join(dirPath, dirent.name), cutoffDate, fsImpl)) {
|
||||
result.messages++
|
||||
}
|
||||
} catch {
|
||||
result.errors++
|
||||
}
|
||||
}
|
||||
|
||||
if (removeEmptyDir) {
|
||||
await tryRmdir(dirPath, fsImpl)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function cleanupOldPlanFiles(): Promise<CleanupResult> {
|
||||
const plansDir = join(getClaudeConfigHomeDir(), 'plans')
|
||||
return cleanupSingleDirectory(plansDir, '.md')
|
||||
}
|
||||
|
||||
export async function cleanupOldFileHistoryBackups(): Promise<CleanupResult> {
|
||||
const cutoffDate = getCutoffDate()
|
||||
const result: CleanupResult = { messages: 0, errors: 0 }
|
||||
const fsImpl = getFsImplementation()
|
||||
|
||||
try {
|
||||
const configDir = getClaudeConfigHomeDir()
|
||||
const fileHistoryStorageDir = join(configDir, 'file-history')
|
||||
|
||||
let dirents
|
||||
try {
|
||||
dirents = await fsImpl.readdir(fileHistoryStorageDir)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
|
||||
const fileHistorySessionsDirs = dirents
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => join(fileHistoryStorageDir, dirent.name))
|
||||
|
||||
await Promise.all(
|
||||
fileHistorySessionsDirs.map(async fileHistorySessionDir => {
|
||||
try {
|
||||
const stats = await fsImpl.stat(fileHistorySessionDir)
|
||||
if (stats.mtime < cutoffDate) {
|
||||
await fsImpl.rm(fileHistorySessionDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
result.messages++
|
||||
}
|
||||
} catch {
|
||||
result.errors++
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
await tryRmdir(fileHistoryStorageDir, fsImpl)
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function cleanupOldSessionEnvDirs(): Promise<CleanupResult> {
|
||||
const cutoffDate = getCutoffDate()
|
||||
const result: CleanupResult = { messages: 0, errors: 0 }
|
||||
const fsImpl = getFsImplementation()
|
||||
|
||||
try {
|
||||
const configDir = getClaudeConfigHomeDir()
|
||||
const sessionEnvBaseDir = join(configDir, 'session-env')
|
||||
|
||||
let dirents
|
||||
try {
|
||||
dirents = await fsImpl.readdir(sessionEnvBaseDir)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
|
||||
const sessionEnvDirs = dirents
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => join(sessionEnvBaseDir, dirent.name))
|
||||
|
||||
for (const sessionEnvDir of sessionEnvDirs) {
|
||||
try {
|
||||
const stats = await fsImpl.stat(sessionEnvDir)
|
||||
if (stats.mtime < cutoffDate) {
|
||||
await fsImpl.rm(sessionEnvDir, { recursive: true, force: true })
|
||||
result.messages++
|
||||
}
|
||||
} catch {
|
||||
result.errors++
|
||||
}
|
||||
}
|
||||
|
||||
await tryRmdir(sessionEnvBaseDir, fsImpl)
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up old debug log files from ~/.claude/debug/
|
||||
* Preserves the 'latest' symlink which points to the current session's log.
|
||||
* Debug logs can grow very large (especially with the infinite logging loop bug)
|
||||
* and accumulate indefinitely without this cleanup.
|
||||
*/
|
||||
export async function cleanupOldDebugLogs(): Promise<CleanupResult> {
|
||||
const cutoffDate = getCutoffDate()
|
||||
const result: CleanupResult = { messages: 0, errors: 0 }
|
||||
const fsImpl = getFsImplementation()
|
||||
const debugDir = join(getClaudeConfigHomeDir(), 'debug')
|
||||
|
||||
let dirents
|
||||
try {
|
||||
dirents = await fsImpl.readdir(debugDir)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
// Preserve the 'latest' symlink
|
||||
if (
|
||||
!dirent.isFile() ||
|
||||
!dirent.name.endsWith('.txt') ||
|
||||
dirent.name === 'latest'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (await unlinkIfOld(join(debugDir, dirent.name), cutoffDate, fsImpl)) {
|
||||
result.messages++
|
||||
}
|
||||
} catch {
|
||||
result.errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally do NOT remove debugDir even if empty — needed for future logs
|
||||
return result
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Clean up old npm cache entries for Anthropic packages.
|
||||
* This helps reduce disk usage since we publish many dev versions per day.
|
||||
* Only runs once per day for Ant users.
|
||||
*/
|
||||
export async function cleanupNpmCacheForAnthropicPackages(): Promise<void> {
|
||||
const markerPath = join(getClaudeConfigHomeDir(), '.npm-cache-cleanup')
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(markerPath)
|
||||
if (Date.now() - stat.mtimeMs < ONE_DAY_MS) {
|
||||
logForDebugging('npm cache cleanup: skipping, ran recently')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, proceed with cleanup
|
||||
}
|
||||
|
||||
try {
|
||||
await lockfile.lock(markerPath, { retries: 0, realpath: false })
|
||||
} catch {
|
||||
logForDebugging('npm cache cleanup: skipping, lock held')
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging('npm cache cleanup: starting')
|
||||
|
||||
const npmCachePath = join(homedir(), '.npm', '_cacache')
|
||||
|
||||
const NPM_CACHE_RETENTION_COUNT = 5
|
||||
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const cacache = await import('cacache')
|
||||
const cutoff = startTime - ONE_DAY_MS
|
||||
|
||||
// Stream index entries and collect all Anthropic package entries.
|
||||
// Previous implementation used cacache.verify() which does a full
|
||||
// integrity check + GC of the ENTIRE cache — O(all content blobs).
|
||||
// On large caches this took 60+ seconds and blocked the event loop.
|
||||
const stream = cacache.ls.stream(npmCachePath)
|
||||
const anthropicEntries: { key: string; time: number }[] = []
|
||||
for await (const entry of stream as AsyncIterable<{
|
||||
key: string
|
||||
time: number
|
||||
}>) {
|
||||
if (entry.key.includes('@anthropic-ai/claude-')) {
|
||||
anthropicEntries.push({ key: entry.key, time: entry.time })
|
||||
}
|
||||
}
|
||||
|
||||
// Group by package name (everything before the last @version separator)
|
||||
const byPackage = new Map<string, { key: string; time: number }[]>()
|
||||
for (const entry of anthropicEntries) {
|
||||
const atVersionIdx = entry.key.lastIndexOf('@')
|
||||
const pkgName =
|
||||
atVersionIdx > 0 ? entry.key.slice(0, atVersionIdx) : entry.key
|
||||
const existing = byPackage.get(pkgName) ?? []
|
||||
existing.push(entry)
|
||||
byPackage.set(pkgName, existing)
|
||||
}
|
||||
|
||||
// Remove entries older than 1 day OR beyond the top N most recent per package
|
||||
const keysToRemove: string[] = []
|
||||
for (const [, entries] of byPackage) {
|
||||
entries.sort((a, b) => b.time - a.time) // newest first
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i]!
|
||||
if (entry.time < cutoff || i >= NPM_CACHE_RETENTION_COUNT) {
|
||||
keysToRemove.push(entry.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
keysToRemove.map(key => cacache.rm.entry(npmCachePath, key)),
|
||||
)
|
||||
|
||||
await fs.writeFile(markerPath, new Date().toISOString())
|
||||
|
||||
const durationMs = Date.now() - startTime
|
||||
if (keysToRemove.length > 0) {
|
||||
logForDebugging(
|
||||
`npm cache cleanup: Removed ${keysToRemove.length} old @anthropic-ai entries in ${durationMs}ms`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(`npm cache cleanup: completed in ${durationMs}ms`)
|
||||
}
|
||||
logEvent('tengu_npm_cache_cleanup', {
|
||||
success: true,
|
||||
durationMs,
|
||||
entriesRemoved: keysToRemove.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
logEvent('tengu_npm_cache_cleanup', {
|
||||
success: false,
|
||||
durationMs: Date.now() - startTime,
|
||||
})
|
||||
} finally {
|
||||
await lockfile.unlock(markerPath, { realpath: false }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled wrapper around cleanupOldVersions for recurring cleanup in long-running sessions.
|
||||
* Uses a marker file and lock to ensure it runs at most once per 24 hours,
|
||||
* and does not block if another process is already running cleanup.
|
||||
* The regular cleanupOldVersions() should still be used for installer flows.
|
||||
*/
|
||||
export async function cleanupOldVersionsThrottled(): Promise<void> {
|
||||
const markerPath = join(getClaudeConfigHomeDir(), '.version-cleanup')
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(markerPath)
|
||||
if (Date.now() - stat.mtimeMs < ONE_DAY_MS) {
|
||||
logForDebugging('version cleanup: skipping, ran recently')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, proceed with cleanup
|
||||
}
|
||||
|
||||
try {
|
||||
await lockfile.lock(markerPath, { retries: 0, realpath: false })
|
||||
} catch {
|
||||
logForDebugging('version cleanup: skipping, lock held')
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging('version cleanup: starting (throttled)')
|
||||
|
||||
try {
|
||||
await cleanupOldVersions()
|
||||
await fs.writeFile(markerPath, new Date().toISOString())
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
} finally {
|
||||
await lockfile.unlock(markerPath, { realpath: false }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupOldMessageFilesInBackground(): Promise<void> {
|
||||
// If settings have validation errors but the user explicitly set cleanupPeriodDays,
|
||||
// skip cleanup entirely rather than falling back to the default (30 days).
|
||||
// This prevents accidentally deleting files when the user intended a different retention period.
|
||||
const { errors } = getSettingsWithAllErrors()
|
||||
if (errors.length > 0 && rawSettingsContainsKey('cleanupPeriodDays')) {
|
||||
logForDebugging(
|
||||
'Skipping cleanup: settings have validation errors but cleanupPeriodDays was explicitly set. Fix settings errors to enable cleanup.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await cleanupOldMessageFiles()
|
||||
await cleanupOldSessionFiles()
|
||||
await cleanupOldPlanFiles()
|
||||
await cleanupOldFileHistoryBackups()
|
||||
await cleanupOldSessionEnvDirs()
|
||||
await cleanupOldDebugLogs()
|
||||
await cleanupOldImageCaches()
|
||||
await cleanupOldPastes(getCutoffDate())
|
||||
const removedWorktrees = await cleanupStaleAgentWorktrees(getCutoffDate())
|
||||
if (removedWorktrees > 0) {
|
||||
logEvent('tengu_worktree_cleanup', { removed: removedWorktrees })
|
||||
}
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
await cleanupNpmCacheForAnthropicPackages()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user