diff --git a/.agents/skills/unidesk-ops/SKILL.md b/.agents/skills/unidesk-ops/SKILL.md index 4348448c..5866f121 100644 --- a/.agents/skills/unidesk-ops/SKILL.md +++ b/.agents/skills/unidesk-ops/SKILL.md @@ -105,9 +105,9 @@ bun scripts/cli.ts gc plan --target-use-percent 69 \ --include-vpn-diagnostic-logs ``` -`--target-use-percent` 按 `df` 显示口径估算 shortfall。工具缓存、`/tmp` 非 allowlist 直接子项、VS Code 历史 server/extension 版本、VS Code CachedExtensionVSIXs 下载缓存、Baidu staging 旧 PGDATA tarball、UniDesk `.state` 历史诊断/部署产物、VPN 诊断 ring pcap 均默认不启用;必须显式 include 后才进入候选,且执行时仍受路径断言保护。stale `/tmp` 扫描按 `--limit` 有界枚举候选,避免为了估算全量临时目录而长时间无输出。`.state` retention 只通过 `--include-state-artifacts --state-artifact-keep-days N` 选择 `.state/e2e`、`.state/validation`、`.state/jobs`、`.state/codex-queue/output-archive` 下超过保留期的普通文件,以及 `.state/deploy/exports`、`.state/deploy/resolve` 下超过保留期的直接子目录;默认保留期 14 天。VS Code cached VSIX 只选择 `/root/.vscode-server/data/CachedExtensionVSIXs` 下超过 `--vscode-cached-vsix-keep-days` 的顶层普通缓存文件,执行前检查 active fd;不删除已安装 extensions、server 或 user data。VPN 诊断日志只选择 `/root/vpn-server/logs/hy2-udp-ring-*.pcap` 和 `hy2-monitor-ring-*.pcap` 中超过 `--vpn-diagnostic-log-keep-hours` 的普通文件,执行前检查 active fd;不删除 evidence JSONL。默认 GC 不触碰 `.state/recovery`、`.state/codex-queue/codex-home`、`.state/deploy/work`、`.state/baidu-netdisk`、PGDATA、Docker volumes/images、Codex sessions/auth state、active worktree、runtime image/snapshot state、Baidu staging 根目录、VPN 日志根目录或 VS Code user data。 +`--target-use-percent` 按 `df` 显示口径估算 shortfall。主 server GC 的默认 include、保留窗口、输出 limit、Codex session root、worktree main/root/baseRef、worktree 扫描预算和 `.state` allowlist roots 由 `config/unidesk-cli.yaml#gc` 拥有;CLI 参数只做一次性显式覆盖。工具缓存、`/tmp` 非 allowlist 直接子项、VS Code 历史 server/extension 版本、VS Code CachedExtensionVSIXs 下载缓存、Baidu staging 旧 PGDATA tarball、UniDesk `.state` 历史诊断/部署产物、`.state` stale scratch、Codex inactive sessions、merged worktrees、VPN 诊断 ring pcap 均默认不启用;必须显式 include 后才进入候选,且执行时仍受路径断言保护。stale `/tmp` 扫描按 `--limit` 有界枚举候选,避免为了估算全量临时目录而长时间无输出。`.state` retention 通过 `--include-state-artifacts` 和 `--include-state-stale-scratch` 读取 YAML allowlist;不得把 `.state` 根目录当成通用清理对象。Codex session 清理只删除 YAML root 下超过 keepHours 的普通 session 文件,永远不删除 auth/config。Worktree 清理只扫描 YAML root 下 inactive 且已合入 YAML baseRef 或 cherry-equivalent 的 worktree,run 删除前重新校验 full clean 状态并使用 `git worktree remove`。VS Code cached VSIX 只选择 `/root/.vscode-server/data/CachedExtensionVSIXs` 下超过 `--vscode-cached-vsix-keep-days` 的顶层普通缓存文件,执行前检查 active fd;不删除已安装 extensions、server 或 user data。VPN 诊断日志只选择 `/root/vpn-server/logs/hy2-udp-ring-*.pcap` 和 `hy2-monitor-ring-*.pcap` 中超过 `--vpn-diagnostic-log-keep-hours` 的普通文件,执行前检查 active fd;不删除 evidence JSONL。默认 GC 不触碰 `.state/recovery`、`.state/codex-queue/codex-home`、`.state/deploy/work`、`.state/baidu-netdisk`、PGDATA、Docker volumes/images、Codex auth/config state、active/unmerged/dirty worktree、runtime image/snapshot state、Baidu staging 根目录、VPN 日志根目录或 VS Code user data。 -`gc policy install` 的每日 timer 会自动执行 24 小时 VPN 诊断 pcap retention、14 天 UniDesk `.state` artifact retention 和 7 天 VS Code CachedExtensionVSIXs retention,用于限制长期诊断/部署产物、tcpdump ring 文件与 VS Code 下载缓存增长;手动 `gc plan/run` 仍必须显式 `--include-vpn-diagnostic-logs` / `--include-state-artifacts` / `--include-vscode-cached-vsix` 才会列出或删除这些对象。 +`gc policy install` 的每日 timer 从 `config/unidesk-cli.yaml#gc.policyTimer` 渲染 VPN 诊断 pcap retention、UniDesk `.state` artifact retention 和 VS Code CachedExtensionVSIXs retention,用于限制长期诊断/部署产物、tcpdump ring 文件与 VS Code 下载缓存增长;手动 `gc plan/run` 仍必须显式 `--include-vpn-diagnostic-logs` / `--include-state-artifacts` / `--include-vscode-cached-vsix` 才会列出或删除这些对象。 --- diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index 3d3d77e5..206c0869 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -12,3 +12,106 @@ github: initialDelayMs: 1000 maxDelayMs: 16000 factor: 2 +gc: + targetUsePercent: null + fileLogs: + enabled: true + keepDays: 7 + maxBytes: 50MiB + tailBytes: 20MiB + dockerLogs: + enabled: true + maxBytes: 50MiB + journal: + enabled: true + targetBytes: 512MiB + buildCache: + enabled: true + until: 24h + all: false + tmp: + enabled: true + minAgeHours: 24 + includeStale: false + browserCache: + enabled: false + toolCaches: + enabled: false + vscode: + staleServers: + enabled: false + keepServers: 2 + staleExtensions: + enabled: false + keepVersions: 1 + cachedVsix: + enabled: false + keepDays: 7 + baiduStaging: + enabled: false + keepDays: 10 + stateArtifacts: + enabled: false + keepDays: 14 + fileRoots: + e2e: .state/e2e + validation: .state/validation + jobs: .state/jobs + codex-queue-output-archive: .state/codex-queue/output-archive + dirRoots: + deploy-exports: .state/deploy/exports + deploy-resolve: .state/deploy/resolve + stateStaleScratch: + enabled: false + keepHours: 24 + fileRoots: + playwright-cli-screenshots: .state/playwright-cli/screenshots + playwright-cli-sessions: .state/playwright-cli/sessions + perf: .state/perf + tmp: .state/tmp + web-observe: .state/web-observe + dirRoots: + hwlab-cd: .state/hwlab-cd + codex-queue-stats-verify: .state/codex-queue-stats-verify + codex-queue-perf: .state/codex-queue-perf + tmp: .state/tmp + codexSessions: + enabled: false + keepHours: 72 + root: /root/.codex/sessions + mergedWorktrees: + enabled: false + keepHours: 24 + mainRoot: /root/unidesk + root: /root/unidesk/.worktree + baseRef: origin/master + scanBudgetMs: 20000 + cherryCheckTimeoutMs: 1000 + estimateSizeInPlan: false + vpnDiagnosticLogs: + enabled: false + keepHours: 24 + databaseSummary: + enabled: true + output: + limit: 50 + resultLimit: 50 + full: false + policyTimer: + journald: + systemMaxUse: 512MiB + runtimeMaxUse: 128MiB + maxRetentionSec: 7day + daily: + buildCacheUntil: 24h + vpnDiagnosticLogs: + enabled: true + keepHours: 24 + stateArtifacts: + enabled: true + keepDays: 14 + vscodeCachedVsix: + enabled: true + keepDays: 7 + limit: 5000 + resultLimit: 25 diff --git a/docs/reference/gc.md b/docs/reference/gc.md index 3dfc2cb3..ee9707a2 100644 --- a/docs/reference/gc.md +++ b/docs/reference/gc.md @@ -2,11 +2,13 @@ UniDesk 的磁盘治理入口是 `bun scripts/cli.ts gc ...`。该入口用于短期一次性止血和低风险防膨胀策略,所有清理动作都必须先有结构化 plan,再通过显式确认执行。GC 不是通用 `rm -rf` 或原生命令集合;当目标磁盘水位无法在保护边界内下降到阈值以下时,应停止并升级为 retention/capacity 决策,而不是扩大清理范围。 +所有主 server GC 可调策略都由 `config/unidesk-cli.yaml#gc` 拥有,包括默认 include 开关、保留窗口、输出 limit、Codex session root、worktree main/root/baseRef、worktree 扫描预算、`.state` allowlist roots 和是否在 plan 阶段估算 worktree size。CLI 参数只作为一次性显式覆盖;代码只校验 YAML 字段存在、类型正确和路径可渲染,不把这些策略值写成隐藏默认。 + ## Command Boundary - `gc plan`:只读生成主 server 清理候选、估算收益、风险等级、保护对象和数据库诊断摘要。 - `gc run --confirm`:只执行当前 plan 可见候选页,默认不执行分页隐藏候选;用 `--limit`、`--result-limit`、`--full|--raw` 控制披露和执行范围。 -- `gc policy plan|install`:渲染或安装低风险长期策略,例如 journald cap、每日 allowlisted 文件/tmp 清理 timer、24 小时 VPN 诊断 pcap retention、14 天 `.state` artifact retention 和 VS Code CachedExtensionVSIXs 下载缓存 retention。 +- `gc policy plan|install`:从 `config/unidesk-cli.yaml#gc.policyTimer` 渲染或安装低风险长期策略,例如 journald cap、每日 allowlisted 文件/tmp 清理 timer、VPN 诊断 pcap retention、`.state` artifact retention 和 VS Code CachedExtensionVSIXs 下载缓存 retention。 - `gc db-trace plan|run --confirm --before-date YYYY-MM-DD --vacuum-full`:显式 trace 遥测留存入口;涉及数据库重写时按维护窗口处理。 - `gc remote plan|run --confirm|status --job-id `:通过 UniDesk SSH 透传在 provider host 上执行受控 GC。远端长任务必须使用异步 job 和 `status` 短查询,不应让单次 SSH 等待完整 registry GC 或其他长清理。 @@ -29,6 +31,12 @@ UniDesk 的磁盘治理入口是 `bun scripts/cli.ts gc ...`。该入口用于 `gc run --confirm --include-state-artifacts` 执行前必须重新校验路径、保留期、对象类型和 symlink 状态。文件候选必须仍是 allowlist 根下的普通文件;deploy 目录候选必须仍是 `.state/deploy/exports` 或 `.state/deploy/resolve` 的直接子目录。该入口不得递归扩大成通用 `.state` 清空器,也不得选择 `.state` 根目录、allowlist 之外的目录、symlink、active worktree、runtime image 或 snapshot 状态。 +`.state` scratch 清理是另一类显式入口:`--include-state-stale-scratch` 只读取 `config/unidesk-cli.yaml#gc.stateStaleScratch` 中声明的 fileRoots/dirRoots 和 keepHours。它用于历史临时验证、性能探针、Web observe、CD scratch 等可重建对象;`.state/recovery`、`.state/codex-queue/codex-home`、`.state/deploy/work`、`.state/baidu-netdisk`、Secret/sourceRef 和 runtime snapshot 仍作为 protected 输出,不允许通过这个入口扩大删除范围。 + +Codex session 清理由 `--include-codex-sessions` 显式启用,只删除 `config/unidesk-cli.yaml#gc.codexSessions.root` 下超过 YAML keepHours 的普通 session 文件。执行前必须重新校验路径仍在 YAML root 下、对象是普通文件、未被进程打开,删除后只向上清理空目录;`auth.json`、config、profile、Secret 或其他 Codex state 永远不通过该入口删除。 + +Worktree 清理由 `--include-merged-worktrees` 显式启用,只扫描 `config/unidesk-cli.yaml#gc.mergedWorktrees.root` 下的 worktree,主 worktree、当前执行 worktree、recent worktree、未合入 YAML baseRef 或 cherry-pick 等价未吸收的 worktree 都作为 protected 输出。plan 阶段按 YAML scanBudgetMs 有界扫描,超预算对象 protected;run 阶段删除前重新执行 full `git status --untracked-files=all`、inactive 和 merge/cherry-equivalence 校验,并通过 `git worktree remove` 删除,不用手工 `rm -rf`。 + 主 server VS Code 下载缓存默认不清理。`/root/.vscode-server/data/CachedExtensionVSIXs` 只用于 VS Code extension VSIX 下载缓存,可通过显式 `--include-vscode-cached-vsix` 进入候选;执行时只允许删除该目录下符合 extension-version 命名的顶层普通文件,并按 `--vscode-cached-vsix-keep-days` 保留近期缓存。执行前必须重新校验路径、文件名、非 symlink/regular file,并用 active-file 检查确认没有进程仍打开该文件。该入口不得触碰 `/root/.vscode-server/extensions`、`/root/.vscode-server/cli/servers`、VS Code user data 或任意 session/auth state。 ## Protected Data @@ -44,7 +52,7 @@ UniDesk 的磁盘治理入口是 `bun scripts/cli.ts gc ...`。该入口用于 | `.state/deploy/work` | 部署工作目录可能包含 active rollout 上下文 | | `.state/baidu-netdisk` | Baidu Netdisk token、任务、备份和 staging 状态需单独判定 | | active worktree、runtime image、runtime snapshot state | 当前执行面和运行面 provenance,不通过 `.state` artifact retention 删除 | -| Codex sessions/auth | `~/.codex/sessions`、`~/.codex/auth.json` 等凭证和会话状态 | +| Codex auth/config | `~/.codex/auth.json`、profile 和 config 等凭证状态;session 文件只能通过显式 `--include-codex-sessions` 按 YAML retention 清理 | | VPN diagnostic evidence logs | `/root/vpn-server/logs/hy2-server-evidence.jsonl` 等 active evidence 流用于网络排障,不随 pcap retention 删除 | | VS Code installed extensions/server/user data | 已安装扩展、server 版本和用户配置不是下载缓存,只能由专门 stale-version 规则或 VS Code 自身管理 | | D601 registry storage | artifact registry retention 需使用专门入口 | @@ -55,7 +63,7 @@ UniDesk 的磁盘治理入口是 `bun scripts/cli.ts gc ...`。该入口用于 如果需要触碰上表对象,必须先补高层 UniDesk CLI 子命令、dry-run 计划、保护对象、验证命令和失败分类;不能把原生 `kubectl`、`docker prune`、`crictl rmi` 或手写 registry shell 作为长期流程。 -`gc policy install` 的每日 timer 会启用 14 天 `.state` artifact retention 和 7 天 VS Code CachedExtensionVSIXs retention,用来限制历史诊断/部署产物与 VS Code 下载缓存长期增长;手动 `gc plan/run` 仍默认不清 `.state` 或 VSIX 缓存,必须显式 `--include-state-artifacts` / `--include-vscode-cached-vsix` 才会列出或执行这些候选。policy timer 仍保护上表对象,并把输出限制在 `.state/gc/last-run.json` 和 `.state/gc/last-run.stderr`。 +`gc policy install` 的每日 timer 按 `config/unidesk-cli.yaml#gc.policyTimer` 启用 `.state` artifact retention、VPN pcap retention 和 VS Code CachedExtensionVSIXs retention,用来限制历史诊断/部署产物、tcpdump ring 文件与 VS Code 下载缓存长期增长;手动 `gc plan/run` 仍默认不清 `.state` 或 VSIX 缓存,必须显式 `--include-state-artifacts` / `--include-vscode-cached-vsix` 才会列出或执行这些候选。policy timer 仍保护上表对象,并把输出限制在 `.state/gc/last-run.json` 和 `.state/gc/last-run.stderr`。 ## Remote G14 Policy diff --git a/scripts/src/gc.ts b/scripts/src/gc.ts index 5f64e868..b8e7df6d 100644 --- a/scripts/src/gc.ts +++ b/scripts/src/gc.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; -import { closeSync, existsSync, ftruncateSync, lstatSync, mkdirSync, opendirSync, openSync, readdirSync, readSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { 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"; @@ -22,6 +22,10 @@ type GcItemKind = | "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 { @@ -51,6 +55,23 @@ interface GcOptions { 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; @@ -60,6 +81,12 @@ interface GcOptions { targetUsePercent: number | null; } +interface GcPathRoot { + id: string; + path: string; + displayPath: string; +} + interface DbTraceGcOptions { beforeDate: string | null; types: string[]; @@ -72,6 +99,21 @@ interface GcPolicyOptions { 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; @@ -112,6 +154,8 @@ interface GcPlan { summary: { candidateCount: number; returnedCandidateCount: number; + protectedCount: number; + returnedProtectedCount: number; estimatedReclaimBytes: number; estimatedReclaim: string; returnedEstimatedReclaimBytes: number; @@ -167,43 +211,9 @@ interface GcTargetSummary { safeStop: boolean; } -const DEFAULT_OPTIONS: GcOptions = { - fileLogs: true, - fileLogKeepDays: 7, - fileLogMaxBytes: 50 * 1024 * 1024, - fileLogTailBytes: 20 * 1024 * 1024, - dockerLogs: true, - dockerLogMaxBytes: 50 * 1024 * 1024, - journal: true, - journalTargetBytes: 512 * 1024 * 1024, - buildCache: true, - buildCacheUntil: "24h", - buildCacheAll: false, - tmp: true, - tmpMinAgeHours: 24, - staleTmp: false, - browserCache: false, - toolCaches: false, - vscodeStaleServers: false, - vscodeKeepServers: 2, - vscodeStaleExtensions: false, - vscodeKeepExtensionVersions: 1, - vscodeCachedVsix: false, - vscodeCachedVsixKeepDays: 7, - baiduStaging: false, - baiduStagingKeepDays: 10, - stateArtifacts: false, - stateArtifactKeepDays: 14, - vpnDiagnosticLogs: false, - vpnDiagnosticLogKeepHours: 24, - dbSummary: true, - limit: 50, - resultLimit: 50, - full: false, - targetUsePercent: null, -}; - const DEFAULT_DB_TRACE_TYPES = ["trace-stats-updated", "trace-step-created", "trace-stats-snapshot"]; +const GC_CONFIG_RELATIVE_PATH = "config/unidesk-cli.yaml"; +const GC_CONFIG_REF = `${GC_CONFIG_RELATIVE_PATH}#gc`; const TMP_PREFIX_ALLOWLIST = [ "hwlab-agent-", @@ -312,16 +322,6 @@ 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 STATE_ARTIFACT_FILE_ROOTS = [ - { id: "e2e", relativeRoot: [".state", "e2e"] }, - { id: "validation", relativeRoot: [".state", "validation"] }, - { id: "jobs", relativeRoot: [".state", "jobs"] }, - { id: "codex-queue-output-archive", relativeRoot: [".state", "codex-queue", "output-archive"] }, -] as const; -const STATE_ARTIFACT_DIR_ROOTS = [ - { id: "deploy-exports", relativeRoot: [".state", "deploy", "exports"] }, - { id: "deploy-resolve", relativeRoot: [".state", "deploy", "resolve"] }, -] as const; 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." }, @@ -405,7 +405,8 @@ export async function runGcCommand(config: UniDeskConfig, args: string[]): Promi }; } -export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIONS): GcPlan { +export function gcPlan(config: UniDeskConfig, options: GcOptions | null = null): GcPlan { + options = options ?? loadGcDefaultOptions(); const observedAt = new Date().toISOString(); const candidates: GcCandidate[] = []; const protectedItems: ProtectedGcItem[] = []; @@ -441,7 +442,7 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO kind: "browser-cache", risk: "blocked", ref: path, - sizeBytes: safePathSize(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.", }); } @@ -449,18 +450,17 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO if (options.toolCaches) { candidates.push(...collectToolCacheCandidates()); } else { - protectedItems.push(...collectProtectedToolCaches()); + protectedItems.push(...collectProtectedToolCaches(options)); } if (options.vscodeStaleServers) { candidates.push(...collectVscodeServerCandidates(options)); } else { - const vscodeSize = safePathSize(VSCODE_SERVER_ROOT); - if (vscodeSize > 0) { + if (existsSync(VSCODE_SERVER_ROOT)) { protectedItems.push({ kind: "vscode-server-cache", risk: "blocked", ref: VSCODE_SERVER_ROOT, - sizeBytes: vscodeSize, + 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.", }); } @@ -468,13 +468,12 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO if (options.vscodeStaleExtensions) { candidates.push(...collectVscodeExtensionCandidates(options)); } else { - const extensionSize = safePathSize(VSCODE_EXTENSION_ROOT); - if (extensionSize > 0) { + if (existsSync(VSCODE_EXTENSION_ROOT)) { protectedItems.push({ kind: "vscode-extension-cache", risk: "blocked", ref: VSCODE_EXTENSION_ROOT, - sizeBytes: extensionSize, + 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.", }); } @@ -482,13 +481,12 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO if (options.vscodeCachedVsix) { candidates.push(...collectVscodeCachedVsixCandidates(options, observedAt)); } else { - const cachedVsixSize = safePathSize(VSCODE_CACHED_VSIX_ROOT); - if (cachedVsixSize > 0) { + if (existsSync(VSCODE_CACHED_VSIX_ROOT)) { protectedItems.push({ kind: "vscode-cached-vsix-cache", risk: "blocked", ref: VSCODE_CACHED_VSIX_ROOT, - sizeBytes: cachedVsixSize, + 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.", }); } @@ -499,6 +497,17 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO 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)); } @@ -508,9 +517,12 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO protectedItems.push(...collectProtectedStorage(config, options)); const databaseSummary = options.dbSummary ? collectDatabaseSummary() : { skipped: true, reason: "disabled-by-option" }; const allCandidates = candidates.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); - const visibleCandidates = options.full ? allCandidates : allCandidates.slice(0, options.limit); + const 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, visibleCandidates, diskBefore, options); + const summary = summarizeCandidates(allCandidates, returnedCandidates, protectedItems, returnedProtectedItems, diskBefore, options); return { ok: true, @@ -522,7 +534,7 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO diskBefore, summary, candidates: visibleCandidates, - protected: protectedItems, + protected: visibleProtectedItems, databaseSummary, policy: { requiresRunConfirm: true, @@ -537,15 +549,17 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO "Baidu Netdisk staging root by default", "D601 registry storage", "Docker images used by containers", - "Codex sessions and auth state", - "active worktree/runtime image/snapshot state", + "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 artifact retention is opt-in for manual plan/run; --include-state-artifacts selects only stale files under .state/e2e, .state/validation, .state/jobs and .state/codex-queue/output-archive plus stale direct directories under .state/deploy/exports and .state/deploy/resolve.", + "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.", @@ -554,7 +568,8 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO }; } -export function gcRun(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIONS): GcRunResult { +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"] = []; @@ -605,7 +620,7 @@ export function gcRun(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTION } function parseGcOptions(args: string[]): GcOptions { - const options: GcOptions = { ...DEFAULT_OPTIONS }; + 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") { @@ -674,6 +689,32 @@ function parseGcOptions(args: string[]): GcOptions { 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") { @@ -714,6 +755,187 @@ function parseGcOptions(args: string[]): GcOptions { return options; } +function loadGcDefaultOptions(): GcOptions { + const configPath = rootPath(GC_CONFIG_RELATIVE_PATH); + const parsed = yamlRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, GC_CONFIG_RELATIVE_PATH); + const gc = yamlRecord(parsed.gc, GC_CONFIG_REF); + const fileLogs = yamlRecord(gc.fileLogs, `${GC_CONFIG_REF}.fileLogs`); + const dockerLogs = yamlRecord(gc.dockerLogs, `${GC_CONFIG_REF}.dockerLogs`); + const journal = yamlRecord(gc.journal, `${GC_CONFIG_REF}.journal`); + const buildCache = yamlRecord(gc.buildCache, `${GC_CONFIG_REF}.buildCache`); + const tmp = yamlRecord(gc.tmp, `${GC_CONFIG_REF}.tmp`); + const browserCache = yamlRecord(gc.browserCache, `${GC_CONFIG_REF}.browserCache`); + const toolCaches = yamlRecord(gc.toolCaches, `${GC_CONFIG_REF}.toolCaches`); + const vscode = yamlRecord(gc.vscode, `${GC_CONFIG_REF}.vscode`); + const vscodeStaleServers = yamlRecord(vscode.staleServers, `${GC_CONFIG_REF}.vscode.staleServers`); + const vscodeStaleExtensions = yamlRecord(vscode.staleExtensions, `${GC_CONFIG_REF}.vscode.staleExtensions`); + const vscodeCachedVsix = yamlRecord(vscode.cachedVsix, `${GC_CONFIG_REF}.vscode.cachedVsix`); + const baiduStaging = yamlRecord(gc.baiduStaging, `${GC_CONFIG_REF}.baiduStaging`); + const stateArtifacts = yamlRecord(gc.stateArtifacts, `${GC_CONFIG_REF}.stateArtifacts`); + const stateStaleScratch = yamlRecord(gc.stateStaleScratch, `${GC_CONFIG_REF}.stateStaleScratch`); + const codexSessions = yamlRecord(gc.codexSessions, `${GC_CONFIG_REF}.codexSessions`); + const mergedWorktrees = yamlRecord(gc.mergedWorktrees, `${GC_CONFIG_REF}.mergedWorktrees`); + const vpnDiagnosticLogs = yamlRecord(gc.vpnDiagnosticLogs, `${GC_CONFIG_REF}.vpnDiagnosticLogs`); + const databaseSummary = yamlRecord(gc.databaseSummary, `${GC_CONFIG_REF}.databaseSummary`); + const output = yamlRecord(gc.output, `${GC_CONFIG_REF}.output`); + return { + fileLogs: yamlBoolean(fileLogs.enabled, `${GC_CONFIG_REF}.fileLogs.enabled`), + fileLogKeepDays: yamlPositiveNumber(fileLogs.keepDays, `${GC_CONFIG_REF}.fileLogs.keepDays`), + fileLogMaxBytes: yamlSize(fileLogs.maxBytes, `${GC_CONFIG_REF}.fileLogs.maxBytes`), + fileLogTailBytes: yamlSize(fileLogs.tailBytes, `${GC_CONFIG_REF}.fileLogs.tailBytes`), + dockerLogs: yamlBoolean(dockerLogs.enabled, `${GC_CONFIG_REF}.dockerLogs.enabled`), + dockerLogMaxBytes: yamlSize(dockerLogs.maxBytes, `${GC_CONFIG_REF}.dockerLogs.maxBytes`), + journal: yamlBoolean(journal.enabled, `${GC_CONFIG_REF}.journal.enabled`), + journalTargetBytes: yamlSize(journal.targetBytes, `${GC_CONFIG_REF}.journal.targetBytes`), + buildCache: yamlBoolean(buildCache.enabled, `${GC_CONFIG_REF}.buildCache.enabled`), + buildCacheUntil: yamlDuration(buildCache.until, `${GC_CONFIG_REF}.buildCache.until`), + buildCacheAll: yamlBoolean(buildCache.all, `${GC_CONFIG_REF}.buildCache.all`), + tmp: yamlBoolean(tmp.enabled, `${GC_CONFIG_REF}.tmp.enabled`), + tmpMinAgeHours: yamlNonNegativeNumber(tmp.minAgeHours, `${GC_CONFIG_REF}.tmp.minAgeHours`), + staleTmp: yamlBoolean(tmp.includeStale, `${GC_CONFIG_REF}.tmp.includeStale`), + browserCache: yamlBoolean(browserCache.enabled, `${GC_CONFIG_REF}.browserCache.enabled`), + toolCaches: yamlBoolean(toolCaches.enabled, `${GC_CONFIG_REF}.toolCaches.enabled`), + vscodeStaleServers: yamlBoolean(vscodeStaleServers.enabled, `${GC_CONFIG_REF}.vscode.staleServers.enabled`), + vscodeKeepServers: yamlPositiveInteger(vscodeStaleServers.keepServers, `${GC_CONFIG_REF}.vscode.staleServers.keepServers`), + vscodeStaleExtensions: yamlBoolean(vscodeStaleExtensions.enabled, `${GC_CONFIG_REF}.vscode.staleExtensions.enabled`), + vscodeKeepExtensionVersions: yamlPositiveInteger(vscodeStaleExtensions.keepVersions, `${GC_CONFIG_REF}.vscode.staleExtensions.keepVersions`), + vscodeCachedVsix: yamlBoolean(vscodeCachedVsix.enabled, `${GC_CONFIG_REF}.vscode.cachedVsix.enabled`), + vscodeCachedVsixKeepDays: yamlPositiveInteger(vscodeCachedVsix.keepDays, `${GC_CONFIG_REF}.vscode.cachedVsix.keepDays`), + baiduStaging: yamlBoolean(baiduStaging.enabled, `${GC_CONFIG_REF}.baiduStaging.enabled`), + baiduStagingKeepDays: yamlPositiveInteger(baiduStaging.keepDays, `${GC_CONFIG_REF}.baiduStaging.keepDays`), + stateArtifacts: yamlBoolean(stateArtifacts.enabled, `${GC_CONFIG_REF}.stateArtifacts.enabled`), + stateArtifactKeepDays: yamlPositiveInteger(stateArtifacts.keepDays, `${GC_CONFIG_REF}.stateArtifacts.keepDays`), + stateArtifactFileRoots: yamlStateRoots(stateArtifacts.fileRoots, `${GC_CONFIG_REF}.stateArtifacts.fileRoots`), + stateArtifactDirRoots: yamlStateRoots(stateArtifacts.dirRoots, `${GC_CONFIG_REF}.stateArtifacts.dirRoots`), + stateStaleScratch: yamlBoolean(stateStaleScratch.enabled, `${GC_CONFIG_REF}.stateStaleScratch.enabled`), + stateStaleScratchKeepHours: yamlPositiveInteger(stateStaleScratch.keepHours, `${GC_CONFIG_REF}.stateStaleScratch.keepHours`), + stateStaleScratchFileRoots: yamlStateRoots(stateStaleScratch.fileRoots, `${GC_CONFIG_REF}.stateStaleScratch.fileRoots`), + stateStaleScratchDirRoots: yamlStateRoots(stateStaleScratch.dirRoots, `${GC_CONFIG_REF}.stateStaleScratch.dirRoots`), + codexSessions: yamlBoolean(codexSessions.enabled, `${GC_CONFIG_REF}.codexSessions.enabled`), + codexSessionKeepHours: yamlPositiveInteger(codexSessions.keepHours, `${GC_CONFIG_REF}.codexSessions.keepHours`), + codexSessionRoot: yamlAbsoluteOrRepoPath(codexSessions.root, `${GC_CONFIG_REF}.codexSessions.root`), + mergedWorktrees: yamlBoolean(mergedWorktrees.enabled, `${GC_CONFIG_REF}.mergedWorktrees.enabled`), + worktreeKeepHours: yamlPositiveInteger(mergedWorktrees.keepHours, `${GC_CONFIG_REF}.mergedWorktrees.keepHours`), + worktreeMainRoot: yamlAbsoluteOrRepoPath(mergedWorktrees.mainRoot, `${GC_CONFIG_REF}.mergedWorktrees.mainRoot`), + worktreeRoot: yamlAbsoluteOrRepoPath(mergedWorktrees.root, `${GC_CONFIG_REF}.mergedWorktrees.root`), + worktreeBaseRef: yamlString(mergedWorktrees.baseRef, `${GC_CONFIG_REF}.mergedWorktrees.baseRef`), + worktreeScanBudgetMs: yamlPositiveInteger(mergedWorktrees.scanBudgetMs, `${GC_CONFIG_REF}.mergedWorktrees.scanBudgetMs`), + worktreeCherryCheckTimeoutMs: yamlPositiveInteger(mergedWorktrees.cherryCheckTimeoutMs, `${GC_CONFIG_REF}.mergedWorktrees.cherryCheckTimeoutMs`), + worktreeEstimateSizeInPlan: yamlBoolean(mergedWorktrees.estimateSizeInPlan, `${GC_CONFIG_REF}.mergedWorktrees.estimateSizeInPlan`), + vpnDiagnosticLogs: yamlBoolean(vpnDiagnosticLogs.enabled, `${GC_CONFIG_REF}.vpnDiagnosticLogs.enabled`), + vpnDiagnosticLogKeepHours: yamlPositiveInteger(vpnDiagnosticLogs.keepHours, `${GC_CONFIG_REF}.vpnDiagnosticLogs.keepHours`), + dbSummary: yamlBoolean(databaseSummary.enabled, `${GC_CONFIG_REF}.databaseSummary.enabled`), + limit: yamlPositiveInteger(output.limit, `${GC_CONFIG_REF}.output.limit`), + resultLimit: yamlPositiveInteger(output.resultLimit, `${GC_CONFIG_REF}.output.resultLimit`), + full: yamlBoolean(output.full, `${GC_CONFIG_REF}.output.full`), + targetUsePercent: yamlOptionalUsePercent(gc.targetUsePercent, `${GC_CONFIG_REF}.targetUsePercent`), + }; +} + +function loadGcPolicyTimerConfig(): GcPolicyTimerConfig { + const configPath = rootPath(GC_CONFIG_RELATIVE_PATH); + const parsed = yamlRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, GC_CONFIG_RELATIVE_PATH); + const gc = yamlRecord(parsed.gc, GC_CONFIG_REF); + const policyTimer = yamlRecord(gc.policyTimer, `${GC_CONFIG_REF}.policyTimer`); + const journald = yamlRecord(policyTimer.journald, `${GC_CONFIG_REF}.policyTimer.journald`); + const daily = yamlRecord(policyTimer.daily, `${GC_CONFIG_REF}.policyTimer.daily`); + const vpnDiagnosticLogs = yamlRecord(daily.vpnDiagnosticLogs, `${GC_CONFIG_REF}.policyTimer.daily.vpnDiagnosticLogs`); + const stateArtifacts = yamlRecord(daily.stateArtifacts, `${GC_CONFIG_REF}.policyTimer.daily.stateArtifacts`); + const vscodeCachedVsix = yamlRecord(daily.vscodeCachedVsix, `${GC_CONFIG_REF}.policyTimer.daily.vscodeCachedVsix`); + return { + journaldSystemMaxUseBytes: yamlSize(journald.systemMaxUse, `${GC_CONFIG_REF}.policyTimer.journald.systemMaxUse`), + journaldRuntimeMaxUseBytes: yamlSize(journald.runtimeMaxUse, `${GC_CONFIG_REF}.policyTimer.journald.runtimeMaxUse`), + journaldMaxRetentionSec: yamlString(journald.maxRetentionSec, `${GC_CONFIG_REF}.policyTimer.journald.maxRetentionSec`), + buildCacheUntil: yamlDuration(daily.buildCacheUntil, `${GC_CONFIG_REF}.policyTimer.daily.buildCacheUntil`), + vpnDiagnosticLogsEnabled: yamlBoolean(vpnDiagnosticLogs.enabled, `${GC_CONFIG_REF}.policyTimer.daily.vpnDiagnosticLogs.enabled`), + vpnDiagnosticLogKeepHours: yamlPositiveInteger(vpnDiagnosticLogs.keepHours, `${GC_CONFIG_REF}.policyTimer.daily.vpnDiagnosticLogs.keepHours`), + stateArtifactsEnabled: yamlBoolean(stateArtifacts.enabled, `${GC_CONFIG_REF}.policyTimer.daily.stateArtifacts.enabled`), + stateArtifactKeepDays: yamlPositiveInteger(stateArtifacts.keepDays, `${GC_CONFIG_REF}.policyTimer.daily.stateArtifacts.keepDays`), + vscodeCachedVsixEnabled: yamlBoolean(vscodeCachedVsix.enabled, `${GC_CONFIG_REF}.policyTimer.daily.vscodeCachedVsix.enabled`), + vscodeCachedVsixKeepDays: yamlPositiveInteger(vscodeCachedVsix.keepDays, `${GC_CONFIG_REF}.policyTimer.daily.vscodeCachedVsix.keepDays`), + limit: yamlPositiveInteger(daily.limit, `${GC_CONFIG_REF}.policyTimer.daily.limit`), + resultLimit: yamlPositiveInteger(daily.resultLimit, `${GC_CONFIG_REF}.policyTimer.daily.resultLimit`), + }; +} + +function yamlRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`); + return value as Record; +} + +function yamlString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`); + if (value.includes("\0")) throw new Error(`${label} must not contain NUL`); + return value; +} + +function yamlBoolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); + return value; +} + +function yamlNonNegativeNumber(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative number`); + return value; +} + +function yamlPositiveNumber(value: unknown, label: string): number { + const number = yamlNonNegativeNumber(value, label); + if (number <= 0) throw new Error(`${label} must be greater than 0`); + return number; +} + +function yamlPositiveInteger(value: unknown, label: string): number { + const number = yamlPositiveNumber(value, label); + if (!Number.isInteger(number)) throw new Error(`${label} must be an integer`); + return number; +} + +function yamlSize(value: unknown, label: string): number { + if (typeof value === "number") { + if (!Number.isFinite(value) || value <= 0) throw new Error(`${label} must be a positive byte count`); + return Math.floor(value); + } + if (typeof value !== "string") throw new Error(`${label} must be a size string or positive byte count`); + const parsed = parseSize(value); + if (parsed === null || parsed <= 0) throw new Error(`${label} must be a positive size such as 512MiB or 50000000`); + return parsed; +} + +function yamlDuration(value: unknown, label: string): string { + const duration = yamlString(value, label); + if (!/^\d+(s|m|h|d)$/u.test(duration)) throw new Error(`${label} must look like 24h, 7d, 30m or 60s`); + return duration; +} + +function yamlOptionalUsePercent(value: unknown, label: string): number | null { + if (value === null) return null; + if (value === undefined) throw new Error(`${label} must be set to a number or null`); + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value >= 100) { + throw new Error(`${label} must be greater than 0 and smaller than 100, or null`); + } + return value; +} + +function yamlAbsoluteOrRepoPath(value: unknown, label: string): string { + const path = yamlString(value, label); + if (path.includes("..")) throw new Error(`${label} must not contain ..`); + return resolvePath(path); +} + +function yamlStateRoots(value: unknown, label: string): GcPathRoot[] { + const record = yamlRecord(value, label); + return Object.entries(record).map(([id, rawPath]) => { + if (!/^[a-z0-9._-]+$/iu.test(id)) throw new Error(`${label}.${id} must use a simple id`); + const displayPath = yamlString(rawPath, `${label}.${id}`); + if (displayPath.startsWith("/") || displayPath.includes("..") || displayPath.includes("\\")) { + throw new Error(`${label}.${id} must be a repo-relative .state path without ..`); + } + if (!displayPath.startsWith(".state/")) throw new Error(`${label}.${id} must start with .state/`); + return { id, displayPath, path: rootPath(...displayPath.split("/")) }; + }); +} + function parseDbTraceGcOptions(args: string[]): DbTraceGcOptions { const options: DbTraceGcOptions = { beforeDate: null, @@ -778,6 +1000,12 @@ function parsePositiveIntegerOption(name: string, raw: string | undefined, max: 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`); @@ -800,6 +1028,7 @@ function parseSize(raw: string): number | null { function publicOptions(options: GcOptions): Record { return { + configSource: GC_CONFIG_REF, fileLogs: options.fileLogs, fileLogKeepDays: options.fileLogKeepDays, fileLogMaxBytes: options.fileLogMaxBytes, @@ -826,6 +1055,23 @@ function publicOptions(options: GcOptions): Record { 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, @@ -1048,17 +1294,15 @@ function collectToolCacheCandidates(): GcCandidate[] { return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); } -function collectProtectedToolCaches(): ProtectedGcItem[] { +function collectProtectedToolCaches(options: GcOptions): ProtectedGcItem[] { const result: ProtectedGcItem[] = []; for (const item of TOOL_CACHE_ALLOWLIST) { if (!existsSync(item.path)) continue; - const sizeBytes = safePathSize(item.path); - if (sizeBytes <= 0) continue; result.push({ kind: "tool-cache", risk: "blocked", ref: item.path, - sizeBytes, + sizeBytes: protectedPathSize(item.path, options), reason: "Rebuildable tool cache is not removed by default; rerun with --include-tool-caches for explicit one-time cleanup.", }); } @@ -1209,8 +1453,8 @@ function collectStateArtifactCandidates(options: GcOptions, observedAt: string): 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 STATE_ARTIFACT_FILE_ROOTS) { - const root = rootPath(...rootInfo.relativeRoot); + 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; @@ -1223,7 +1467,7 @@ function collectStateArtifactFileCandidates(options: GcOptions, observedAt: stri path: file.path, sizeBytes: file.sizeBytes, estimatedReclaimBytes: file.sizeBytes, - action: { op: "unlink", allowlist: "state-artifact-file", root: rootInfo.relativeRoot.join("/"), keepDays: options.stateArtifactKeepDays }, + action: { op: "unlink", allowlist: "state-artifact-file", root: rootInfo.displayPath, keepDays: options.stateArtifactKeepDays }, }); } } @@ -1233,8 +1477,8 @@ function collectStateArtifactFileCandidates(options: GcOptions, observedAt: stri 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 STATE_ARTIFACT_DIR_ROOTS) { - const root = rootPath(...rootInfo.relativeRoot); + 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; @@ -1256,13 +1500,336 @@ function collectStateArtifactDirCandidates(options: GcOptions, observedAt: strin path, sizeBytes, estimatedReclaimBytes: sizeBytes, - action: { op: "rm-recursive", allowlist: "state-artifact-direct-dir", root: rootInfo.relativeRoot.join("/"), keepDays: options.stateArtifactKeepDays }, + action: { op: "rm-recursive", allowlist: "state-artifact-direct-dir", root: rootInfo.displayPath, keepDays: options.stateArtifactKeepDays }, }); } } return result; } +function collectStateStaleScratchCandidates(options: GcOptions, observedAt: string): GcCandidate[] { + const dirCandidates = collectStateStaleScratchDirCandidates(options, observedAt); + const selectedDirRoots = dirCandidates + .map((candidate) => candidate.path) + .filter((path): path is string => path !== undefined) + .map((path) => `${resolve(path)}/`); + const fileCandidates = collectStateStaleScratchFileCandidates(options, observedAt) + .filter((candidate) => { + if (candidate.path === undefined) return true; + const resolved = resolve(candidate.path); + return !selectedDirRoots.some((root) => resolved.startsWith(root)); + }); + return [...dirCandidates, ...fileCandidates].sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); +} + +function collectStateStaleScratchFileCandidates(options: GcOptions, observedAt: string): GcCandidate[] { + const cutoffMs = new Date(observedAt).getTime() - options.stateStaleScratchKeepHours * 60 * 60 * 1000; + const result: GcCandidate[] = []; + for (const rootInfo of options.stateStaleScratchFileRoots) { + const root = rootInfo.path; + if (!isPlainDirectory(root)) continue; + for (const file of collectFiles(root)) { + if (file.mtimeMs >= cutoffMs || file.sizeBytes <= 0) continue; + const relativePath = file.path.slice(resolve(root).length + 1); + result.push({ + id: `state-stale-scratch-file:${rootInfo.id}:${relativePath}`, + kind: "state-stale-scratch-file-delete", + risk: "medium", + description: `Delete stale UniDesk .state scratch file older than ${options.stateStaleScratchKeepHours} hours`, + path: file.path, + sizeBytes: file.sizeBytes, + estimatedReclaimBytes: file.sizeBytes, + action: { op: "unlink", allowlist: "state-stale-scratch-file", root: rootInfo.displayPath, keepHours: options.stateStaleScratchKeepHours }, + }); + } + } + return result; +} + +function collectStateStaleScratchDirCandidates(options: GcOptions, observedAt: string): GcCandidate[] { + const cutoffMs = new Date(observedAt).getTime() - options.stateStaleScratchKeepHours * 60 * 60 * 1000; + const result: GcCandidate[] = []; + for (const rootInfo of options.stateStaleScratchDirRoots) { + const root = rootInfo.path; + if (!isPlainDirectory(root)) continue; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const path = join(root, entry.name); + let stat; + try { + stat = lstatSync(path); + } catch { + continue; + } + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.mtimeMs >= cutoffMs) continue; + const sizeBytes = safePathSize(path); + if (sizeBytes <= 0) continue; + result.push({ + id: `state-stale-scratch-dir:${rootInfo.id}:${entry.name}`, + kind: "state-stale-scratch-dir-delete", + risk: "medium", + description: `Delete stale UniDesk .state scratch directory older than ${options.stateStaleScratchKeepHours} hours`, + path, + sizeBytes, + estimatedReclaimBytes: sizeBytes, + action: { op: "rm-recursive", allowlist: "state-stale-scratch-direct-dir", root: rootInfo.displayPath, keepHours: options.stateStaleScratchKeepHours }, + }); + } + } + return result; +} + +function collectCodexSessionCandidates(options: GcOptions, observedAt: string): GcCandidate[] { + if (!isPlainDirectory(options.codexSessionRoot)) return []; + const cutoffMs = new Date(observedAt).getTime() - options.codexSessionKeepHours * 60 * 60 * 1000; + const result: GcCandidate[] = []; + for (const file of collectFiles(options.codexSessionRoot)) { + if (file.mtimeMs >= cutoffMs || file.sizeBytes <= 0) continue; + const relativePath = file.path.slice(resolve(options.codexSessionRoot).length + 1); + result.push({ + id: `codex-session:${relativePath}`, + kind: "codex-session-file-delete", + risk: "medium", + description: `Delete inactive Codex session file older than ${options.codexSessionKeepHours} hours`, + path: file.path, + sizeBytes: file.sizeBytes, + estimatedReclaimBytes: file.sizeBytes, + action: { op: "unlink", allowlist: "codex-session-file", keepHours: options.codexSessionKeepHours, valuesPrinted: false, activeCheck: "fuser-before-delete" }, + }); + } + return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); +} + +interface MergedWorktreePlan { + candidates: GcCandidate[]; + protectedItems: ProtectedGcItem[]; +} + +interface GitWorktreeEntry { + path: string; + head: string | null; + branch: string | null; + detached: boolean; + bare: boolean; +} + +interface GitMergeContext { + gitRoot: string; + baseRef: string; + originMaster: string; + reachableCommits: Set; + cherryCheckTimeoutMs: number; +} + +function collectMergedWorktreeCandidates(options: GcOptions): MergedWorktreePlan { + const candidates: GcCandidate[] = []; + const protectedItems: ProtectedGcItem[] = []; + const mainRoot = resolve(options.worktreeMainRoot); + const worktreeRoot = resolve(options.worktreeRoot); + const currentRoot = resolve(repoRoot); + const currentCwd = resolve(process.cwd()); + const entries = gitWorktreeEntries(mainRoot); + const mergeContext = gitMergeContext(mainRoot, options); + const scanStartedAt = Date.now(); + if (!mergeContext.ok) { + return { + candidates, + protectedItems: entries + .map((entry) => resolve(entry.path)) + .filter((path) => path.startsWith(`${worktreeRoot}/`)) + .map((path) => protectedPathItem("worktree-merge-context-error", path, mergeContext.reason)), + }; + } + for (const entry of entries) { + const path = resolve(entry.path); + if (!path.startsWith(`${worktreeRoot}/`)) continue; + if (Date.now() - scanStartedAt > options.worktreeScanBudgetMs) { + protectedItems.push(protectedPathItem("worktree-scan-budget-exceeded", path, `Merged worktree scan exceeded YAML budget ${options.worktreeScanBudgetMs}ms; rerun with a larger config/unidesk-cli.yaml#gc.mergedWorktrees.scanBudgetMs or inspect this worktree directly.`)); + continue; + } + if (path === currentRoot || path === currentCwd || path === resolve(mainRoot) || entry.bare) { + protectedItems.push(protectedPathItem("active-worktree", path, "Active/current worktree is protected and is not selected for merged worktree cleanup.")); + continue; + } + if (!isPlainDirectory(path)) { + protectedItems.push(protectedPathItem("worktree-missing", path, "Worktree path is not a plain directory; use git worktree prune/manual audit rather than gc removal.")); + continue; + } + if (worktreeHasRecentActivity(path, options.worktreeKeepHours)) { + protectedItems.push({ + kind: "recent-worktree", + risk: "blocked", + ref: path, + reason: `Worktree has file or directory activity within ${options.worktreeKeepHours} hours.`, + }); + continue; + } + if (entry.head === null) { + protectedItems.push(protectedPathItem("worktree-head-missing", path, "Worktree HEAD is missing from git worktree list output; gc will not remove it.")); + continue; + } + const merge = gitCommitSemanticMergeStatus(mergeContext, entry.head); + if (!merge.ok) { + protectedItems.push(protectedPathItem("worktree-merge-check-error", path, merge.reason)); + continue; + } + if (!merge.merged) { + protectedItems.push({ + kind: "unmerged-worktree", + risk: "blocked", + ref: path, + reason: `Worktree HEAD is not merged into or cherry-equivalent to origin/master; unabsorbedCommitCount=${merge.unabsorbedCommitCount}.`, + }); + continue; + } + const sizeBytes = options.worktreeEstimateSizeInPlan ? safePathSize(path, 300) : 0; + candidates.push({ + id: `merged-worktree:${basename(path)}`, + kind: "merged-worktree-remove", + risk: "medium", + description: `Remove clean merged UniDesk worktree inactive for at least ${options.worktreeKeepHours} hours`, + path, + sizeBytes, + estimatedReclaimBytes: sizeBytes, + action: { + op: "git-worktree-remove", + allowlist: "unidesk-dot-worktree", + worktreeRoot, + branch: entry.branch, + head: entry.head, + originMaster: merge.originMaster, + mergeMode: merge.mode, + planCleanCheck: "deferred-to-run", + runCleanCheck: "full-status-with-untracked", + sizeEstimate: options.worktreeEstimateSizeInPlan ? "du" : "deferred-to-run", + minAgeHours: options.worktreeKeepHours, + }, + }); + } + return { + candidates: candidates.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes), + protectedItems, + }; +} + +function gitWorktreeEntries(mainRoot: string): GitWorktreeEntry[] { + const result = command(["git", "-C", mainRoot, "worktree", "list", "--porcelain"], 10000); + if (result.exitCode !== 0) return []; + const entries: GitWorktreeEntry[] = []; + let current: GitWorktreeEntry | null = null; + for (const line of result.stdout.split(/\r?\n/u)) { + if (line.length === 0) { + if (current !== null) entries.push(current); + current = null; + continue; + } + const [key, ...rest] = line.split(" "); + const value = rest.join(" "); + if (key === "worktree") { + if (current !== null) entries.push(current); + current = { path: value, head: null, branch: null, detached: false, bare: false }; + } else if (current !== null && key === "HEAD") { + current.head = value; + } else if (current !== null && key === "branch") { + current.branch = value.replace(/^refs\/heads\//u, ""); + } else if (current !== null && key === "detached") { + current.detached = true; + } else if (current !== null && key === "bare") { + current.bare = true; + } + } + if (current !== null) entries.push(current); + return entries; +} + +function worktreeHasRecentActivity(path: string, keepHours: number): boolean { + try { + const stat = lstatSync(path); + const cutoffMs = Date.now() - keepHours * 60 * 60 * 1000; + return stat.mtimeMs >= cutoffMs; + } catch { + return true; + } +} + +function gitWorktreeStatus(path: string, includeUntracked = true): { ok: true; clean: boolean; changedPathCount: number } | { ok: false; reason: string } { + const untrackedMode = includeUntracked ? "all" : "no"; + const result = command(["git", "-C", path, "status", "--porcelain=v1", `--untracked-files=${untrackedMode}`], 5000); + if (result.exitCode !== 0 || result.timedOut) { + return { ok: false, reason: result.timedOut ? "git status timed out" : result.stderr.trim() || `git status exited ${result.exitCode}` }; + } + const lines = result.stdout.trim().length === 0 ? [] : result.stdout.trim().split(/\r?\n/u); + return { ok: true, clean: lines.length === 0, changedPathCount: lines.length }; +} + +function gitWorktreeTrackedClean(path: string): { ok: true; clean: boolean } | { ok: false; reason: string } { + const result = command(["git", "-C", path, "diff-index", "--quiet", "HEAD", "--"], 3000); + if (result.exitCode === 0) return { ok: true, clean: true }; + if (result.exitCode === 1) return { ok: true, clean: false }; + return { ok: false, reason: result.timedOut ? "git diff-index timed out" : result.stderr.trim() || `git diff-index exited ${result.exitCode}` }; +} + +function gitMergeContext(gitRoot: string, options: GcOptions): { ok: true } & GitMergeContext | { ok: false; reason: string } { + const baseRef = options.worktreeBaseRef; + const base = command(["git", "-C", gitRoot, "rev-parse", "--verify", `${baseRef}^{commit}`], 10000); + if (base.exitCode !== 0 || base.timedOut) { + return { ok: false, reason: base.timedOut ? `git rev-parse ${baseRef} timed out` : base.stderr.trim() || `cannot resolve ${baseRef}` }; + } + const reachable = command(["git", "-C", gitRoot, "rev-list", baseRef], 30000); + if (reachable.exitCode !== 0 || reachable.timedOut) { + return { ok: false, reason: reachable.timedOut ? `git rev-list ${baseRef} timed out` : reachable.stderr.trim() || `git rev-list ${baseRef} exited ${reachable.exitCode}` }; + } + return { + ok: true, + gitRoot, + baseRef, + originMaster: base.stdout.trim(), + reachableCommits: new Set(reachable.stdout.trim().length === 0 ? [] : reachable.stdout.trim().split(/\r?\n/u)), + cherryCheckTimeoutMs: options.worktreeCherryCheckTimeoutMs, + }; +} + +function gitWorktreeSemanticMergeStatus(path: string, options: GcOptions): { + ok: true; + merged: boolean; + mode: "ancestor" | "cherry-equivalent" | "unmerged"; + originMaster: string; + unabsorbedCommitCount: number; +} | { ok: false; reason: string } { + const head = command(["git", "-C", path, "rev-parse", "--verify", "HEAD^{commit}"], 10000); + if (head.exitCode !== 0 || head.timedOut) { + return { ok: false, reason: head.timedOut ? "git rev-parse HEAD timed out" : head.stderr.trim() || "cannot resolve worktree HEAD" }; + } + const context = gitMergeContext(path, options); + if (!context.ok) return context; + return gitCommitSemanticMergeStatus(context, head.stdout.trim()); +} + +function gitCommitSemanticMergeStatus(context: GitMergeContext, head: string): { + ok: true; + merged: boolean; + mode: "ancestor" | "cherry-equivalent" | "unmerged"; + originMaster: string; + unabsorbedCommitCount: number; +} | { ok: false; reason: string } { + if (context.reachableCommits.has(head)) { + return { ok: true, merged: true, mode: "ancestor", originMaster: context.originMaster, unabsorbedCommitCount: 0 }; + } + const cherry = command(["git", "-C", context.gitRoot, "rev-list", "--cherry-pick", "--right-only", "--count", `${context.baseRef}...${head}`], context.cherryCheckTimeoutMs); + if (cherry.exitCode !== 0 || cherry.timedOut) { + return { ok: false, reason: cherry.timedOut ? "git cherry-equivalence check timed out" : cherry.stderr.trim() || `git rev-list --cherry-pick exited ${cherry.exitCode}` }; + } + const unabsorbedCommitCount = Number(cherry.stdout.trim()); + if (!Number.isFinite(unabsorbedCommitCount)) return { ok: false, reason: `git rev-list --cherry-pick returned non-numeric count: ${cherry.stdout.trim()}` }; + return { + ok: true, + merged: unabsorbedCommitCount === 0, + mode: unabsorbedCommitCount === 0 ? "cherry-equivalent" : "unmerged", + originMaster: context.originMaster, + unabsorbedCommitCount, + }; +} + function collectVpnDiagnosticPcapCandidates(options: GcOptions, observedAt: string): GcCandidate[] { if (!existsSync(VPN_DIAGNOSTIC_LOG_ROOT)) return []; const cutoffMs = new Date(observedAt).getTime() - options.vpnDiagnosticLogKeepHours * 60 * 60 * 1000; @@ -1293,8 +1860,8 @@ function collectVpnDiagnosticPcapCandidates(options: GcOptions, observedAt: stri function collectProtectedStateArtifacts(options: GcOptions): ProtectedGcItem[] { const result: ProtectedGcItem[] = []; - for (const rootInfo of [...STATE_ARTIFACT_FILE_ROOTS, ...STATE_ARTIFACT_DIR_ROOTS]) { - const ref = rootPath(...rootInfo.relativeRoot); + 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, @@ -1303,11 +1870,26 @@ function collectProtectedStateArtifacts(options: GcOptions): ProtectedGcItem[] { : "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("codex-sessions", "/root/.codex/sessions", "Codex sessions are protected and are not selected by UniDesk gc."), + 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."), @@ -1327,13 +1909,12 @@ function protectedPathItem(kind: string, ref: string, reason: string): Protected function collectProtectedVpnDiagnosticLogs(options: GcOptions): ProtectedGcItem[] { const result: ProtectedGcItem[] = []; if (!existsSync(VPN_DIAGNOSTIC_LOG_ROOT)) return result; - const rootSize = safePathSize(VPN_DIAGNOSTIC_LOG_ROOT); - if (rootSize > 0) { + 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: rootSize, + 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.", @@ -1345,7 +1926,7 @@ function collectProtectedVpnDiagnosticLogs(options: GcOptions): ProtectedGcItem[ kind: "vpn-diagnostic-evidence-log", risk: "blocked", ref: evidencePath, - sizeBytes: safeFileSize(evidencePath), + sizeBytes: protectedFileSize(evidencePath, options), reason: "Evidence JSONL is an active diagnostic stream and is not removed by gc pcap retention.", }); } @@ -1373,7 +1954,7 @@ function collectProtectedStorage(config: UniDeskConfig, options: GcOptions): Pro kind: options.baiduStaging ? "baidu-netdisk-staging-root" : "baidu-netdisk-staging", risk: "blocked", ref: baiduStaging, - sizeBytes: safePathSize(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.", @@ -1523,6 +2104,7 @@ function gcDbTraceRun(options: DbTraceGcOptions): unknown { } function gcPolicyPlan(options: GcPolicyOptions): unknown { + const timerConfig = loadGcPolicyTimerConfig(); const files = gcPolicyFiles(); return { ok: true, @@ -1541,9 +2123,10 @@ function gcPolicyPlan(options: GcPolicyOptions): unknown { journalRestart: ["systemctl", "restart", "systemd-journald"], }, policy: { + configSource: `${GC_CONFIG_REF}.policyTimer`, safeScope: [ - "systemd journal is capped at 512MiB", - "daily timer runs file-log, Docker json logs, 24h BuildKit cache, allowlisted /tmp gc, 24h VPN diagnostic pcap retention, 14-day UniDesk .state artifact retention and 7d VS Code CachedExtensionVSIXs retention", + "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", ], @@ -1593,17 +2176,32 @@ function gcPolicyInstall(options: GcPolicyOptions): unknown { } function gcPolicyFiles(): Record { + const timerConfig = loadGcPolicyTimerConfig(); const gcStateDir = rootPath(".state", "gc"); const bunPath = bunExecutablePath(); - const gcScript = `cd ${shellQuote(repoRoot)} && mkdir -p ${shellQuote(gcStateDir)} && ${shellQuote(bunPath)} scripts/cli.ts gc run --confirm --no-db-summary --no-journal --build-cache-until 24h --include-vpn-diagnostic-logs --vpn-diagnostic-log-keep-hours 24 --include-state-artifacts --state-artifact-keep-days 14 --include-vscode-cached-vsix --vscode-cached-vsix-keep-days 7 --limit 5000 --result-limit 25 > ${shellQuote(join(gcStateDir, "last-run.json"))} 2> ${shellQuote(join(gcStateDir, "last-run.stderr"))}`; + 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=512M", - "RuntimeMaxUse=128M", - "MaxRetentionSec=7day", + `SystemMaxUse=${timerConfig.journaldSystemMaxUseBytes}`, + `RuntimeMaxUse=${timerConfig.journaldRuntimeMaxUseBytes}`, + `MaxRetentionSec=${timerConfig.journaldMaxRetentionSec}`, "", ].join("\n"), }, @@ -1760,6 +2358,35 @@ function executeCandidate(candidate: GcCandidate, options: GcOptions): { reclaim 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); @@ -1812,6 +2439,21 @@ function ftruncateFile(path: string, size: number): void { } } +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}`); @@ -1879,7 +2521,7 @@ function assertBaiduStagingCandidatePath(path: string): void { 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 = matchingStateArtifactFileRoot(resolved); + 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}`); @@ -1891,7 +2533,7 @@ function assertStateArtifactFileCandidatePath(path: string, options: GcOptions): 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 = matchingStateArtifactDirRoot(resolved); + 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("/")) { @@ -1902,18 +2544,63 @@ function assertStateArtifactDirCandidatePath(path: string, options: GcOptions): assertStateArtifactAge(stat.mtimeMs, options.stateArtifactKeepDays, path); } -function matchingStateArtifactFileRoot(resolved: string): string | null { - for (const rootInfo of STATE_ARTIFACT_FILE_ROOTS) { - const root = resolve(rootPath(...rootInfo.relativeRoot)); - if (!isPlainDirectory(root)) continue; - if (resolved.startsWith(`${root}/`)) return root; - } - return null; +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 matchingStateArtifactDirRoot(resolved: string): string | null { - for (const rootInfo of STATE_ARTIFACT_DIR_ROOTS) { - const root = resolve(rootPath(...rootInfo.relativeRoot)); +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; } @@ -1925,6 +2612,11 @@ function assertStateArtifactAge(mtimeMs: number, keepDays: number, path: string) 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); @@ -1946,7 +2638,34 @@ function assertPathNotOpen(path: string): void { } } -function summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCandidate[], diskBefore: DiskSnapshot | null, options: GcOptions): GcPlan["summary"] { +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) { @@ -1961,6 +2680,8 @@ function summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCa return { candidateCount: candidates.length, returnedCandidateCount: returnedCandidates.length, + protectedCount: protectedItems.length, + returnedProtectedCount: returnedProtectedItems.length, estimatedReclaimBytes, estimatedReclaim: formatBytes(estimatedReclaimBytes), returnedEstimatedReclaimBytes, @@ -2062,6 +2783,14 @@ function safeFileSize(path: string): number { } } +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); } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index f0aeb380..42f686d5 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -296,6 +296,7 @@ function gcHelp(): unknown { "bun scripts/cli.ts gc run --confirm --build-cache-all --include-browser-cache", "bun scripts/cli.ts gc run --confirm --include-browser-cache", "bun scripts/cli.ts gc plan --target-use-percent 59 --include-state-artifacts --state-artifact-keep-days 14 --full", + "bun scripts/cli.ts gc plan --include-codex-sessions --include-merged-worktrees --include-state-stale-scratch", "bun scripts/cli.ts gc db-trace plan --before-date 2026-05-25", "bun scripts/cli.ts gc db-trace run --confirm --before-date 2026-05-25 --vacuum-full", "bun scripts/cli.ts gc policy plan", @@ -306,11 +307,12 @@ function gcHelp(): unknown { "bun scripts/cli.ts gc remote G14 status --job-id ", "bun scripts/cli.ts gc plan --full", ], - description: "Plan or execute bounded one-time disk relief for file logs, Docker json logs, systemd journal, Docker BuildKit cache, allowlisted /tmp artifacts, opt-in UniDesk .state artifact retention, scoped remote core dumps and explicitly scoped database trace telemetry retention.", + description: "Plan or execute YAML-configured bounded one-time disk relief for file logs, Docker json logs, systemd journal, Docker BuildKit cache, allowlisted /tmp artifacts, opt-in UniDesk .state/session/worktree retention, scoped remote core dumps and explicitly scoped database trace telemetry retention.", safety: { default: "plan is read-only and mutation=false", runGuard: "run requires --confirm", - protected: ["PostgreSQL PGDATA", "Docker volumes", "Docker images", ".state/recovery", ".state/codex-queue/codex-home", ".state/deploy/work", ".state/baidu-netdisk", "Codex sessions/auth", "active worktree/runtime image/snapshot", "D601 registry storage"], + configSource: "config/unidesk-cli.yaml#gc owns retention windows, include defaults, worktree roots/baseRef, scan budgets, output limits and .state allowlist roots", + protected: ["PostgreSQL PGDATA", "Docker volumes", "Docker images", ".state/recovery", ".state/codex-queue/codex-home", ".state/deploy/work", ".state/baidu-netdisk", "Codex auth/config", "active/unmerged/dirty worktree/runtime image/snapshot", "D601 registry storage"], database: "default gc run is database diagnostic-only; gc db-trace is the explicit trace telemetry retention path and requires --confirm plus --vacuum-full", }, options: { @@ -336,9 +338,14 @@ function gcHelp(): unknown { "--include-browser-cache": "also remove repo-local .state/playwright-browsers cache", "--include-state-artifacts": "manual local gc only: opt in to stale UniDesk .state artifact retention for allowlisted diagnostic files and deploy artifact direct directories", "--state-artifact-keep-days N": "keep recent UniDesk .state artifacts for N days; default 14; must be a positive integer", + "--include-state-stale-scratch": "manual local gc only: opt in to YAML allowlisted stale .state scratch roots; roots and keepHours come from config/unidesk-cli.yaml#gc", + "--include-codex-sessions": "manual local gc only: delete inactive session files under YAML codexSessions.root after codexSessions.keepHours; never deletes auth/config", + "--include-merged-worktrees": "manual local gc only: remove inactive .worktree entries whose HEAD is merged into or cherry-equivalent to YAML baseRef; run rechecks full clean status before deletion", + "--worktree-scan-budget-ms N": "temporary override for YAML mergedWorktrees.scanBudgetMs; over-budget worktrees are protected, not deleted", + "--worktree-cherry-check-timeout-ms N": "temporary override for YAML mergedWorktrees.cherryCheckTimeoutMs", "db-trace --before-date YYYY-MM-DD": "plan or delete default trace telemetry event types before the date", "db-trace run --vacuum-full": "rewrite public.oa_events after deletion so df can reclaim disk; requires maintenance window", - "policy plan|install": "render or install journald caps and a daily file-log, allowlisted /tmp, VPN pcap and 14-day UniDesk .state artifact low-risk gc systemd timer", + "policy plan|install": "render or install journald caps and a daily low-risk gc systemd timer from config/unidesk-cli.yaml#gc.policyTimer", "remote plan|run": "run bounded GC through UniDesk SSH passthrough on a provider host; G14 protects HWLAB k3s/runtime/PVC/workspace paths, and HWLAB registry retention is explicit opt-in with workload-ref, digest-closure, recent-tag and per-repo tag protection", "--no-file-logs|--no-docker-logs|--no-journal|--no-build-cache|--no-tmp|--no-db-summary": "disable one collector", },