From c9ffeaf7751f56d577cfdaec56e9db4ede43f9c2 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 17:44:39 +0000 Subject: [PATCH] feat: add gc disk relief command --- AGENTS.md | 1 + docs/reference/cli.md | 1 + scripts/cli.ts | 9 + scripts/src/gc.ts | 1093 +++++++++++++++++++++++++++++++++++++++++ scripts/src/help.ts | 43 ++ 5 files changed, 1147 insertions(+) create mode 100644 scripts/src/gc.ts diff --git a/AGENTS.md b/AGENTS.md index 39be2c98..2687ea46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server swap status|ensure [--path /swapfile] [--size 2GiB] [--dry-run]`:以 JSON 查看或幂等创建主 server swapfile,`ensure` 输出 before/after、动作、持久化状态和 degraded/failed 详情,规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server logs [--tail-bytes N]`:分页返回文件日志与 Docker 日志尾部并带截断元数据,日志规则见 `docs/reference/observability.md`。 - `bun scripts/cli.ts server cleanup plan [--min-age-hours N] [--limit N]`:只读/干跑生成主 server Docker 镜像清理计划,默认只列出至少 24 小时前创建的非保护镜像,输出 active/protected images、stale candidates、预计释放空间、风险等级和必须人工确认的 `docker image rm` 命令;禁止默认删除、禁止 prune、禁止触碰 database volume、registry storage 或 Baidu Netdisk 状态。 +- `bun scripts/cli.ts gc plan|run|db-trace --confirm`:主 server 磁盘高水位一次性缓解入口,覆盖日志、journald、Docker BuildKit cache、allowlisted `/tmp` 诊断目录和显式 trace 遥测留存;默认 `gc run` 不碰数据库,`gc db-trace` 需要单独确认和 `--vacuum-full`,规则见 `docs/reference/cli.md`。 - `bun scripts/cli.ts server rebuild `:以 build-first、Compose lock、no-deps force-recreate 和 post-up validation 的异步 job 重建主 server Compose 内单个服务;对 database、File Browser、Code Queue 执行面、k3sctl-adapter 或未知对象返回结构化 `unsupported-server-rebuild`,规则见 `docs/reference/deployment.md` 与 `docs/reference/cicd-standardization.md`。 - `bun scripts/cli.ts provider attach [--master-server URL] [--up] [--force]` / `bun scripts/cli.ts provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]`:前者在新增计算节点上生成两项配置的 provider-gateway 挂载包;后者是只读多信号健康裁决入口,默认低噪声输出 `decision`、`healthyScopes`、`failedScopes`、`retryable` 和异常信号摘要,用来把单路径 `provider is not online`、SSH 超时、registry 失败或 proxy 失败归类为 `retryable-transient`、`service-degraded` 或 `global-offline`,完整 evidence 需显式 `--full|--raw`,规则见 `docs/reference/provider-gateway.md` 和 `docs/reference/code-queue-supervision.md`。 - `bun scripts/cli.ts ssh [operation args...]` / `tran [operation args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥进入 provider、host workspace、Windows cmd route、k3s 控制面或 pod workspace,并提供带 SHA-256 校验的 `upload`/`download` 文件传输;主 server 人工/Codex 分布式操作必须用本机 `tran` wrapper,CLI 参考和可移植脚本可保留完整命令,细则见 `docs/reference/cli.md`、`docs/reference/windows-passthrough.md` 和 `docs/reference/provider-gateway.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e575c168..652adbb3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -25,6 +25,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P - `server swap status|ensure [--path /swapfile] [--size 2GiB] [--dry-run]` 是主 server swap 管理入口。`status` 仅读 `/proc/meminfo`、`/proc/swaps` 和 `/etc/fstab` 并返回 JSON;`ensure` 在已有任何 active swap 时只报告 no-op,在无 active swap 时创建固定 swapfile、`chmod 600`、`mkswap`、`swapon` 并尽量写入 `/etc/fstab`。输出必须包含 `before`、`after`、total memory、active swap、持久化状态、关键动作和错误详情;若 swap 已启用但 fstab 写入失败,状态为 `degraded`,调用者需按返回的 detail 修复持久化。 - `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。实现必须只读取文件末尾字节,不得为了 tail 先把巨大日志完整读入 CLI 内存。 - `server cleanup plan [--min-age-hours N] [--limit N]` 只生成主 server Docker 镜像清理 dry-run 计划,不执行删除;默认 `--min-age-hours 24`,避免把刚发布或刚验证的镜像列为 stale。输出必须包含 `dryRun=true`、`mutation=false`、`policy.deletionExecuted=false`、active containers/images、受保护镜像、candidate stale images、估算释放空间、风险等级、`commandsToReview` 和人工审批清单。计划必须保守白名单:保留 running containers 使用的 image ID,保留 stopped containers 引用的 image ID 直到人工先复核容器,保留 `deploy.json`/`CI.json` 当前 commit-pinned artifact、Compose stable image、上游 digest pin 和 provider-gateway runner image;`protectedStorage` 必须显式列出 PostgreSQL named volume、Baidu Netdisk `.state`、D601 registry storage 和 Docker volumes/host data policy。该入口禁止生成或执行 `docker system prune`、`docker image prune`、`docker builder prune`、`docker volume rm`、`docker compose down -v`、数据库清理或 host data `rm` 命令;未来若增加真实删除,必须另设显式审批参数并先复核 dry-run 输出。 +- `gc plan|run --confirm` 是主 server 磁盘高水位的一次性短期缓解入口,覆盖 UniDesk 文件日志、Docker `json-file` 容器日志、systemd journal、Docker BuildKit cache 和 allowlisted `/tmp` 诊断目录。`gc plan` 默认只读并返回候选、风险、估算释放空间、受保护对象和数据库只读摘要;候选列表默认按释放空间排序限制为 50 条,`--limit N` 调整页大小,`--full|--raw` 才展开全部候选。输出中的 `estimatedReclaim` 表示全量候选估算,`returnedEstimatedReclaim` 表示当前输出页估算。`gc run` 必须显式 `--confirm`,只执行当前输出候选中的短期清理动作,因此默认不会执行被分页隐藏的候选。`gc` 不删除 Docker image/container/volume,不触碰 PostgreSQL PGDATA,不删除 Baidu Netdisk staging/backups 或 D601 registry storage;默认路径中数据库只做诊断摘要。`gc db-trace plan|run --confirm --before-date YYYY-MM-DD --vacuum-full` 是显式数据库 trace 遥测留存入口,只删除 `oa_events` 中默认 trace 高频事件类型在指定日期前的数据,并在 `--vacuum-full` 下重写 `public.oa_events` 让 `df` 真正回收磁盘;执行前必须确认近期 PostgreSQL basebackup,且应视为维护窗口操作。常用参数包括 `--logs-keep-days N`、`--file-log-max-bytes SIZE`、`--file-log-tail-bytes SIZE`、`--docker-log-max-bytes SIZE`、`--journal-target-size SIZE`、`--build-cache-until 24h`、显式清空全部 BuildKit cache 的 `--build-cache-all`、`--tmp-min-age-hours N` 和显式 `--include-browser-cache`。 - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `dev-frontend-proxy` 只更新主 server dev 入口薄代理,`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 和 `oa-event-flow` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。D601 Code Queue 执行面不由 `server rebuild` 管理,Rust backend-core 迭代不得用 `server rebuild backend-core` 在 master server 编译,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`ssh argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 - `ssh [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows cmd 入口固定写 `tran D601:win cmd `,需要 Windows cwd 时用 `tran D601:win/c/test cmd cd`,由 CLI 自动设置 `chcp 65001`、`PYTHONUTF8=1` 和 `PYTHONIOENCODING=utf-8`;命名只允许 `win`,不得使用 `win32`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'`、`tran G14:k3s script <<'SCRIPT'` 或 `tran G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin。`script -- '<单个字符串>'` 是无需 stdin 的远端 shell one-liner,例如 `tran G14:/root/hwlab script -- 'cd /root/hwlab && git status --short --branch'`;`script -- <多个 argv>` 才是 direct argv,适合 `tran D601:/path script -- sed -n '1,20p' file` 这类带短横线的单进程命令。顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 ` upload ` 和 ` download `,CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `:k3s:: apply-patch-v1` 或 ` apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 7fe13841..e8fc9664 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -26,6 +26,7 @@ import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from import { runServerCleanupCommand } from "./src/server-cleanup"; import { runHwlabCdCommand } from "./src/hwlab-cd"; import { runHwlabG14Command } from "./src/hwlab-g14"; +import { runGcCommand } from "./src/gc"; const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); const args = remoteOptions.args; @@ -389,6 +390,14 @@ async function main(): Promise { } } + if (top === "gc") { + const result = await runGcCommand(config, args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + if (top === "microservice") { const result = await runMicroserviceCommand(config, args.slice(1)); const ok = resultOk(result); diff --git a/scripts/src/gc.ts b/scripts/src/gc.ts new file mode 100644 index 00000000..7b0cb7a9 --- /dev/null +++ b/scripts/src/gc.ts @@ -0,0 +1,1093 @@ +import { spawnSync } from "node:child_process"; +import { closeSync, existsSync, ftruncateSync, lstatSync, openSync, readdirSync, readSync, rmSync, statSync, unlinkSync, writeSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +import { type UniDeskConfig, repoRoot, rootPath } from "./config"; + +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" + | "browser-cache-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; + browserCache: boolean; + dbSummary: boolean; + limit: number; + full: boolean; +} + +interface DbTraceGcOptions { + beforeDate: string | null; + types: string[]; + vacuumFull: boolean; + confirm: boolean; +} + +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; +} + +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; + diskBefore: DiskSnapshot | null; + summary: { + candidateCount: number; + returnedCandidateCount: number; + estimatedReclaimBytes: number; + estimatedReclaim: string; + returnedEstimatedReclaimBytes: number; + returnedEstimatedReclaim: string; + byKind: Record; + }; + 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; + diskBefore: DiskSnapshot | null; + diskAfter: DiskSnapshot | null; + summary: { + plannedCandidateCount: number; + attemptedCount: number; + succeededCount: number; + failedCount: number; + estimatedReclaimBytes: number; + actualDiskReclaimBytes: number | null; + }; + results: Array; + protected: ProtectedGcItem[]; +} + +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, + browserCache: false, + dbSummary: true, + limit: 50, + full: false, +}; + +const DEFAULT_DB_TRACE_TYPES = ["trace-stats-updated", "trace-step-created", "trace-stats-snapshot"]; + +const TMP_PREFIX_ALLOWLIST = [ + "hwlab-agent-", + "hwlab-cd-", + "hwlab-cli-cicd-", + "hwlab-codeagent-trace", + "hwlab-desired-state-", + "hwlab-main-", + "hwlab-merge-", + "hwlab-pr", + "hwlab-refresh-", + "playwright-artifacts-", + "playwright_chromiumdev_profile-", + "unidesk-clean-", + "unidesk-code-queue", + "unidesk-hwlab-cd-", + "unidesk-pr", + "unidesk-tran-runner", + "bunx-", + "codex-app-schema", + "codex-app-ts", + "marked-", + "node-compile-cache", +]; + +const TMP_EXACT_PROTECT = new Set([ + "/tmp/codex-apply-patch", + "/tmp/codex-ipc", + "/tmp/tmux-0", +]); + +export async function runGcCommand(config: UniDeskConfig, args: string[]): Promise { + const [action = "plan", ...rest] = args; + 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"], + }; +} + +export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIONS): GcPlan { + 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.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: safePathSize(path), + reason: "Playwright browser cache is not removed by default; rerun with --include-browser-cache if this cache is approved for one-time cleanup.", + }); + } + } + + protectedItems.push(...collectProtectedStorage(config)); + const databaseSummary = options.dbSummary ? collectDatabaseSummary() : { skipped: true, reason: "disabled-by-option" }; + const allCandidates = candidates.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes); + const visibleCandidates = options.full ? allCandidates : allCandidates.slice(0, options.limit); + const summary = summarizeCandidates(allCandidates, visibleCandidates); + + return { + ok: true, + action: "gc plan", + dryRun: true, + mutation: false, + observedAt, + options: publicOptions(options), + diskBefore: rootDiskSnapshot(), + summary, + candidates: visibleCandidates, + protected: protectedItems, + databaseSummary, + policy: { + requiresRunConfirm: true, + runCommand: "bun scripts/cli.ts gc run --confirm", + neverTouches: [ + "Docker volumes", + "PostgreSQL PGDATA", + "Baidu Netdisk staging/backups", + "D601 registry storage", + "Docker images used by containers", + ], + 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.`, + "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 = DEFAULT_OPTIONS): GcRunResult { + 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; + + 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, + }, + results, + protected: plan.protected, + }; +} + +function parseGcOptions(args: string[]): GcOptions { + const options: GcOptions = { ...DEFAULT_OPTIONS }; + 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-browser-cache") { + options.browserCache = true; + } else if (arg === "--no-browser-cache") { + options.browserCache = false; + } 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 === "--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 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 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 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 { + return { + 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, + browserCache: options.browserCache, + dbSummary: options.dbSummary, + limit: options.limit, + full: options.full, + }; +} + +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); + 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 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 collectProtectedStorage(config: UniDeskConfig): 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: "baidu-netdisk-staging", + risk: "blocked", + ref: baiduStaging, + sizeBytes: safePathSize(baiduStaging), + reason: "Baidu Netdisk staging may contain backups or transfer state and is not touched by gc.", + }); + } + 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 sqlLiteral(value: string): string { + return `'${value.replace(/'/gu, "''")}'`; +} + +function sqlStringArray(values: string[]): string { + return `ARRAY[${values.map(sqlLiteral).join(",")}]`; +} + +function parseCountBytes(result: ReturnType): 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): 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 === "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 === "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 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 summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCandidate[]): 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, + estimatedReclaimBytes, + estimatedReclaim: formatBytes(estimatedReclaimBytes), + returnedEstimatedReclaimBytes, + returnedEstimatedReclaim: formatBytes(returnedEstimatedReclaimBytes), + byKind, + }; +} + +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 safePathSize(path: string): number { + try { + const stat = lstatSync(path); + if (stat.isFile() || stat.isSymbolicLink()) return stat.size; + if (!stat.isDirectory()) return 0; + let total = 0; + for (const entry of readdirSync(path)) { + total += safePathSize(join(path, entry)); + } + return total; + } catch { + return 0; + } +} + +function safeFileSize(path: string): number { + try { + return statSync(path).size; + } catch { + return 0; + } +} + +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; + const config = typeof record.Config === "object" && record.Config !== null ? record.Config as Record : {}; + 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]}`; +} diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 116cb515..6d5b031c 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -18,6 +18,7 @@ export function rootHelp(): unknown { { command: "server swap status|ensure [--path /swapfile] [--size 2GiB] [--dry-run]", description: "Inspect or idempotently create host swap for low-memory main-server operation." }, { command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." }, { command: "server cleanup plan [--min-age-hours N] [--limit N]", description: "Dry-run Docker image cleanup plan only: list active/protected images, stale candidates older than the default 24h threshold, risk, estimated reclaim, and manual review commands without deleting anything." }, + { command: "gc plan|run|db-trace --confirm [--logs-keep-days N] [--include-browser-cache]", description: "One-time main-server disk relief for logs, journald, Docker build cache, allowlisted /tmp artifacts and explicit trace telemetry retention; plan is read-only and run requires --confirm." }, { command: "server rebuild ", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." }, { command: "provider attach [--master-server URL] [--up] [--force] | provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." }, { command: "ssh [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `G14:k3s` or `D601:win/c/test` only locates distributed targets." }, @@ -260,6 +261,47 @@ function providerHelp(): unknown { }; } +function gcHelp(): unknown { + return { + command: "gc plan|run|db-trace", + output: "json", + usage: [ + "bun scripts/cli.ts gc plan", + "bun scripts/cli.ts gc run --confirm", + "bun scripts/cli.ts gc plan --logs-keep-days 7 --docker-log-max-bytes 50M --journal-target-size 512M", + "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 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 plan --full", + ], + description: "Plan or execute bounded one-time main-server disk relief for file logs, Docker json logs, systemd journal, Docker BuildKit cache, allowlisted /tmp artifacts 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", "Baidu Netdisk staging/backups", "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: { + "--logs-keep-days N": "delete UniDesk file logs older than N days; default 7", + "--file-log-max-bytes SIZE": "compact newer large file logs above SIZE; default 50M", + "--file-log-tail-bytes SIZE": "tail bytes kept when compacting file logs; default 20M", + "--docker-log-max-bytes SIZE": "truncate Docker json-file logs above SIZE; default 50M", + "--journal-target-size SIZE": "vacuum systemd journal to SIZE; default 512M", + "--build-cache-until DURATION": "prune Docker builder cache unused for duration; default 24h", + "--build-cache-all": "prune all Docker builder cache without an until filter", + "--tmp-min-age-hours N": "delete allowlisted /tmp artifacts older than N hours; default 24", + "--limit N": "number of candidates returned and executed by run when --full is not set; default 50", + "--full|--raw": "return and run against all candidates rather than the default bounded page", + "--include-browser-cache": "also remove repo-local .state/playwright-browsers cache", + "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", + "--no-file-logs|--no-docker-logs|--no-journal|--no-build-cache|--no-tmp|--no-db-summary": "disable one collector", + }, + reference: "docs/reference/cli.md", + }; +} + function commanderHelp(): unknown { return { command: "commander contract|plan|smoke|approval|prompt-lint", @@ -530,6 +572,7 @@ export function staticNamespaceHelp(args: string[]): unknown | null { if (top === "microservice") return microserviceHelp(); if (top === "decision" || top === "decision-center") return decisionHelp(); if (top === "provider") return providerHelp(); + if (top === "gc") return gcHelp(); if (top === "commander") return commanderHelp(); if (top === "schedule") return scheduleHelp(); if (top === "codex") return codexHelp();