diff --git a/.agents/skills/unidesk-ops/SKILL.md b/.agents/skills/unidesk-ops/SKILL.md index bbb698b3..c346f5b4 100644 --- a/.agents/skills/unidesk-ops/SKILL.md +++ b/.agents/skills/unidesk-ops/SKILL.md @@ -77,6 +77,18 @@ bun scripts/cli.ts gc remote [--target-use-percent N] [--dry-run|-- 主 server 和 provider 磁盘高水位缓解。`plan` 只读输出候选、风险、估算收益和保护对象。`run` 必须 `--confirm`。`remote` 通过 SSH 透传执行远端 GC。 +常用显式候选和目标口径: + +```bash +bun scripts/cli.ts gc plan --target-use-percent 69 \ + --include-tool-caches \ + --include-vscode-stale-servers \ + --include-vscode-stale-extensions \ + --include-baidu-staging +``` + +`--target-use-percent` 按 `df` 显示口径估算 shortfall。工具缓存、VS Code 历史 server/extension 版本、Baidu staging 旧 PGDATA tarball 均默认不启用;必须显式 include 后才进入候选,且执行时仍受 allowlist 路径断言保护。默认 GC 不触碰 PGDATA、Docker volumes/images、Codex sessions/auth state 或 Baidu staging 根目录。 + --- ## 服务重建 diff --git a/scripts/src/gc.ts b/scripts/src/gc.ts index 4db8f4e8..3c9db0f6 100644 --- a/scripts/src/gc.ts +++ b/scripts/src/gc.ts @@ -13,7 +13,11 @@ type GcItemKind = | "journal-vacuum" | "docker-build-cache-prune" | "tmp-path-delete" - | "browser-cache-delete"; + | "browser-cache-delete" + | "tool-cache-delete" + | "vscode-server-delete" + | "vscode-extension-delete" + | "baidu-staging-file-delete"; interface GcOptions { fileLogs: boolean; @@ -30,10 +34,18 @@ interface GcOptions { tmp: boolean; tmpMinAgeHours: number; browserCache: boolean; + toolCaches: boolean; + vscodeStaleServers: boolean; + vscodeKeepServers: number; + vscodeStaleExtensions: boolean; + vscodeKeepExtensionVersions: number; + baiduStaging: boolean; + baiduStagingKeepDays: number; dbSummary: boolean; limit: number; resultLimit: number; full: boolean; + targetUsePercent: number | null; } interface DbTraceGcOptions { @@ -93,6 +105,7 @@ interface GcPlan { returnedEstimatedReclaimBytes: number; returnedEstimatedReclaim: string; byKind: Record; + target: GcTargetSummary | null; }; candidates: GcCandidate[]; protected: ProtectedGcItem[]; @@ -129,6 +142,19 @@ interface GcRunResult { 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_OPTIONS: GcOptions = { fileLogs: true, fileLogKeepDays: 7, @@ -144,16 +170,25 @@ const DEFAULT_OPTIONS: GcOptions = { tmp: true, tmpMinAgeHours: 24, browserCache: false, + toolCaches: false, + vscodeStaleServers: false, + vscodeKeepServers: 2, + vscodeStaleExtensions: false, + vscodeKeepExtensionVersions: 1, + baiduStaging: false, + baiduStagingKeepDays: 10, dbSummary: true, limit: 50, resultLimit: 50, full: false, + targetUsePercent: null, }; const DEFAULT_DB_TRACE_TYPES = ["trace-stats-updated", "trace-step-created", "trace-stats-snapshot"]; const TMP_PREFIX_ALLOWLIST = [ "hwlab-agent-", + "hwlab-", "hwlab-cd-", "hwlab-cli-cicd-", "hwlab-codeagent-trace", @@ -162,16 +197,24 @@ const TMP_PREFIX_ALLOWLIST = [ "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", ]; @@ -182,6 +225,63 @@ const TMP_EXACT_PROTECT = new Set([ "/tmp/tmux-0", ]); +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 BAIDU_STAGING_RELATIVE_ROOT = [".state", "baidu-netdisk", "staging"]; + export async function runGcCommand(config: UniDeskConfig, args: string[]): Promise { const [action = "plan", ...rest] = args; if (action === "remote") { @@ -289,12 +389,49 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO }); } } + if (options.toolCaches) { + candidates.push(...collectToolCacheCandidates()); + } else { + protectedItems.push(...collectProtectedToolCaches()); + } + if (options.vscodeStaleServers) { + candidates.push(...collectVscodeServerCandidates(options)); + } else { + const vscodeSize = safePathSize(VSCODE_SERVER_ROOT); + if (vscodeSize > 0) { + protectedItems.push({ + kind: "vscode-server-cache", + risk: "blocked", + ref: VSCODE_SERVER_ROOT, + sizeBytes: vscodeSize, + 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 { + const extensionSize = safePathSize(VSCODE_EXTENSION_ROOT); + if (extensionSize > 0) { + protectedItems.push({ + kind: "vscode-extension-cache", + risk: "blocked", + ref: VSCODE_EXTENSION_ROOT, + sizeBytes: extensionSize, + 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.baiduStaging) { + candidates.push(...collectBaiduStagingCandidates(options, observedAt)); + } - protectedItems.push(...collectProtectedStorage(config)); + 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 visibleCandidates = options.full ? allCandidates : allCandidates.slice(0, options.limit); - const summary = summarizeCandidates(allCandidates, visibleCandidates); + const diskBefore = rootDiskSnapshot(); + const summary = summarizeCandidates(allCandidates, visibleCandidates, diskBefore, options); return { ok: true, @@ -303,7 +440,7 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO mutation: false, observedAt, options: publicOptions(options), - diskBefore: rootDiskSnapshot(), + diskBefore, summary, candidates: visibleCandidates, protected: protectedItems, @@ -314,13 +451,16 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO neverTouches: [ "Docker volumes", "PostgreSQL PGDATA", - "Baidu Netdisk staging/backups", + "Baidu Netdisk staging root by default", "D601 registry storage", "Docker images used by containers", + "Codex sessions and auth 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 VS Code server versions and stale VS Code extension versions 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.", "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.", ], @@ -404,6 +544,34 @@ function parseGcOptions(args: string[]): GcOptions { 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 === "--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 === "--no-file-logs" || arg === "--no-logs") { options.fileLogs = false; } else if (arg === "--no-docker-logs") { @@ -490,6 +658,18 @@ function parseNonNegativeNumber(name: string, raw: string | undefined): 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 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`); @@ -526,10 +706,18 @@ function publicOptions(options: GcOptions): Record { tmp: options.tmp, tmpMinAgeHours: options.tmpMinAgeHours, browserCache: options.browserCache, + toolCaches: options.toolCaches, + vscodeStaleServers: options.vscodeStaleServers, + vscodeKeepServers: options.vscodeKeepServers, + vscodeStaleExtensions: options.vscodeStaleExtensions, + vscodeKeepExtensionVersions: options.vscodeKeepExtensionVersions, + baiduStaging: options.baiduStaging, + baiduStagingKeepDays: options.baiduStagingKeepDays, dbSummary: options.dbSummary, limit: options.limit, resultLimit: options.resultLimit, full: options.full, + targetUsePercent: options.targetUsePercent, }; } @@ -679,7 +867,149 @@ function collectBrowserCacheCandidate(): GcCandidate | null { }; } -function collectProtectedStorage(config: UniDeskConfig): ProtectedGcItem[] { +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(): ProtectedGcItem[] { + const result: ProtectedGcItem[] = []; + for (const item of TOOL_CACHE_ALLOWLIST) { + if (!existsSync(item.path)) continue; + const sizeBytes = safePathSize(item.path); + if (sizeBytes <= 0) continue; + result.push({ + kind: "tool-cache", + risk: "blocked", + ref: item.path, + sizeBytes, + 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 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 collectProtectedStorage(config: UniDeskConfig, options: GcOptions): ProtectedGcItem[] { const result: ProtectedGcItem[] = [ { kind: "docker-volume", @@ -697,11 +1027,13 @@ function collectProtectedStorage(config: UniDeskConfig): ProtectedGcItem[] { const baiduStaging = rootPath(".state", "baidu-netdisk", "staging"); if (existsSync(baiduStaging)) { result.push({ - kind: "baidu-netdisk-staging", + kind: options.baiduStaging ? "baidu-netdisk-staging-root" : "baidu-netdisk-staging", risk: "blocked", ref: baiduStaging, sizeBytes: safePathSize(baiduStaging), - reason: "Baidu Netdisk staging may contain backups or transfer state and is not touched by gc.", + 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; @@ -868,8 +1200,8 @@ function gcPolicyPlan(options: GcPolicyOptions): unknown { policy: { safeScope: [ "systemd journal is capped at 512MiB", - "daily timer runs file-log and allowlisted /tmp low-risk gc only", - "timer does not touch PostgreSQL PGDATA, Docker images, Docker volumes or Baidu Netdisk staging", + "daily timer runs file-log, Docker json logs, 24h BuildKit cache and allowlisted /tmp gc", + "timer does not touch PostgreSQL PGDATA, Docker images, Docker volumes, tool caches, VS Code servers/extensions 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.", @@ -920,7 +1252,7 @@ function gcPolicyInstall(options: GcPolicyOptions): unknown { function gcPolicyFiles(): Record { const gcStateDir = rootPath(".state", "gc"); const bunPath = bunExecutablePath(); - const gcScript = `cd ${shellQuote(repoRoot)} && mkdir -p ${shellQuote(gcStateDir)} && ${shellQuote(bunPath)} scripts/cli.ts gc run --confirm --no-db-summary --no-build-cache --no-docker-logs --no-journal --limit 5000 --result-limit 25 > ${shellQuote(join(gcStateDir, "last-run.json"))} 2> ${shellQuote(join(gcStateDir, "last-run.stderr"))}`; + const gcScript = `cd ${shellQuote(repoRoot)} && mkdir -p ${shellQuote(gcStateDir)} && ${shellQuote(bunPath)} scripts/cli.ts gc run --confirm --no-db-summary --no-journal --build-cache-until 24h --limit 5000 --result-limit 25 > ${shellQuote(join(gcStateDir, "last-run.json"))} 2> ${shellQuote(join(gcStateDir, "last-run.stderr"))}`; return { journald: { path: "/etc/systemd/journald.conf.d/unidesk-gc.conf", @@ -1036,6 +1368,30 @@ function executeCandidate(candidate: GcCandidate, options: GcOptions): { reclaim 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 === "baidu-staging-file-delete" && candidate.path !== undefined) { + assertBaiduStagingCandidatePath(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"); @@ -1091,7 +1447,40 @@ function assertTmpCandidatePath(path: string): void { } } -function summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCandidate[]): GcPlan["summary"] { +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 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 summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCandidate[], diskBefore: DiskSnapshot | null, options: GcOptions): GcPlan["summary"] { const byKind: GcPlan["summary"]["byKind"] = {}; let estimatedReclaimBytes = 0; for (const candidate of candidates) { @@ -1111,6 +1500,29 @@ function summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCa 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, }; }