import { spawnSync } from "node:child_process"; import { closeSync, existsSync, ftruncateSync, lstatSync, mkdirSync, opendirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { runRemoteGcCommand } from "./gc-remote"; type GcRisk = "low" | "medium" | "high" | "blocked"; type GcItemKind = | "file-log-delete" | "file-log-compact" | "docker-json-log-truncate" | "journal-vacuum" | "docker-build-cache-prune" | "tmp-path-delete" | "stale-tmp-path-delete" | "browser-cache-delete" | "tool-cache-delete" | "vscode-server-delete" | "vscode-extension-delete" | "vscode-cached-vsix-delete" | "baidu-staging-file-delete" | "state-artifact-file-delete" | "state-artifact-dir-delete" | "state-stale-scratch-file-delete" | "state-stale-scratch-dir-delete" | "codex-session-file-delete" | "merged-worktree-remove" | "vpn-diagnostic-pcap-delete"; interface GcOptions { fileLogs: boolean; fileLogKeepDays: number; fileLogMaxBytes: number; fileLogTailBytes: number; dockerLogs: boolean; dockerLogMaxBytes: number; journal: boolean; journalTargetBytes: number; buildCache: boolean; buildCacheUntil: string; buildCacheAll: boolean; tmp: boolean; tmpMinAgeHours: number; staleTmp: boolean; browserCache: boolean; toolCaches: boolean; vscodeStaleServers: boolean; vscodeKeepServers: number; vscodeStaleExtensions: boolean; vscodeKeepExtensionVersions: number; vscodeCachedVsix: boolean; vscodeCachedVsixKeepDays: number; baiduStaging: boolean; baiduStagingKeepDays: number; stateArtifacts: boolean; stateArtifactKeepDays: number; stateArtifactFileRoots: GcPathRoot[]; stateArtifactDirRoots: GcPathRoot[]; stateStaleScratch: boolean; stateStaleScratchKeepHours: number; stateStaleScratchFileRoots: GcPathRoot[]; stateStaleScratchDirRoots: GcPathRoot[]; codexSessions: boolean; codexSessionKeepHours: number; codexSessionRoot: string; mergedWorktrees: boolean; worktreeKeepHours: number; worktreeMainRoot: string; worktreeRoot: string; worktreeBaseRef: string; worktreeScanBudgetMs: number; worktreeCherryCheckTimeoutMs: number; worktreeEstimateSizeInPlan: boolean; vpnDiagnosticLogs: boolean; vpnDiagnosticLogKeepHours: number; dbSummary: boolean; limit: number; resultLimit: number; full: boolean; targetUsePercent: number | null; } interface GcPathRoot { id: string; path: string; displayPath: string; } interface DbTraceGcOptions { beforeDate: string | null; types: string[]; vacuumFull: boolean; confirm: boolean; } interface GcPolicyOptions { dryRun: boolean; enableNow: boolean; } interface GcPolicyTimerConfig { journaldSystemMaxUseBytes: number; journaldRuntimeMaxUseBytes: number; journaldMaxRetentionSec: string; buildCacheUntil: string; vpnDiagnosticLogsEnabled: boolean; vpnDiagnosticLogKeepHours: number; stateArtifactsEnabled: boolean; stateArtifactKeepDays: number; vscodeCachedVsixEnabled: boolean; vscodeCachedVsixKeepDays: number; limit: number; resultLimit: number; } interface DiskSnapshot { filesystem: string; sizeBytes: number; usedBytes: number; availableBytes: number; usePercent: number; mount: string; } interface GcCandidate { id: string; kind: GcItemKind; risk: GcRisk; description: string; path?: string; container?: { id: string; name: string; image: string }; sizeBytes: number; estimatedReclaimBytes: number; action: Record; } interface ProtectedGcItem { kind: string; risk: "blocked"; ref: string; sizeBytes?: number; reason: string; } interface GcPlan { ok: true; action: "gc plan"; dryRun: true; mutation: false; observedAt: string; options: Record; diskBefore: DiskSnapshot | null; summary: { candidateCount: number; returnedCandidateCount: number; protectedCount: number; returnedProtectedCount: number; estimatedReclaimBytes: number; estimatedReclaim: string; returnedEstimatedReclaimBytes: number; returnedEstimatedReclaim: string; byKind: Record; target: GcTargetSummary | null; }; candidates: GcCandidate[]; protected: ProtectedGcItem[]; databaseSummary: unknown; policy: { requiresRunConfirm: true; runCommand: string; neverTouches: string[]; notes: string[]; }; } interface GcRunResult { ok: boolean; action: "gc run"; dryRun: false; mutation: true; observedAt: string; options: Record; diskBefore: DiskSnapshot | null; diskAfter: DiskSnapshot | null; summary: { plannedCandidateCount: number; attemptedCount: number; succeededCount: number; failedCount: number; estimatedReclaimBytes: number; actualDiskReclaimBytes: number | null; resultCount: number; returnedResultCount: number; omittedResultCount: number; }; results: Array; protected: ProtectedGcItem[]; } interface GcTargetSummary { targetUsePercent: number; currentUsePercent: number; basis: string; requiredReclaimBytes: number; requiredReclaim: string; estimatedAfterUsedBytes: number; estimatedAfterUsePercent: number; shortfallBytes: number; shortfall: string; safeStop: boolean; } const DEFAULT_DB_TRACE_TYPES = ["trace-stats-updated", "trace-step-created", "trace-stats-snapshot"]; const GC_CONFIG_RELATIVE_PATH = "config/unidesk-cli.yaml"; const GC_CONFIG_REF = `${GC_CONFIG_RELATIVE_PATH}#gc`; const TMP_PREFIX_ALLOWLIST = [ "hwlab-agent-", "hwlab-", "hwlab-cd-", "hwlab-cli-cicd-", "hwlab-codeagent-trace", "hwlab-desired-state-", "hwlab-main-", "hwlab-merge-", "hwlab-pr", "hwlab-refresh-", "hwpod-", "playwright-artifacts-", "playwright_chromiumdev_profile-", "sub2api", "trans-d601-pool-", "unidesk-", "unidesk-clean-", "unidesk-code-queue", "unidesk-hwlab-cd-", "unidesk-pr", "unidesk-tran-runner", "webterm-", "bunx-", "claude-", "codex-app-schema", "codex-app-ts", "codex-probe-", "mxcx-", "marked-", "node-compile-cache", ]; const TMP_EXACT_PROTECT = new Set([ "/tmp/codex-apply-patch", "/tmp/codex-ipc", "/tmp/tmux-0", ]); const STALE_TMP_PROTECTED_PREFIXES = [ ".X", ".ICE", ".font-unix", ".Test-unix", "systemd-private-", "tmux-", "ssh-", "vscode-", ]; const TOOL_CACHE_ALLOWLIST = [ { id: "npm-cacache", path: "/root/.npm/_cacache", description: "Delete npm content-addressable package cache; npm can rebuild it.", }, { id: "npm-npx", path: "/root/.npm/_npx", description: "Delete npx execution cache; npx can rebuild it.", }, { id: "playwright-ms-cache", path: "/root/.cache/ms-playwright", description: "Delete user-level Playwright browser cache; browsers can be reinstalled by Playwright.", }, { id: "go-build-cache", path: "/root/.cache/go-build", description: "Delete Go build cache; Go can rebuild it.", }, { id: "go-module-cache", path: "/root/go/pkg/mod", description: "Delete Go module download cache; Go can re-download modules from project lock/module files.", }, { id: "bun-install-cache", path: "/root/.bun/install/cache", description: "Delete Bun package install cache; Bun can re-download packages on demand.", }, { id: "electron-cache", path: "/root/.cache/electron", description: "Delete Electron download cache; Electron tooling can re-download it.", }, { id: "node-gyp-cache", path: "/root/.cache/node-gyp", description: "Delete node-gyp header cache; node-gyp can re-download it.", }, { id: "typescript-cache", path: "/root/.cache/typescript", description: "Delete TypeScript cache; TypeScript can rebuild it.", }, { id: "opencode-cache", path: "/root/.cache/opencode", description: "Delete OpenCode cache; OpenCode can rebuild it.", }, ]; const VSCODE_SERVER_ROOT = "/root/.vscode-server/cli/servers"; const VSCODE_EXTENSION_ROOT = "/root/.vscode-server/extensions"; const VSCODE_CACHED_VSIX_ROOT = "/root/.vscode-server/data/CachedExtensionVSIXs"; const BAIDU_STAGING_RELATIVE_ROOT = [".state", "baidu-netdisk", "staging"]; const PROTECTED_STATE_PATHS = [ { kind: "state-recovery", relativePath: [".state", "recovery"], reason: ".state/recovery is recovery state and is never selected by state artifact retention." }, { kind: "state-codex-home", relativePath: [".state", "codex-queue", "codex-home"], reason: "Codex home contains sessions/auth/runtime profile state and is never selected by state artifact retention." }, { kind: "state-deploy-work", relativePath: [".state", "deploy", "work"], reason: "Deploy work state can contain active rollout context and is never selected by state artifact retention." }, { kind: "baidu-netdisk-state", relativePath: [".state", "baidu-netdisk"], reason: "Baidu Netdisk state and backups are protected; only the separate Baidu staging tarball allowlist can select old PGDATA tarballs." }, { kind: "runtime-snapshot", relativePath: [".state", "snapshots"], reason: "Runtime snapshots are protected unless a dedicated retention policy classifies them." }, ] as const; const VPN_DIAGNOSTIC_LOG_ROOT = "/root/vpn-server/logs"; const VPN_DIAGNOSTIC_RING_PCAP_PATTERN = /^hy2-(?:udp|monitor)-ring-\d{14}\.pcap$/u; const DEFAULT_PATH_SIZE_TIMEOUT_MS = 5_000; const STALE_TMP_PATH_SIZE_TIMEOUT_MS = 1_500; const STALE_TMP_MAX_CANDIDATES = 1_000; export async function runGcCommand(config: UniDeskConfig, args: string[]): Promise { const [action = "plan", ...rest] = args; if (action === "remote") { const [providerId, subaction = "plan", ...remoteArgs] = rest; return await runRemoteGcCommand(config, providerId, subaction, remoteArgs); } if (action === "policy") { const [subaction = "plan", ...policyArgs] = rest; const options = parseGcPolicyOptions(policyArgs); if (subaction === "plan" || subaction === "render" || subaction === "dry-run") return gcPolicyPlan(options); if (subaction === "install") return gcPolicyInstall(options); return { ok: false, error: "unsupported-gc-policy-action", action: subaction, supportedActions: ["plan", "render", "dry-run", "install"], }; } if (action === "db-trace" || action === "db-trace-retention") { const [subaction = "plan", ...dbArgs] = rest; const options = parseDbTraceGcOptions(dbArgs); if (subaction === "plan" || subaction === "dry-run") return gcDbTracePlan(options); if (subaction === "run") { if (!options.confirm) { return { ok: false, error: "gc-db-trace-run-requires-confirm", dryRun: true, mutation: false, requiredFlags: ["--confirm", "--before-date YYYY-MM-DD", "--vacuum-full"], planCommand: "bun scripts/cli.ts gc db-trace plan --before-date YYYY-MM-DD", runCommand: "bun scripts/cli.ts gc db-trace run --confirm --before-date YYYY-MM-DD --vacuum-full", }; } return gcDbTraceRun(options); } return { ok: false, error: "unsupported-gc-db-trace-action", action: subaction, supportedActions: ["plan", "run"], }; } if (action === "plan" || action === "dry-run") { return gcPlan(config, parseGcOptions(rest)); } if (action === "run") { const confirmIndex = rest.indexOf("--confirm"); if (confirmIndex === -1) { return { ok: false, error: "gc-run-requires-confirm", dryRun: true, mutation: false, requiredFlag: "--confirm", planCommand: "bun scripts/cli.ts gc plan", runCommand: "bun scripts/cli.ts gc run --confirm", }; } const runArgs = rest.filter((arg, index) => index !== confirmIndex); return gcRun(config, parseGcOptions(runArgs)); } return { ok: false, error: "unsupported-gc-action", action, supportedActions: ["plan", "run", "db-trace", "policy", "remote"], }; } export function gcPlan(config: UniDeskConfig, options: GcOptions | null = null): GcPlan { options = options ?? loadGcDefaultOptions(); const observedAt = new Date().toISOString(); const candidates: GcCandidate[] = []; const protectedItems: ProtectedGcItem[] = []; if (options.fileLogs) { candidates.push(...collectFileLogCandidates(config, options, observedAt)); } if (options.dockerLogs) { candidates.push(...collectDockerLogCandidates(options)); } if (options.journal) { const item = collectJournalCandidate(options); if (item !== null) candidates.push(item); } if (options.buildCache) { const item = collectBuildCacheCandidate(options); if (item !== null) candidates.push(item); } if (options.tmp) { candidates.push(...collectTmpCandidates(options, observedAt)); } if (options.staleTmp) { const alreadySelected = new Set(candidates.map((candidate) => candidate.path).filter((path): path is string => path !== undefined)); candidates.push(...collectStaleTmpCandidates(options, observedAt, alreadySelected)); } if (options.browserCache) { const item = collectBrowserCacheCandidate(); if (item !== null) candidates.push(item); } else { const path = rootPath(".state", "playwright-browsers"); if (existsSync(path)) { protectedItems.push({ kind: "browser-cache", risk: "blocked", ref: path, sizeBytes: protectedPathSize(path, options), reason: "Playwright browser cache is not removed by default; rerun with --include-browser-cache if this cache is approved for one-time cleanup.", }); } } if (options.toolCaches) { candidates.push(...collectToolCacheCandidates()); } else { protectedItems.push(...collectProtectedToolCaches(options)); } if (options.vscodeStaleServers) { candidates.push(...collectVscodeServerCandidates(options)); } else { if (existsSync(VSCODE_SERVER_ROOT)) { protectedItems.push({ kind: "vscode-server-cache", risk: "blocked", ref: VSCODE_SERVER_ROOT, sizeBytes: protectedPathSize(VSCODE_SERVER_ROOT, options), reason: "VS Code server versions are not removed by default; rerun with --include-vscode-stale-servers to keep only recent server versions.", }); } } if (options.vscodeStaleExtensions) { candidates.push(...collectVscodeExtensionCandidates(options)); } else { if (existsSync(VSCODE_EXTENSION_ROOT)) { protectedItems.push({ kind: "vscode-extension-cache", risk: "blocked", ref: VSCODE_EXTENSION_ROOT, sizeBytes: protectedPathSize(VSCODE_EXTENSION_ROOT, options), reason: "VS Code extension versions are not removed by default; rerun with --include-vscode-stale-extensions to keep only recent versions per extension.", }); } } if (options.vscodeCachedVsix) { candidates.push(...collectVscodeCachedVsixCandidates(options, observedAt)); } else { if (existsSync(VSCODE_CACHED_VSIX_ROOT)) { protectedItems.push({ kind: "vscode-cached-vsix-cache", risk: "blocked", ref: VSCODE_CACHED_VSIX_ROOT, sizeBytes: protectedPathSize(VSCODE_CACHED_VSIX_ROOT, options), reason: "VS Code CachedExtensionVSIXs download cache is not removed by default; rerun with --include-vscode-cached-vsix to remove stale cached VSIX files only.", }); } } if (options.baiduStaging) { candidates.push(...collectBaiduStagingCandidates(options, observedAt)); } if (options.stateArtifacts) { candidates.push(...collectStateArtifactCandidates(options, observedAt)); } if (options.stateStaleScratch) { candidates.push(...collectStateStaleScratchCandidates(options, observedAt)); } if (options.codexSessions) { candidates.push(...collectCodexSessionCandidates(options, observedAt)); } if (options.mergedWorktrees) { const worktreePlan = collectMergedWorktreeCandidates(options); candidates.push(...worktreePlan.candidates); protectedItems.push(...worktreePlan.protectedItems); } if (options.vpnDiagnosticLogs) { candidates.push(...collectVpnDiagnosticPcapCandidates(options, observedAt)); } protectedItems.push(...collectProtectedStateArtifacts(options)); protectedItems.push(...collectProtectedVpnDiagnosticLogs(options)); protectedItems.push(...collectProtectedStorage(config, options)); const databaseSummary = options.dbSummary ? collectDatabaseSummary() : { skipped: true, reason: "disabled-by-option" }; const allCandidates = candidates.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); const returnedCandidates = options.full ? allCandidates : allCandidates.slice(0, options.limit); const returnedProtectedItems = options.full ? protectedItems : protectedItems.slice(0, options.limit); const visibleCandidates = options.full ? returnedCandidates : returnedCandidates.map(compactCandidate); const visibleProtectedItems = options.full ? returnedProtectedItems : returnedProtectedItems.map(compactProtectedItem); const diskBefore = rootDiskSnapshot(); const summary = summarizeCandidates(allCandidates, returnedCandidates, protectedItems, returnedProtectedItems, diskBefore, options); return { ok: true, action: "gc plan", dryRun: true, mutation: false, observedAt, options: publicOptions(options), diskBefore, summary, candidates: visibleCandidates, protected: visibleProtectedItems, databaseSummary, policy: { requiresRunConfirm: true, runCommand: "bun scripts/cli.ts gc run --confirm", neverTouches: [ "Docker volumes", "PostgreSQL PGDATA", ".state/recovery", ".state/codex-queue/codex-home", ".state/deploy/work", ".state/baidu-netdisk", "Baidu Netdisk staging root by default", "D601 registry storage", "Docker images used by containers", "Codex auth/config state", "active or unmerged/dirty worktree/runtime image/snapshot state", ], notes: [ "gc run only executes listed one-time cleanup actions after --confirm.", options.full ? "Full candidate output requested." : `Default output is capped to ${options.limit} candidates; use --full or --limit N for broader disclosure.`, "Tool caches, stale /tmp direct children, stale VS Code server versions, stale VS Code extension versions and stale VS Code cached VSIX downloads are opt-in and require explicit include flags.", "Baidu Netdisk staging cleanup is opt-in and only selects old PGDATA backup tarballs under server-data/unidesk-pg-data.", "State cleanup is opt-in for manual plan/run; --include-state-artifacts selects historical diagnostic/deploy artifacts, and --include-state-stale-scratch selects only stale allowlisted scratch roots.", "Codex session cleanup is opt-in; --include-codex-sessions selects only stale session files under ~/.codex/sessions and never auth/config.", "Worktree cleanup is opt-in; --include-merged-worktrees plans inactive .worktree entries whose HEAD is merged into or cherry-equivalent to origin/master, then run rechecks full clean status before removal.", "VPN diagnostic pcap cleanup is opt-in and only selects stale hy2 ring pcap files; active pcap files and evidence JSONL are protected.", "Database event retention is diagnostic-only in this command; cleanups for oa_events require a backup and a separate schema/retention change.", "Docker image cleanup stays under server cleanup plan; gc does not run docker system prune or docker image prune.", ], }, }; } export function gcRun(config: UniDeskConfig, options: GcOptions | null = null): GcRunResult { options = options ?? loadGcDefaultOptions(); const plan = gcPlan(config, options); const diskBefore = plan.diskBefore; const results: GcRunResult["results"] = []; for (const candidate of plan.candidates) { try { const result = executeCandidate(candidate, options); results.push({ ...candidate, status: "succeeded", reclaimedBytes: result.reclaimedBytes, commandOutput: result.commandOutput }); } catch (error) { results.push({ ...candidate, status: "failed", reclaimedBytes: null, error: error instanceof Error ? error.message : String(error), }); } } const diskAfter = rootDiskSnapshot(); const failedCount = results.filter((item) => item.status === "failed").length; const estimatedReclaimBytes = plan.summary.returnedEstimatedReclaimBytes; const actualDiskReclaimBytes = diskBefore !== null && diskAfter !== null ? diskAfter.availableBytes - diskBefore.availableBytes : null; const returnedResults = returnedRunResults(results, options); return { ok: failedCount === 0, action: "gc run", dryRun: false, mutation: true, observedAt: new Date().toISOString(), options: publicOptions(options), diskBefore, diskAfter, summary: { plannedCandidateCount: plan.candidates.length, attemptedCount: results.length, succeededCount: results.length - failedCount, failedCount, estimatedReclaimBytes, actualDiskReclaimBytes, resultCount: results.length, returnedResultCount: returnedResults.length, omittedResultCount: Math.max(0, results.length - returnedResults.length), }, results: returnedResults, protected: plan.protected, }; } function parseGcOptions(args: string[]): GcOptions { const options: GcOptions = loadGcDefaultOptions(); for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; if (arg === "--logs-keep-days" || arg === "--file-log-keep-days") { options.fileLogKeepDays = parseNonNegativeNumber(arg, args[++index]); } else if (arg === "--file-log-max-bytes") { options.fileLogMaxBytes = parseSizeOption(arg, args[++index]); } else if (arg === "--file-log-tail-bytes") { options.fileLogTailBytes = parseSizeOption(arg, args[++index]); } else if (arg === "--docker-log-max-bytes") { options.dockerLogMaxBytes = parseSizeOption(arg, args[++index]); } else if (arg === "--journal-target-size") { options.journalTargetBytes = parseSizeOption(arg, args[++index]); } else if (arg === "--build-cache-until") { const value = args[++index]; if (!value || !/^\d+(s|m|h|d)$/u.test(value)) throw new Error(`${arg} must look like 24h, 7d, 30m or 60s`); options.buildCacheUntil = value; } else if (arg === "--build-cache-all") { options.buildCacheAll = true; } else if (arg === "--tmp-min-age-hours") { options.tmpMinAgeHours = parseNonNegativeNumber(arg, args[++index]); } else if (arg === "--include-stale-tmp") { options.staleTmp = true; } else if (arg === "--no-stale-tmp") { options.staleTmp = false; } else if (arg === "--include-browser-cache") { options.browserCache = true; } else if (arg === "--no-browser-cache") { options.browserCache = false; } else if (arg === "--include-tool-caches") { options.toolCaches = true; } else if (arg === "--no-tool-caches") { options.toolCaches = false; } else if (arg === "--include-vscode-stale-servers") { options.vscodeStaleServers = true; } else if (arg === "--no-vscode-stale-servers") { options.vscodeStaleServers = false; } else if (arg === "--vscode-keep-servers") { const value = parseNonNegativeNumber(arg, args[++index]); if (!Number.isInteger(value) || value <= 0) throw new Error("--vscode-keep-servers must be a positive integer"); options.vscodeKeepServers = Math.min(value, 20); } else if (arg === "--include-vscode-stale-extensions") { options.vscodeStaleExtensions = true; } else if (arg === "--no-vscode-stale-extensions") { options.vscodeStaleExtensions = false; } else if (arg === "--vscode-keep-extension-versions") { const value = parseNonNegativeNumber(arg, args[++index]); if (!Number.isInteger(value) || value <= 0) throw new Error("--vscode-keep-extension-versions must be a positive integer"); options.vscodeKeepExtensionVersions = Math.min(value, 20); } else if (arg === "--include-vscode-cached-vsix") { options.vscodeCachedVsix = true; } else if (arg === "--no-vscode-cached-vsix") { options.vscodeCachedVsix = false; } else if (arg === "--vscode-cached-vsix-keep-days") { options.vscodeCachedVsixKeepDays = parsePositiveIntegerOption(arg, args[++index], 365); } else if (arg === "--target-use-percent") { options.targetUsePercent = parseUsePercentOption(arg, args[++index]); } else if (arg === "--include-baidu-staging") { options.baiduStaging = true; } else if (arg === "--no-baidu-staging") { options.baiduStaging = false; } else if (arg === "--baidu-staging-keep-days") { options.baiduStagingKeepDays = parsePositiveIntegerOption(arg, args[++index], 3650); } else if (arg === "--include-state-artifacts") { options.stateArtifacts = true; } else if (arg === "--no-state-artifacts") { options.stateArtifacts = false; } else if (arg === "--state-artifact-keep-days") { options.stateArtifactKeepDays = parsePositiveIntegerOption(arg, args[++index], 3650); } else if (arg === "--include-state-stale-scratch" || arg === "--include-state-stale") { options.stateStaleScratch = true; } else if (arg === "--no-state-stale-scratch" || arg === "--no-state-stale") { options.stateStaleScratch = false; } else if (arg === "--state-stale-scratch-keep-hours" || arg === "--state-stale-keep-hours") { options.stateStaleScratchKeepHours = parsePositiveIntegerCliOption(arg, args[++index]); } else if (arg === "--include-codex-sessions") { options.codexSessions = true; } else if (arg === "--no-codex-sessions") { options.codexSessions = false; } else if (arg === "--codex-session-keep-hours") { options.codexSessionKeepHours = parsePositiveIntegerCliOption(arg, args[++index]); } else if (arg === "--include-merged-worktrees") { options.mergedWorktrees = true; } else if (arg === "--no-merged-worktrees") { options.mergedWorktrees = false; } else if (arg === "--worktree-keep-hours") { options.worktreeKeepHours = parsePositiveIntegerCliOption(arg, args[++index]); } else if (arg === "--worktree-scan-budget-ms") { options.worktreeScanBudgetMs = parsePositiveIntegerCliOption(arg, args[++index]); } else if (arg === "--worktree-cherry-check-timeout-ms") { options.worktreeCherryCheckTimeoutMs = parsePositiveIntegerCliOption(arg, args[++index]); } else if (arg === "--worktree-estimate-size-in-plan") { options.worktreeEstimateSizeInPlan = true; } else if (arg === "--no-worktree-estimate-size-in-plan") { options.worktreeEstimateSizeInPlan = false; } else if (arg === "--include-vpn-diagnostic-logs") { options.vpnDiagnosticLogs = true; } else if (arg === "--no-vpn-diagnostic-logs") { options.vpnDiagnosticLogs = false; } else if (arg === "--vpn-diagnostic-log-keep-hours") { options.vpnDiagnosticLogKeepHours = parsePositiveIntegerOption(arg, args[++index], 365 * 24); } else if (arg === "--no-file-logs" || arg === "--no-logs") { options.fileLogs = false; } else if (arg === "--no-docker-logs") { options.dockerLogs = false; } else if (arg === "--no-journal") { options.journal = false; } else if (arg === "--no-build-cache") { options.buildCache = false; } else if (arg === "--no-tmp") { options.tmp = false; } else if (arg === "--no-db-summary" || arg === "--no-db") { options.dbSummary = false; } else if (arg === "--limit") { const value = parseNonNegativeNumber(arg, args[++index]); if (!Number.isInteger(value) || value <= 0) throw new Error("--limit must be a positive integer"); options.limit = Math.min(value, 5000); } else if (arg === "--result-limit") { const value = parseNonNegativeNumber(arg, args[++index]); if (!Number.isInteger(value) || value <= 0) throw new Error("--result-limit must be a positive integer"); options.resultLimit = Math.min(value, 5000); } else if (arg === "--full" || arg === "--raw") { options.full = true; } else if (arg === "--confirm" || arg === "--dry-run") { // handled by caller or accepted as a no-op for plan compatibility } else { throw new Error(`unknown gc option: ${arg}`); } } if (options.fileLogTailBytes >= options.fileLogMaxBytes) { throw new Error("--file-log-tail-bytes must be smaller than --file-log-max-bytes"); } return options; } function loadGcDefaultOptions(): GcOptions { const configPath = rootPath(GC_CONFIG_RELATIVE_PATH); const parsed = yamlRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, GC_CONFIG_RELATIVE_PATH); const gc = yamlRecord(parsed.gc, GC_CONFIG_REF); const fileLogs = yamlRecord(gc.fileLogs, `${GC_CONFIG_REF}.fileLogs`); const dockerLogs = yamlRecord(gc.dockerLogs, `${GC_CONFIG_REF}.dockerLogs`); const journal = yamlRecord(gc.journal, `${GC_CONFIG_REF}.journal`); const buildCache = yamlRecord(gc.buildCache, `${GC_CONFIG_REF}.buildCache`); const tmp = yamlRecord(gc.tmp, `${GC_CONFIG_REF}.tmp`); const browserCache = yamlRecord(gc.browserCache, `${GC_CONFIG_REF}.browserCache`); const toolCaches = yamlRecord(gc.toolCaches, `${GC_CONFIG_REF}.toolCaches`); const vscode = yamlRecord(gc.vscode, `${GC_CONFIG_REF}.vscode`); const vscodeStaleServers = yamlRecord(vscode.staleServers, `${GC_CONFIG_REF}.vscode.staleServers`); const vscodeStaleExtensions = yamlRecord(vscode.staleExtensions, `${GC_CONFIG_REF}.vscode.staleExtensions`); const vscodeCachedVsix = yamlRecord(vscode.cachedVsix, `${GC_CONFIG_REF}.vscode.cachedVsix`); const baiduStaging = yamlRecord(gc.baiduStaging, `${GC_CONFIG_REF}.baiduStaging`); const stateArtifacts = yamlRecord(gc.stateArtifacts, `${GC_CONFIG_REF}.stateArtifacts`); const stateStaleScratch = yamlRecord(gc.stateStaleScratch, `${GC_CONFIG_REF}.stateStaleScratch`); const codexSessions = yamlRecord(gc.codexSessions, `${GC_CONFIG_REF}.codexSessions`); const mergedWorktrees = yamlRecord(gc.mergedWorktrees, `${GC_CONFIG_REF}.mergedWorktrees`); const vpnDiagnosticLogs = yamlRecord(gc.vpnDiagnosticLogs, `${GC_CONFIG_REF}.vpnDiagnosticLogs`); const databaseSummary = yamlRecord(gc.databaseSummary, `${GC_CONFIG_REF}.databaseSummary`); const output = yamlRecord(gc.output, `${GC_CONFIG_REF}.output`); return { fileLogs: yamlBoolean(fileLogs.enabled, `${GC_CONFIG_REF}.fileLogs.enabled`), fileLogKeepDays: yamlPositiveNumber(fileLogs.keepDays, `${GC_CONFIG_REF}.fileLogs.keepDays`), fileLogMaxBytes: yamlSize(fileLogs.maxBytes, `${GC_CONFIG_REF}.fileLogs.maxBytes`), fileLogTailBytes: yamlSize(fileLogs.tailBytes, `${GC_CONFIG_REF}.fileLogs.tailBytes`), dockerLogs: yamlBoolean(dockerLogs.enabled, `${GC_CONFIG_REF}.dockerLogs.enabled`), dockerLogMaxBytes: yamlSize(dockerLogs.maxBytes, `${GC_CONFIG_REF}.dockerLogs.maxBytes`), journal: yamlBoolean(journal.enabled, `${GC_CONFIG_REF}.journal.enabled`), journalTargetBytes: yamlSize(journal.targetBytes, `${GC_CONFIG_REF}.journal.targetBytes`), buildCache: yamlBoolean(buildCache.enabled, `${GC_CONFIG_REF}.buildCache.enabled`), buildCacheUntil: yamlDuration(buildCache.until, `${GC_CONFIG_REF}.buildCache.until`), buildCacheAll: yamlBoolean(buildCache.all, `${GC_CONFIG_REF}.buildCache.all`), tmp: yamlBoolean(tmp.enabled, `${GC_CONFIG_REF}.tmp.enabled`), tmpMinAgeHours: yamlNonNegativeNumber(tmp.minAgeHours, `${GC_CONFIG_REF}.tmp.minAgeHours`), staleTmp: yamlBoolean(tmp.includeStale, `${GC_CONFIG_REF}.tmp.includeStale`), browserCache: yamlBoolean(browserCache.enabled, `${GC_CONFIG_REF}.browserCache.enabled`), toolCaches: yamlBoolean(toolCaches.enabled, `${GC_CONFIG_REF}.toolCaches.enabled`), vscodeStaleServers: yamlBoolean(vscodeStaleServers.enabled, `${GC_CONFIG_REF}.vscode.staleServers.enabled`), vscodeKeepServers: yamlPositiveInteger(vscodeStaleServers.keepServers, `${GC_CONFIG_REF}.vscode.staleServers.keepServers`), vscodeStaleExtensions: yamlBoolean(vscodeStaleExtensions.enabled, `${GC_CONFIG_REF}.vscode.staleExtensions.enabled`), vscodeKeepExtensionVersions: yamlPositiveInteger(vscodeStaleExtensions.keepVersions, `${GC_CONFIG_REF}.vscode.staleExtensions.keepVersions`), vscodeCachedVsix: yamlBoolean(vscodeCachedVsix.enabled, `${GC_CONFIG_REF}.vscode.cachedVsix.enabled`), vscodeCachedVsixKeepDays: yamlPositiveInteger(vscodeCachedVsix.keepDays, `${GC_CONFIG_REF}.vscode.cachedVsix.keepDays`), baiduStaging: yamlBoolean(baiduStaging.enabled, `${GC_CONFIG_REF}.baiduStaging.enabled`), baiduStagingKeepDays: yamlPositiveInteger(baiduStaging.keepDays, `${GC_CONFIG_REF}.baiduStaging.keepDays`), stateArtifacts: yamlBoolean(stateArtifacts.enabled, `${GC_CONFIG_REF}.stateArtifacts.enabled`), stateArtifactKeepDays: yamlPositiveInteger(stateArtifacts.keepDays, `${GC_CONFIG_REF}.stateArtifacts.keepDays`), stateArtifactFileRoots: yamlStateRoots(stateArtifacts.fileRoots, `${GC_CONFIG_REF}.stateArtifacts.fileRoots`), stateArtifactDirRoots: yamlStateRoots(stateArtifacts.dirRoots, `${GC_CONFIG_REF}.stateArtifacts.dirRoots`), stateStaleScratch: yamlBoolean(stateStaleScratch.enabled, `${GC_CONFIG_REF}.stateStaleScratch.enabled`), stateStaleScratchKeepHours: yamlPositiveInteger(stateStaleScratch.keepHours, `${GC_CONFIG_REF}.stateStaleScratch.keepHours`), stateStaleScratchFileRoots: yamlStateRoots(stateStaleScratch.fileRoots, `${GC_CONFIG_REF}.stateStaleScratch.fileRoots`), stateStaleScratchDirRoots: yamlStateRoots(stateStaleScratch.dirRoots, `${GC_CONFIG_REF}.stateStaleScratch.dirRoots`), codexSessions: yamlBoolean(codexSessions.enabled, `${GC_CONFIG_REF}.codexSessions.enabled`), codexSessionKeepHours: yamlPositiveInteger(codexSessions.keepHours, `${GC_CONFIG_REF}.codexSessions.keepHours`), codexSessionRoot: yamlAbsoluteOrRepoPath(codexSessions.root, `${GC_CONFIG_REF}.codexSessions.root`), mergedWorktrees: yamlBoolean(mergedWorktrees.enabled, `${GC_CONFIG_REF}.mergedWorktrees.enabled`), worktreeKeepHours: yamlPositiveInteger(mergedWorktrees.keepHours, `${GC_CONFIG_REF}.mergedWorktrees.keepHours`), worktreeMainRoot: yamlAbsoluteOrRepoPath(mergedWorktrees.mainRoot, `${GC_CONFIG_REF}.mergedWorktrees.mainRoot`), worktreeRoot: yamlAbsoluteOrRepoPath(mergedWorktrees.root, `${GC_CONFIG_REF}.mergedWorktrees.root`), worktreeBaseRef: yamlString(mergedWorktrees.baseRef, `${GC_CONFIG_REF}.mergedWorktrees.baseRef`), worktreeScanBudgetMs: yamlPositiveInteger(mergedWorktrees.scanBudgetMs, `${GC_CONFIG_REF}.mergedWorktrees.scanBudgetMs`), worktreeCherryCheckTimeoutMs: yamlPositiveInteger(mergedWorktrees.cherryCheckTimeoutMs, `${GC_CONFIG_REF}.mergedWorktrees.cherryCheckTimeoutMs`), worktreeEstimateSizeInPlan: yamlBoolean(mergedWorktrees.estimateSizeInPlan, `${GC_CONFIG_REF}.mergedWorktrees.estimateSizeInPlan`), vpnDiagnosticLogs: yamlBoolean(vpnDiagnosticLogs.enabled, `${GC_CONFIG_REF}.vpnDiagnosticLogs.enabled`), vpnDiagnosticLogKeepHours: yamlPositiveInteger(vpnDiagnosticLogs.keepHours, `${GC_CONFIG_REF}.vpnDiagnosticLogs.keepHours`), dbSummary: yamlBoolean(databaseSummary.enabled, `${GC_CONFIG_REF}.databaseSummary.enabled`), limit: yamlPositiveInteger(output.limit, `${GC_CONFIG_REF}.output.limit`), resultLimit: yamlPositiveInteger(output.resultLimit, `${GC_CONFIG_REF}.output.resultLimit`), full: yamlBoolean(output.full, `${GC_CONFIG_REF}.output.full`), targetUsePercent: yamlOptionalUsePercent(gc.targetUsePercent, `${GC_CONFIG_REF}.targetUsePercent`), }; } function loadGcPolicyTimerConfig(): GcPolicyTimerConfig { const configPath = rootPath(GC_CONFIG_RELATIVE_PATH); const parsed = yamlRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, GC_CONFIG_RELATIVE_PATH); const gc = yamlRecord(parsed.gc, GC_CONFIG_REF); const policyTimer = yamlRecord(gc.policyTimer, `${GC_CONFIG_REF}.policyTimer`); const journald = yamlRecord(policyTimer.journald, `${GC_CONFIG_REF}.policyTimer.journald`); const daily = yamlRecord(policyTimer.daily, `${GC_CONFIG_REF}.policyTimer.daily`); const vpnDiagnosticLogs = yamlRecord(daily.vpnDiagnosticLogs, `${GC_CONFIG_REF}.policyTimer.daily.vpnDiagnosticLogs`); const stateArtifacts = yamlRecord(daily.stateArtifacts, `${GC_CONFIG_REF}.policyTimer.daily.stateArtifacts`); const vscodeCachedVsix = yamlRecord(daily.vscodeCachedVsix, `${GC_CONFIG_REF}.policyTimer.daily.vscodeCachedVsix`); return { journaldSystemMaxUseBytes: yamlSize(journald.systemMaxUse, `${GC_CONFIG_REF}.policyTimer.journald.systemMaxUse`), journaldRuntimeMaxUseBytes: yamlSize(journald.runtimeMaxUse, `${GC_CONFIG_REF}.policyTimer.journald.runtimeMaxUse`), journaldMaxRetentionSec: yamlString(journald.maxRetentionSec, `${GC_CONFIG_REF}.policyTimer.journald.maxRetentionSec`), buildCacheUntil: yamlDuration(daily.buildCacheUntil, `${GC_CONFIG_REF}.policyTimer.daily.buildCacheUntil`), vpnDiagnosticLogsEnabled: yamlBoolean(vpnDiagnosticLogs.enabled, `${GC_CONFIG_REF}.policyTimer.daily.vpnDiagnosticLogs.enabled`), vpnDiagnosticLogKeepHours: yamlPositiveInteger(vpnDiagnosticLogs.keepHours, `${GC_CONFIG_REF}.policyTimer.daily.vpnDiagnosticLogs.keepHours`), stateArtifactsEnabled: yamlBoolean(stateArtifacts.enabled, `${GC_CONFIG_REF}.policyTimer.daily.stateArtifacts.enabled`), stateArtifactKeepDays: yamlPositiveInteger(stateArtifacts.keepDays, `${GC_CONFIG_REF}.policyTimer.daily.stateArtifacts.keepDays`), vscodeCachedVsixEnabled: yamlBoolean(vscodeCachedVsix.enabled, `${GC_CONFIG_REF}.policyTimer.daily.vscodeCachedVsix.enabled`), vscodeCachedVsixKeepDays: yamlPositiveInteger(vscodeCachedVsix.keepDays, `${GC_CONFIG_REF}.policyTimer.daily.vscodeCachedVsix.keepDays`), limit: yamlPositiveInteger(daily.limit, `${GC_CONFIG_REF}.policyTimer.daily.limit`), resultLimit: yamlPositiveInteger(daily.resultLimit, `${GC_CONFIG_REF}.policyTimer.daily.resultLimit`), }; } function yamlRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`); return value as Record; } function yamlString(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`); if (value.includes("\0")) throw new Error(`${label} must not contain NUL`); return value; } function yamlBoolean(value: unknown, label: string): boolean { if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); return value; } function yamlNonNegativeNumber(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative number`); return value; } function yamlPositiveNumber(value: unknown, label: string): number { const number = yamlNonNegativeNumber(value, label); if (number <= 0) throw new Error(`${label} must be greater than 0`); return number; } function yamlPositiveInteger(value: unknown, label: string): number { const number = yamlPositiveNumber(value, label); if (!Number.isInteger(number)) throw new Error(`${label} must be an integer`); return number; } function yamlSize(value: unknown, label: string): number { if (typeof value === "number") { if (!Number.isFinite(value) || value <= 0) throw new Error(`${label} must be a positive byte count`); return Math.floor(value); } if (typeof value !== "string") throw new Error(`${label} must be a size string or positive byte count`); const parsed = parseSize(value); if (parsed === null || parsed <= 0) throw new Error(`${label} must be a positive size such as 512MiB or 50000000`); return parsed; } function yamlDuration(value: unknown, label: string): string { const duration = yamlString(value, label); if (!/^\d+(s|m|h|d)$/u.test(duration)) throw new Error(`${label} must look like 24h, 7d, 30m or 60s`); return duration; } function yamlOptionalUsePercent(value: unknown, label: string): number | null { if (value === null) return null; if (value === undefined) throw new Error(`${label} must be set to a number or null`); if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value >= 100) { throw new Error(`${label} must be greater than 0 and smaller than 100, or null`); } return value; } function yamlAbsoluteOrRepoPath(value: unknown, label: string): string { const path = yamlString(value, label); if (path.includes("..")) throw new Error(`${label} must not contain ..`); return resolvePath(path); } function yamlStateRoots(value: unknown, label: string): GcPathRoot[] { const record = yamlRecord(value, label); return Object.entries(record).map(([id, rawPath]) => { if (!/^[a-z0-9._-]+$/iu.test(id)) throw new Error(`${label}.${id} must use a simple id`); const displayPath = yamlString(rawPath, `${label}.${id}`); if (displayPath.startsWith("/") || displayPath.includes("..") || displayPath.includes("\\")) { throw new Error(`${label}.${id} must be a repo-relative .state path without ..`); } if (!displayPath.startsWith(".state/")) throw new Error(`${label}.${id} must start with .state/`); return { id, displayPath, path: rootPath(...displayPath.split("/")) }; }); } function parseDbTraceGcOptions(args: string[]): DbTraceGcOptions { const options: DbTraceGcOptions = { beforeDate: null, types: [...DEFAULT_DB_TRACE_TYPES], vacuumFull: false, confirm: false, }; for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; if (arg === "--before-date") { const value = args[++index]; if (!value || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) throw new Error("--before-date must be YYYY-MM-DD"); options.beforeDate = value; } else if (arg === "--type") { const value = args[++index]; if (!value || !/^[a-z0-9._:-]+$/iu.test(value)) throw new Error("--type must be a simple event type"); options.types.push(value); } else if (arg === "--only-default-trace-types") { options.types = [...DEFAULT_DB_TRACE_TYPES]; } else if (arg === "--vacuum-full") { options.vacuumFull = true; } else if (arg === "--confirm") { options.confirm = true; } else if (arg === "--dry-run") { // accepted for plan compatibility } else { throw new Error(`unknown gc db-trace option: ${arg}`); } } if (options.beforeDate === null) throw new Error("--before-date YYYY-MM-DD is required"); options.types = [...new Set(options.types)]; return options; } function parseGcPolicyOptions(args: string[]): GcPolicyOptions { const options: GcPolicyOptions = { dryRun: args.includes("--dry-run"), enableNow: !args.includes("--no-enable-now"), }; for (const arg of args) { if (arg === "--dry-run" || arg === "--no-enable-now") continue; throw new Error(`unknown gc policy option: ${arg}`); } return options; } function parseNonNegativeNumber(name: string, raw: string | undefined): number { const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`); return value; } function parseUsePercentOption(name: string, raw: string | undefined): number { const value = Number(raw); if (!Number.isFinite(value) || value <= 0 || value >= 100) throw new Error(`${name} must be greater than 0 and smaller than 100`); return value; } function parsePositiveIntegerOption(name: string, raw: string | undefined, max: number): number { const value = parseNonNegativeNumber(name, raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return Math.min(value, max); } function parsePositiveIntegerCliOption(name: string, raw: string | undefined): number { const value = parseNonNegativeNumber(name, raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return value; } function parseSizeOption(name: string, raw: string | undefined): number { const value = parseSize(raw ?? ""); if (value === null || value <= 0) throw new Error(`${name} must be a positive size such as 512M, 1GiB or 50000000`); return value; } function parseSize(raw: string): number | null { const match = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(b|k|kb|kib|m|mb|mib|g|gb|gib)?$/iu); if (!match) return null; const value = Number(match[1]); const unit = (match[2] ?? "b").toLowerCase(); const multiplier = unit === "g" || unit === "gb" || unit === "gib" ? 1024 ** 3 : unit === "m" || unit === "mb" || unit === "mib" ? 1024 ** 2 : unit === "k" || unit === "kb" || unit === "kib" ? 1024 : 1; const bytes = Math.floor(value * multiplier); return Number.isFinite(bytes) ? bytes : null; } function publicOptions(options: GcOptions): Record { return { configSource: GC_CONFIG_REF, fileLogs: options.fileLogs, fileLogKeepDays: options.fileLogKeepDays, fileLogMaxBytes: options.fileLogMaxBytes, fileLogTailBytes: options.fileLogTailBytes, dockerLogs: options.dockerLogs, dockerLogMaxBytes: options.dockerLogMaxBytes, journal: options.journal, journalTargetBytes: options.journalTargetBytes, buildCache: options.buildCache, buildCacheUntil: options.buildCacheUntil, buildCacheAll: options.buildCacheAll, tmp: options.tmp, tmpMinAgeHours: options.tmpMinAgeHours, staleTmp: options.staleTmp, browserCache: options.browserCache, toolCaches: options.toolCaches, vscodeStaleServers: options.vscodeStaleServers, vscodeKeepServers: options.vscodeKeepServers, vscodeStaleExtensions: options.vscodeStaleExtensions, vscodeKeepExtensionVersions: options.vscodeKeepExtensionVersions, vscodeCachedVsix: options.vscodeCachedVsix, vscodeCachedVsixKeepDays: options.vscodeCachedVsixKeepDays, baiduStaging: options.baiduStaging, baiduStagingKeepDays: options.baiduStagingKeepDays, stateArtifacts: options.stateArtifacts, stateArtifactKeepDays: options.stateArtifactKeepDays, stateArtifactFileRootCount: options.stateArtifactFileRoots.length, stateArtifactDirRootCount: options.stateArtifactDirRoots.length, stateStaleScratch: options.stateStaleScratch, stateStaleScratchKeepHours: options.stateStaleScratchKeepHours, stateStaleScratchFileRootCount: options.stateStaleScratchFileRoots.length, stateStaleScratchDirRootCount: options.stateStaleScratchDirRoots.length, codexSessions: options.codexSessions, codexSessionKeepHours: options.codexSessionKeepHours, codexSessionRoot: options.codexSessionRoot, mergedWorktrees: options.mergedWorktrees, worktreeKeepHours: options.worktreeKeepHours, worktreeMainRoot: options.worktreeMainRoot, worktreeRoot: options.worktreeRoot, worktreeBaseRef: options.worktreeBaseRef, worktreeScanBudgetMs: options.worktreeScanBudgetMs, worktreeCherryCheckTimeoutMs: options.worktreeCherryCheckTimeoutMs, worktreeEstimateSizeInPlan: options.worktreeEstimateSizeInPlan, vpnDiagnosticLogs: options.vpnDiagnosticLogs, vpnDiagnosticLogKeepHours: options.vpnDiagnosticLogKeepHours, dbSummary: options.dbSummary, limit: options.limit, resultLimit: options.resultLimit, full: options.full, targetUsePercent: options.targetUsePercent, }; } function collectFileLogCandidates(config: UniDeskConfig, options: GcOptions, observedAt: string): GcCandidate[] { const logsRoot = resolvePath(config.paths.logsDir); if (!existsSync(logsRoot)) return []; const cutoffMs = new Date(observedAt).getTime() - options.fileLogKeepDays * 24 * 60 * 60 * 1000; const files = collectFiles(logsRoot); const candidates: GcCandidate[] = []; for (const file of files) { if (!/\.(jsonl|log|txt)$/iu.test(file.path)) continue; if (file.sizeBytes <= 0) continue; const id = `file-log:${file.path}`; if (file.mtimeMs < cutoffMs) { candidates.push({ id, kind: "file-log-delete", risk: "medium", description: `Delete UniDesk file log older than ${options.fileLogKeepDays} days`, path: file.path, sizeBytes: file.sizeBytes, estimatedReclaimBytes: file.sizeBytes, action: { op: "unlink" }, }); } else if (file.sizeBytes > options.fileLogMaxBytes) { candidates.push({ id, kind: "file-log-compact", risk: "medium", description: `Compact large UniDesk file log to tail ${formatBytes(options.fileLogTailBytes)}`, path: file.path, sizeBytes: file.sizeBytes, estimatedReclaimBytes: Math.max(0, file.sizeBytes - options.fileLogTailBytes), action: { op: "keep-tail", tailBytes: options.fileLogTailBytes }, }); } } return candidates.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function collectDockerLogCandidates(options: GcOptions): GcCandidate[] { const containers = dockerContainers(); const candidates: GcCandidate[] = []; for (const container of containers) { if (!container.logPath || !existsSync(container.logPath)) continue; let stat; try { stat = statSync(container.logPath); } catch { continue; } if (stat.size <= options.dockerLogMaxBytes) continue; candidates.push({ id: `docker-json-log:${container.id}`, kind: "docker-json-log-truncate", risk: "medium", description: `Truncate Docker json-file log larger than ${formatBytes(options.dockerLogMaxBytes)}`, path: container.logPath, container: { id: container.id.slice(0, 12), name: container.name, image: container.image }, sizeBytes: stat.size, estimatedReclaimBytes: stat.size, action: { op: "truncate", targetBytes: 0 }, }); } return candidates.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function collectJournalCandidate(options: GcOptions): GcCandidate | null { const result = command(["journalctl", "--disk-usage"], 5000); if (result.exitCode !== 0) return null; const currentBytes = parseJournalUsage(result.stdout + result.stderr); if (currentBytes === null || currentBytes <= options.journalTargetBytes) return null; return { id: "journalctl:vacuum", kind: "journal-vacuum", risk: "medium", description: `Vacuum systemd journal to ${formatBytes(options.journalTargetBytes)}`, sizeBytes: currentBytes, estimatedReclaimBytes: Math.max(0, currentBytes - options.journalTargetBytes), action: { command: ["journalctl", `--vacuum-size=${options.journalTargetBytes}`] }, }; } function collectBuildCacheCandidate(options: GcOptions): GcCandidate | null { const result = command(["docker", "system", "df"], 8000); if (result.exitCode !== 0) return null; const cache = parseDockerSystemDfBuildCache(result.stdout); if (cache === null || cache.reclaimableBytes <= 0) return null; return { id: "docker-builder:prune", kind: "docker-build-cache-prune", risk: "low", description: options.buildCacheAll ? "Prune all Docker BuildKit cache" : `Prune all Docker BuildKit cache unused for ${options.buildCacheUntil}`, sizeBytes: cache.sizeBytes, estimatedReclaimBytes: cache.reclaimableBytes, action: { command: buildCachePruneCommand(options), estimate: "docker-system-df-reclaimable-upper-bound" }, }; } function collectTmpCandidates(options: GcOptions, observedAt: string): GcCandidate[] { const root = "/tmp"; if (!existsSync(root)) return []; const cutoffMs = new Date(observedAt).getTime() - options.tmpMinAgeHours * 60 * 60 * 1000; const result: GcCandidate[] = []; for (const entry of readdirSync(root, { withFileTypes: true })) { const name = entry.name; const path = join(root, name); if (TMP_EXACT_PROTECT.has(path)) continue; if (!TMP_PREFIX_ALLOWLIST.some((prefix) => name.startsWith(prefix))) continue; let stat; try { stat = lstatSync(path); } catch { continue; } if (stat.mtimeMs >= cutoffMs) continue; const sizeBytes = safePathSize(path, STALE_TMP_PATH_SIZE_TIMEOUT_MS); if (sizeBytes <= 0) continue; result.push({ id: `tmp:${path}`, kind: "tmp-path-delete", risk: "low", description: `Delete allowlisted /tmp path older than ${options.tmpMinAgeHours} hours`, path, sizeBytes, estimatedReclaimBytes: sizeBytes, action: { op: "rm-recursive", allowlist: "tmp-prefix" }, }); } return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function collectStaleTmpCandidates(options: GcOptions, observedAt: string, alreadySelected: Set): GcCandidate[] { const root = "/tmp"; if (!existsSync(root)) return []; const cutoffMs = new Date(observedAt).getTime() - options.tmpMinAgeHours * 60 * 60 * 1000; const candidateLimit = Math.min(options.limit, STALE_TMP_MAX_CANDIDATES); const result: GcCandidate[] = []; const dir = opendirSync(root); try { let entry; while ((entry = dir.readSync()) !== null && result.length < candidateLimit) { const name = entry.name; const path = join(root, name); if (alreadySelected.has(path)) continue; if (TMP_EXACT_PROTECT.has(path)) continue; if (isStaleTmpProtectedName(name)) continue; if (!entry.isDirectory() && !entry.isFile() && !entry.isSymbolicLink()) continue; let stat; try { stat = lstatSync(path); } catch { continue; } if (stat.mtimeMs >= cutoffMs) continue; const sizeBytes = safePathSize(path, STALE_TMP_PATH_SIZE_TIMEOUT_MS); if (sizeBytes <= 0) continue; result.push({ id: `stale-tmp:${path}`, kind: "stale-tmp-path-delete", risk: "medium", description: `Delete one bounded direct /tmp child older than ${options.tmpMinAgeHours} hours`, path, sizeBytes, estimatedReclaimBytes: sizeBytes, action: { op: "rm-recursive", allowlist: "tmp-direct-stale", minAgeHours: options.tmpMinAgeHours, boundedByLimit: candidateLimit }, }); } } finally { dir.closeSync(); } return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function isStaleTmpProtectedName(name: string): boolean { return STALE_TMP_PROTECTED_PREFIXES.some((prefix) => name.startsWith(prefix)); } function collectBrowserCacheCandidate(): GcCandidate | null { const path = rootPath(".state", "playwright-browsers"); if (!existsSync(path)) return null; const sizeBytes = safePathSize(path); if (sizeBytes <= 0) return null; return { id: `browser-cache:${path}`, kind: "browser-cache-delete", risk: "medium", description: "Delete repo-local Playwright browser cache", path, sizeBytes, estimatedReclaimBytes: sizeBytes, action: { op: "rm-recursive" }, }; } function collectToolCacheCandidates(): GcCandidate[] { const result: GcCandidate[] = []; for (const item of TOOL_CACHE_ALLOWLIST) { if (!existsSync(item.path)) continue; const sizeBytes = safePathSize(item.path); if (sizeBytes <= 0) continue; result.push({ id: `tool-cache:${item.id}`, kind: "tool-cache-delete", risk: "medium", description: item.description, path: item.path, sizeBytes, estimatedReclaimBytes: sizeBytes, action: { op: "rm-recursive", allowlist: "tool-cache" }, }); } return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function collectProtectedToolCaches(options: GcOptions): ProtectedGcItem[] { const result: ProtectedGcItem[] = []; for (const item of TOOL_CACHE_ALLOWLIST) { if (!existsSync(item.path)) continue; result.push({ kind: "tool-cache", risk: "blocked", ref: item.path, sizeBytes: protectedPathSize(item.path, options), reason: "Rebuildable tool cache is not removed by default; rerun with --include-tool-caches for explicit one-time cleanup.", }); } return result; } function collectVscodeServerCandidates(options: GcOptions): GcCandidate[] { if (!existsSync(VSCODE_SERVER_ROOT)) return []; const entries = readdirSync(VSCODE_SERVER_ROOT, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith("Stable-")) .flatMap((entry) => { const path = join(VSCODE_SERVER_ROOT, entry.name); try { const stat = lstatSync(path); return [{ path, name: entry.name, mtimeMs: stat.mtimeMs, sizeBytes: safePathSize(path) }]; } catch { return []; } }) .filter((entry) => entry.sizeBytes > 0) .sort((left, right) => right.mtimeMs - left.mtimeMs); const stale = entries.slice(options.vscodeKeepServers); return stale.map((entry) => ({ id: `vscode-server:${entry.name}`, kind: "vscode-server-delete", risk: "medium", description: `Delete stale VS Code server version while keeping ${options.vscodeKeepServers} newest versions`, path: entry.path, sizeBytes: entry.sizeBytes, estimatedReclaimBytes: entry.sizeBytes, action: { op: "rm-recursive", keepLatest: options.vscodeKeepServers, allowlist: "vscode-server-stable" }, })); } function collectVscodeExtensionCandidates(options: GcOptions): GcCandidate[] { if (!existsSync(VSCODE_EXTENSION_ROOT)) return []; const groups = new Map>(); for (const entry of readdirSync(VSCODE_EXTENSION_ROOT, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const extensionId = vscodeExtensionIdFromDirectory(entry.name); if (extensionId === null) continue; const path = join(VSCODE_EXTENSION_ROOT, entry.name); try { const stat = lstatSync(path); const sizeBytes = safePathSize(path); if (sizeBytes <= 0) continue; const current = groups.get(extensionId) ?? []; current.push({ path, name: entry.name, mtimeMs: stat.mtimeMs, sizeBytes }); groups.set(extensionId, current); } catch { // Ignore paths that disappear while gc is planning. } } const result: GcCandidate[] = []; for (const [extensionId, entries] of groups.entries()) { const stale = entries.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(options.vscodeKeepExtensionVersions); for (const entry of stale) { result.push({ id: `vscode-extension:${entry.name}`, kind: "vscode-extension-delete", risk: "medium", description: `Delete stale VS Code extension version for ${extensionId} while keeping ${options.vscodeKeepExtensionVersions} newest version(s)`, path: entry.path, sizeBytes: entry.sizeBytes, estimatedReclaimBytes: entry.sizeBytes, action: { op: "rm-recursive", keepLatest: options.vscodeKeepExtensionVersions, allowlist: "vscode-extension-version" }, }); } } return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function vscodeExtensionIdFromDirectory(name: string): string | null { const match = name.match(/^(.+)-(\d+\.\d[\w.+-]*)$/u); return match?.[1] ?? null; } function collectVscodeCachedVsixCandidates(options: GcOptions, observedAt: string): GcCandidate[] { if (!existsSync(VSCODE_CACHED_VSIX_ROOT)) return []; const cutoffMs = new Date(observedAt).getTime() - options.vscodeCachedVsixKeepDays * 24 * 60 * 60 * 1000; const result: GcCandidate[] = []; for (const entry of readdirSync(VSCODE_CACHED_VSIX_ROOT, { withFileTypes: true })) { if (!entry.isFile()) continue; if (vscodeExtensionIdFromDirectory(entry.name) === null) continue; const path = join(VSCODE_CACHED_VSIX_ROOT, entry.name); let stat; try { stat = lstatSync(path); } catch { continue; } if (!stat.isFile() || stat.isSymbolicLink() || stat.mtimeMs >= cutoffMs || stat.size <= 0) continue; result.push({ id: `vscode-cached-vsix:${entry.name}`, kind: "vscode-cached-vsix-delete", risk: "medium", description: `Delete stale VS Code CachedExtensionVSIXs download older than ${options.vscodeCachedVsixKeepDays} days`, path, sizeBytes: stat.size, estimatedReclaimBytes: stat.size, action: { op: "unlink", allowlist: "vscode-cached-vsix", keepDays: options.vscodeCachedVsixKeepDays, activeCheck: "fuser-before-delete" }, }); } return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function collectBaiduStagingCandidates(options: GcOptions, observedAt: string): GcCandidate[] { const root = rootPath(...BAIDU_STAGING_RELATIVE_ROOT); const pgdataRoot = join(root, "server-data", "unidesk-pg-data"); if (!existsSync(pgdataRoot)) return []; const cutoffMs = new Date(observedAt).getTime() - options.baiduStagingKeepDays * 24 * 60 * 60 * 1000; const result: GcCandidate[] = []; for (const month of readdirSync(pgdataRoot, { withFileTypes: true })) { if (!month.isDirectory() || !/^\d{6}$/u.test(month.name)) continue; const monthPath = join(pgdataRoot, month.name); for (const entry of readdirSync(monthPath, { withFileTypes: true })) { if (!entry.isFile() || !/\.pg_basebackup\.tar\.gz$/u.test(entry.name)) continue; const path = join(monthPath, entry.name); let stat; try { stat = lstatSync(path); } catch { continue; } if (stat.mtimeMs >= cutoffMs || stat.size <= 0) continue; result.push({ id: `baidu-staging:${month.name}/${entry.name}`, kind: "baidu-staging-file-delete", risk: "medium", description: `Delete local Baidu Netdisk PGDATA staging backup older than ${options.baiduStagingKeepDays} days`, path, sizeBytes: stat.size, estimatedReclaimBytes: stat.size, action: { op: "unlink", allowlist: "baidu-pgdata-staging", keepDays: options.baiduStagingKeepDays }, }); } } return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function collectStateArtifactCandidates(options: GcOptions, observedAt: string): GcCandidate[] { return [ ...collectStateArtifactFileCandidates(options, observedAt), ...collectStateArtifactDirCandidates(options, observedAt), ].sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function collectStateArtifactFileCandidates(options: GcOptions, observedAt: string): GcCandidate[] { const cutoffMs = new Date(observedAt).getTime() - options.stateArtifactKeepDays * 24 * 60 * 60 * 1000; const result: GcCandidate[] = []; for (const rootInfo of options.stateArtifactFileRoots) { const root = rootInfo.path; if (!isPlainDirectory(root)) continue; for (const file of collectFiles(root)) { if (file.mtimeMs >= cutoffMs || file.sizeBytes <= 0) continue; const relativePath = file.path.slice(resolve(root).length + 1); result.push({ id: `state-artifact-file:${rootInfo.id}:${relativePath}`, kind: "state-artifact-file-delete", risk: "medium", description: `Delete stale UniDesk .state artifact file older than ${options.stateArtifactKeepDays} days`, path: file.path, sizeBytes: file.sizeBytes, estimatedReclaimBytes: file.sizeBytes, action: { op: "unlink", allowlist: "state-artifact-file", root: rootInfo.displayPath, keepDays: options.stateArtifactKeepDays }, }); } } return result; } function collectStateArtifactDirCandidates(options: GcOptions, observedAt: string): GcCandidate[] { const cutoffMs = new Date(observedAt).getTime() - options.stateArtifactKeepDays * 24 * 60 * 60 * 1000; const result: GcCandidate[] = []; for (const rootInfo of options.stateArtifactDirRoots) { const root = rootInfo.path; if (!isPlainDirectory(root)) continue; for (const entry of readdirSync(root, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const path = join(root, entry.name); let stat; try { stat = lstatSync(path); } catch { continue; } if (!stat.isDirectory() || stat.isSymbolicLink() || stat.mtimeMs >= cutoffMs) continue; const sizeBytes = safePathSize(path); if (sizeBytes <= 0) continue; result.push({ id: `state-artifact-dir:${rootInfo.id}:${entry.name}`, kind: "state-artifact-dir-delete", risk: "medium", description: `Delete stale UniDesk .state deploy artifact directory older than ${options.stateArtifactKeepDays} days`, path, sizeBytes, estimatedReclaimBytes: sizeBytes, action: { op: "rm-recursive", allowlist: "state-artifact-direct-dir", root: rootInfo.displayPath, keepDays: options.stateArtifactKeepDays }, }); } } return result; } function collectStateStaleScratchCandidates(options: GcOptions, observedAt: string): GcCandidate[] { const dirCandidates = collectStateStaleScratchDirCandidates(options, observedAt); const selectedDirRoots = dirCandidates .map((candidate) => candidate.path) .filter((path): path is string => path !== undefined) .map((path) => `${resolve(path)}/`); const fileCandidates = collectStateStaleScratchFileCandidates(options, observedAt) .filter((candidate) => { if (candidate.path === undefined) return true; const resolved = resolve(candidate.path); return !selectedDirRoots.some((root) => resolved.startsWith(root)); }); return [...dirCandidates, ...fileCandidates].sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function collectStateStaleScratchFileCandidates(options: GcOptions, observedAt: string): GcCandidate[] { const cutoffMs = new Date(observedAt).getTime() - options.stateStaleScratchKeepHours * 60 * 60 * 1000; const result: GcCandidate[] = []; for (const rootInfo of options.stateStaleScratchFileRoots) { const root = rootInfo.path; if (!isPlainDirectory(root)) continue; for (const file of collectFiles(root)) { if (file.mtimeMs >= cutoffMs || file.sizeBytes <= 0) continue; const relativePath = file.path.slice(resolve(root).length + 1); result.push({ id: `state-stale-scratch-file:${rootInfo.id}:${relativePath}`, kind: "state-stale-scratch-file-delete", risk: "medium", description: `Delete stale UniDesk .state scratch file older than ${options.stateStaleScratchKeepHours} hours`, path: file.path, sizeBytes: file.sizeBytes, estimatedReclaimBytes: file.sizeBytes, action: { op: "unlink", allowlist: "state-stale-scratch-file", root: rootInfo.displayPath, keepHours: options.stateStaleScratchKeepHours }, }); } } return result; } function collectStateStaleScratchDirCandidates(options: GcOptions, observedAt: string): GcCandidate[] { const cutoffMs = new Date(observedAt).getTime() - options.stateStaleScratchKeepHours * 60 * 60 * 1000; const result: GcCandidate[] = []; for (const rootInfo of options.stateStaleScratchDirRoots) { const root = rootInfo.path; if (!isPlainDirectory(root)) continue; for (const entry of readdirSync(root, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const path = join(root, entry.name); let stat; try { stat = lstatSync(path); } catch { continue; } if (!stat.isDirectory() || stat.isSymbolicLink() || stat.mtimeMs >= cutoffMs) continue; const sizeBytes = safePathSize(path); if (sizeBytes <= 0) continue; result.push({ id: `state-stale-scratch-dir:${rootInfo.id}:${entry.name}`, kind: "state-stale-scratch-dir-delete", risk: "medium", description: `Delete stale UniDesk .state scratch directory older than ${options.stateStaleScratchKeepHours} hours`, path, sizeBytes, estimatedReclaimBytes: sizeBytes, action: { op: "rm-recursive", allowlist: "state-stale-scratch-direct-dir", root: rootInfo.displayPath, keepHours: options.stateStaleScratchKeepHours }, }); } } return result; } function collectCodexSessionCandidates(options: GcOptions, observedAt: string): GcCandidate[] { if (!isPlainDirectory(options.codexSessionRoot)) return []; const cutoffMs = new Date(observedAt).getTime() - options.codexSessionKeepHours * 60 * 60 * 1000; const result: GcCandidate[] = []; for (const file of collectFiles(options.codexSessionRoot)) { if (file.mtimeMs >= cutoffMs || file.sizeBytes <= 0) continue; const relativePath = file.path.slice(resolve(options.codexSessionRoot).length + 1); result.push({ id: `codex-session:${relativePath}`, kind: "codex-session-file-delete", risk: "medium", description: `Delete inactive Codex session file older than ${options.codexSessionKeepHours} hours`, path: file.path, sizeBytes: file.sizeBytes, estimatedReclaimBytes: file.sizeBytes, action: { op: "unlink", allowlist: "codex-session-file", keepHours: options.codexSessionKeepHours, valuesPrinted: false, activeCheck: "fuser-before-delete" }, }); } return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } interface MergedWorktreePlan { candidates: GcCandidate[]; protectedItems: ProtectedGcItem[]; } interface GitWorktreeEntry { path: string; head: string | null; branch: string | null; detached: boolean; bare: boolean; } interface GitMergeContext { gitRoot: string; baseRef: string; originMaster: string; reachableCommits: Set; cherryCheckTimeoutMs: number; } function collectMergedWorktreeCandidates(options: GcOptions): MergedWorktreePlan { const candidates: GcCandidate[] = []; const protectedItems: ProtectedGcItem[] = []; const mainRoot = resolve(options.worktreeMainRoot); const worktreeRoot = resolve(options.worktreeRoot); const currentRoot = resolve(repoRoot); const currentCwd = resolve(process.cwd()); const entries = gitWorktreeEntries(mainRoot); const mergeContext = gitMergeContext(mainRoot, options); const scanStartedAt = Date.now(); if (!mergeContext.ok) { return { candidates, protectedItems: entries .map((entry) => resolve(entry.path)) .filter((path) => path.startsWith(`${worktreeRoot}/`)) .map((path) => protectedPathItem("worktree-merge-context-error", path, mergeContext.reason)), }; } for (const entry of entries) { const path = resolve(entry.path); if (!path.startsWith(`${worktreeRoot}/`)) continue; if (Date.now() - scanStartedAt > options.worktreeScanBudgetMs) { protectedItems.push(protectedPathItem("worktree-scan-budget-exceeded", path, `Merged worktree scan exceeded YAML budget ${options.worktreeScanBudgetMs}ms; rerun with a larger config/unidesk-cli.yaml#gc.mergedWorktrees.scanBudgetMs or inspect this worktree directly.`)); continue; } if (path === currentRoot || path === currentCwd || path === resolve(mainRoot) || entry.bare) { protectedItems.push(protectedPathItem("active-worktree", path, "Active/current worktree is protected and is not selected for merged worktree cleanup.")); continue; } if (!isPlainDirectory(path)) { protectedItems.push(protectedPathItem("worktree-missing", path, "Worktree path is not a plain directory; use git worktree prune/manual audit rather than gc removal.")); continue; } if (worktreeHasRecentActivity(path, options.worktreeKeepHours)) { protectedItems.push({ kind: "recent-worktree", risk: "blocked", ref: path, reason: `Worktree has file or directory activity within ${options.worktreeKeepHours} hours.`, }); continue; } if (entry.head === null) { protectedItems.push(protectedPathItem("worktree-head-missing", path, "Worktree HEAD is missing from git worktree list output; gc will not remove it.")); continue; } const merge = gitCommitSemanticMergeStatus(mergeContext, entry.head); if (!merge.ok) { protectedItems.push(protectedPathItem("worktree-merge-check-error", path, merge.reason)); continue; } if (!merge.merged) { protectedItems.push({ kind: "unmerged-worktree", risk: "blocked", ref: path, reason: `Worktree HEAD is not merged into or cherry-equivalent to origin/master; unabsorbedCommitCount=${merge.unabsorbedCommitCount}.`, }); continue; } const sizeBytes = options.worktreeEstimateSizeInPlan ? safePathSize(path, 300) : 0; candidates.push({ id: `merged-worktree:${basename(path)}`, kind: "merged-worktree-remove", risk: "medium", description: `Remove clean merged UniDesk worktree inactive for at least ${options.worktreeKeepHours} hours`, path, sizeBytes, estimatedReclaimBytes: sizeBytes, action: { op: "git-worktree-remove", allowlist: "unidesk-dot-worktree", worktreeRoot, branch: entry.branch, head: entry.head, originMaster: merge.originMaster, mergeMode: merge.mode, planCleanCheck: "deferred-to-run", runCleanCheck: "full-status-with-untracked", sizeEstimate: options.worktreeEstimateSizeInPlan ? "du" : "deferred-to-run", minAgeHours: options.worktreeKeepHours, }, }); } return { candidates: candidates.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes), protectedItems, }; } function gitWorktreeEntries(mainRoot: string): GitWorktreeEntry[] { const result = command(["git", "-C", mainRoot, "worktree", "list", "--porcelain"], 10000); if (result.exitCode !== 0) return []; const entries: GitWorktreeEntry[] = []; let current: GitWorktreeEntry | null = null; for (const line of result.stdout.split(/\r?\n/u)) { if (line.length === 0) { if (current !== null) entries.push(current); current = null; continue; } const [key, ...rest] = line.split(" "); const value = rest.join(" "); if (key === "worktree") { if (current !== null) entries.push(current); current = { path: value, head: null, branch: null, detached: false, bare: false }; } else if (current !== null && key === "HEAD") { current.head = value; } else if (current !== null && key === "branch") { current.branch = value.replace(/^refs\/heads\//u, ""); } else if (current !== null && key === "detached") { current.detached = true; } else if (current !== null && key === "bare") { current.bare = true; } } if (current !== null) entries.push(current); return entries; } function worktreeHasRecentActivity(path: string, keepHours: number): boolean { try { const stat = lstatSync(path); const cutoffMs = Date.now() - keepHours * 60 * 60 * 1000; return stat.mtimeMs >= cutoffMs; } catch { return true; } } function gitWorktreeStatus(path: string, includeUntracked = true): { ok: true; clean: boolean; changedPathCount: number } | { ok: false; reason: string } { const untrackedMode = includeUntracked ? "all" : "no"; const result = command(["git", "-C", path, "status", "--porcelain=v1", `--untracked-files=${untrackedMode}`], 5000); if (result.exitCode !== 0 || result.timedOut) { return { ok: false, reason: result.timedOut ? "git status timed out" : result.stderr.trim() || `git status exited ${result.exitCode}` }; } const lines = result.stdout.trim().length === 0 ? [] : result.stdout.trim().split(/\r?\n/u); return { ok: true, clean: lines.length === 0, changedPathCount: lines.length }; } function gitWorktreeTrackedClean(path: string): { ok: true; clean: boolean } | { ok: false; reason: string } { const result = command(["git", "-C", path, "diff-index", "--quiet", "HEAD", "--"], 3000); if (result.exitCode === 0) return { ok: true, clean: true }; if (result.exitCode === 1) return { ok: true, clean: false }; return { ok: false, reason: result.timedOut ? "git diff-index timed out" : result.stderr.trim() || `git diff-index exited ${result.exitCode}` }; } function gitMergeContext(gitRoot: string, options: GcOptions): { ok: true } & GitMergeContext | { ok: false; reason: string } { const baseRef = options.worktreeBaseRef; const base = command(["git", "-C", gitRoot, "rev-parse", "--verify", `${baseRef}^{commit}`], 10000); if (base.exitCode !== 0 || base.timedOut) { return { ok: false, reason: base.timedOut ? `git rev-parse ${baseRef} timed out` : base.stderr.trim() || `cannot resolve ${baseRef}` }; } const reachable = command(["git", "-C", gitRoot, "rev-list", baseRef], 30000); if (reachable.exitCode !== 0 || reachable.timedOut) { return { ok: false, reason: reachable.timedOut ? `git rev-list ${baseRef} timed out` : reachable.stderr.trim() || `git rev-list ${baseRef} exited ${reachable.exitCode}` }; } return { ok: true, gitRoot, baseRef, originMaster: base.stdout.trim(), reachableCommits: new Set(reachable.stdout.trim().length === 0 ? [] : reachable.stdout.trim().split(/\r?\n/u)), cherryCheckTimeoutMs: options.worktreeCherryCheckTimeoutMs, }; } function gitWorktreeSemanticMergeStatus(path: string, options: GcOptions): { ok: true; merged: boolean; mode: "ancestor" | "cherry-equivalent" | "unmerged"; originMaster: string; unabsorbedCommitCount: number; } | { ok: false; reason: string } { const head = command(["git", "-C", path, "rev-parse", "--verify", "HEAD^{commit}"], 10000); if (head.exitCode !== 0 || head.timedOut) { return { ok: false, reason: head.timedOut ? "git rev-parse HEAD timed out" : head.stderr.trim() || "cannot resolve worktree HEAD" }; } const context = gitMergeContext(path, options); if (!context.ok) return context; return gitCommitSemanticMergeStatus(context, head.stdout.trim()); } function gitCommitSemanticMergeStatus(context: GitMergeContext, head: string): { ok: true; merged: boolean; mode: "ancestor" | "cherry-equivalent" | "unmerged"; originMaster: string; unabsorbedCommitCount: number; } | { ok: false; reason: string } { if (context.reachableCommits.has(head)) { return { ok: true, merged: true, mode: "ancestor", originMaster: context.originMaster, unabsorbedCommitCount: 0 }; } const cherry = command(["git", "-C", context.gitRoot, "rev-list", "--cherry-pick", "--right-only", "--count", `${context.baseRef}...${head}`], context.cherryCheckTimeoutMs); if (cherry.exitCode !== 0 || cherry.timedOut) { return { ok: false, reason: cherry.timedOut ? "git cherry-equivalence check timed out" : cherry.stderr.trim() || `git rev-list --cherry-pick exited ${cherry.exitCode}` }; } const unabsorbedCommitCount = Number(cherry.stdout.trim()); if (!Number.isFinite(unabsorbedCommitCount)) return { ok: false, reason: `git rev-list --cherry-pick returned non-numeric count: ${cherry.stdout.trim()}` }; return { ok: true, merged: unabsorbedCommitCount === 0, mode: unabsorbedCommitCount === 0 ? "cherry-equivalent" : "unmerged", originMaster: context.originMaster, unabsorbedCommitCount, }; } function collectVpnDiagnosticPcapCandidates(options: GcOptions, observedAt: string): GcCandidate[] { if (!existsSync(VPN_DIAGNOSTIC_LOG_ROOT)) return []; const cutoffMs = new Date(observedAt).getTime() - options.vpnDiagnosticLogKeepHours * 60 * 60 * 1000; const result: GcCandidate[] = []; for (const entry of readdirSync(VPN_DIAGNOSTIC_LOG_ROOT, { withFileTypes: true })) { if (!entry.isFile() || !VPN_DIAGNOSTIC_RING_PCAP_PATTERN.test(entry.name)) continue; const path = join(VPN_DIAGNOSTIC_LOG_ROOT, entry.name); let stat; try { stat = lstatSync(path); } catch { continue; } if (!stat.isFile() || stat.isSymbolicLink() || stat.mtimeMs >= cutoffMs || stat.size <= 0) continue; result.push({ id: `vpn-diagnostic-pcap:${entry.name}`, kind: "vpn-diagnostic-pcap-delete", risk: "medium", description: `Delete stale VPN diagnostic ring pcap older than ${options.vpnDiagnosticLogKeepHours} hours`, path, sizeBytes: stat.size, estimatedReclaimBytes: stat.size, action: { op: "unlink", allowlist: "vpn-diagnostic-ring-pcap", keepHours: options.vpnDiagnosticLogKeepHours, activeCheck: "fuser-before-delete" }, }); } return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } function collectProtectedStateArtifacts(options: GcOptions): ProtectedGcItem[] { const result: ProtectedGcItem[] = []; for (const rootInfo of [...options.stateArtifactFileRoots, ...options.stateArtifactDirRoots]) { const ref = rootInfo.path; result.push(protectedPathItem( options.stateArtifacts ? "state-artifact-root" : "state-artifact-retention-disabled", ref, options.stateArtifacts ? "State artifact root is protected as a root; only stale allowlisted files or direct deploy artifact directories are candidates." : "State artifact retention is disabled for manual gc by default; rerun with --include-state-artifacts to apply the bounded allowlist.", )); } for (const rootInfo of [...options.stateStaleScratchFileRoots, ...options.stateStaleScratchDirRoots]) { result.push(protectedPathItem( options.stateStaleScratch ? "state-stale-scratch-root" : "state-stale-scratch-disabled", rootInfo.path, options.stateStaleScratch ? "State stale scratch root is protected as a root; only stale allowlisted files or direct child scratch directories are candidates." : "State stale scratch cleanup is disabled for manual gc by default; rerun with --include-state-stale-scratch to apply the YAML allowlist.", )); } for (const item of PROTECTED_STATE_PATHS) { result.push(protectedPathItem(item.kind, rootPath(...item.relativePath), item.reason)); } result.push( protectedPathItem( options.codexSessions ? "codex-session-root" : "codex-sessions-disabled", options.codexSessionRoot, options.codexSessions ? "Codex session root is protected as a root; only inactive session files under the YAML root are candidates." : "Codex sessions are not selected by UniDesk gc unless --include-codex-sessions is set.", ), protectedPathItem("codex-auth", "/root/.codex/auth.json", "Codex auth state is protected and is not selected by UniDesk gc."), protectedPathItem("active-worktree", repoRoot, "The active UniDesk worktree is protected; gc never deletes source worktrees as state artifacts."), protectedPathItem("runtime-image", "docker-images-used-by-containers", "Runtime Docker images are protected; image cleanup stays under server cleanup plan and container image guards."), ); return result; } function protectedPathItem(kind: string, ref: string, reason: string): ProtectedGcItem { return { kind, risk: "blocked", ref, reason, }; } function collectProtectedVpnDiagnosticLogs(options: GcOptions): ProtectedGcItem[] { const result: ProtectedGcItem[] = []; if (!existsSync(VPN_DIAGNOSTIC_LOG_ROOT)) return result; if (existsSync(VPN_DIAGNOSTIC_LOG_ROOT)) { result.push({ kind: options.vpnDiagnosticLogs ? "vpn-diagnostic-log-root" : "vpn-diagnostic-log", risk: "blocked", ref: VPN_DIAGNOSTIC_LOG_ROOT, sizeBytes: protectedPathSize(VPN_DIAGNOSTIC_LOG_ROOT, options), reason: options.vpnDiagnosticLogs ? "VPN diagnostic log root is protected; only stale hy2 ring pcap files are candidate files." : "VPN diagnostic logs are not removed by default; rerun with --include-vpn-diagnostic-logs to remove only stale hy2 ring pcap files.", }); } const evidencePath = join(VPN_DIAGNOSTIC_LOG_ROOT, "hy2-server-evidence.jsonl"); if (existsSync(evidencePath)) { result.push({ kind: "vpn-diagnostic-evidence-log", risk: "blocked", ref: evidencePath, sizeBytes: protectedFileSize(evidencePath, options), reason: "Evidence JSONL is an active diagnostic stream and is not removed by gc pcap retention.", }); } return result; } function collectProtectedStorage(config: UniDeskConfig, options: GcOptions): ProtectedGcItem[] { const result: ProtectedGcItem[] = [ { kind: "docker-volume", risk: "blocked", ref: config.database.volume, reason: "PostgreSQL PGDATA is protected; database cleanup requires verified backup and a schema-aware retention plan.", }, { kind: "policy", risk: "blocked", ref: "docker-images-and-volumes", reason: "gc does not remove Docker images, containers, volumes or Compose projects.", }, ]; const baiduStaging = rootPath(".state", "baidu-netdisk", "staging"); if (existsSync(baiduStaging)) { result.push({ kind: options.baiduStaging ? "baidu-netdisk-staging-root" : "baidu-netdisk-staging", risk: "blocked", ref: baiduStaging, sizeBytes: protectedPathSize(baiduStaging, options), reason: options.baiduStaging ? "Baidu Netdisk staging root is protected; only selected old PGDATA backup tarballs are candidate files." : "Baidu Netdisk staging may contain backups or transfer state and is not touched by gc unless --include-baidu-staging is set.", }); } return result; } function collectDatabaseSummary(): unknown { const dbSize = psql("SELECT pg_size_pretty(pg_database_size(current_database())) AS database_size;"); const tables = psql( "SELECT nspname || '.' || relname AS relation, pg_total_relation_size(c.oid)::text AS bytes, pg_size_pretty(pg_total_relation_size(c.oid)) AS size FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r','m','t') AND n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY pg_total_relation_size(c.oid) DESC LIMIT 10;", ); return { mutation: false, note: "database is diagnostic-only in gc; oa_events cleanup is intentionally not automated", databaseSize: dbSize.ok ? dbSize.lines[0] ?? null : null, largestRelations: tables.ok ? tables.lines.map((line) => { const [relation, bytes, size] = line.split("|"); return { relation, bytes: Number(bytes), size }; }) : [], errors: [dbSize, tables].filter((item) => !item.ok).map((item) => item.error), }; } function psql(sql: string, timeoutMs = 6000): { ok: true; lines: string[] } | { ok: false; error: string } { const result = psqlCommand(sql, timeoutMs); if (result.timedOut) return { ok: false, error: `psql timed out after ${timeoutMs}ms` }; if (result.exitCode !== 0) return { ok: false, error: result.stderr.trim() || `psql exited ${result.exitCode}` }; return { ok: true, lines: result.stdout.trim().length === 0 ? [] : result.stdout.trim().split(/\r?\n/u) }; } function psqlCommand(sql: string, timeoutMs = 6000): { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean } { return command([ "docker", "exec", "unidesk-database", "psql", "-U", "unidesk", "-d", "unidesk", "-v", "ON_ERROR_STOP=1", "-Atc", sql, ], timeoutMs); } function gcDbTracePlan(options: DbTraceGcOptions): unknown { const beforeDateSql = sqlLiteral(options.beforeDate ?? ""); const typesSql = sqlStringArray(options.types); const matches = psql( `SELECT count(*)::text, coalesce(sum(pg_column_size(payload)),0)::text, pg_size_pretty(coalesce(sum(pg_column_size(payload)),0)) FROM public.oa_events WHERE created_at < ${beforeDateSql}::timestamptz AND type = ANY (${typesSql}::text[]);`, 120000, ); const remaining = psql( `SELECT count(*)::text, coalesce(sum(pg_column_size(payload)),0)::text, pg_size_pretty(coalesce(sum(pg_column_size(payload)),0)) FROM public.oa_events WHERE NOT (created_at < ${beforeDateSql}::timestamptz AND type = ANY (${typesSql}::text[]));`, 120000, ); const tableSize = psql("SELECT pg_total_relation_size('public.oa_events')::text, pg_size_pretty(pg_total_relation_size('public.oa_events'));"); return { ok: matches.ok && remaining.ok && tableSize.ok, action: "gc db-trace plan", dryRun: true, mutation: false, observedAt: new Date().toISOString(), options: { beforeDate: options.beforeDate, types: options.types, vacuumFull: options.vacuumFull }, diskBefore: rootDiskSnapshot(), target: parseCountBytes(matches), remaining: parseCountBytes(remaining), oaEventsTable: parseBytesPretty(tableSize), policy: { requiresRunConfirm: true, requiresVacuumFullForDfReclaim: true, runCommand: `bun scripts/cli.ts gc db-trace run --confirm --before-date ${options.beforeDate} --vacuum-full`, backupRequired: "Verify recent PostgreSQL basebackup before running.", scope: "Deletes only selected trace telemetry event types from public.oa_events before beforeDate.", }, errors: [matches, remaining, tableSize].filter((item) => !item.ok).map((item) => (item as { error: string }).error), }; } function gcDbTraceRun(options: DbTraceGcOptions): unknown { if (!options.vacuumFull) { return { ok: false, error: "gc-db-trace-run-requires-vacuum-full", dryRun: true, mutation: false, reason: "Plain DELETE frees rows for PostgreSQL reuse but usually does not lower df; --vacuum-full is required for the explicit disk relief path.", runCommand: `bun scripts/cli.ts gc db-trace run --confirm --before-date ${options.beforeDate} --vacuum-full`, }; } const plan = gcDbTracePlan(options); const before = rootDiskSnapshot(); const beforeDateSql = sqlLiteral(options.beforeDate ?? ""); const typesSql = sqlStringArray(options.types); const deleted = psqlCommand( `DELETE FROM public.oa_events WHERE created_at < ${beforeDateSql}::timestamptz AND type = ANY (${typesSql}::text[]);`, 10 * 60 * 1000, ); if (deleted.timedOut || deleted.exitCode !== 0) { return { ok: false, action: "gc db-trace run", dryRun: false, mutation: true, observedAt: new Date().toISOString(), options: { beforeDate: options.beforeDate, types: options.types, vacuumFull: options.vacuumFull }, diskBefore: before, error: deleted.timedOut ? "delete timed out" : deleted.stderr.trim() || `delete exited ${deleted.exitCode}`, plan, }; } const vacuum = command([ "docker", "exec", "unidesk-database", "psql", "-U", "unidesk", "-d", "unidesk", "-c", "VACUUM (FULL, ANALYZE) public.oa_events;", ], 20 * 60 * 1000); const after = rootDiskSnapshot(); return { ok: vacuum.exitCode === 0, action: "gc db-trace run", dryRun: false, mutation: true, observedAt: new Date().toISOString(), options: { beforeDate: options.beforeDate, types: options.types, vacuumFull: options.vacuumFull }, diskBefore: before, diskAfter: after, summary: { deletedRows: parseDeleteCount(deleted.stdout), actualDiskReclaimBytes: before !== null && after !== null ? after.availableBytes - before.availableBytes : null, }, vacuum: boundedCommandOutput(vacuum), plan, }; } function gcPolicyPlan(options: GcPolicyOptions): unknown { const timerConfig = loadGcPolicyTimerConfig(); const files = gcPolicyFiles(); return { ok: true, action: "gc policy plan", dryRun: true, mutation: false, observedAt: new Date().toISOString(), options, files, commands: { install: [ ["mkdir", "-p", "/etc/systemd/journald.conf.d", "/etc/systemd/system"], ["systemctl", "daemon-reload"], ["systemctl", "enable", "--now", "unidesk-gc.timer"], ], journalRestart: ["systemctl", "restart", "systemd-journald"], }, policy: { configSource: `${GC_CONFIG_REF}.policyTimer`, safeScope: [ "systemd journal caps are rendered from YAML policyTimer.journald", `daily timer runs YAML-configured file-log, Docker json logs, ${timerConfig.buildCacheUntil} BuildKit cache, allowlisted /tmp gc, VPN diagnostic pcap retention, UniDesk .state artifact retention and VS Code CachedExtensionVSIXs retention`, "timer does not touch PostgreSQL PGDATA, Docker images, Docker volumes, tool caches, installed VS Code servers/extensions, VS Code user data or Baidu Netdisk staging", "timer output is redirected under .state/gc and capped by gc --result-limit", ], manualDbRetention: "gc db-trace remains explicit maintenance and is not scheduled automatically.", }, }; } function gcPolicyInstall(options: GcPolicyOptions): unknown { const plan = gcPolicyPlan(options); if (options.dryRun) return plan; const files = gcPolicyFiles(); mkdirSync("/etc/systemd/journald.conf.d", { recursive: true }); mkdirSync("/etc/systemd/system", { recursive: true }); mkdirSync(rootPath(".state", "gc"), { recursive: true }); writeFileSync(files.journald.path, files.journald.content, "utf8"); writeFileSync(files.service.path, files.service.content, "utf8"); writeFileSync(files.timer.path, files.timer.content, "utf8"); const daemonReload = command(["systemctl", "daemon-reload"], 15000); const enableTimer = options.enableNow ? command(["systemctl", "enable", "--now", "unidesk-gc.timer"], 15000) : null; const restartJournald = command(["systemctl", "restart", "systemd-journald"], 15000); const timerStatus = command(["systemctl", "is-enabled", "unidesk-gc.timer"], 5000); const activeStatus = command(["systemctl", "is-active", "unidesk-gc.timer"], 5000); const results = { daemonReload: boundedCommandOutput(daemonReload), enableTimer: enableTimer === null ? { skipped: true } : boundedCommandOutput(enableTimer), restartJournald: boundedCommandOutput(restartJournald), timerStatus: { enabled: timerStatus.stdout.trim(), active: activeStatus.stdout.trim(), }, }; const failed = daemonReload.exitCode !== 0 || restartJournald.exitCode !== 0 || (enableTimer !== null && enableTimer.exitCode !== 0); return { ok: !failed, action: "gc policy install", dryRun: false, mutation: true, observedAt: new Date().toISOString(), options, files, results, plan, }; } function gcPolicyFiles(): Record { const timerConfig = loadGcPolicyTimerConfig(); const gcStateDir = rootPath(".state", "gc"); const bunPath = bunExecutablePath(); const gcArgs = [ "scripts/cli.ts", "gc", "run", "--confirm", "--no-db-summary", "--no-journal", "--build-cache-until", timerConfig.buildCacheUntil, ]; if (timerConfig.vpnDiagnosticLogsEnabled) gcArgs.push("--include-vpn-diagnostic-logs", "--vpn-diagnostic-log-keep-hours", String(timerConfig.vpnDiagnosticLogKeepHours)); if (timerConfig.stateArtifactsEnabled) gcArgs.push("--include-state-artifacts", "--state-artifact-keep-days", String(timerConfig.stateArtifactKeepDays)); if (timerConfig.vscodeCachedVsixEnabled) gcArgs.push("--include-vscode-cached-vsix", "--vscode-cached-vsix-keep-days", String(timerConfig.vscodeCachedVsixKeepDays)); gcArgs.push("--limit", String(timerConfig.limit), "--result-limit", String(timerConfig.resultLimit)); const gcScript = `cd ${shellQuote(repoRoot)} && mkdir -p ${shellQuote(gcStateDir)} && ${shellQuote(bunPath)} ${gcArgs.map(shellQuote).join(" ")} > ${shellQuote(join(gcStateDir, "last-run.json"))} 2> ${shellQuote(join(gcStateDir, "last-run.stderr"))}`; return { journald: { path: "/etc/systemd/journald.conf.d/unidesk-gc.conf", content: [ "[Journal]", `SystemMaxUse=${timerConfig.journaldSystemMaxUseBytes}`, `RuntimeMaxUse=${timerConfig.journaldRuntimeMaxUseBytes}`, `MaxRetentionSec=${timerConfig.journaldMaxRetentionSec}`, "", ].join("\n"), }, service: { path: "/etc/systemd/system/unidesk-gc.service", content: [ "[Unit]", "Description=UniDesk low-risk disk garbage collection", "Documentation=file:///root/unidesk/docs/reference/cli.md", "", "[Service]", "Type=oneshot", `WorkingDirectory=${repoRoot}`, `ExecStart=/bin/bash -lc "${systemdDoubleQuoted(gcScript)}"`, "", ].join("\n"), }, timer: { path: "/etc/systemd/system/unidesk-gc.timer", content: [ "[Unit]", "Description=Daily UniDesk low-risk disk garbage collection", "", "[Timer]", "OnCalendar=*-*-* 03:20:00", "RandomizedDelaySec=20m", "Persistent=true", "", "[Install]", "WantedBy=timers.target", "", ].join("\n"), }, }; } function bunExecutablePath(): string { const candidates = ["/usr/bin/bun", "/root/.bun/bin/bun", process.argv[0] ?? ""]; for (const candidate of candidates) { if (candidate.length > 0 && existsSync(candidate)) return resolve(candidate); } return "bun"; } function systemdDoubleQuoted(value: string): string { return value .replace(/\\/gu, "\\\\") .replace(/"/gu, "\\\"") .replace(/%/gu, "%%"); } function shellQuote(value: string): string { return `'${value.replace(/'/gu, "'\\''")}'`; } function sqlLiteral(value: string): string { return `'${value.replace(/'/gu, "''")}'`; } function sqlStringArray(values: string[]): string { return `ARRAY[${values.map(sqlLiteral).join(",")}]`; } function parseCountBytes(result: ReturnType): unknown { if (!result.ok) return null; const [count, bytes, pretty] = (result.lines[0] ?? "0|0|0 B").split("|"); return { count: Number(count), payloadBytes: Number(bytes), payloadSize: pretty }; } function parseBytesPretty(result: ReturnType): unknown { if (!result.ok) return null; const [bytes, pretty] = (result.lines[0] ?? "0|0 B").split("|"); return { bytes: Number(bytes), size: pretty }; } function parseDeleteCount(output: string): number | null { const match = output.match(/DELETE\s+(\d+)/u); return match ? Number(match[1]) : null; } function executeCandidate(candidate: GcCandidate, options: GcOptions): { reclaimedBytes: number | null; commandOutput?: unknown } { if (candidate.kind === "file-log-delete" && candidate.path !== undefined) { const before = safeFileSize(candidate.path); unlinkSync(candidate.path); return { reclaimedBytes: before }; } if (candidate.kind === "file-log-compact" && candidate.path !== undefined) { return { reclaimedBytes: keepFileTail(candidate.path, options.fileLogTailBytes) }; } if (candidate.kind === "docker-json-log-truncate" && candidate.path !== undefined) { const before = safeFileSize(candidate.path); ftruncateFile(candidate.path, 0); return { reclaimedBytes: before }; } if (candidate.kind === "tmp-path-delete" && candidate.path !== undefined) { assertTmpCandidatePath(candidate.path); const before = safePathSize(candidate.path); rmSync(candidate.path, { recursive: true, force: true }); return { reclaimedBytes: before }; } if (candidate.kind === "stale-tmp-path-delete" && candidate.path !== undefined) { assertStaleTmpCandidatePath(candidate.path); const before = safePathSize(candidate.path); rmSync(candidate.path, { recursive: true, force: true }); return { reclaimedBytes: before }; } if (candidate.kind === "browser-cache-delete" && candidate.path !== undefined) { const expected = rootPath(".state", "playwright-browsers"); if (resolve(candidate.path) !== resolve(expected)) throw new Error(`refusing to remove unexpected browser cache path: ${candidate.path}`); const before = safePathSize(candidate.path); rmSync(candidate.path, { recursive: true, force: true }); return { reclaimedBytes: before }; } if (candidate.kind === "tool-cache-delete" && candidate.path !== undefined) { assertToolCacheCandidatePath(candidate.path); const before = safePathSize(candidate.path); rmSync(candidate.path, { recursive: true, force: true }); return { reclaimedBytes: before }; } if (candidate.kind === "vscode-server-delete" && candidate.path !== undefined) { assertVscodeServerCandidatePath(candidate.path); const before = safePathSize(candidate.path); rmSync(candidate.path, { recursive: true, force: true }); return { reclaimedBytes: before }; } if (candidate.kind === "vscode-extension-delete" && candidate.path !== undefined) { assertVscodeExtensionCandidatePath(candidate.path); const before = safePathSize(candidate.path); rmSync(candidate.path, { recursive: true, force: true }); return { reclaimedBytes: before }; } if (candidate.kind === "vscode-cached-vsix-delete" && candidate.path !== undefined) { assertVscodeCachedVsixCandidatePath(candidate.path); assertPathNotOpen(candidate.path); const before = safeFileSize(candidate.path); unlinkSync(candidate.path); return { reclaimedBytes: before }; } if (candidate.kind === "baidu-staging-file-delete" && candidate.path !== undefined) { assertBaiduStagingCandidatePath(candidate.path); const before = safeFileSize(candidate.path); unlinkSync(candidate.path); return { reclaimedBytes: before }; } if (candidate.kind === "state-artifact-file-delete" && candidate.path !== undefined) { assertStateArtifactFileCandidatePath(candidate.path, options); const before = safeFileSize(candidate.path); unlinkSync(candidate.path); return { reclaimedBytes: before }; } if (candidate.kind === "state-artifact-dir-delete" && candidate.path !== undefined) { assertStateArtifactDirCandidatePath(candidate.path, options); const before = safePathSize(candidate.path); rmSync(candidate.path, { recursive: true, force: true }); return { reclaimedBytes: before }; } if (candidate.kind === "state-stale-scratch-file-delete" && candidate.path !== undefined) { assertStateStaleScratchFileCandidatePath(candidate.path, options); const before = safeFileSize(candidate.path); unlinkSync(candidate.path); return { reclaimedBytes: before }; } if (candidate.kind === "state-stale-scratch-dir-delete" && candidate.path !== undefined) { assertStateStaleScratchDirCandidatePath(candidate.path, options); const before = safePathSize(candidate.path); rmSync(candidate.path, { recursive: true, force: true }); return { reclaimedBytes: before }; } if (candidate.kind === "codex-session-file-delete" && candidate.path !== undefined) { assertCodexSessionCandidatePath(candidate.path, options); assertPathNotOpen(candidate.path); const before = safeFileSize(candidate.path); unlinkSync(candidate.path); pruneEmptyDirectories(dirname(candidate.path), options.codexSessionRoot); return { reclaimedBytes: before }; } if (candidate.kind === "merged-worktree-remove" && candidate.path !== undefined) { assertMergedWorktreeCandidatePath(candidate.path, options); const before = safePathSize(candidate.path, 15000); const result = command(["git", "-C", options.worktreeMainRoot, "worktree", "remove", "--", candidate.path], 60000); if (result.exitCode !== 0 || result.timedOut) { throw new Error(result.timedOut ? "git worktree remove timed out" : result.stderr.trim() || `git worktree remove exited ${result.exitCode}`); } return { reclaimedBytes: before, commandOutput: boundedCommandOutput(result) }; } if (candidate.kind === "vpn-diagnostic-pcap-delete" && candidate.path !== undefined) { assertVpnDiagnosticPcapCandidatePath(candidate.path); assertPathNotOpen(candidate.path); const before = safeFileSize(candidate.path); unlinkSync(candidate.path); return { reclaimedBytes: before }; } if (candidate.kind === "journal-vacuum") { const result = command(["journalctl", `--vacuum-size=${options.journalTargetBytes}`], 30000); if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "journalctl vacuum failed"); return { reclaimedBytes: null, commandOutput: boundedCommandOutput(result) }; } if (candidate.kind === "docker-build-cache-prune") { const result = command(buildCachePruneCommand(options), 45000); if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "docker builder prune failed"); return { reclaimedBytes: null, commandOutput: boundedCommandOutput(result) }; } throw new Error(`unsupported gc candidate kind: ${candidate.kind}`); } function buildCachePruneCommand(options: GcOptions): string[] { const commandArgs = ["docker", "builder", "prune", "--all", "--force"]; if (!options.buildCacheAll) commandArgs.push("--filter", `until=${options.buildCacheUntil}`); return commandArgs; } function keepFileTail(path: string, tailBytes: number): number { const before = safeFileSize(path); if (before <= tailBytes) return 0; const bytesToRead = Math.min(before, tailBytes); const fd = openSync(path, "r+"); const buffer = Buffer.alloc(bytesToRead); try { readSync(fd, buffer, 0, bytesToRead, before - bytesToRead); ftruncateSync(fd, 0); writeSync(fd, buffer, 0, bytesToRead, 0); ftruncateSync(fd, bytesToRead); } finally { closeSync(fd); } return Math.max(0, before - bytesToRead); } function ftruncateFile(path: string, size: number): void { const fd = openSync(path, "r+"); try { ftruncateSync(fd, size); } finally { closeSync(fd); } } function pruneEmptyDirectories(startPath: string, stopRoot: string): void { const root = resolve(stopRoot); let current = resolve(startPath); while (current.startsWith(`${root}/`)) { try { const entries = readdirSync(current); if (entries.length > 0) return; rmSync(current, { recursive: false, force: false }); } catch { return; } current = dirname(current); } } function assertTmpCandidatePath(path: string): void { const resolved = resolve(path); if (!resolved.startsWith("/tmp/")) throw new Error(`refusing to remove non-/tmp path: ${path}`); if (TMP_EXACT_PROTECT.has(resolved)) throw new Error(`refusing to remove protected tmp path: ${path}`); const name = basename(resolved); if (!TMP_PREFIX_ALLOWLIST.some((prefix) => name.startsWith(prefix))) { throw new Error(`refusing to remove tmp path outside allowlist: ${path}`); } } function assertStaleTmpCandidatePath(path: string): void { const resolved = resolve(path); if (!resolved.startsWith("/tmp/")) throw new Error(`refusing to remove non-/tmp path: ${path}`); if (TMP_EXACT_PROTECT.has(resolved)) throw new Error(`refusing to remove protected tmp path: ${path}`); const relativePath = resolved.slice("/tmp/".length); if (relativePath.length === 0 || relativePath.includes("/")) throw new Error(`refusing to remove nested tmp path: ${path}`); const name = basename(resolved); if (isStaleTmpProtectedName(name)) throw new Error(`refusing to remove protected stale tmp path: ${path}`); } function assertToolCacheCandidatePath(path: string): void { const resolved = resolve(path); const allowed = TOOL_CACHE_ALLOWLIST.some((item) => resolve(item.path) === resolved); if (!allowed) throw new Error(`refusing to remove tool cache path outside allowlist: ${path}`); } function assertVscodeServerCandidatePath(path: string): void { const resolved = resolve(path); const root = resolve(VSCODE_SERVER_ROOT); if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove VS Code server outside server root: ${path}`); const name = basename(resolved); if (!/^Stable-[a-f0-9]{40}$/u.test(name)) throw new Error(`refusing to remove unexpected VS Code server name: ${path}`); } function assertVscodeExtensionCandidatePath(path: string): void { const resolved = resolve(path); const root = resolve(VSCODE_EXTENSION_ROOT); if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove VS Code extension outside extension root: ${path}`); const relativePath = resolved.slice(root.length + 1); if (relativePath.includes("/")) throw new Error(`refusing to remove nested VS Code extension path: ${path}`); if (vscodeExtensionIdFromDirectory(basename(resolved)) === null) throw new Error(`refusing to remove unexpected VS Code extension directory: ${path}`); } function assertVscodeCachedVsixCandidatePath(path: string): void { const resolved = resolve(path); const root = resolve(VSCODE_CACHED_VSIX_ROOT); if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove VS Code cached VSIX outside cache root: ${path}`); const relativePath = resolved.slice(root.length + 1); if (relativePath.includes("/")) throw new Error(`refusing to remove nested VS Code cached VSIX path: ${path}`); if (vscodeExtensionIdFromDirectory(basename(resolved)) === null) throw new Error(`refusing to remove unexpected VS Code cached VSIX name: ${path}`); const stat = lstatSync(resolved); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`refusing to remove non-regular VS Code cached VSIX: ${path}`); } function assertBaiduStagingCandidatePath(path: string): void { const resolved = resolve(path); const root = resolve(rootPath(...BAIDU_STAGING_RELATIVE_ROOT, "server-data", "unidesk-pg-data")); if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove Baidu staging file outside PGDATA staging root: ${path}`); const relativePath = resolved.slice(root.length + 1); if (!/^\d{6}\/[^/]+\.pg_basebackup\.tar\.gz$/u.test(relativePath)) { throw new Error(`refusing to remove unexpected Baidu staging file: ${path}`); } } function assertStateArtifactFileCandidatePath(path: string, options: GcOptions): void { if (!options.stateArtifacts) throw new Error("refusing to remove state artifact without --include-state-artifacts"); const resolved = resolve(path); const root = matchingConfiguredRoot(resolved, options.stateArtifactFileRoots); if (root === null) throw new Error(`refusing to remove state artifact file outside allowlist: ${path}`); const relativePath = resolved.slice(root.length + 1); if (relativePath.length === 0) throw new Error(`refusing to remove state artifact root as file: ${path}`); const stat = lstatSync(resolved); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`refusing to remove non-regular state artifact file: ${path}`); assertStateArtifactAge(stat.mtimeMs, options.stateArtifactKeepDays, path); } function assertStateArtifactDirCandidatePath(path: string, options: GcOptions): void { if (!options.stateArtifacts) throw new Error("refusing to remove state artifact directory without --include-state-artifacts"); const resolved = resolve(path); const root = matchingConfiguredRoot(resolved, options.stateArtifactDirRoots); if (root === null) throw new Error(`refusing to remove state artifact directory outside allowlist: ${path}`); const relativePath = resolved.slice(root.length + 1); if (relativePath.length === 0 || relativePath.includes("/")) { throw new Error(`refusing to remove nested or root state artifact directory: ${path}`); } const stat = lstatSync(resolved); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`refusing to remove non-directory state artifact path: ${path}`); assertStateArtifactAge(stat.mtimeMs, options.stateArtifactKeepDays, path); } function assertStateStaleScratchFileCandidatePath(path: string, options: GcOptions): void { if (!options.stateStaleScratch) throw new Error("refusing to remove state stale scratch without --include-state-stale-scratch"); const resolved = resolve(path); const root = matchingConfiguredRoot(resolved, options.stateStaleScratchFileRoots); if (root === null) throw new Error(`refusing to remove state stale scratch file outside allowlist: ${path}`); const relativePath = resolved.slice(root.length + 1); if (relativePath.length === 0) throw new Error(`refusing to remove state stale scratch root as file: ${path}`); const stat = lstatSync(resolved); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`refusing to remove non-regular state stale scratch file: ${path}`); assertAgeHours(stat.mtimeMs, options.stateStaleScratchKeepHours, path, "state stale scratch file"); } function assertStateStaleScratchDirCandidatePath(path: string, options: GcOptions): void { if (!options.stateStaleScratch) throw new Error("refusing to remove state stale scratch directory without --include-state-stale-scratch"); const resolved = resolve(path); const root = matchingConfiguredRoot(resolved, options.stateStaleScratchDirRoots); if (root === null) throw new Error(`refusing to remove state stale scratch directory outside allowlist: ${path}`); const relativePath = resolved.slice(root.length + 1); if (relativePath.length === 0 || relativePath.includes("/")) { throw new Error(`refusing to remove nested or root state stale scratch directory: ${path}`); } const stat = lstatSync(resolved); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`refusing to remove non-directory state stale scratch path: ${path}`); assertAgeHours(stat.mtimeMs, options.stateStaleScratchKeepHours, path, "state stale scratch directory"); } function assertCodexSessionCandidatePath(path: string, options: GcOptions): void { if (!options.codexSessions) throw new Error("refusing to remove Codex session without --include-codex-sessions"); const resolved = resolve(path); const root = resolve(options.codexSessionRoot); if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove Codex session outside YAML root: ${path}`); const stat = lstatSync(resolved); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`refusing to remove non-regular Codex session file: ${path}`); assertAgeHours(stat.mtimeMs, options.codexSessionKeepHours, path, "Codex session"); } function assertMergedWorktreeCandidatePath(path: string, options: GcOptions): void { if (!options.mergedWorktrees) throw new Error("refusing to remove worktree without --include-merged-worktrees"); const resolved = resolve(path); const root = resolve(options.worktreeRoot); if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove worktree outside YAML root: ${path}`); if (resolved === resolve(options.worktreeMainRoot) || resolved === resolve(repoRoot) || resolved === resolve(process.cwd())) { throw new Error(`refusing to remove active/main worktree: ${path}`); } if (!isPlainDirectory(resolved)) throw new Error(`refusing to remove non-directory worktree path: ${path}`); if (worktreeHasRecentActivity(resolved, options.worktreeKeepHours)) throw new Error(`refusing to remove worktree active within ${options.worktreeKeepHours} hours: ${path}`); const status = gitWorktreeStatus(resolved); if (!status.ok) throw new Error(status.reason); if (!status.clean) throw new Error(`refusing to remove dirty worktree with ${status.changedPathCount} changed path(s): ${path}`); const merge = gitWorktreeSemanticMergeStatus(resolved, options); if (!merge.ok) throw new Error(merge.reason); if (!merge.merged) throw new Error(`refusing to remove unmerged worktree; unabsorbedCommitCount=${merge.unabsorbedCommitCount}: ${path}`); } function matchingConfiguredRoot(resolved: string, roots: GcPathRoot[]): string | null { for (const rootInfo of roots) { const root = resolve(rootInfo.path); if (!isPlainDirectory(root)) continue; if (resolved.startsWith(`${root}/`)) return root; } return null; } function assertStateArtifactAge(mtimeMs: number, keepDays: number, path: string): void { const cutoffMs = Date.now() - keepDays * 24 * 60 * 60 * 1000; if (mtimeMs >= cutoffMs) throw new Error(`refusing to remove state artifact newer than ${keepDays} days: ${path}`); } function assertAgeHours(mtimeMs: number, keepHours: number, path: string, label: string): void { const cutoffMs = Date.now() - keepHours * 60 * 60 * 1000; if (mtimeMs >= cutoffMs) throw new Error(`refusing to remove ${label} newer than ${keepHours} hours: ${path}`); } function assertVpnDiagnosticPcapCandidatePath(path: string): void { const resolved = resolve(path); const root = resolve(VPN_DIAGNOSTIC_LOG_ROOT); if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove VPN diagnostic pcap outside log root: ${path}`); const relativePath = resolved.slice(root.length + 1); if (relativePath.includes("/")) throw new Error(`refusing to remove nested VPN diagnostic path: ${path}`); if (!VPN_DIAGNOSTIC_RING_PCAP_PATTERN.test(basename(resolved))) throw new Error(`refusing to remove unexpected VPN diagnostic pcap name: ${path}`); const stat = lstatSync(resolved); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`refusing to remove non-regular VPN diagnostic pcap: ${path}`); } function assertPathNotOpen(path: string): void { const result = command(["fuser", "--", path], 1000); if (result.exitCode === 0) throw new Error(`refusing to remove active file still open by a process: ${path}`); if (result.timedOut) throw new Error(`refusing to remove file because active-file check timed out: ${path}`); if (result.exitCode !== 1) { const detail = (result.stderr || result.stdout).trim(); throw new Error(`refusing to remove file because active-file check failed: ${detail || path}`); } } function compactCandidate(candidate: GcCandidate): GcCandidate { return { ...candidate, description: truncateText(candidate.description, 140), action: { omitted: true, drillDown: "rerun with --full or --raw" }, }; } function compactProtectedItem(item: ProtectedGcItem): ProtectedGcItem { return { ...item, reason: truncateText(item.reason, 140), }; } function truncateText(value: string, maxLength: number): string { if (value.length <= maxLength) return value; return `${value.slice(0, Math.max(0, maxLength - 3))}...`; } function summarizeCandidates( candidates: GcCandidate[], returnedCandidates: GcCandidate[], protectedItems: ProtectedGcItem[], returnedProtectedItems: ProtectedGcItem[], diskBefore: DiskSnapshot | null, options: GcOptions, ): GcPlan["summary"] { const byKind: GcPlan["summary"]["byKind"] = {}; let estimatedReclaimBytes = 0; for (const candidate of candidates) { estimatedReclaimBytes += candidate.estimatedReclaimBytes; const current = byKind[candidate.kind] ?? { count: 0, estimatedReclaimBytes: 0, estimatedReclaim: "0 B" }; current.count += 1; current.estimatedReclaimBytes += candidate.estimatedReclaimBytes; current.estimatedReclaim = formatBytes(current.estimatedReclaimBytes); byKind[candidate.kind] = current; } const returnedEstimatedReclaimBytes = returnedCandidates.reduce((sum, candidate) => sum + candidate.estimatedReclaimBytes, 0); return { candidateCount: candidates.length, returnedCandidateCount: returnedCandidates.length, protectedCount: protectedItems.length, returnedProtectedCount: returnedProtectedItems.length, estimatedReclaimBytes, estimatedReclaim: formatBytes(estimatedReclaimBytes), returnedEstimatedReclaimBytes, returnedEstimatedReclaim: formatBytes(returnedEstimatedReclaimBytes), byKind, target: targetSummary(diskBefore, options, estimatedReclaimBytes), }; } function targetSummary(diskBefore: DiskSnapshot | null, options: GcOptions, estimatedReclaimBytes: number): GcTargetSummary | null { if (diskBefore === null || options.targetUsePercent === null) return null; const dfEffectiveBytes = Math.max(1, diskBefore.usedBytes + diskBefore.availableBytes); const targetUsedBytes = Math.floor(dfEffectiveBytes * (options.targetUsePercent / 100)); const requiredReclaimBytes = Math.max(0, diskBefore.usedBytes - targetUsedBytes); const estimatedAfterUsedBytes = Math.max(0, diskBefore.usedBytes - estimatedReclaimBytes); const estimatedAfterUsePercent = Math.ceil((estimatedAfterUsedBytes / dfEffectiveBytes) * 100); const shortfallBytes = Math.max(0, requiredReclaimBytes - estimatedReclaimBytes); return { targetUsePercent: options.targetUsePercent, currentUsePercent: diskBefore.usePercent, basis: "df-used-over-used-plus-available", requiredReclaimBytes, requiredReclaim: formatBytes(requiredReclaimBytes), estimatedAfterUsedBytes, estimatedAfterUsePercent, shortfallBytes, shortfall: formatBytes(shortfallBytes), safeStop: shortfallBytes > 0, }; } function returnedRunResults(results: GcRunResult["results"], options: GcOptions): GcRunResult["results"] { if (options.full) return results; const failed = results.filter((item) => item.status === "failed"); const succeeded = results.filter((item) => item.status === "succeeded"); return [...failed, ...succeeded].slice(0, options.resultLimit); } function collectFiles(root: string): Array<{ path: string; sizeBytes: number; mtimeMs: number }> { const result: Array<{ path: string; sizeBytes: number; mtimeMs: number }> = []; const visit = (dir: string): void => { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const path = join(dir, entry.name); try { const stat = lstatSync(path); if (entry.isDirectory()) { visit(path); } else if (entry.isFile()) { result.push({ path, sizeBytes: stat.size, mtimeMs: stat.mtimeMs }); } } catch { // Ignore paths that disappear while gc is planning. } } }; visit(root); return result; } function isPlainDirectory(path: string): boolean { try { const stat = lstatSync(path); return stat.isDirectory() && !stat.isSymbolicLink(); } catch { return false; } } function safePathSize(path: string, timeoutMs = DEFAULT_PATH_SIZE_TIMEOUT_MS): number { return pathSizeFromDu(path, timeoutMs) ?? 0; } function pathSizeFromDu(path: string, timeoutMs: number): number | null { try { const stat = lstatSync(path); if (stat.isFile() || stat.isSymbolicLink()) return stat.size; if (!stat.isDirectory()) return null; } catch { return null; } const result = command(["du", "-sb", "--one-file-system", "--", path], timeoutMs); if (result.exitCode !== 0 || result.timedOut) return null; const rawSize = result.stdout.trim().split(/\s+/u)[0] ?? ""; const sizeBytes = Number(rawSize); return Number.isFinite(sizeBytes) && sizeBytes >= 0 ? sizeBytes : null; } function safeFileSize(path: string): number { try { return statSync(path).size; } catch { return 0; } } function protectedPathSize(path: string, options: GcOptions): number | undefined { return options.full ? safePathSize(path) : undefined; } function protectedFileSize(path: string, options: GcOptions): number | undefined { return options.full ? safeFileSize(path) : undefined; } function resolvePath(path: string): string { return path.startsWith("/") ? path : rootPath(path); } function rootDiskSnapshot(): DiskSnapshot | null { const result = command(["df", "-B1", "-P", "/"], 5000); if (result.exitCode !== 0) return null; const line = result.stdout.trim().split(/\r?\n/u)[1]; if (!line) return null; const parts = line.trim().split(/\s+/u); if (parts.length < 6) return null; return { filesystem: parts[0] ?? "", sizeBytes: Number(parts[1]), usedBytes: Number(parts[2]), availableBytes: Number(parts[3]), usePercent: Number((parts[4] ?? "0").replace("%", "")), mount: parts[5] ?? "/", }; } function dockerContainers(): Array<{ id: string; name: string; image: string; logPath: string }> { const ps = command(["docker", "ps", "-qa", "--no-trunc"], 5000); if (ps.exitCode !== 0 || ps.stdout.trim().length === 0) return []; const ids = ps.stdout.trim().split(/\s+/u); const inspect = command(["docker", "inspect", ...ids], 10000); if (inspect.exitCode !== 0 || inspect.stdout.trim().length === 0) return []; try { const parsed = JSON.parse(inspect.stdout) as unknown; if (!Array.isArray(parsed)) return []; return parsed.map((item) => { const record = item as Record; const config = typeof record.Config === "object" && record.Config !== null ? record.Config as Record : {}; return { id: String(record.Id ?? ""), name: String(record.Name ?? "").replace(/^\//u, ""), image: String(config.Image ?? record.Image ?? ""), logPath: String(record.LogPath ?? ""), }; }).filter((item) => item.id.length > 0); } catch { return []; } } function parseJournalUsage(text: string): number | null { const match = text.match(/take up\s+([0-9.]+)\s*([KMGT]?)(?:i?B|B)?/iu); if (!match) return null; const value = Number(match[1]); const unit = (match[2] ?? "").toUpperCase(); const multiplier = unit === "T" ? 1024 ** 4 : unit === "G" ? 1024 ** 3 : unit === "M" ? 1024 ** 2 : unit === "K" ? 1024 : 1; return Math.floor(value * multiplier); } function parseDockerSystemDfBuildCache(text: string): { sizeBytes: number; reclaimableBytes: number } | null { for (const line of text.split(/\r?\n/u)) { if (!line.startsWith("Build Cache")) continue; const match = line.trim().match(/^Build Cache\s+\S+\s+\S+\s+(\S+)\s+(\S+)/u); if (!match) continue; const sizeBytes = parseDockerHumanSize(match[1] ?? ""); const reclaimableBytes = parseDockerHumanSize(match[2] ?? ""); if (sizeBytes === null || reclaimableBytes === null) return null; return { sizeBytes, reclaimableBytes }; } return null; } function parseDockerHumanSize(raw: string): number | null { const cleaned = raw.replace(/\(.+$/u, ""); const match = cleaned.match(/^([0-9.]+)\s*([KMGT]?B)$/iu); if (!match) return null; const value = Number(match[1]); const unit = match[2]?.toUpperCase(); const multiplier = unit === "TB" ? 1000 ** 4 : unit === "GB" ? 1000 ** 3 : unit === "MB" ? 1000 ** 2 : unit === "KB" ? 1000 : 1; return Math.floor(value * multiplier); } function command(commandArgs: string[], timeoutMs: number): { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean } { const result = spawnSync(commandArgs[0], commandArgs.slice(1), { cwd: repoRoot, encoding: "utf8", maxBuffer: 1024 * 1024 * 8, timeout: timeoutMs, }); const error = result.error as (Error & { code?: string }) | undefined; return { exitCode: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? error?.message ?? "", timedOut: error?.code === "ETIMEDOUT", }; } function boundedCommandOutput(result: { stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }): unknown { return { exitCode: result.exitCode, timedOut: result.timedOut, stdoutTail: result.stdout.slice(-2000), stderrTail: result.stderr.slice(-2000), }; } function formatBytes(bytes: number): string { const units = ["B", "KiB", "MiB", "GiB", "TiB"]; let value = Math.max(0, bytes); let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex += 1; } return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`; }