feat: extend disk gc candidates

This commit is contained in:
Codex
2026-06-11 14:23:41 +00:00
parent 204a7570b7
commit f8dcdf4139
2 changed files with 436 additions and 12 deletions
+12
View File
@@ -77,6 +77,18 @@ bun scripts/cli.ts gc remote <providerId> [--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 根目录。
---
## 服务重建
+424 -12
View File
@@ -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<string, { count: number; estimatedReclaimBytes: number; estimatedReclaim: string }>;
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<unknown> {
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<string, unknown> {
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<string, Array<{ path: string; name: string; mtimeMs: number; sizeBytes: number }>>();
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<string, { path: string; content: string }> {
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,
};
}