feat: add confirmed server cleanup execution

This commit is contained in:
Codex
2026-06-11 16:16:13 +00:00
parent 01b19d9238
commit 05c9e9a7de
7 changed files with 315 additions and 24 deletions
+103 -11
View File
@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
import { closeSync, existsSync, ftruncateSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
import { closeSync, existsSync, ftruncateSync, lstatSync, mkdirSync, opendirSync, openSync, readdirSync, readSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
@@ -13,6 +13,7 @@ type GcItemKind =
| "journal-vacuum"
| "docker-build-cache-prune"
| "tmp-path-delete"
| "stale-tmp-path-delete"
| "browser-cache-delete"
| "tool-cache-delete"
| "vscode-server-delete"
@@ -33,6 +34,7 @@ interface GcOptions {
buildCacheAll: boolean;
tmp: boolean;
tmpMinAgeHours: number;
staleTmp: boolean;
browserCache: boolean;
toolCaches: boolean;
vscodeStaleServers: boolean;
@@ -169,6 +171,7 @@ const DEFAULT_OPTIONS: GcOptions = {
buildCacheAll: false,
tmp: true,
tmpMinAgeHours: 24,
staleTmp: false,
browserCache: false,
toolCaches: false,
vscodeStaleServers: false,
@@ -225,6 +228,17 @@ const TMP_EXACT_PROTECT = new Set([
"/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",
@@ -281,6 +295,9 @@ const TOOL_CACHE_ALLOWLIST = [
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"];
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<unknown> {
const [action = "plan", ...rest] = args;
@@ -374,6 +391,10 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO
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);
@@ -459,7 +480,7 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO
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.",
"Tool caches, stale /tmp direct children, 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.",
@@ -540,6 +561,10 @@ function parseGcOptions(args: string[]): GcOptions {
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") {
@@ -705,6 +730,7 @@ function publicOptions(options: GcOptions): Record<string, unknown> {
buildCacheAll: options.buildCacheAll,
tmp: options.tmp,
tmpMinAgeHours: options.tmpMinAgeHours,
staleTmp: options.staleTmp,
browserCache: options.browserCache,
toolCaches: options.toolCaches,
vscodeStaleServers: options.vscodeStaleServers,
@@ -834,7 +860,7 @@ function collectTmpCandidates(options: GcOptions, observedAt: string): GcCandida
continue;
}
if (stat.mtimeMs >= cutoffMs) continue;
const sizeBytes = safePathSize(path);
const sizeBytes = safePathSize(path, STALE_TMP_PATH_SIZE_TIMEOUT_MS);
if (sizeBytes <= 0) continue;
result.push({
id: `tmp:${path}`,
@@ -850,6 +876,52 @@ function collectTmpCandidates(options: GcOptions, observedAt: string): GcCandida
return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes);
}
function collectStaleTmpCandidates(options: GcOptions, observedAt: string, alreadySelected: Set<string>): 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;
@@ -1361,6 +1433,12 @@ function executeCandidate(candidate: GcCandidate, options: GcOptions): { reclaim
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}`);
@@ -1447,6 +1525,16 @@ function assertTmpCandidatePath(path: string): void {
}
}
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);
@@ -1560,19 +1648,23 @@ function collectFiles(root: string): Array<{ path: string; sizeBytes: number; mt
return result;
}
function safePathSize(path: string): number {
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 0;
let total = 0;
for (const entry of readdirSync(path)) {
total += safePathSize(join(path, entry));
}
return total;
if (!stat.isDirectory()) return null;
} catch {
return 0;
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 {