e51a2e8edf
Co-authored-by: Codex <codex@noreply.local>
2906 lines
125 KiB
TypeScript
2906 lines
125 KiB
TypeScript
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<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
diskBefore: DiskSnapshot | null;
|
|
summary: {
|
|
candidateCount: number;
|
|
returnedCandidateCount: number;
|
|
protectedCount: number;
|
|
returnedProtectedCount: number;
|
|
estimatedReclaimBytes: number;
|
|
estimatedReclaim: string;
|
|
returnedEstimatedReclaimBytes: number;
|
|
returnedEstimatedReclaim: string;
|
|
byKind: Record<string, { count: number; estimatedReclaimBytes: number; estimatedReclaim: string }>;
|
|
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<string, unknown>;
|
|
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<GcCandidate & { status: "succeeded" | "failed"; reclaimedBytes: number | null; error?: string; commandOutput?: unknown }>;
|
|
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<unknown> {
|
|
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<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown> {
|
|
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<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;
|
|
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<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 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<string>;
|
|
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<string, { path: string; content: string }> {
|
|
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<typeof psql>): 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<typeof psql>): 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<string, unknown>;
|
|
const config = typeof record.Config === "object" && record.Config !== null ? record.Config as Record<string, unknown> : {};
|
|
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]}`;
|
|
}
|