From 77ef2291d75a0df9f3af2e3ee8d8c9f2c0d32ed1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 09:39:54 -0700 Subject: [PATCH 1/2] Add a Disk section: see where the bytes went, and get them back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every recreation runs `docker commit`, which stacks a layer and never rewrites one, and 24 conditions in `container_needs_recreation` trigger a recreation. Prevention landed earlier on this branch; this is the half a user can act on. The per-project table leads with the two numbers that explain the mechanism rather than just the total: how many commit layers a snapshot has stacked above its base, and what the container's writable layer will add at the next commit. Backend (`docker/disk.rs`, commands in `docker_commands.rs`): - `get_docker_disk_usage` — one `df()` joined against the project store, behind an explicit Scan button because it walks the whole daemon. - `list_reclaimable` / `reclaim` — classified buckets with measured bytes, planned off the existing report so re-planning costs no second scan. - `destroy_project_disk_object` — one object, typed confirmation. - `sweep_orphaned_snapshots` — exposed, so its report is finally visible. Safety is structural: `reclaim` takes `ReclaimTarget`, which has no variant that can name a live project's data. Destructive work is a separate type reached only through `destroy`. No unfiltered prune is called anywhere, and nothing outside a `triple-c*` name or `triple-c.*` label is touched. Orphan detection subtracts ids from the project store and consults nothing else. From the daemon's side an idle live project and a deleted one are indistinguishable — volumes present, no container, no image — so inferring from container or image absence would offer a live project's credentials and transcripts for deletion. A store that loaded empty from an existing `projects.json` is treated as a failed load, not as "no projects", because `ProjectsStore::new()` recovers from a corrupt file by starting empty. Three things verified against a live Docker 29.7.2 rather than assumed: - Compaction is a two-stage build (`FROM scratch` + `COPY --from`), which keeps every byte inside the daemon; bollard's import buffers a whole image into memory. uid/gid and setuid survive; a 192.6 MB/4-layer synthetic came out 45.7 MB/1 layer. Image config does not survive, so it is replayed via create+commit, which round-trips a multi-line env var that a Dockerfile `ENV` could not. - Flattening breaks base-layer sharing, so the result carries its own copy of the base. Eight of ten real projects had a 0.10–1.32 GB delta over a 4.72 GB shared base — compacting those costs ~4 GB. The bound now subtracts that penalty, such projects are not offered at all, and the run compares unique bytes and abandons a rewrite that would grow. - `docker builder prune` reports `Total:`, not `Total reclaimed space:`, so the first parser scored every prune as freeing nothing. The Windows/WSL2 note is mandatory and its copy lives in Rust beside the tests that pin it: pruning frees space inside `ext4.vhdx`, which never shrinks on its own, so C: does not change until the disk is compacted. Also adds `lib/formatBytes.ts` — the app had four disagreeing copies, and `projects/home/format.ts` and `migrationCopy.ts` now delegate to it with byte-identical output. Base 1000 by default, matching what Docker prints. Tests: 502 frontend (was 453), 365 Rust (was 322). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- app/src-tauri/src/commands/docker_commands.rs | 77 + app/src-tauri/src/docker/container.rs | 37 +- app/src-tauri/src/docker/disk.rs | 3052 +++++++++++++++++ app/src-tauri/src/docker/disk_tests.rs | 823 +++++ app/src-tauri/src/docker/mod.rs | 5 + app/src-tauri/src/lib.rs | 6 + app/src/components/projects/home/format.ts | 14 +- app/src/components/projects/migrationCopy.ts | 10 +- .../components/settings/DiskProjectTable.tsx | 168 + .../components/settings/DiskSettings.test.tsx | 510 +++ app/src/components/settings/DiskSettings.tsx | 512 +++ app/src/components/settings/SettingsPanel.tsx | 5 + .../components/ui/TypedConfirmModal.test.tsx | 110 + app/src/components/ui/TypedConfirmModal.tsx | 113 + app/src/hooks/useDiskUsage.test.tsx | 161 + app/src/hooks/useDiskUsage.ts | 114 + app/src/lib/formatBytes.test.ts | 87 + app/src/lib/formatBytes.ts | 86 + app/src/lib/tauri-commands.ts | 33 +- app/src/lib/types.ts | 195 ++ 20 files changed, 6098 insertions(+), 20 deletions(-) create mode 100644 app/src-tauri/src/docker/disk.rs create mode 100644 app/src-tauri/src/docker/disk_tests.rs create mode 100644 app/src/components/settings/DiskProjectTable.tsx create mode 100644 app/src/components/settings/DiskSettings.test.tsx create mode 100644 app/src/components/settings/DiskSettings.tsx create mode 100644 app/src/components/ui/TypedConfirmModal.test.tsx create mode 100644 app/src/components/ui/TypedConfirmModal.tsx create mode 100644 app/src/hooks/useDiskUsage.test.tsx create mode 100644 app/src/hooks/useDiskUsage.ts create mode 100644 app/src/lib/formatBytes.test.ts create mode 100644 app/src/lib/formatBytes.ts diff --git a/app/src-tauri/src/commands/docker_commands.rs b/app/src-tauri/src/commands/docker_commands.rs index d8f7a32..6dcc687 100644 --- a/app/src-tauri/src/commands/docker_commands.rs +++ b/app/src-tauri/src/commands/docker_commands.rs @@ -54,3 +54,80 @@ pub async fn list_sibling_containers() -> Result, String> .collect(); Ok(result) } + +// --------------------------------------------------------------------------- +// Disk +// --------------------------------------------------------------------------- +// +// The disk view's IPC surface. It lives here rather than in a module of its own +// for the same reason `check_image_exists` does: these are thin shims over +// `crate::docker`, and the logic they call is in `docker/disk.rs` where it can +// be unit-tested without a daemon. + +/// Measure where the daemon's bytes have gone. +/// +/// **Expensive on purpose.** This is `GET /system/df` plus an `image_history` +/// per distinct image, and `df()` walks every image, container and volume on +/// the daemon to compute shared-layer sizes. On a 100 GB store that is seconds. +/// The frontend must keep it behind an explicit Scan button — never on panel +/// open, never on a timer. +#[tauri::command] +pub async fn get_docker_disk_usage( + state: State<'_, AppState>, +) -> Result { + let projects = state.projects_store.list(); + docker::disk::scan(&projects).await +} + +/// Everything that could be reclaimed, each with its measured cost. +/// +/// Takes the report from [`get_docker_disk_usage`] rather than re-measuring, so +/// a user who re-plans after ticking a box does not pay for a second `df()`. +#[tauri::command] +pub async fn list_reclaimable( + report: docker::disk::DiskUsageReport, + state: State<'_, AppState>, +) -> Result { + let projects = state.projects_store.list(); + docker::disk::list_reclaimable(&projects, &report).await +} + +/// Run the ticked targets and report what each one actually freed. +/// +/// `ReclaimTarget` cannot express a destructive action — that is a different +/// type, reached only through [`destroy_project_disk_object`] with a typed +/// confirmation — so there is no selection a user can build here that deletes a +/// live project's data. +#[tauri::command] +pub async fn reclaim( + targets: Vec, + state: State<'_, AppState>, +) -> Result { + let projects = state.projects_store.list(); + Ok(docker::disk::reclaim(&targets, &projects).await) +} + +/// Delete one object that has no other copy, against a typed confirmation of +/// the project's name. +/// +/// Deliberately one target per call: this is never part of a bulk action. +#[tauri::command] +pub async fn destroy_project_disk_object( + target: docker::disk::DestructiveTarget, + confirmation: String, + state: State<'_, AppState>, +) -> Result { + let projects = state.projects_store.list(); + docker::disk::destroy(&target, &confirmation, &projects).await +} + +/// Run the orphaned-snapshot sweep on demand and return its report. +/// +/// The sweep already runs at startup, after every recreation and after a +/// migration settles, but every one of those callers throws the report away — +/// so a user has never been able to see that 11.9 GB of superseded images were +/// found and left because a stopped container still pinned them. +#[tauri::command] +pub async fn sweep_orphaned_snapshots() -> Result { + Ok(docker::sweep_orphaned_snapshots().await) +} diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 3ccdfba..16a86dc 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -216,13 +216,13 @@ pub const SECRET_ENV_KEYS: &[&str] = &[ /// `docker commit` copies a container's labels onto the image, every snapshot it /// commits. [`sweep_orphaned_snapshots`] treats it as the mark of provenance, /// which is what keeps the sweep away from the user's own images. -const LABEL_MANAGED: &str = "triple-c.managed"; +pub(crate) const LABEL_MANAGED: &str = "triple-c.managed"; /// Marks the image built from `container/Dockerfile` itself, as opposed to a /// project snapshot committed from a container. Only ever `"true"` on a base /// image; `create_container` writes it explicitly empty so an inherited value /// cannot travel onto a snapshot. See the `LABEL` block in the Dockerfile. -const LABEL_BASE: &str = "triple-c.base"; +pub(crate) const LABEL_BASE: &str = "triple-c.base"; const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; @@ -1541,7 +1541,7 @@ pub async fn create_container( // container stop/start cycles. mounts.push(Mount { target: Some("/home/claude".to_string()), - source: Some(format!("triple-c-home-{}", project.id)), + source: Some(home_volume_name(&project.id)), typ: Some(MountTypeEnum::VOLUME), read_only: Some(false), ..Default::default() @@ -1551,7 +1551,7 @@ pub async fn create_container( // inside the home volume; Docker gives the more-specific mount precedence. mounts.push(Mount { target: Some("/home/claude/.claude".to_string()), - source: Some(format!("triple-c-claude-config-{}", project.id)), + source: Some(config_volume_name(&project.id)), typ: Some(MountTypeEnum::VOLUME), read_only: Some(false), ..Default::default() @@ -1877,6 +1877,29 @@ pub fn get_snapshot_image_name(project: &Project) -> String { format!("triple-c-snapshot-{}:latest", project.id) } +/// Name of the named volume mounted at `/home/claude`. +/// +/// Takes the id rather than the `Project` because the disk view runs this +/// mapping backwards: it reads volume names off the daemon and has to decide +/// which project — if any — each one belongs to. See [`HOME_VOLUME_PREFIX`]. +pub fn home_volume_name(project_id: &str) -> String { + format!("{}{}", HOME_VOLUME_PREFIX, project_id) +} + +/// Name of the named volume mounted at `/home/claude/.claude`, nested inside +/// the home volume. This is the one holding the OAuth credential, the plugins +/// and every session transcript. +pub fn config_volume_name(project_id: &str) -> String { + format!("{}{}", CONFIG_VOLUME_PREFIX, project_id) +} + +/// Prefix of [`home_volume_name`]. Split out because orphan detection scans the +/// daemon's volume list for these prefixes and strips them back to a project id. +pub const HOME_VOLUME_PREFIX: &str = "triple-c-home-"; + +/// Prefix of [`config_volume_name`]. See [`HOME_VOLUME_PREFIX`]. +pub const CONFIG_VOLUME_PREFIX: &str = "triple-c-claude-config-"; + /// Keep the container's `~/.aws/credentials` in sync with the project's Bedrock /// auth on every container start: /// - **Bedrock + static credentials**: (re)write `~/.aws/credentials` from the @@ -2058,7 +2081,7 @@ const SCRUB_MARKER: &str = "###TRIPLE-C-SCRUBBED "; /// matches nothing is a no-op rather than an `rm` of a literal path. /// Inside the loop `$p` is quoted, so a filename containing whitespace is one /// argument. -fn snapshot_scrub_script() -> String { +pub(crate) fn snapshot_scrub_script() -> String { format!( r#"total=0 for p in {paths}; do @@ -2650,8 +2673,8 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> { pub async fn remove_project_volumes(project: &Project) -> Result<(), String> { let docker = get_docker()?; for vol in [ - format!("triple-c-home-{}", project.id), - format!("triple-c-claude-config-{}", project.id), + home_volume_name(&project.id), + config_volume_name(&project.id), ] { match docker.remove_volume(&vol, None).await { Ok(_) => log::info!("Removed volume {}", vol), diff --git a/app/src-tauri/src/docker/disk.rs b/app/src-tauri/src/docker/disk.rs new file mode 100644 index 0000000..ef0d818 --- /dev/null +++ b/app/src-tauri/src/docker/disk.rs @@ -0,0 +1,3052 @@ +//! Disk accounting and reclaim for the objects Triple-C creates. +//! +//! ## The problem this exists to make visible +//! +//! Every recreation runs `docker commit`, and a commit **stacks a new layer** +//! rather than rewriting one. A file deleted after it has been committed does +//! not give its bytes back — the layer above records a whiteout and the +//! original bytes stay below it forever. `container_needs_recreation` has 24 +//! conditions, so changing one settings field costs a multi-gigabyte layer that +//! nothing in the app ever reclaims. One project was measured at 14 stacked +//! commit layers, ~5.1 GB above its base, 12.3 GB total. +//! +//! Prevention already landed (the pre-commit scrub, capped container logs, +//! base-image labels, the startup sweep, the migration-pin reaper). What was +//! missing is the half a user can act on: *seeing* where the bytes are, and +//! being able to get them back. That is this module. +//! +//! **Making the layer count visible is the point.** "Snapshot 12.3 GB / 14 +//! layers / next commit adds 868 MB" explains the growth mechanism in one row, +//! which no total ever does. +//! +//! ## Why the scan is explicit +//! +//! [`scan`] is built on `Docker::df()` (`GET /system/df`), which walks every +//! image, container and volume on the daemon and computes shared-layer sizes. +//! On a 100 GB store that is seconds, not milliseconds, and it is the *only* +//! call that populates `ImageSummary::shared_size`, `ContainerSummary::size_rw` +//! and `VolumeUsageData::size` at all. So it sits behind a Scan button and is +//! never run on panel open or on a timer. +//! +//! ## Safety, which is the whole design +//! +//! Reclaim targets are split across **two Rust types that cannot be confused +//! for one another**: +//! +//! * [`ReclaimTarget`] — safe and semi-safe work. Everything here is either +//! already unreachable (dangling images, ownerless rollback pins, probe and +//! scrub leftovers), regenerable (build cache, package caches), or a rewrite +//! that preserves content (snapshot compaction). [`reclaim`] accepts these. +//! * [`DestructiveTarget`] — a live project's home volume, config volume, +//! snapshot image, or a rollback pin whose migration is still awaiting +//! confirmation. [`destroy`] accepts these, one at a time, and only against a +//! typed confirmation of the project name. +//! +//! `reclaim` does not have a code path that can reach a `DestructiveTarget` — +//! it cannot be passed one. That is deliberate: the guarantee is in the type +//! system rather than in a runtime check somebody can forget to write. +//! +//! Two rules apply throughout, and both are inherited from +//! `sweep_orphaned_snapshots`: +//! +//! * **Never call an unfiltered prune.** `prune_images`/`prune_volumes` without +//! filters would reach the user's own postgres, mysql and site-builder work +//! on the same daemon. Every removal here names one object we created. +//! * **Only ever touch a `triple-c*` name or a `triple-c.*` label.** + +use std::collections::{HashMap, HashSet}; + +use bollard::container::ListContainersOptions; +use bollard::image::{ListImagesOptions, RemoveImageOptions}; +use bollard::models::{BuildCache, ContainerSummary, ImageSummary, Volume}; +use serde::{Deserialize, Serialize}; + +use super::client::get_docker; +use super::container::{ + self, config_volume_name, home_volume_name, get_snapshot_image_name, CONFIG_VOLUME_PREFIX, + HOME_VOLUME_PREFIX, LABEL_BASE, LABEL_MANAGED, +}; +use super::migration; +use crate::models::Project; +use crate::storage::migration_store; + +/// Default age filter for a build-cache prune, matching `docker builder prune +/// --filter until=168h`. A week is long enough that an active build tree keeps +/// its warm cache and short enough that abandoned trees are collected. +pub const BUILD_CACHE_DEFAULT_UNTIL_HOURS: i64 = 168; + +// --------------------------------------------------------------------------- +// Scan result +// --------------------------------------------------------------------------- + +/// One row of the per-project table — the mental model users actually have. +/// +/// Serde is plain snake_case, matching every other IPC struct in this crate and +/// `app/src/lib/types.ts`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ProjectDiskRow { + pub project_id: String, + pub project_name: String, + /// `triple-c-snapshot-{id}:latest`, present whether or not it exists yet. + pub snapshot_image: String, + pub snapshot_exists: bool, + /// Total size of the snapshot image, base image included. + pub snapshot_bytes: i64, + /// Bytes of the snapshot that are *also* in some other image — almost + /// always the shared base. Only `df()` computes this. + pub snapshot_shared_bytes: i64, + /// How many layers the snapshot has stacked **above its base image**. This + /// is the number that explains the growth: one per recreation. + pub snapshot_commit_layers: u32, + /// Bytes those stacked layers account for. `None` when the base image the + /// snapshot descends from is no longer on the daemon, so the split cannot + /// be measured and must not be guessed. + pub snapshot_above_base_bytes: Option, + pub container_exists: bool, + pub container_running: bool, + /// The container's writable layer — i.e. **exactly what the next commit + /// will add** to the snapshot. Surfaced under that name in the UI. + pub container_writable_bytes: i64, + pub home_volume_bytes: i64, + pub home_volume_present: bool, + pub config_volume_bytes: i64, + pub config_volume_present: bool, + pub total_bytes: i64, + /// A migration is in flight; every action on this row is blocked. + pub migrating: bool, +} + +/// A base image, shared by every project built from it. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct BaseImageRow { + pub reference: String, + pub bytes: i64, + pub shared_bytes: i64, + /// Containers still built from it, as `df()` counts them. A base with + /// `containers > 0` cannot be removed and is not offered. + pub containers: i64, + /// Carries `triple-c.base=true`. + pub is_labelled_base: bool, +} + +/// Where the daemon actually keeps its bytes, and whether the Windows/WSL2 +/// caveat applies. See [`WSL2_VHDX_NOTE`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct HostStorage { + /// `docker info`'s `DockerRootDir`. On Docker Desktop this is a path + /// *inside the VM*, not something the host can stat — which is the whole + /// reason the WSL2 note exists. + pub docker_root_dir: String, + /// `docker info`'s `OperatingSystem`, e.g. `"Docker Desktop"`. + pub operating_system: String, + pub is_docker_desktop: bool, + /// The app itself is running on Windows. + pub is_windows_host: bool, + /// Windows + Docker Desktop: pruning frees space *inside* `ext4.vhdx` and + /// returns nothing to `C:` until the disk is compacted. + pub vhdx_applies: bool, + /// [`WSL2_VHDX_NOTE`], [`WSL2_VHDX_FIX`] and [`WSL2_VHDX_FIX_GUI`], carried + /// over IPC rather than restated in the frontend. + /// + /// A second copy of this copy in TypeScript would drift from the one the + /// Rust tests pin, and this is the paragraph that stops a user reporting + /// "I pruned and C: did not change" as a bug. Empty when the caveat does + /// not apply, so the UI has nothing to decide. + pub vhdx_note: String, + pub vhdx_fix: Vec, + pub vhdx_fix_gui: String, +} + +/// Everything one Scan produces. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct DiskUsageReport { + /// RFC3339. The UI shows how stale the numbers are rather than refreshing + /// them, because a refresh costs another `df()`. + pub scanned_at: String, + pub projects: Vec, + pub base_images: Vec, + pub base_images_bytes: i64, + /// Dangling `triple-c.managed=true` images — superseded snapshot commits. + pub orphan_image_bytes: i64, + pub orphan_image_count: usize, + /// `triple-c-home-*` / `triple-c-claude-config-*` volumes belonging to no + /// project in the store. Empty — and `orphan_volumes_unavailable` set — + /// when the store could not be read. + pub orphan_volumes: Vec, + pub orphan_volume_bytes: i64, + pub orphan_volumes_unavailable: Option, + pub build_cache: BuildCacheUsage, + /// Daemon-wide totals, for context: the user's unrelated work lives here + /// too, and the per-project rows will not add up to `docker system df`. + pub images_total_bytes: i64, + pub containers_total_bytes: i64, + pub volumes_total_bytes: i64, + /// Sum of the per-project rows — the part of the daemon that is ours. + pub triple_c_total_bytes: i64, + pub host: HostStorage, +} + +/// Build-cache figures, and where they came from. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct BuildCacheUsage { + pub total_bytes: i64, + pub reclaimable_bytes: i64, + /// Bytes a `--filter until=168h` prune would reach. + pub stale_bytes: i64, + /// `"buildx du"` or `"system df"`. + /// + /// **`docker system df` under-reports build-cache reclaimable while + /// `docker buildx du` reports it correctly** (df only counts records with + /// no parent as reclaimable). The buildx figure is preferred whenever the + /// CLI is reachable; the field says which one is on screen so a user + /// comparing against their terminal is not left guessing. + pub source: String, + /// Set when the `docker` CLI could not be run, so `source` fell back. + pub cli_error: Option, +} + +/// A per-project volume whose project id is not in Triple-C's project store. +/// +/// **Not "a volume with no container".** See [`orphan_volumes`] for why that +/// distinction is the whole safety property. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct OrphanVolume { + pub name: String, + /// The project id parsed out of the name. Shown, because a user who + /// recognises it may want to recover it rather than delete it. + pub project_id: String, + pub bytes: i64, + /// `"home"` or `"config"`. The config volume is the one that held Claude + /// credentials and transcripts, so it is worth saying which is which. + pub role: String, + /// When Docker created the volume, from `df()`'s own metadata. + /// + /// Evidence, not bookkeeping: a size and a UUID identify nothing, and this + /// is the only cheap fact that lets a user recognise which project a + /// candidate was before deleting it. It costs no extra call. + /// + /// **Never inspect a volume by mounting it.** `docker run -v :/path` + /// *creates* the volume when it does not exist, so a "just look inside" + /// probe can conjure the very thing it was checking for. Everything shown + /// about a volume here comes from `df()` metadata. + pub created_at: Option, +} + +// --------------------------------------------------------------------------- +// Reclaim targets +// --------------------------------------------------------------------------- + +/// How much trust an action needs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Safety { + /// Already unreachable, or regenerated on demand. One button, no + /// confirmation. + Safe, + /// Reversible in substance but not in time — a rewrite, or a cache the user + /// pays to refill. One clear confirmation. + SemiSafe, +} + +/// Work [`reclaim`] is allowed to do. +/// +/// **This type cannot express a destructive action.** Adding a variant that +/// deletes a live project's data would be the mistake this split exists to +/// prevent; put it on [`DestructiveTarget`] instead. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ReclaimTarget { + /// Dangling `triple-c.managed=true` images that are *not* base images — + /// the superseded snapshot commits every recreation leaves behind. This is + /// `sweep_orphaned_snapshots`, made visible and runnable on demand. + DanglingSnapshots, + /// Dangling `triple-c.managed=true` images that *are* base images + /// (`triple-c.base=true`). Same sweep, reported separately because a user + /// recognises "the old sandbox image" and not "a dangling commit". + SupersededBaseImages, + /// `docker builder prune`. **Daemon-wide, not Triple-C-only.** + BuildCache { + /// `true` prunes everything; `false` filters `until=168h`. + all: bool, + }, + /// `pre-migration-*` tags no migration record claims. Untagged only — the + /// image becomes dangling and the sweep collects it under its own rules. + MigrationPins, + /// `{id}-payload.tar` staging files with no record beside them. These are + /// host files under the user's data dir, **not** inside the daemon's + /// storage — on Windows they sit on `C:` directly rather than in the vhdx, + /// so this is the one bucket that gives space back to `C:` immediately. + MigrationStaging, + /// Containers labelled `triple-c.probe=migration`. + ProbeContainers, + /// `triple-c-scrub-*` containers left by an interrupted secret rewrite. + ScrubContainers, + /// One orphaned volume, ticked individually by name. + OrphanVolume { name: String }, + /// Rewrite a project's stacked commit layers into a single layer. The + /// highest-yield action in this module. + CompactSnapshot { project_id: String }, + /// `rm -rf` the regenerable package caches in a running container's home + /// volume. + ClearCaches { + project_id: String, + /// `~/.rustup/toolchains` — regenerable, but expensive to re-download, + /// so it is a separate tick rather than part of the set. + include_rustup: bool, + }, +} + +/// Work [`destroy`] is allowed to do, one item at a time, against a typed +/// confirmation. Every variant deletes something with no other copy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DestructiveTarget { + /// The project's home volume: shell history, dotfiles, installed + /// toolchains, Playwright browsers. + HomeVolume { project_id: String }, + /// The project's config volume: **Claude credentials, plugins and every + /// conversation transcript**. + ConfigVolume { project_id: String }, + /// The project's snapshot image: every package the agent ever installed. + /// The project falls back to the base image on its next start. + SnapshotImage { project_id: String }, + /// A rollback pin whose migration is still awaiting confirmation — the only + /// copy of that migration's rollback target. + RollbackPin { project_id: String, tag: String }, +} + +impl ReclaimTarget { + /// How much confirmation the UI must ask for. Pure, and pinned by a test + /// that walks every variant. + pub fn safety(&self) -> Safety { + match self { + // Unreachable already, or a host file nothing refers to. + ReclaimTarget::DanglingSnapshots + | ReclaimTarget::SupersededBaseImages + | ReclaimTarget::MigrationPins + | ReclaimTarget::MigrationStaging + | ReclaimTarget::ProbeContainers + | ReclaimTarget::ScrubContainers + | ReclaimTarget::OrphanVolume { .. } + | ReclaimTarget::BuildCache { .. } => Safety::Safe, + // A rewrite and a cache flush: nothing is lost, but time is. + ReclaimTarget::CompactSnapshot { .. } | ReclaimTarget::ClearCaches { .. } => { + Safety::SemiSafe + } + } + } + + /// Whether the action reaches beyond Triple-C's own objects. + /// + /// Only the build cache does, and the UI has to say so out loud: the same + /// daemon holds the user's unrelated postgres/mysql/site-builder work, and + /// a prune takes their warm cache with ours. + pub fn is_daemon_wide(&self) -> bool { + matches!(self, ReclaimTarget::BuildCache { .. }) + } + + /// The project this acts on, when it acts on one. + pub fn project_id(&self) -> Option<&str> { + match self { + ReclaimTarget::CompactSnapshot { project_id } + | ReclaimTarget::ClearCaches { project_id, .. } => Some(project_id), + _ => None, + } + } +} + +impl DestructiveTarget { + pub fn project_id(&self) -> &str { + match self { + DestructiveTarget::HomeVolume { project_id } + | DestructiveTarget::ConfigVolume { project_id } + | DestructiveTarget::SnapshotImage { project_id } + | DestructiveTarget::RollbackPin { project_id, .. } => project_id, + } + } +} + +/// One offered action, with its measured cost. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ReclaimItem { + pub target: ReclaimTarget, + pub safety: Safety, + pub daemon_wide: bool, + pub label: String, + pub detail: String, + /// Bytes this would free, **measured**, never estimated. + pub bytes: i64, + /// `false` when `bytes` is a bound rather than a measurement — set only by + /// snapshot compaction, whose real yield cannot be known until it runs. + /// The UI must say "up to" whenever this is false. + pub bytes_are_exact: bool, + /// For compaction: the lower bound of the range, when `bytes` is the upper. + pub bytes_floor: Option, + /// Populated when the action cannot run right now (migration in flight, + /// container in the wrong state). The UI disables the tick and shows this. + pub blocked: Option, +} + +/// Everything [`list_reclaimable`] found. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ReclaimPlan { + pub items: Vec, + /// Destructive per-project objects, surfaced for display only. These are + /// never in `items` and [`reclaim`] cannot act on them. + pub destructive: Vec, + /// Set when the project store could not be read. Orphan detection is + /// suppressed entirely in that case — see [`orphan_volumes`]. + pub store_error: Option, +} + +/// A destructive object, described so the UI can offer it per project. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DestructiveItem { + pub target: DestructiveTarget, + pub project_id: String, + pub project_name: String, + pub label: String, + /// Spelled out in full — this is the copy the confirmation shows. + pub loses: String, + pub bytes: i64, + pub blocked: Option, +} + +/// What actually happened. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ReclaimOutcome { + pub results: Vec, + /// Sum of the measured `freed_bytes` below. + pub total_freed_bytes: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ReclaimResult { + pub target: ReclaimTarget, + pub ok: bool, + /// Bytes actually freed, measured after the fact. + pub freed_bytes: i64, + /// What was projected before the run, for the one action that projects. + pub projected_bytes: Option, + pub message: String, +} + +// --------------------------------------------------------------------------- +// Pure helpers — everything below is unit-tested without a daemon +// --------------------------------------------------------------------------- + +/// Classification of a dangling `triple-c.managed=true` image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DanglingClass { + /// Built from `container/Dockerfile`, which stamps `triple-c.base=true`. + Base, + /// A superseded `docker commit` from some project's recreation. + SnapshotCommit, +} + +/// Split the dangling managed images into the two buckets the UI shows. +/// +/// The base label is the only thing separating them, and it is reliable for the +/// same reason `triple-c.managed` is: `create_container` writes +/// `triple-c.base` **explicitly empty**, so a container built from a base +/// cannot inherit `true` and have its commit claim to be a base image. +pub fn classify_dangling(labels: &HashMap) -> DanglingClass { + if labels.get(LABEL_BASE).map(String::as_str) == Some("true") { + DanglingClass::Base + } else { + DanglingClass::SnapshotCommit + } +} + +/// A volume as far as orphan detection is concerned. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct VolumeFacts { + pub name: String, + pub bytes: i64, + /// `VolumeUsageData::ref_count` — containers currently referencing it. + /// `-1` means the daemon did not compute it, which is **not** zero. + /// + /// Note what this is *not* used for: a zero ref count is not evidence that + /// a volume is unclaimed. An idle project whose container has been removed + /// has exactly this shape. It is only ever an extra brake on top of the + /// store check. + pub links: i64, + pub created_at: Option, +} + +/// Volumes that look like ours and belong to no project in the store. +/// +/// ## The only authority is the project store +/// +/// From the daemon's side an **idle live project and a deleted one are +/// indistinguishable**. A project that has not been opened for a while has had +/// its container removed and may have no snapshot image either — volumes alone, +/// no container, nothing running. That is the *normal* resting state of a live +/// project, not a signal. +/// +/// This is not hypothetical: the heuristic "no container and no snapshot image +/// means orphaned" was tried against a real project list and flagged two live +/// projects whose volumes held `.credentials.json`, Claude transcripts and +/// shell history. So nothing in this function looks at containers, images or +/// activity. The test is membership in the project store, and only that. +/// +/// ## Why a store-load failure returns nothing +/// +/// The whole test is "in the store? then live". If the store failed to load, +/// *every* project's volumes look unclaimed — a blanket delete would wipe the +/// credentials, transcripts and toolchains of every project the user has. So a +/// failure returns an empty set and the caller says why, rather than returning +/// what would look like a very productive reclaim. +/// +/// `links == 0` is required on top of that. A volume with a container attached +/// belongs to something, whatever the store says, and `-1` (not computed) is +/// treated as "attached" for the same reason: unknown is never permission. +pub fn orphan_volumes( + volumes: &[VolumeFacts], + known_project_ids: &HashSet, + store_loaded: bool, +) -> Vec { + if !store_loaded { + return Vec::new(); + } + let mut out = Vec::new(); + for volume in volumes { + let (project_id, role) = match parse_project_volume_name(&volume.name) { + Some(parsed) => parsed, + None => continue, + }; + if known_project_ids.contains(project_id) { + continue; + } + if volume.links != 0 { + continue; + } + out.push(OrphanVolume { + name: volume.name.clone(), + project_id: project_id.to_string(), + bytes: volume.bytes.max(0), + role: role.to_string(), + created_at: volume.created_at.clone(), + }); + } + out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name))); + out +} + +/// Split a project volume name into `(project_id, role)`. +/// +/// Order matters and is not interchangeable: `triple-c-claude-config-` is +/// checked first because `triple-c-home-` does not prefix it, but a future +/// prefix that *does* nest would silently mis-attribute if this were reversed. +pub fn parse_project_volume_name(name: &str) -> Option<(&str, &'static str)> { + if let Some(id) = name.strip_prefix(CONFIG_VOLUME_PREFIX) { + if id.is_empty() { + return None; + } + return Some((id, "config")); + } + if let Some(id) = name.strip_prefix(HOME_VOLUME_PREFIX) { + if id.is_empty() { + return None; + } + return Some((id, "home")); + } + None +} + +/// What a snapshot's layer stack looks like relative to its base. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LayerStats { + /// Layers stacked above the base image — one per recreation. + pub commit_layers: u32, + /// Bytes those layers account for, or `None` when the base is unknown. + pub above_base_bytes: Option, +} + +/// Work out how much of a snapshot is stacked commits. +/// +/// `Docker::image_history` returns entries **newest first**, and a snapshot's +/// history is its base's history with the commits appended — so the base is the +/// tail, and the commits are the first `len - base_len` entries. Comparing +/// lengths rather than layer digests keeps this a pure function over two +/// vectors of sizes, and is exactly as accurate: the base is by construction a +/// prefix of the snapshot's chain. +/// +/// `base_history_len` is `None` when the base image is no longer on the daemon. +/// In that case the count falls back to "layers that carry bytes", which is an +/// honest upper bound on the commits, and the byte split is reported as unknown +/// rather than guessed. +pub fn layer_stats(snapshot_history_sizes: &[i64], base_history_len: Option) -> LayerStats { + match base_history_len { + Some(base_len) if base_len <= snapshot_history_sizes.len() => { + let commits = &snapshot_history_sizes[..snapshot_history_sizes.len() - base_len]; + LayerStats { + commit_layers: commits.len() as u32, + above_base_bytes: Some(commits.iter().sum()), + } + } + // Either no base, or a base longer than the snapshot's own history, + // which means they are not in the same lineage at all. + _ => LayerStats { + commit_layers: snapshot_history_sizes.iter().filter(|s| **s > 0).count() as u32, + above_base_bytes: None, + }, + } +} + +/// The range a compaction can land in, given the stacked layers it will merge. +/// +/// Nothing can measure the real figure in advance: it depends on how much of +/// each layer is superseded by a later one, which is exactly what flattening +/// discovers. But it is bounded, and both bounds are computable: +/// +/// * **Floor: zero.** Every byte may still be live, in which case flattening +/// frees nothing — and can even cost a little, since the merged layer +/// recompresses independently. Verified on a synthetic stack with nothing +/// superseded: 29.8 MB → 30.8 MB. +/// * **Ceiling: everything but the largest layer.** The result cannot be +/// smaller than the biggest single layer's worth of content, so at most +/// `sum - max` is superseded. +/// +/// Reporting a range is why [`ReclaimItem::bytes_are_exact`] exists. A single +/// invented number here would be the one place in this module that shows a +/// guess as if it were a measurement. +pub fn compaction_bounds(commit_layer_sizes: &[i64]) -> (i64, i64) { + let sum: i64 = commit_layer_sizes.iter().filter(|s| **s > 0).sum(); + let max = commit_layer_sizes.iter().copied().max().unwrap_or(0).max(0); + (0, (sum - max).max(0)) +} + +/// Bytes a `docker builder prune --filter until={hours}h` would reach. +/// +/// Records still in use are never reclaimable however old they are, and a +/// record the daemon gave no `last_used_at` is treated as too young to touch — +/// unknown is not permission here either. +pub fn stale_build_cache_bytes( + entries: &[BuildCacheFacts], + until_hours: i64, + now: chrono::DateTime, +) -> i64 { + let cutoff = now - chrono::Duration::hours(until_hours); + entries + .iter() + .filter(|e| !e.in_use) + .filter(|e| e.last_used_at.map(|t| t < cutoff).unwrap_or(false)) + .map(|e| e.size.max(0)) + .sum() +} + +/// A build-cache record, reduced to what the age filter needs. +#[derive(Debug, Clone, PartialEq)] +pub struct BuildCacheFacts { + pub size: i64, + pub in_use: bool, + pub last_used_at: Option>, +} + +impl From<&BuildCache> for BuildCacheFacts { + fn from(entry: &BuildCache) -> Self { + BuildCacheFacts { + size: entry.size.unwrap_or(0), + in_use: entry.in_use.unwrap_or(false), + last_used_at: entry.last_used_at.as_ref().and_then(parse_bollard_date), + } + } +} + +/// bollard's `BollardDate` is a `chrono` type behind a feature flag and a +/// string otherwise; going through its `Display` keeps this working either way +/// without pinning the feature. +fn parse_bollard_date(date: &bollard::models::BollardDate) -> Option> { + chrono::DateTime::parse_from_rfc3339(&date.to_string()) + .ok() + .map(|d| d.with_timezone(&chrono::Utc)) +} + +/// Parse a size the `docker` CLI printed, e.g. `"46.88GB"`, `"0B"`, `"1.5kB"`. +/// +/// Docker formats these with `units.HumanSize`, which is **base 1000**, not +/// 1024 — using 1024 here would overstate a 28 GB build cache by ~7%. Returns +/// `None` for anything unrecognised so a CLI output change degrades to "fall +/// back to `df()`" rather than to a wrong number. +pub fn parse_docker_size(raw: &str) -> Option { + let raw = raw.trim(); + let split = raw.find(|c: char| c.is_ascii_alphabetic())?; + let (number, unit) = raw.split_at(split); + let value: f64 = number.trim().parse().ok()?; + // Docker never prints a negative size, and a negative `freed_bytes` reaching + // the UI would subtract from the running total. Fail rather than propagate. + if !value.is_finite() || value < 0.0 { + return None; + } + let multiplier: f64 = match unit.trim() { + "B" => 1.0, + "kB" | "KB" => 1e3, + "MB" => 1e6, + "GB" => 1e9, + "TB" => 1e12, + "PB" => 1e15, + _ => return None, + }; + Some((value * multiplier) as i64) +} + +/// Pull `Reclaimable:` and `Total:` out of `docker buildx du`'s trailing +/// summary. Returns `(total, reclaimable)`. +pub fn parse_buildx_du(output: &str) -> Option<(i64, i64)> { + let mut total = None; + let mut reclaimable = None; + for line in output.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("Reclaimable:") { + reclaimable = parse_docker_size(rest); + } else if let Some(rest) = line.strip_prefix("Total:") { + total = parse_docker_size(rest); + } + } + Some((total?, reclaimable.unwrap_or(0))) +} + +/// Pull the figure a prune reports out of its output. +/// +/// **Two different wordings, and `builder prune` uses the less obvious one.** +/// Verified against Docker 29.7.2: `docker builder prune` ends with a bare +/// `Total:\t20.59MB`, while `docker system prune` and `docker image prune` end +/// with `Total reclaimed space: 20.59MB`. A parser that only knew the second +/// form would silently report every build-cache prune as having freed nothing — +/// which is exactly what the first draft of this did. +/// +/// The scan is last-line-first so the summary wins over any record line that +/// happens to contain the word. +pub fn parse_reclaimed_space(output: &str) -> i64 { + output + .lines() + .rev() + .find_map(|line| { + let line = line.trim(); + let rest = line + .strip_prefix("Total reclaimed space:") + .or_else(|| line.strip_prefix("Total:"))?; + parse_docker_size(rest) + }) + .unwrap_or(0) +} + +/// The Dockerfile that flattens a snapshot into a single layer. +/// +/// ## Why a build and not `docker commit --squash` or export/import +/// +/// `commit` cannot squash — squashing is the one thing it does not do, and it +/// is why the stack grows. `--squash` on the classic builder needs an +/// experimental daemon. `docker export | docker import` moves the whole +/// filesystem through this process, and bollard's import takes a fully +/// buffered `Bytes` — a 12 GB image in RAM. +/// +/// A two-stage build keeps every byte inside the daemon. `COPY --from` a whole +/// root into `FROM scratch` collapses the chain to one layer, and it preserves +/// uid/gid and setuid bits — **verified against Docker 29.7.2**: a 192.6 MB / +/// 4-layer synthetic came out 45.7 MB / 1 layer with `-rwsr-xr-x root` and +/// `uid 1000` intact, through the plain `POST /build` endpoint bollard uses +/// (no BuildKit session). +/// +/// ## Why the scrub runs in the first stage +/// +/// The bytes are only free to drop *before* the layer that captures them is +/// written, and here that layer is the flattened one. Running +/// [`container::snapshot_scrub_script`] in the `src` stage costs a throwaway +/// layer on a stage that is discarded, and reuses the one reviewed path list — +/// `SNAPSHOT_SCRUB_PATHS` — rather than forking a second copy of it, which is +/// the failure mode a list like that invites. +/// +/// The image's config (env, cmd, entrypoint, labels, workdir) does **not** +/// survive `FROM scratch`; it is replayed afterwards by +/// [`restore_image_config`], which is why this function does not try to emit it +/// as Dockerfile instructions. A multi-line `CLAUDE_INSTRUCTIONS` env var alone +/// makes that escaping a bad bet. +pub fn compaction_dockerfile(snapshot_ref: &str, scrub_script: &str) -> String { + // The scrub script is multi-line shell. `RUN` takes it verbatim only if the + // newlines are escaped, so it is folded onto one line with `;` separators — + // the script is already a sequence of statements and a `for` loop, both of + // which survive that. + let folded = fold_shell_script(scrub_script); + format!( + "FROM {snapshot_ref} AS src\n\ + RUN {folded}\n\ + FROM scratch\n\ + COPY --from=src / /\n" + ) +} + +/// Collapse a multi-line `/bin/sh` program into a single `RUN` line. +/// +/// Blank lines go; every other line is joined with a space. The script's own +/// syntax already terminates its statements (`;` inside the `for`, newlines +/// after each simple command are not load-bearing because each line here is a +/// complete word sequence), so this is a join and not a rewrite — but it is +/// pinned by a test against the real script for exactly that reason. +fn fold_shell_script(script: &str) -> String { + script + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") +} + +/// The `rm -rf` program that clears a project's regenerable package caches. +/// +/// Every path here is a cache a tool refills on its next run. None of them is +/// user data, none is a bind mount (all live under `$HOME`, i.e. the project's +/// own home volume), and none overlaps `SNAPSHOT_SCRUB_PATHS` — that list +/// covers `/tmp` and `/var` debris in the *writable layer*, this one covers the +/// *volume*, and the two never see the same bytes. +/// +/// Measured on one 8.4 GB home volume: ~6 GB. `~/go/pkg/mod` is read-only by +/// default, so it needs `chmod` before `rm` can touch it — `go clean -modcache` +/// is the supported route and is tried first. +/// +/// `~/.rustup/toolchains` is behind `include_rustup` rather than in the set: it +/// is just as regenerable, but a re-download is hundreds of megabytes over the +/// network rather than a rebuild from a local cache. +/// +/// Playwright is the one entry that is *not* a blanket delete. The current +/// revision is what an installed browser resolves to; deleting it turns a +/// working browser-view project into one that downloads 400 MB on next use. So +/// only the revisions that are not the newest are dropped. +pub fn cache_clear_script(include_rustup: bool) -> String { + let mut paths: Vec<&str> = vec![ + "$HOME/.npm/_cacache", + "$HOME/.npm/_npx", + "$HOME/.cache/go-build", + "$HOME/.cache/pip", + "$HOME/.cache/uv", + "$HOME/.cache/act", + "$HOME/.cache/chrome-devtools-mcp", + ]; + if include_rustup { + paths.push("$HOME/.rustup/toolchains"); + } + + // `du -sb` before each `rm` so the reported total is measured rather than + // inferred from a df() delta, which a concurrently running agent would + // corrupt. + let mut script = String::from("total=0\n"); + for path in &paths { + script.push_str(&format!( + "if [ -e \"{path}\" ]; then sz=$(du -sb \"{path}\" 2>/dev/null | cut -f1); \ + case \"$sz\" in ''|*[!0-9]*) sz=0 ;; esac; \ + rm -rf -- \"{path}\" 2>/dev/null && total=$((total + sz)); fi\n" + )); + } + + // Go's module cache is written read-only; `go clean -modcache` handles that + // properly, and the chmod fallback covers a container with no Go on PATH. + script.push_str( + "if [ -d \"$HOME/go/pkg/mod\" ]; then \ + sz=$(du -sb \"$HOME/go/pkg/mod\" 2>/dev/null | cut -f1); \ + case \"$sz\" in ''|*[!0-9]*) sz=0 ;; esac; \ + (command -v go >/dev/null 2>&1 && go clean -modcache 2>/dev/null) || \ + (chmod -R u+w \"$HOME/go/pkg/mod\" 2>/dev/null; rm -rf -- \"$HOME/go/pkg/mod\" 2>/dev/null); \ + [ -d \"$HOME/go/pkg/mod\" ] || total=$((total + sz)); fi\n", + ); + + // Playwright: keep the newest chromium revision, drop the rest. `ls -1` + // sorts lexically, which is right here because the revision suffix is a + // fixed-width zero-padded number. + script.push_str( + "if [ -d \"$HOME/.cache/ms-playwright\" ]; then \ + keep=$(ls -1 \"$HOME/.cache/ms-playwright\" 2>/dev/null | grep '^chromium' | sort | tail -n 1); \ + for d in \"$HOME/.cache/ms-playwright\"/chromium*; do \ + [ -d \"$d\" ] || continue; \ + [ \"$(basename \"$d\")\" = \"$keep\" ] && continue; \ + sz=$(du -sb \"$d\" 2>/dev/null | cut -f1); \ + case \"$sz\" in ''|*[!0-9]*) sz=0 ;; esac; \ + rm -rf -- \"$d\" 2>/dev/null && total=$((total + sz)); done; fi\n", + ); + + script.push_str(&format!("echo \"{CACHE_MARKER}$total\"\nexit 0\n")); + script +} + +/// Marker the cache-clear script prints so the byte total can be read back out +/// of the exec's interleaved output. Same trick as the snapshot scrub's. +const CACHE_MARKER: &str = "###TRIPLE-C-CACHE-CLEARED "; + +/// Read the byte total back. `None` means the script never reached its final +/// line, which is how a killed exec is told apart from one that freed nothing. +pub fn parse_cache_total(output: &str) -> Option { + output + .lines() + .rev() + .find_map(|line| line.trim().strip_prefix(CACHE_MARKER)?.trim().parse().ok()) +} + +/// Whether a typed confirmation matches the project it claims to. +/// +/// Trimmed, because a trailing space from a paste is not a different intent, +/// but **case-sensitive**: two projects called `Api` and `api` are different +/// projects, and this is the only thing standing between a user and their +/// transcripts. +pub fn confirmation_matches(expected_project_name: &str, typed: &str) -> bool { + !expected_project_name.is_empty() && typed.trim() == expected_project_name.trim() +} + +/// The Windows/WSL2 caveat, in one place so the UI and the logs cannot drift. +/// +/// Docker Desktop keeps the whole daemon inside `ext4.vhdx` under +/// `docker-desktop-data`. That file grows to a high-water mark and **never +/// shrinks on its own**. Everything this module reclaims frees space *inside* +/// the vhdx — real, and it is what stops the file growing further — but `C:` +/// does not change until the disk is compacted. Users who are not told this +/// report it as a bug. +pub const WSL2_VHDX_NOTE: &str = concat!( + "Docker Desktop keeps this daemon inside ext4.vhdx on C:. That file grows to a ", + "high-water mark and never shrinks by itself, so reclaiming here frees space inside ", + "the vhdx — which is what stops it growing — but C: will not change until the disk ", + "is compacted." +); + +/// The two ways to actually shrink the vhdx, in the order to try them. +pub const WSL2_VHDX_FIX: &[&str] = &[ + "wsl --shutdown", + "Optimize-VHD -Path \"$env:LOCALAPPDATA\\Docker\\wsl\\disk\\docker_data.vhdx\" -Mode Full", +]; + +/// The GUI route, for users without Hyper-V's `Optimize-VHD`. +pub const WSL2_VHDX_FIX_GUI: &str = + "Docker Desktop → Settings → Resources → Advanced → Clean up / Purge data"; + +/// Whether the vhdx caveat applies to this host. +/// +/// Both halves are required. Docker Desktop on macOS has the same +/// never-shrinks property but a different file and a different fix, and a +/// Windows host talking to a remote or native daemon has neither. +pub fn vhdx_applies(is_windows_host: bool, operating_system: &str) -> bool { + is_windows_host && is_docker_desktop(operating_system) +} + +/// Docker Desktop reports exactly `"Docker Desktop"` in `docker info`'s +/// `OperatingSystem` on every platform it ships for. Matched loosely so a +/// future suffix does not silently flip the answer — the same test +/// `docker/gateway.rs` already makes. +pub fn is_docker_desktop(operating_system: &str) -> bool { + operating_system.to_ascii_lowercase().contains("docker desktop") +} + +/// Whether the project store can be trusted to say which volumes are live. +/// +/// ## Why "the list is empty" is not the same as "there are no projects" +/// +/// `ProjectsStore::new()` treats an unparseable `projects.json` as recoverable: +/// it copies the file to `.bak`, **starts with an empty list**, and the app runs +/// normally. That is the right call for the app — and it is catastrophic for +/// orphan detection, because in that state every live project's home and config +/// volume looks unclaimed. Deleting them would take the user's credentials, +/// transcripts and toolchains for every project they have. +/// +/// So an empty list is only believed when the file is *also* absent, which is +/// the genuine fresh-install case and the one where there is nothing on the +/// daemon to mis-attribute anyway. Anything else returns the reason, and orphan +/// detection is suppressed rather than run optimistically. +pub fn project_store_trust( + projects: &[Project], + json_exists: bool, + json_parsed: bool, +) -> Result, String> { + if !json_parsed { + return Err( + "projects.json could not be read, so there is no way to tell an orphaned volume from a \ + live project's. Nothing is listed here until it can be." + .to_string(), + ); + } + if projects.is_empty() && json_exists { + return Err( + "The project list loaded empty from a projects.json that exists, which is what a \ + recovered-from-corrupt store looks like. Orphan detection is suppressed rather than \ + treat every project's volumes as unclaimed." + .to_string(), + ); + } + Ok(projects.iter().map(|p| p.id.clone()).collect()) +} + +/// Re-read `projects.json` from disk to answer [`project_store_trust`]'s two +/// questions. The in-memory store cannot answer them: by the time it is +/// consulted, a corrupt file has already been swallowed into an empty list. +fn projects_json_health() -> (bool, bool) { + let Some(path) = dirs::data_dir().map(|d| d.join("triple-c").join("projects.json")) else { + return (false, false); + }; + if !path.exists() { + return (false, true); + } + let parsed = std::fs::read_to_string(&path) + .ok() + .and_then(|data| serde_json::from_str::>(&data).ok()) + .is_some(); + (true, parsed) +} + +// --------------------------------------------------------------------------- +// Scan +// --------------------------------------------------------------------------- + +/// Names of the base images a project can be built from, for the globals block. +/// +/// Deletion never keys off this list — that stays on `dangling` + +/// `triple-c.managed` + `triple-c.base`, exactly as the sweep does. This is for +/// *display*: a base image pulled before `container/Dockerfile` grew its +/// `LABEL` lines carries neither label, and a globals block that could not name +/// the 4.7 GB image every project sits on would be missing the obvious. +fn is_base_image_reference(reference: &str) -> bool { + let repo = reference.split(':').next().unwrap_or(reference); + repo == "triple-c" + || repo.ends_with("/triple-c-sandbox") + || repo == "triple-c-sandbox" +} + +/// One `df()`, one `info()`, and one `image_history` per distinct image, joined +/// against the project store. +/// +/// Expensive on purpose — see the module docs. Everything the UI needs comes +/// out of this single call so a user never pays for it twice by accident. +pub async fn scan(projects: &[Project]) -> Result { + let docker = get_docker()?; + + let usage = docker + .df() + .await + .map_err(|e| format!("Could not read Docker disk usage: {}", e))?; + let images = usage.images.unwrap_or_default(); + let containers = usage.containers.unwrap_or_default(); + let volumes = usage.volumes.unwrap_or_default(); + let build_cache_records = usage.build_cache.unwrap_or_default(); + + let info = docker.info().await.ok(); + let operating_system = info + .as_ref() + .and_then(|i| i.operating_system.clone()) + .unwrap_or_default(); + let applies = vhdx_applies(cfg!(target_os = "windows"), &operating_system); + let host = HostStorage { + docker_root_dir: info + .as_ref() + .and_then(|i| i.docker_root_dir.clone()) + .unwrap_or_default(), + is_docker_desktop: is_docker_desktop(&operating_system), + is_windows_host: cfg!(target_os = "windows"), + vhdx_applies: applies, + vhdx_note: if applies { WSL2_VHDX_NOTE.to_string() } else { String::new() }, + vhdx_fix: if applies { + WSL2_VHDX_FIX.iter().map(|s| (*s).to_string()).collect() + } else { + Vec::new() + }, + vhdx_fix_gui: if applies { WSL2_VHDX_FIX_GUI.to_string() } else { String::new() }, + operating_system, + }; + + // Index by tag and by name so the per-project join is a lookup rather than a + // scan per project — 123 images × 8 projects is otherwise 1,000 comparisons + // for no reason. + let mut image_by_tag: HashMap<&str, &ImageSummary> = HashMap::new(); + for image in &images { + for tag in &image.repo_tags { + image_by_tag.insert(tag.as_str(), image); + } + } + let mut container_by_name: HashMap = HashMap::new(); + for container in &containers { + for name in container.names.as_deref().unwrap_or(&[]) { + // Docker prefixes every name with `/`. + container_by_name.insert(name.trim_start_matches('/').to_string(), container); + } + } + let volume_by_name: HashMap<&str, &Volume> = + volumes.iter().map(|v| (v.name.as_str(), v)).collect(); + + // `image_history` is one round trip per image, so base histories are shared + // across every project on the same base — which is all of them, normally. + let mut base_history_len: HashMap> = HashMap::new(); + + let mut rows = Vec::new(); + for project in projects { + let snapshot_image = get_snapshot_image_name(project); + let snapshot = image_by_tag.get(snapshot_image.as_str()).copied(); + + let (snapshot_bytes, snapshot_shared_bytes) = match snapshot { + Some(image) => (image.size, image.shared_size.max(0)), + None => (0, 0), + }; + + let mut stats = LayerStats::default(); + if let Some(image) = snapshot { + let history = docker + .image_history(&image.id) + .await + .map(|entries| entries.into_iter().map(|e| e.size).collect::>()) + .unwrap_or_default(); + + // The lineage label names the base by image id, which survives the + // base being retagged. `triple-c.create-image` does not work here: + // on a project that has been recreated it names the project's own + // snapshot, not the base. + let base_ref = image + .labels + .get(migration::LABEL_BASE_IMAGE_ID) + .filter(|id| !id.is_empty()) + .cloned(); + let base_len = match base_ref { + Some(base_ref) => match base_history_len.get(&base_ref) { + Some(cached) => *cached, + None => { + let len = docker + .image_history(&base_ref) + .await + .ok() + .map(|entries| entries.len()); + base_history_len.insert(base_ref, len); + len + } + }, + None => None, + }; + stats = layer_stats(&history, base_len); + } + + let container = container_by_name.get(&project.container_name()).copied(); + let home = volume_by_name + .get(home_volume_name(&project.id).as_str()) + .copied(); + let config = volume_by_name + .get(config_volume_name(&project.id).as_str()) + .copied(); + + let home_volume_bytes = home.and_then(volume_bytes).unwrap_or(0); + let config_volume_bytes = config.and_then(volume_bytes).unwrap_or(0); + let container_writable_bytes = container.and_then(|c| c.size_rw).unwrap_or(0).max(0); + + // The snapshot's *unique* bytes, not its total: the base is shared with + // every other project, so counting it per row would show ~4.7 GB of the + // same image once per project and make the column meaningless. + let snapshot_unique = (snapshot_bytes - snapshot_shared_bytes).max(0); + + rows.push(ProjectDiskRow { + project_id: project.id.clone(), + project_name: project.name.clone(), + snapshot_image, + snapshot_exists: snapshot.is_some(), + snapshot_bytes, + snapshot_shared_bytes, + snapshot_commit_layers: stats.commit_layers, + // Prefer the daemon's own measurement of what is unique to this + // image over layer arithmetic; fall back to the layer sum when + // `df()` did not compute a shared size. + snapshot_above_base_bytes: if snapshot_shared_bytes > 0 { + Some(snapshot_unique) + } else { + stats.above_base_bytes + }, + container_exists: container.is_some(), + container_running: container.and_then(|c| c.state.as_deref()) == Some("running"), + container_writable_bytes, + home_volume_bytes, + home_volume_present: home.is_some(), + config_volume_bytes, + config_volume_present: config.is_some(), + total_bytes: snapshot_unique + + container_writable_bytes + + home_volume_bytes + + config_volume_bytes, + migrating: crate::commands::migration_commands::is_migrating(&project.id), + }); + } + rows.sort_by(|a, b| b.total_bytes.cmp(&a.total_bytes)); + + // Globals. + let mut base_images = Vec::new(); + let mut orphan_image_bytes = 0i64; + let mut orphan_image_count = 0usize; + for image in &images { + let managed = image.labels.get(LABEL_MANAGED).map(String::as_str) == Some("true"); + let labelled_base = image.labels.get(LABEL_BASE).map(String::as_str) == Some("true"); + if image.repo_tags.is_empty() || image.repo_tags.iter().all(|t| t == ":") { + if managed { + orphan_image_bytes += (image.size - image.shared_size.max(0)).max(0); + orphan_image_count += 1; + } + continue; + } + // One row per *image*, not per tag. A base carrying both + // `triple-c-sandbox:latest` and `ghcr.io/shadowdao/triple-c-sandbox:latest` + // is one 4.7 GB image, and pushing a row per tag would count it twice + // in the total below. + let matching: Vec<&String> = image + .repo_tags + .iter() + .filter(|tag| labelled_base || is_base_image_reference(tag)) + .collect(); + if let Some(primary) = matching.first() { + base_images.push(BaseImageRow { + reference: if matching.len() > 1 { + // Name the aliases rather than hiding them; a user looking + // for "the sandbox image" should find it under whichever + // name they know it by. + matching + .iter() + .map(|tag| tag.as_str()) + .collect::>() + .join(", ") + } else { + (*primary).clone() + }, + bytes: image.size, + shared_bytes: image.shared_size.max(0), + containers: image.containers, + is_labelled_base: labelled_base, + }); + } + } + base_images.sort_by(|a, b| b.bytes.cmp(&a.bytes)); + let base_images_bytes = base_images.iter().map(|b| b.bytes).sum(); + + let (json_exists, json_parsed) = projects_json_health(); + let (orphan_volumes_list, orphan_volumes_unavailable) = + match project_store_trust(projects, json_exists, json_parsed) { + Ok(known) => { + let facts: Vec = volumes.iter().map(volume_facts).collect(); + (orphan_volumes(&facts, &known, true), None) + } + Err(reason) => (Vec::new(), Some(reason)), + }; + let orphan_volume_bytes = orphan_volumes_list.iter().map(|v| v.bytes).sum(); + + let build_cache = build_cache_usage(&build_cache_records).await; + + let triple_c_total_bytes = rows.iter().map(|r| r.total_bytes).sum::() + + base_images_bytes + + orphan_image_bytes + + orphan_volume_bytes; + + Ok(DiskUsageReport { + scanned_at: chrono::Utc::now().to_rfc3339(), + projects: rows, + base_images, + base_images_bytes, + orphan_image_bytes, + orphan_image_count, + orphan_volumes: orphan_volumes_list, + orphan_volume_bytes, + orphan_volumes_unavailable, + build_cache, + images_total_bytes: images.iter().map(|i| i.size).sum(), + containers_total_bytes: containers + .iter() + .map(|c| c.size_rw.unwrap_or(0).max(0)) + .sum(), + volumes_total_bytes: volumes.iter().filter_map(volume_bytes).sum(), + triple_c_total_bytes, + host, + }) +} + +fn volume_bytes(volume: &Volume) -> Option { + volume + .usage_data + .as_ref() + .map(|u| u.size) + .filter(|size| *size >= 0) +} + +fn volume_facts(volume: &Volume) -> VolumeFacts { + VolumeFacts { + name: volume.name.clone(), + bytes: volume.usage_data.as_ref().map(|u| u.size).unwrap_or(0), + // A daemon that did not compute the ref count reports `-1`, and that is + // deliberately *not* folded into 0 — `orphan_volumes` requires exactly + // zero, so unknown fails closed. + links: volume.usage_data.as_ref().map(|u| u.ref_count).unwrap_or(-1), + created_at: volume.created_at.as_ref().map(|d| d.to_string()), + } +} + +/// Build-cache figures, preferring `docker buildx du` over `df()`. +/// +/// `docker system df` counts only records with no parent as reclaimable, which +/// under-reports a real build tree badly; `buildx du` reports it correctly. The +/// CLI is not a hard dependency though — it ships with Docker Desktop and every +/// normal engine install, but a daemon reached over TCP might have no local +/// binary at all, so a failure falls back to the `df()` numbers and says so. +async fn build_cache_usage(records: &[BuildCache]) -> BuildCacheUsage { + let facts: Vec = records.iter().map(BuildCacheFacts::from).collect(); + let df_total: i64 = facts.iter().map(|f| f.size.max(0)).sum(); + let df_reclaimable: i64 = facts + .iter() + .filter(|f| !f.in_use) + .map(|f| f.size.max(0)) + .sum(); + let stale_bytes = stale_build_cache_bytes( + &facts, + BUILD_CACHE_DEFAULT_UNTIL_HOURS, + chrono::Utc::now(), + ); + + match docker_cli(&["buildx", "du"]).await { + Ok(output) => match parse_buildx_du(&output) { + Some((total, reclaimable)) => BuildCacheUsage { + total_bytes: total, + reclaimable_bytes: reclaimable, + // The age split only exists in `df()`'s records, so it is kept + // from there even when the totals come from buildx. It is a + // lower bound against the buildx total, which is the safe + // direction for a number attached to a prune button. + stale_bytes: stale_bytes.min(reclaimable), + source: "buildx du".to_string(), + cli_error: None, + }, + None => BuildCacheUsage { + total_bytes: df_total, + reclaimable_bytes: df_reclaimable, + stale_bytes, + source: "system df".to_string(), + cli_error: Some("`docker buildx du` output could not be parsed".to_string()), + }, + }, + Err(e) => BuildCacheUsage { + total_bytes: df_total, + reclaimable_bytes: df_reclaimable, + stale_bytes, + source: "system df".to_string(), + cli_error: Some(e), + }, + } +} + +/// Run the `docker` CLI and return its stdout. +/// +/// Used for exactly one thing: the build cache. bollard 0.18 has no wrapper for +/// `POST /build/prune` and no `buildx du` equivalent — the endpoint simply is +/// not in the crate — and adding a second HTTP client to reach a unix socket +/// (or a Windows named pipe) for one endpoint is a worse trade than shelling +/// out, which `commands/aws_commands.rs` already does for the AWS CLI. +async fn docker_cli(args: &[&str]) -> Result { + let output = tokio::process::Command::new("docker") + .args(args) + .output() + .await + .map_err(|e| format!("Could not run `docker {}`: {}", args.join(" "), e))?; + if !output.status.success() { + return Err(format!( + "`docker {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +// --------------------------------------------------------------------------- +// Planning +// --------------------------------------------------------------------------- + +/// Everything that could be reclaimed, with the bytes measured rather than +/// guessed. +/// +/// Takes the already-computed [`DiskUsageReport`] so a plan costs no second +/// `df()` — the UI scans once and then plans, re-plans and re-plans again off +/// the same measurement. +pub async fn list_reclaimable( + projects: &[Project], + report: &DiskUsageReport, +) -> Result { + let docker = get_docker()?; + let mut items = Vec::new(); + + // --- Dangling managed images, split by whether they are bases ----------- + // + // Both come from the same two conditions the startup sweep runs under — + // dangling *and* `triple-c.managed=true` — and they are mutually exclusive + // because `triple-c.base` is only ever `true` on an image built from + // `container/Dockerfile`. + let dangling = docker + .list_images(Some(ListImagesOptions { + all: false, + filters: HashMap::from([ + ("dangling".to_string(), vec!["true".to_string()]), + ("label".to_string(), vec![format!("{}=true", LABEL_MANAGED)]), + ]), + ..Default::default() + })) + .await + .map_err(|e| format!("Could not list orphaned images: {}", e))?; + + let mut commit_bytes = 0i64; + let mut commit_count = 0usize; + let mut base_bytes = 0i64; + let mut base_count = 0usize; + for image in &dangling { + // `list_images` leaves `shared_size` at -1, so the scan's own figure is + // the measured one; this loop only needs the split by class. + match classify_dangling(&image.labels) { + DanglingClass::Base => { + base_bytes += image.size; + base_count += 1; + } + DanglingClass::SnapshotCommit => { + commit_bytes += image.size; + commit_count += 1; + } + } + } + + if commit_count > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::DanglingSnapshots; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Superseded snapshot layers ({} images)", commit_count), + detail: "Untagged images left behind by past container recreations. Nothing can \ + start from them and no project refers to them." + .to_string(), + bytes: commit_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + if base_count > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::SupersededBaseImages; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Superseded base images ({} images)", base_count), + detail: "Older Triple-C sandbox images that a newer build or pull replaced. Docker \ + refuses to remove one that a stopped project still needs, so a project \ + that has not been migrated keeps its own." + .to_string(), + bytes: base_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Build cache -------------------------------------------------------- + if report.build_cache.total_bytes > 0 { + if report.build_cache.stale_bytes > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::BuildCache { all: false }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: "Build cache older than 7 days".to_string(), + detail: "Docker's BuildKit cache, for the WHOLE daemon — not just Triple-C. \ + Anything else you build here loses its warm cache too and rebuilds \ + from scratch once." + .to_string(), + bytes: report.build_cache.stale_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::BuildCache { all: true }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: "Build cache, all of it".to_string(), + detail: "Every reclaimable BuildKit record on the daemon, at any age. Same \ + daemon-wide caveat, with nothing held back." + .to_string(), + bytes: report.build_cache.reclaimable_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Migration leftovers ------------------------------------------------ + let (pin_bytes, pin_count, live_pins) = survey_rollback_pins(projects).await; + if pin_count > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::MigrationPins; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Ownerless rollback pins ({} images)", pin_count), + detail: "`pre-migration-*` tags whose migration record is gone, so nothing can roll \ + back to them. Untagged here; the image is then collected as a superseded \ + snapshot layer under the same rules." + .to_string(), + bytes: pin_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + let staging_bytes = survey_migration_staging(projects); + if staging_bytes > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::MigrationStaging; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: "Migration staging files".to_string(), + detail: "Half-finished `*-payload.tar` files with no migration record beside them. \ + These are ordinary files in your data directory, not Docker storage — on \ + Windows they are on C: itself, so this is the one item here that gives \ + space back to C: immediately." + .to_string(), + bytes: staging_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Leftover throwaway containers -------------------------------------- + let probes = survey_containers_by_filter( + HashMap::from([( + "label".to_string(), + vec![format!( + "{}={}", + migration::LABEL_PROBE, + migration::PROBE_LABEL_MIGRATION + )], + )]), + is_migration_probe, + ) + .await; + if probes.1 > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::ProbeContainers; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Migration probe containers ({})", probes.1), + detail: "Throwaway containers a migration used to read a filesystem manifest and \ + did not get to remove." + .to_string(), + bytes: probes.0, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + let scrubs = survey_containers_by_filter( + HashMap::from([("name".to_string(), vec!["triple-c-scrub-".to_string()])]), + is_scrub_container, + ) + .await; + if scrubs.1 > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::ScrubContainers; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Secret-scrub scratch containers ({})", scrubs.1), + detail: "`triple-c-scrub-*` containers left by an interrupted rewrite of a snapshot's \ + baked-in environment." + .to_string(), + bytes: scrubs.0, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Orphaned volumes, one tick each ------------------------------------ + // + // Deliberately not aggregated. Each of these was some project's home or + // `.claude` directory, and the user is the only one who can say whether the + // project it belonged to is really gone. + for volume in &report.orphan_volumes { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::OrphanVolume { + name: volume.name.clone(), + }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("{} ({} volume)", volume.name, volume.role), + detail: format!( + "Named for project id {}, which is not in Triple-C's project list, and no \ + container is attached to it.{} {}", + volume.project_id, + match &volume.created_at { + Some(created) => format!(" Docker created it on {}.", created), + None => String::new(), + }, + if volume.role == "config" { + "This is a `.claude` volume — it held that project's Claude credential, \ + plugins and session transcripts." + } else { + "This is a home volume — it held that project's dotfiles, shell history and \ + installed toolchains." + } + ), + bytes: volume.bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Per-project semi-safe work ----------------------------------------- + for row in &report.projects { + let blocked_by_migration = row + .migrating + .then(|| "A base-image migration is in flight for this project.".to_string()); + + // A ceiling of zero means flattening this snapshot cannot come out + // ahead — almost always because its unique delta is smaller than the + // base it would have to re-duplicate. Offering it anyway would be + // offering a loss, so it is simply not in the list. + let ceiling = row + .snapshot_above_base_bytes + .map(|unique| { + compaction_ceiling_for( + unique, + row.snapshot_shared_bytes, + row.snapshot_commit_layers, + ) + }) + .unwrap_or(0); + if row.snapshot_exists && row.snapshot_commit_layers > 1 && ceiling > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::CompactSnapshot { + project_id: row.project_id.clone(), + }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Compact {}'s snapshot", row.project_name), + detail: format!( + "{} stacked commit layers are rewritten into one, dropping every byte a \ + later layer already superseded. Nothing installed is lost. The container \ + must be stopped, and the rewrite needs room for a second copy while it \ + runs. Note that the result no longer shares the base image with your other \ + projects — that cost is already subtracted from the figure here, and the \ + rewrite is abandoned if it turns out not to come out ahead.", + row.snapshot_commit_layers + ), + bytes: ceiling, + bytes_are_exact: false, + bytes_floor: Some(0), + blocked: blocked_by_migration.clone().or_else(|| { + row.container_running.then(|| { + "Stop this project's container first — compaction rewrites the image it \ + is running from." + .to_string() + }) + }), + } + }); + } + + if row.container_exists { + for include_rustup in [false, true] { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::ClearCaches { + project_id: row.project_id.clone(), + include_rustup, + }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: if include_rustup { + format!("Clear {}'s caches, Rust toolchains included", row.project_name) + } else { + format!("Clear {}'s package caches", row.project_name) + }, + detail: if include_rustup { + "Everything below, plus `~/.rustup/toolchains`. Also regenerable, but \ + re-downloading a toolchain is hundreds of megabytes over the network \ + rather than a rebuild from a local cache." + .to_string() + } else { + "npm, npx, pip, uv, Go build and module caches, act, chrome-devtools-mcp, \ + and the Playwright browser revisions that are not the newest. All \ + refilled by the next command that needs them." + .to_string() + }, + // Measured by the script itself, inside the container, + // after the fact — a `df()` delta would be corrupted by + // whatever the agent is doing at the same moment. + bytes: 0, + bytes_are_exact: false, + bytes_floor: Some(0), + blocked: blocked_by_migration.clone().or_else(|| { + (!row.container_running).then(|| { + "Start this project's container first — the caches live in its home \ + volume and are cleared from inside." + .to_string() + }) + }), + } + }); + } + } + } + + // --- Destructive, for display only -------------------------------------- + let mut destructive = Vec::new(); + for row in &report.projects { + let blocked = row + .migrating + .then(|| "A base-image migration is in flight for this project.".to_string()); + if row.home_volume_present { + destructive.push(DestructiveItem { + target: DestructiveTarget::HomeVolume { + project_id: row.project_id.clone(), + }, + project_id: row.project_id.clone(), + project_name: row.project_name.clone(), + label: "Home volume".to_string(), + loses: "Shell history, dotfiles, every toolchain installed under $HOME, and any \ + Playwright browsers. Not recoverable." + .to_string(), + bytes: row.home_volume_bytes, + blocked: blocked.clone(), + }); + } + if row.config_volume_present { + destructive.push(DestructiveItem { + target: DestructiveTarget::ConfigVolume { + project_id: row.project_id.clone(), + }, + project_id: row.project_id.clone(), + project_name: row.project_name.clone(), + label: "Claude config volume".to_string(), + loses: "The Claude login credential, installed plugins and skills, and EVERY \ + conversation transcript for this project. Not recoverable." + .to_string(), + bytes: row.config_volume_bytes, + blocked: blocked.clone(), + }); + } + if row.snapshot_exists { + destructive.push(DestructiveItem { + target: DestructiveTarget::SnapshotImage { + project_id: row.project_id.clone(), + }, + project_id: row.project_id.clone(), + project_name: row.project_name.clone(), + label: "Snapshot image".to_string(), + loses: "Every package the agent ever installed in this project's system layer. \ + The project falls back to the base image next time it starts, and \ + rebuilds from there. Volumes are untouched." + .to_string(), + bytes: row.snapshot_above_base_bytes.unwrap_or(row.snapshot_bytes), + blocked: blocked.clone(), + }); + } + } + for (project_id, project_name, tag, bytes) in live_pins { + destructive.push(DestructiveItem { + target: DestructiveTarget::RollbackPin { + project_id: project_id.clone(), + tag: tag.clone(), + }, + project_id, + project_name, + label: format!("Rollback pin {}", tag), + loses: "The only copy of this project's pre-migration system layer. Its migration is \ + still waiting to be confirmed or rolled back — delete this and rolling back \ + becomes impossible." + .to_string(), + bytes, + blocked: None, + }); + } + destructive.sort_by(|a, b| b.bytes.cmp(&a.bytes)); + + items.sort_by(|a, b| { + // Safe first (that is the one-button group), then by size. Compaction + // ends up beneath the safe list even when it is the biggest number, + // which is the right reading order: try the free wins first. + (a.safety == Safety::SemiSafe) + .cmp(&(b.safety == Safety::SemiSafe)) + .then_with(|| b.bytes.cmp(&a.bytes)) + }); + + Ok(ReclaimPlan { + items, + destructive, + store_error: report.orphan_volumes_unavailable.clone(), + }) +} + +/// Upper bound on a compaction's yield. +/// +/// ## Flattening breaks base-layer sharing, and that is the dominant term +/// +/// `FROM scratch` + `COPY --from` produces an image that shares **nothing**. +/// The base image stays on disk, because every other project is still built +/// from it — so the flattened snapshot now carries its own private copy of all +/// ~4.7 GB of it. That cost is paid whatever the layers held. +/// +/// Measured on a real daemon: eight of ten projects had a unique delta between +/// 0.10 GB and 1.32 GB over a 4.72 GB shared base. Flattening any of those +/// turns a 0.63 GB cost into a ~4.7 GB one — **a net loss of about 4 GB**, on +/// an action sold as reclaiming space. Only the project carrying 8.44 GB across +/// 14 layers was plausibly a win. +/// +/// So the yield is bounded by two independent things and it is the smaller that +/// binds: +/// +/// * **Superseded bytes.** At most everything but the largest layer, which +/// without per-layer sizes to hand is approximated by the even split +/// `unique * (n-1)/n`. See [`compaction_bounds`]. +/// * **What is left after re-duplicating the base**: `unique - shared`. A +/// project whose unique delta is smaller than its shared base can never come +/// out ahead, and this returns zero for it — which keeps it out of the plan +/// entirely rather than offering a loss as a saving. +fn compaction_ceiling_for(unique_bytes: i64, shared_bytes: i64, commit_layers: u32) -> i64 { + if commit_layers < 2 { + return 0; + } + let n = commit_layers as i64; + let unique = unique_bytes.max(0); + let superseded_ceiling = (unique / n) * (n - 1); + // What survives re-duplicating the base. Negative means the flattened image + // would be bigger than what it replaces. + let after_base_penalty = unique - shared_bytes.max(0); + superseded_ceiling.min(after_base_penalty).max(0) +} + +/// Rollback pins, split into the ones nothing claims and the ones a live +/// migration still needs. +/// +/// Returns `(ownerless_bytes, ownerless_count, live_pins)` where each live pin +/// is `(project_id, project_name, tag, bytes)`. +async fn survey_rollback_pins( + projects: &[Project], +) -> (i64, usize, Vec<(String, String, String, i64)>) { + let Ok(docker) = get_docker() else { + return (0, 0, Vec::new()); + }; + let names: HashMap<&str, &str> = projects + .iter() + .map(|p| (p.id.as_str(), p.name.as_str())) + .collect(); + + let images = docker + .list_images(Some(ListImagesOptions { + all: false, + filters: HashMap::from([( + "reference".to_string(), + vec!["triple-c-snapshot-*:pre-migration-*".to_string()], + )]), + ..Default::default() + })) + .await + .unwrap_or_default(); + + let mut ownerless_bytes = 0; + let mut ownerless_count = 0; + let mut live = Vec::new(); + for image in images { + for reference in &image.repo_tags { + let Some((project_id, tag)) = migration::parse_snapshot_reference(reference) else { + continue; + }; + // Filesystem presence, not `load`: a record that cannot be parsed + // must still count as "somebody may want this back". Same rule the + // pin reaper uses, for the same reason. + let has_record = migration_store::has_record(&project_id).unwrap_or(true); + if has_record { + let name = names + .get(project_id.as_str()) + .map(|n| (*n).to_string()) + .unwrap_or_else(|| project_id.clone()); + live.push((project_id, name, tag, image.size)); + } else { + ownerless_bytes += image.size; + ownerless_count += 1; + } + } + } + (ownerless_bytes, ownerless_count, live) +} + +/// Total bytes of `*-payload.tar` staging files no migration record claims. +fn survey_migration_staging(projects: &[Project]) -> i64 { + let Ok(dir) = migration_store::migrations_dir() else { + return 0; + }; + let Ok(entries) = std::fs::read_dir(&dir) else { + return 0; + }; + let mut total = 0i64; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let Some(project_id) = name.strip_suffix("-payload.tar") else { + continue; + }; + // A record beside it means a migration may still be mid-flight and this + // tar is its input. Only ownerless ones are offered. + if migration_store::has_record(project_id).unwrap_or(true) { + continue; + } + // A project the store still knows about, currently migrating, is + // covered by the record check above; this is belt and braces for the + // window where the record has been cleared but the run has not + // unwound. + if projects + .iter() + .any(|p| p.id == project_id && crate::commands::migration_commands::is_migrating(&p.id)) + { + continue; + } + if let Ok(meta) = entry.metadata() { + total += meta.len() as i64; + } + } + total +} + +/// A migration probe: a throwaway container carrying +/// `triple-c.probe=migration`. +/// +/// Re-checked on the summary rather than trusted from the daemon's filter. +/// Docker's `label=k=v` filter is an exact match and would be enough — but +/// "enough" is not the standard for a function that removes containers, and a +/// filter is a string built somewhere else in the file. +fn is_migration_probe(summary: &ContainerSummary) -> bool { + summary + .labels + .as_ref() + .and_then(|labels| labels.get(migration::LABEL_PROBE)) + .map(String::as_str) + == Some(migration::PROBE_LABEL_MIGRATION) +} + +/// A scratch container from an interrupted secret rewrite. +/// +/// **Docker's `name` filter is a substring match**, so it also returns a user's +/// own `my-triple-c-scrub-notes`. The full name is what decides. +fn is_scrub_container(summary: &ContainerSummary) -> bool { + summary + .names + .as_deref() + .unwrap_or(&[]) + .iter() + .any(|name| name.trim_start_matches('/').starts_with("triple-c-scrub-")) +} + +/// `(writable_bytes, count)` for containers matching a filter *and* a predicate +/// re-checked on each summary. See [`is_migration_probe`] and +/// [`is_scrub_container`] for why the second check is not redundant. +async fn survey_containers_by_filter( + filters: HashMap>, + predicate: fn(&ContainerSummary) -> bool, +) -> (i64, usize) { + let Ok(docker) = get_docker() else { + return (0, 0); + }; + let containers = docker + .list_containers(Some(ListContainersOptions { + all: true, + size: true, + filters, + ..Default::default() + })) + .await + .unwrap_or_default(); + let mut bytes = 0; + let mut count = 0; + for container in containers { + if !predicate(&container) { + continue; + } + bytes += container.size_rw.unwrap_or(0).max(0); + count += 1; + } + (bytes, count) +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +/// Run the ticked targets, in order, and report what each one actually freed. +/// +/// One failing target never stops the others: these are independent housekeeping +/// jobs, and a build cache the daemon refuses to prune is no reason to leave a +/// dangling image behind. Every failure becomes a `ReclaimResult { ok: false }` +/// the UI shows beside the item. +pub async fn reclaim(targets: &[ReclaimTarget], projects: &[Project]) -> ReclaimOutcome { + let mut outcome = ReclaimOutcome::default(); + for target in targets { + // Anything that stops, removes or rewrites a project's container or + // image consults `is_migrating` first — the same rule the rest of the + // app obeys. Checked once here so a new project-scoped target cannot + // be added without it; the executors re-check for their own callers. + if let Some(project_id) = target.project_id() { + if crate::commands::migration_commands::is_migrating(project_id) { + let result = failed( + target.clone(), + "A base-image migration is in flight for this project.".to_string(), + ); + outcome.results.push(result); + continue; + } + } + let result = match target { + ReclaimTarget::DanglingSnapshots | ReclaimTarget::SupersededBaseImages => { + reclaim_dangling(target).await + } + ReclaimTarget::BuildCache { all } => reclaim_build_cache(*all).await, + ReclaimTarget::MigrationPins => reclaim_migration_pins().await, + ReclaimTarget::MigrationStaging => reclaim_migration_staging(projects), + ReclaimTarget::ProbeContainers => { + reclaim_containers( + HashMap::from([( + "label".to_string(), + vec![format!( + "{}={}", + migration::LABEL_PROBE, + migration::PROBE_LABEL_MIGRATION + )], + )]), + is_migration_probe, + ReclaimTarget::ProbeContainers, + ) + .await + } + ReclaimTarget::ScrubContainers => { + reclaim_containers( + HashMap::from([("name".to_string(), vec!["triple-c-scrub-".to_string()])]), + is_scrub_container, + ReclaimTarget::ScrubContainers, + ) + .await + } + ReclaimTarget::OrphanVolume { name } => reclaim_orphan_volume(name, projects).await, + ReclaimTarget::CompactSnapshot { project_id } => { + match find_project(projects, project_id) { + Ok(project) => compact_snapshot(project).await, + Err(e) => failed(target.clone(), e), + } + } + ReclaimTarget::ClearCaches { + project_id, + include_rustup, + } => match find_project(projects, project_id) { + Ok(project) => clear_caches(project, *include_rustup).await, + Err(e) => failed(target.clone(), e), + }, + }; + outcome.total_freed_bytes += result.freed_bytes; + outcome.results.push(result); + } + outcome +} + +fn find_project<'a>(projects: &'a [Project], project_id: &str) -> Result<&'a Project, String> { + projects + .iter() + .find(|p| p.id == project_id) + .ok_or_else(|| format!("Project {} is not in the project list", project_id)) +} + +fn failed(target: ReclaimTarget, message: String) -> ReclaimResult { + ReclaimResult { + target, + ok: false, + freed_bytes: 0, + projected_bytes: None, + message, + } +} + +/// Remove the dangling managed images of one class. +/// +/// `force: false` is load-bearing and is the same choice `sweep_orphaned_ +/// snapshots` documents: Docker refuses with a 409 while any container is still +/// built from an image, including the stopped container of a project that is +/// not running. Forcing would untag that image out from under the project and +/// leave a container that cannot start. Those are counted and left. +async fn reclaim_dangling(target: &ReclaimTarget) -> ReclaimResult { + let want = match target { + ReclaimTarget::SupersededBaseImages => DanglingClass::Base, + _ => DanglingClass::SnapshotCommit, + }; + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target.clone(), e), + }; + let images = match docker + .list_images(Some(ListImagesOptions { + all: false, + filters: HashMap::from([ + ("dangling".to_string(), vec!["true".to_string()]), + ("label".to_string(), vec![format!("{}=true", LABEL_MANAGED)]), + ]), + ..Default::default() + })) + .await + { + Ok(images) => images, + Err(e) => return failed(target.clone(), format!("Could not list images: {}", e)), + }; + + let mut freed = 0i64; + let mut removed = 0usize; + let mut in_use = 0usize; + let mut errors = 0usize; + for image in images { + if classify_dangling(&image.labels) != want { + continue; + } + match docker + .remove_image( + &image.id, + Some(RemoveImageOptions { + force: false, + noprune: false, + }), + None, + ) + .await + { + Ok(_) => { + freed += image.size; + removed += 1; + } + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 409, .. + }) => in_use += 1, + Err(e) => { + log::warn!("Could not remove dangling image {}: {}", image.id, e); + errors += 1; + } + } + } + + let mut message = format!("Removed {} image(s).", removed); + if in_use > 0 { + message.push_str(&format!( + " {} still had a container built from it and were left alone — start and stop, or \ + recreate, that project and they become collectable.", + in_use + )); + } + if errors > 0 { + message.push_str(&format!(" {} could not be removed; see the log.", errors)); + } + ReclaimResult { + target: target.clone(), + ok: errors == 0, + freed_bytes: freed, + projected_bytes: None, + message, + } +} + +/// Prune the BuildKit cache through the `docker` CLI. +/// +/// bollard 0.18 has no wrapper for `POST /build/prune` — see [`docker_cli`]. +/// The freed figure comes from the CLI's own `Total reclaimed space:` line, +/// which is a measurement rather than a re-`df()` that a concurrent build could +/// have moved underneath us. +async fn reclaim_build_cache(all: bool) -> ReclaimResult { + let target = ReclaimTarget::BuildCache { all }; + let filter = format!("until={}h", BUILD_CACHE_DEFAULT_UNTIL_HOURS); + let mut args: Vec<&str> = vec!["builder", "prune", "--force"]; + if all { + args.push("--all"); + } else { + args.push("--filter"); + args.push(&filter); + } + match docker_cli(&args).await { + Ok(output) => ReclaimResult { + target, + ok: true, + freed_bytes: parse_reclaimed_space(&output), + projected_bytes: None, + message: if all { + "Pruned the whole build cache.".to_string() + } else { + "Pruned build cache records older than 7 days.".to_string() + }, + }, + Err(e) => failed( + target, + format!( + "{e}. Pruning the build cache needs the `docker` command line tool, which the \ + Docker Engine API does not expose an equivalent for." + ), + ), + } +} + +/// Untag the rollback pins nothing claims. +/// +/// Untag only, never `rmi`: dropping the tag makes the image dangling, and it +/// already carries `triple-c.managed=true` because `docker commit` created it, +/// so the sweep collects it under its own two conditions with the daemon's +/// still-in-use refusal in front. Nothing here removes a reachable image. +async fn reclaim_migration_pins() -> ReclaimResult { + let target = ReclaimTarget::MigrationPins; + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target, e), + }; + let images = match docker + .list_images(Some(ListImagesOptions { + all: false, + filters: HashMap::from([( + "reference".to_string(), + vec!["triple-c-snapshot-*:pre-migration-*".to_string()], + )]), + ..Default::default() + })) + .await + { + Ok(images) => images, + Err(e) => return failed(target, format!("Could not list rollback pins: {}", e)), + }; + + let mut freed = 0i64; + let mut dropped = 0usize; + for image in images { + for reference in &image.repo_tags { + let Some((project_id, _tag)) = migration::parse_snapshot_reference(reference) else { + continue; + }; + if migration_store::has_record(&project_id).unwrap_or(true) { + continue; + } + match migration::untag_image(reference).await { + Ok(()) => { + freed += image.size; + dropped += 1; + } + Err(e) => log::warn!("Could not drop rollback pin {}: {}", reference, e), + } + } + } + + // Untagging alone frees nothing — it only makes the image dangling. The + // sweep that follows is the single thing that actually removed layers, so + // its figure is the only measurement there is. `freed` above is the size of + // what was *untagged*, which the sweep may well have been refused on + // because a stopped container still pins it. + let sweep = container::sweep_orphaned_snapshots().await; + log::info!( + "Dropped {} ownerless rollback pin(s) covering {} bytes; the sweep reclaimed {}", + dropped, + freed, + sweep.reclaimed_bytes + ); + ReclaimResult { + target, + ok: true, + freed_bytes: sweep.reclaimed_bytes, + projected_bytes: None, + message: format!( + "Dropped {} ownerless pin(s) and swept {} image(s).", + dropped, + sweep.removed.len() + ), + } +} + +/// Delete ownerless `*-payload.tar` staging files. +fn reclaim_migration_staging(projects: &[Project]) -> ReclaimResult { + let target = ReclaimTarget::MigrationStaging; + let dir = match migration_store::migrations_dir() { + Ok(dir) => dir, + Err(e) => return failed(target, e), + }; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) => return failed(target, format!("Could not read {}: {}", dir.display(), e)), + }; + + let mut freed = 0i64; + let mut removed = 0usize; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let Some(project_id) = name.strip_suffix("-payload.tar") else { + continue; + }; + if migration_store::has_record(project_id).unwrap_or(true) { + continue; + } + if projects + .iter() + .any(|p| p.id == project_id && crate::commands::migration_commands::is_migrating(&p.id)) + { + continue; + } + let size = entry.metadata().map(|m| m.len() as i64).unwrap_or(0); + match std::fs::remove_file(entry.path()) { + Ok(()) => { + freed += size; + removed += 1; + } + Err(e) => log::warn!("Could not remove {}: {}", entry.path().display(), e), + } + } + ReclaimResult { + target, + ok: true, + freed_bytes: freed, + projected_bytes: None, + message: format!("Removed {} staging file(s).", removed), + } +} + +/// Remove throwaway containers matching a filter *and* a predicate re-checked +/// on each summary. +async fn reclaim_containers( + filters: HashMap>, + predicate: fn(&ContainerSummary) -> bool, + target: ReclaimTarget, +) -> ReclaimResult { + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target, e), + }; + let containers = match docker + .list_containers(Some(ListContainersOptions { + all: true, + size: true, + filters, + ..Default::default() + })) + .await + { + Ok(containers) => containers, + Err(e) => return failed(target, format!("Could not list containers: {}", e)), + }; + + let mut freed = 0i64; + let mut removed = 0usize; + let mut errors = 0usize; + for summary in containers { + // The daemon's filter is never the only guard on a removal. + if !predicate(&summary) { + continue; + } + let Some(id) = summary.id.as_deref() else { + continue; + }; + match container::remove_container(id).await { + Ok(()) => { + freed += summary.size_rw.unwrap_or(0).max(0); + removed += 1; + } + Err(e) => { + log::warn!("Could not remove container {}: {}", id, e); + errors += 1; + } + } + } + ReclaimResult { + target, + ok: errors == 0, + freed_bytes: freed, + projected_bytes: None, + message: format!("Removed {} container(s).", removed), + } +} + +/// Remove one orphaned volume, re-checking every safety condition first. +/// +/// The plan that offered this was computed against a `df()` from some seconds +/// ago. A project could have been added since, and a container could have +/// attached. So the name is re-parsed, the store is re-consulted and the live +/// ref count is re-read here — the tick is permission to act, not a promise +/// that the world stood still. +async fn reclaim_orphan_volume(name: &str, projects: &[Project]) -> ReclaimResult { + let target = ReclaimTarget::OrphanVolume { + name: name.to_string(), + }; + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target, e), + }; + + let (json_exists, json_parsed) = projects_json_health(); + let known = match project_store_trust(projects, json_exists, json_parsed) { + Ok(known) => known, + Err(reason) => return failed(target, reason), + }; + + let usage = match docker.df().await { + Ok(usage) => usage, + Err(e) => return failed(target, format!("Could not re-check volume usage: {}", e)), + }; + let volumes = usage.volumes.unwrap_or_default(); + let facts: Vec = volumes.iter().map(volume_facts).collect(); + let still_orphaned = orphan_volumes(&facts, &known, true) + .into_iter() + .find(|v| v.name == name); + let Some(volume) = still_orphaned else { + return failed( + target, + format!( + "{} now matches a project in your project list, or a container has attached to \ + it, so it is no longer unclaimed. Nothing was removed.", + name + ), + ); + }; + + match docker.remove_volume(name, None).await { + Ok(()) => ReclaimResult { + target, + ok: true, + freed_bytes: volume.bytes, + projected_bytes: None, + message: format!("Removed volume {}.", name), + }, + Err(e) => failed( + ReclaimTarget::OrphanVolume { + name: name.to_string(), + }, + format!("Could not remove volume {}: {}", name, e), + ), + } +} + +/// Rewrite a project's stacked commit layers into a single layer. +/// +/// ## The sequence, and why each step is where it is +/// +/// 1. **Refuse while a migration is in flight or the container runs.** The same +/// rule everything else that touches a project's image obeys: the window +/// between a migration's `remove_container` and the create that follows +/// looks exactly like "no container", and rewriting `:latest` underneath a +/// running container's image is not something Docker protects you from. +/// 2. **Capture the image config** — env, cmd, entrypoint, labels, workdir. It +/// does not survive `FROM scratch` and has to be replayed. +/// 3. **Build to a temporary tag**, never over `:latest`. If anything below +/// fails, the project's snapshot is exactly as it was. +/// 4. **Compare sizes.** Compaction is not always a win: with nothing +/// superseded, the merged layer can recompress *larger* — measured at 29.8 +/// MB → 30.8 MB on a synthetic stack with no waste in it. A result that is +/// not smaller is discarded and reported as "nothing to reclaim", rather +/// than shipped as an improvement. +/// 5. **Replay the config** by creating a container from the flat image with +/// that config and committing it. `docker commit` bakes the container's +/// config into the image, and it round-trips values a Dockerfile `ENV` could +/// not survive — verified with a multi-line env var and a label containing a +/// double quote. +/// 6. **Move `:latest` last.** Until the final tag, the old snapshot is still +/// what the project starts from, so every failure above self-heals. +/// +/// Nothing here forces a removal, and nothing touches a volume. +pub async fn compact_snapshot(project: &Project) -> ReclaimResult { + let target = ReclaimTarget::CompactSnapshot { + project_id: project.id.clone(), + }; + if crate::commands::migration_commands::is_migrating(&project.id) { + return failed( + target, + "A base-image migration is in flight for this project.".to_string(), + ); + } + + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target, e), + }; + let snapshot_ref = get_snapshot_image_name(project); + + // The container must be stopped: this rewrites the image it is running + // from, and a running container also means the writable layer holds work + // that has not been committed and would be stranded. + if let Ok(Some(container_id)) = container::find_existing_container(project).await { + if container::is_container_running(&container_id) + .await + .unwrap_or(false) + { + return failed( + target, + "Stop this project's container before compacting its snapshot.".to_string(), + ); + } + } + + let before = match docker.inspect_image(&snapshot_ref).await { + Ok(image) => image, + Err(e) => { + return failed(target, format!("Could not inspect {}: {}", snapshot_ref, e)); + } + }; + + // **What this snapshot actually costs is its *unique* bytes, not its size.** + // Its size includes the base image, which every other project is still + // built from and which is not going anywhere. The flattened replacement, + // by contrast, shares nothing — so it is charged in full. Comparing size to + // size would score a project with a 0.63 GB delta over a 4.72 GB base as a + // 0.5 GB saving while it in fact cost 4.1 GB. Measured on a real daemon: + // eight of ten projects were in exactly that shape. + let before_bytes = image_unique_bytes(&snapshot_ref).await; + let before_total = before.size.unwrap_or(0); + + // Projected before the run, so the outcome can be read against it. The + // plan's figure came from an even-split approximation over the aggregate; + // here the per-layer sizes are to hand, so the bound is exact — see + // [`compaction_bounds`] for why it is a bound at all. + let projected = docker + .image_history(&snapshot_ref) + .await + .ok() + .map(|entries| { + let sizes: Vec = entries.iter().map(|e| e.size).collect(); + // Bounded by the superseded bytes *and* by what survives + // re-duplicating the shared base — the same two terms + // `compaction_ceiling_for` weighs. The history here covers the base + // layers too, so the first term is looser than it could be; the + // second is what binds in practice and it caps the result anyway. + let superseded = compaction_bounds(&sizes).1; + let shared = before_total - before_bytes; + superseded.min(before_bytes - shared).max(0) + }); + let Some(config) = before.config.clone() else { + return failed( + target, + format!( + "{} has no image config to preserve, which means it is not a snapshot this app \ + committed. Refusing to rewrite it.", + snapshot_ref + ), + ); + }; + + // `:compacting` rather than `:latest`. Nothing starts from this tag, and it + // is removed on every exit path below. + let staging_ref = format!( + "triple-c-snapshot-{}:compacting", + project.id + ); + let dockerfile = compaction_dockerfile(&snapshot_ref, &container::snapshot_scrub_script()); + + if let Err(e) = build_from_dockerfile(&dockerfile, &staging_ref).await { + let _ = migration::untag_image(&staging_ref).await; + return failed(target, format!("Compaction build failed: {}", e)); + } + + // The flat image shares nothing, so its unique cost *is* its size. + let after_bytes = match docker.inspect_image(&staging_ref).await { + Ok(_) => image_unique_bytes(&staging_ref).await, + Err(e) => { + let _ = migration::untag_image(&staging_ref).await; + return failed(target, format!("Could not measure the result: {}", e)); + } + }; + + if after_bytes >= before_bytes { + let _ = migration::untag_image(&staging_ref).await; + let _ = container::sweep_orphaned_snapshots().await; + return ReclaimResult { + target, + ok: true, + freed_bytes: 0, + projected_bytes: projected, + message: format!( + "Left untouched — flattening would have made it bigger. This snapshot costs {} \ + beyond the base image it shares with your other projects, and a flattened copy \ + would cost {} because it shares nothing. That happens when the layers hold \ + little superseded data relative to the base.", + human(before_bytes), + human(after_bytes) + ), + }; + } + + // Replay the config onto the flat image. + if let Err(e) = restore_image_config(&staging_ref, &snapshot_ref, config).await { + let _ = migration::untag_image(&staging_ref).await; + return failed( + target, + format!("Could not restore the snapshot's configuration: {}", e), + ); + } + + // The staging tag has served its purpose; dropping it leaves the flat + // intermediate dangling and labelled, so the sweep collects it. + let _ = migration::untag_image(&staging_ref).await; + let sweep = container::sweep_orphaned_snapshots().await; + + let freed = (before_bytes - after_bytes).max(0); + log::info!( + "Compacted {} ({} total): unique cost went from {} to {} bytes, {} reclaimed, and the \ + sweep collected {} superseded image(s)", + snapshot_ref, + before_total, + before_bytes, + after_bytes, + freed, + sweep.removed.len() + ); + ReclaimResult { + target, + ok: true, + freed_bytes: freed, + projected_bytes: projected, + message: format!( + "Rewrote {} into a single layer. Its cost on disk went from {} to {}.", + snapshot_ref, + human(before_bytes), + human(after_bytes) + ), + } +} + +/// Bytes as a short human string, for a message a user reads. +/// +/// Base 1000, matching `docker system df` and the frontend's `formatBytes` — +/// a message saying 4.4 GiB beside a table saying 4.7 GB would read as two +/// different numbers for the same thing. +fn human(bytes: i64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value.abs() >= 1000.0 && unit < UNITS.len() - 1 { + value /= 1000.0; + unit += 1; + } + if unit == 0 { + format!("{} {}", bytes, UNITS[0]) + } else { + format!("{:.1} {}", value, UNITS[unit]) + } +} + +/// Build a one-file context and hand it to the daemon. +/// +/// The context holds nothing but the Dockerfile — every byte the build touches +/// is already inside the daemon, which is the entire point of doing this as a +/// build rather than an export/import round trip through this process. +async fn build_from_dockerfile(dockerfile: &str, tag: &str) -> Result<(), String> { + use bollard::image::BuildImageOptions; + use futures_util::StreamExt; + + let mut context = Vec::new(); + { + let mut archive = tar::Builder::new(&mut context); + let mut header = tar::Header::new_gnu(); + header.set_path("Dockerfile").map_err(|e| e.to_string())?; + header.set_size(dockerfile.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive + .append(&header, dockerfile.as_bytes()) + .map_err(|e| e.to_string())?; + archive.finish().map_err(|e| e.to_string())?; + } + + let docker = get_docker()?; + let options = BuildImageOptions { + dockerfile: "Dockerfile".to_string(), + t: tag.to_string(), + rm: true, + forcerm: true, + ..Default::default() + }; + + let mut stream = docker.build_image(options, None, Some(context.into())); + while let Some(item) = stream.next().await { + match item { + Ok(info) => { + if let Some(error) = info.error { + return Err(error); + } + } + Err(e) => return Err(e.to_string()), + } + } + Ok(()) +} + +/// Put a captured image config back onto a flattened image, under the original +/// tag. +/// +/// `FROM scratch` discards env, cmd, entrypoint, workdir, user, labels and +/// exposed ports, and there is no way to hand them to a build without rendering +/// them as Dockerfile instructions — which a multi-line `CLAUDE_INSTRUCTIONS` +/// or a label containing a quote would not survive. Creating a container with +/// the config and committing it round-trips the structured values instead. +/// Verified against Docker 29.7.2 with both of those cases. +/// +/// The container is never started. `docker commit` on a created container is +/// well defined and adds an empty layer, so the result is still one layer of +/// content. +async fn restore_image_config( + flat_ref: &str, + final_ref: &str, + config: bollard::models::ImageConfig, +) -> Result<(), String> { + use bollard::container::{Config, CreateContainerOptions, RemoveContainerOptions}; + use bollard::image::CommitContainerOptions; + + let docker = get_docker()?; + // Deliberately the same `triple-c-scrub-*` name `rewrite_image_without_secrets` + // uses. If this process dies between the create and the remove below, the + // leftover is already covered by the "secret-scrub scratch containers" + // bucket in this very panel rather than needing a second reaper. The two + // never run at once: `reclaim` executes its targets in sequence. + let scratch_name = format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()); + + // `image` is the flat build; everything else is copied from the original so + // the committed image is byte-for-byte the same configuration. + let create_config = Config:: { + image: Some(flat_ref.to_string()), + env: config.env.clone(), + cmd: config.cmd.clone(), + entrypoint: config.entrypoint.clone(), + working_dir: config.working_dir.clone(), + user: config.user.clone(), + labels: config.labels.clone(), + exposed_ports: config.exposed_ports.clone(), + volumes: config.volumes.clone(), + stop_signal: config.stop_signal.clone(), + shell: config.shell.clone(), + healthcheck: config.healthcheck.clone(), + ..Default::default() + }; + + docker + .create_container( + Some(CreateContainerOptions { + name: scratch_name.clone(), + platform: None, + }), + create_config, + ) + .await + .map_err(|e| format!("Could not create the config-restore container: {}", e))?; + + let (repo, tag) = migration::split_image_ref(final_ref); + let commit = docker + .commit_container( + CommitContainerOptions { + container: scratch_name.clone(), + repo, + tag, + // Never started, so there is nothing to pause. + pause: false, + ..Default::default() + }, + // Deliberately empty: the container was created with the config + // already on it, and commit inherits every field it is not told + // about. Passing the config twice would be the only way to get the + // two copies out of step. + Config::::default(), + ) + .await + .map_err(|e| format!("Could not commit the compacted snapshot: {}", e)); + + // Remove the scratch container whether or not the commit worked — a + // leftover would pin the flat image and show up in this very panel as a + // scrub container. + if let Err(e) = docker + .remove_container( + &scratch_name, + Some(RemoveContainerOptions { + v: false, + force: true, + ..Default::default() + }), + ) + .await + { + log::warn!( + "Could not remove the config-restore container {}: {}", + scratch_name, + e + ); + } + + commit.map(|_| ()) +} + +/// Delete the regenerable package caches inside a running container. +/// +/// A `docker exec`, so the container has to be running — the caches are in the +/// home *volume*, and the only way to size and delete them accurately is from +/// inside, where `du` can see them. +/// +/// Runs as `claude`, not root: every path is under that user's `$HOME`, and a +/// root `rm` that got a path wrong would have the authority to act on it. +pub async fn clear_caches(project: &Project, include_rustup: bool) -> ReclaimResult { + let target = ReclaimTarget::ClearCaches { + project_id: project.id.clone(), + include_rustup, + }; + if crate::commands::migration_commands::is_migrating(&project.id) { + return failed( + target, + "A base-image migration is in flight for this project.".to_string(), + ); + } + + let container_id = match container::find_existing_container(project).await { + Ok(Some(id)) => id, + Ok(None) => { + return failed( + target, + "This project has no container. Start it first — the caches are cleared from \ + inside." + .to_string(), + ) + } + Err(e) => return failed(target, e), + }; + if !container::is_container_running(&container_id) + .await + .unwrap_or(false) + { + return failed( + target, + "Start this project's container first — the caches live in its home volume and are \ + cleared from inside." + .to_string(), + ); + } + + let script = cache_clear_script(include_rustup); + let cmd = vec!["/bin/sh".to_string(), "-c".to_string(), script]; + match super::exec::exec_oneshot_as(&container_id, "claude", cmd, Vec::new()).await { + Ok((output, _exit)) => match parse_cache_total(&output) { + Some(bytes) => ReclaimResult { + target, + ok: true, + freed_bytes: bytes as i64, + projected_bytes: None, + message: "Cleared. Every one of these refills itself the next time a tool needs \ + it." + .to_string(), + }, + None => failed( + target, + format!( + "The cache clear did not report a total, so nothing can be confirmed. \ + Output: {}", + output.trim() + ), + ), + }, + Err(e) => failed(target, format!("Could not clear caches: {}", e)), + } +} + +// --------------------------------------------------------------------------- +// Destructive +// --------------------------------------------------------------------------- + +/// Delete one object that has no other copy. +/// +/// Takes a [`DestructiveTarget`], which [`reclaim`] cannot construct or be +/// handed — a bulk selection has no way to reach this function. `confirmation` +/// must be the project's name, typed. +/// +/// Everything here is refused while a migration is in flight, and while the +/// project's container exists in a state that would be broken by the removal. +pub async fn destroy( + target: &DestructiveTarget, + confirmation: &str, + projects: &[Project], +) -> Result { + let project = find_project(projects, target.project_id())?; + if !confirmation_matches(&project.name, confirmation) { + return Err(format!( + "Type the project name ({}) exactly to confirm. Nothing was removed.", + project.name + )); + } + if crate::commands::migration_commands::is_migrating(&project.id) { + return Err( + "A base-image migration is in flight for this project. Finish or roll it back first." + .to_string(), + ); + } + + let docker = get_docker()?; + + // A running container holds all three of these open, and Docker's refusal + // is not something to lean on for the volumes: it would happily leave a + // half-removed project behind. + if let Ok(Some(container_id)) = container::find_existing_container(project).await { + if container::is_container_running(&container_id) + .await + .unwrap_or(false) + { + return Err( + "Stop this project's container first. Nothing was removed.".to_string(), + ); + } + } + + match target { + DestructiveTarget::HomeVolume { .. } | DestructiveTarget::ConfigVolume { .. } => { + let name = match target { + DestructiveTarget::HomeVolume { .. } => home_volume_name(&project.id), + _ => config_volume_name(&project.id), + }; + // Size it before it goes, so the report is a measurement. + let bytes = volume_size(&name).await; + docker + .remove_volume(&name, None) + .await + .map_err(|e| format!("Could not remove volume {}: {}", name, e))?; + log::info!("Removed volume {} on explicit confirmation", name); + Ok(ReclaimResult { + target: ReclaimTarget::OrphanVolume { name: name.clone() }, + ok: true, + freed_bytes: bytes, + projected_bytes: None, + message: format!("Removed volume {}.", name), + }) + } + DestructiveTarget::SnapshotImage { .. } => { + let reference = get_snapshot_image_name(project); + // Measured before the removal, and net of the shared base — which + // other projects are still built from and which is not freed. + let bytes = image_unique_bytes(&reference).await; + container::remove_snapshot_image(project).await?; + let sweep = container::sweep_orphaned_snapshots().await; + Ok(ReclaimResult { + target: ReclaimTarget::DanglingSnapshots, + ok: true, + freed_bytes: bytes + sweep.reclaimed_bytes, + projected_bytes: None, + message: format!( + "Removed {}. This project will build from the base image next time it starts.", + reference + ), + }) + } + DestructiveTarget::RollbackPin { tag, .. } => { + let reference = format!("triple-c-snapshot-{}:{}", project.id, tag); + migration::untag_image(&reference).await?; + // Untagging only makes the image dangling. Whatever came back came + // back through the sweep, under its own refusal rules. + let sweep = container::sweep_orphaned_snapshots().await; + log::info!("Dropped rollback pin {} on explicit confirmation", reference); + Ok(ReclaimResult { + target: ReclaimTarget::MigrationPins, + ok: true, + freed_bytes: sweep.reclaimed_bytes, + projected_bytes: None, + message: format!( + "Dropped {}. Rolling that migration back is no longer possible.", + reference + ), + }) + } + } +} + +/// Bytes an image would actually give back if it were removed: its size minus +/// what it shares with other images. +/// +/// The plain `Size` from `inspect_image` includes the base, which several other +/// projects are still built from and which is not going anywhere. Reporting it +/// as freed would overstate a snapshot removal by ~4.7 GB every time. Only +/// `df()` computes `SharedSize`, so this costs one — acceptable on the +/// destructive path, which handles exactly one object per call. +async fn image_unique_bytes(reference: &str) -> i64 { + let Ok(docker) = get_docker() else { + return 0; + }; + let Ok(usage) = docker.df().await else { + return 0; + }; + usage + .images + .unwrap_or_default() + .iter() + .find(|image| image.repo_tags.iter().any(|tag| tag == reference)) + .map(|image| (image.size - image.shared_size.max(0)).max(0)) + .unwrap_or(0) +} + +/// One volume's size, or 0 when the daemon will not say. Costs a `df()`, which +/// is why it is only used on the destructive path where there is exactly one. +async fn volume_size(name: &str) -> i64 { + let Ok(docker) = get_docker() else { + return 0; + }; + let Ok(usage) = docker.df().await else { + return 0; + }; + usage + .volumes + .unwrap_or_default() + .iter() + .find(|v| v.name == name) + .and_then(volume_bytes) + .unwrap_or(0) +} + +#[cfg(test)] +#[path = "disk_tests.rs"] +mod tests; diff --git a/app/src-tauri/src/docker/disk_tests.rs b/app/src-tauri/src/docker/disk_tests.rs new file mode 100644 index 0000000..0aae0b1 --- /dev/null +++ b/app/src-tauri/src/docker/disk_tests.rs @@ -0,0 +1,823 @@ +//! Tests for the disk view's pure logic. +//! +//! Split into its own file because `disk.rs` is already long and because +//! everything here has to stay runnable without a daemon — which is the point +//! of keeping the classification, the orphan set and the script builders pure. +//! +//! The blast radius of a mistake in this module is a user's credentials, +//! transcripts and toolchains, so the tests below are deliberately about +//! *refusing*, not about succeeding. + +use super::*; + +// --------------------------------------------------------------------------- +// Safety classification +// --------------------------------------------------------------------------- + +/// Every `ReclaimTarget` variant, so the walks below cannot silently skip a new +/// one. A variant added without a line here fails `every_variant_is_covered`. +fn all_reclaim_targets() -> Vec { + vec![ + ReclaimTarget::DanglingSnapshots, + ReclaimTarget::SupersededBaseImages, + ReclaimTarget::BuildCache { all: false }, + ReclaimTarget::BuildCache { all: true }, + ReclaimTarget::MigrationPins, + ReclaimTarget::MigrationStaging, + ReclaimTarget::ProbeContainers, + ReclaimTarget::ScrubContainers, + ReclaimTarget::OrphanVolume { + name: "triple-c-home-gone".to_string(), + }, + ReclaimTarget::CompactSnapshot { + project_id: "p1".to_string(), + }, + ReclaimTarget::ClearCaches { + project_id: "p1".to_string(), + include_rustup: false, + }, + ReclaimTarget::ClearCaches { + project_id: "p1".to_string(), + include_rustup: true, + }, + ] +} + +#[test] +fn every_variant_is_covered_by_the_safety_walk() { + // `ReclaimTarget` has no way to enumerate itself, so this pins the count by + // hand. Bumping it is the prompt to add the new variant above *and* decide + // its safety deliberately rather than by whatever the match arm falls into. + let discriminants: HashSet = all_reclaim_targets() + .iter() + .map(|t| serde_json::to_value(t).unwrap()["kind"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + discriminants.len(), + 10, + "a ReclaimTarget variant was added or removed; update all_reclaim_targets() and check its \ + safety: {:?}", + discriminants + ); +} + +#[test] +fn nothing_destructive_can_land_in_the_safe_bucket() { + // The strongest form of this guarantee is structural: `reclaim` takes + // `&[ReclaimTarget]` and `DestructiveTarget` is a different type, so a + // destructive action cannot be passed to a bulk reclaim at all. What this + // test pins is the second half — that no *safe*-classified target names a + // live project's data either. + for target in all_reclaim_targets() { + match &target { + // These act on a project, and both are rewrites or cache flushes. + // Neither may ever be classified Safe: one rebuilds an image and + // the other costs a re-download. + ReclaimTarget::CompactSnapshot { .. } | ReclaimTarget::ClearCaches { .. } => { + assert_eq!( + target.safety(), + Safety::SemiSafe, + "{:?} must ask for confirmation", + target + ); + } + // A safe target may name a *volume*, but only ever one that orphan + // detection produced — which by construction belongs to no project + // in the store. + other => assert_eq!( + other.safety(), + Safety::Safe, + "{:?} was expected to need no confirmation", + other + ), + } + } +} + +#[test] +fn only_the_build_cache_reaches_outside_triple_c() { + // The user's daemon also holds their unrelated postgres, mysql and + // site-builder work. Exactly one action here touches it, and the UI has to + // say so — so if a second one ever does, this fails loudly. + let daemon_wide: Vec<_> = all_reclaim_targets() + .into_iter() + .filter(ReclaimTarget::is_daemon_wide) + .collect(); + assert_eq!(daemon_wide.len(), 2, "expected only the two BuildCache variants"); + assert!(daemon_wide + .iter() + .all(|t| matches!(t, ReclaimTarget::BuildCache { .. }))); +} + +#[test] +fn destructive_targets_all_name_a_project() { + // The typed confirmation is "type the project name". A destructive target + // that could not name a project would have nothing to confirm against. + for target in [ + DestructiveTarget::HomeVolume { + project_id: "p1".to_string(), + }, + DestructiveTarget::ConfigVolume { + project_id: "p1".to_string(), + }, + DestructiveTarget::SnapshotImage { + project_id: "p1".to_string(), + }, + DestructiveTarget::RollbackPin { + project_id: "p1".to_string(), + tag: "pre-migration-20260101-101500".to_string(), + }, + ] { + assert_eq!(target.project_id(), "p1"); + } +} + +#[test] +fn a_dangling_image_is_a_base_only_when_it_says_so() { + let base = HashMap::from([(LABEL_BASE.to_string(), "true".to_string())]); + assert_eq!(classify_dangling(&base), DanglingClass::Base); + + // `create_container` writes `triple-c.base` explicitly *empty* precisely so + // an inherited `true` cannot ride a commit onto a snapshot and make it + // claim to be a base image. + let commit = HashMap::from([(LABEL_BASE.to_string(), String::new())]); + assert_eq!(classify_dangling(&commit), DanglingClass::SnapshotCommit); + + // Images committed before the label existed carry it not at all. + assert_eq!( + classify_dangling(&HashMap::new()), + DanglingClass::SnapshotCommit + ); + + // Anything other than the exact string `true` is not a base. + let liar = HashMap::from([(LABEL_BASE.to_string(), "yes".to_string())]); + assert_eq!(classify_dangling(&liar), DanglingClass::SnapshotCommit); +} + +// --------------------------------------------------------------------------- +// Orphan detection — the part that can delete a user's transcripts +// --------------------------------------------------------------------------- + +fn vol(name: &str, bytes: i64, links: i64) -> VolumeFacts { + VolumeFacts { + name: name.to_string(), + bytes, + links, + created_at: Some("2026-03-14T09:00:00Z".to_string()), + } +} + +#[test] +fn orphan_detection_skips_every_project_in_the_store() { + let volumes = vec![ + vol("triple-c-home-live", 1_000, 0), + vol("triple-c-claude-config-live", 2_000, 0), + vol("triple-c-home-gone", 3_000, 0), + vol("triple-c-claude-config-gone", 4_000, 0), + ]; + let known = HashSet::from(["live".to_string()]); + let orphans = orphan_volumes(&volumes, &known, true); + + let names: Vec<&str> = orphans.iter().map(|o| o.name.as_str()).collect(); + assert_eq!( + names, + vec!["triple-c-claude-config-gone", "triple-c-home-gone"], + "sorted biggest first" + ); + assert!( + !names.iter().any(|n| n.contains("live")), + "a live project's volumes were offered for deletion" + ); +} + +#[test] +fn a_store_that_did_not_load_yields_no_orphans_at_all() { + // This is the case the whole design turns on, and the one that would wipe + // every project's credentials, transcripts and toolchains at once. With the + // store unreadable, *every* project's volumes look unclaimed — so the + // answer has to be "nothing, and here is why", never "everything". + let volumes = vec![ + vol("triple-c-home-a", 1_000, 0), + vol("triple-c-claude-config-a", 2_000, 0), + vol("triple-c-home-b", 3_000, 0), + ]; + assert!(orphan_volumes(&volumes, &HashSet::new(), false).is_empty()); + + // And with the store loaded but genuinely empty, they *are* orphans — the + // distinction is the flag, not the emptiness of the set. + assert_eq!(orphan_volumes(&volumes, &HashSet::new(), true).len(), 3); +} + +#[test] +fn an_idle_live_project_is_never_mistaken_for_a_deleted_one() { + // The exact mistake this guard exists for. An "orphan" heuristic of "no + // container and no snapshot image" was tried against a real project list + // and flagged two live projects — `site-builder` and `cal-dav-mcp` — that + // had simply been idle long enough for their containers to be removed. + // Their volumes held `.credentials.json`, Claude transcripts and shell + // history. + // + // From the daemon's side those look identical to a deleted project's + // leftovers: volumes present, ref count zero, no container, no image. The + // *only* thing that tells them apart is membership in Triple-C's own + // project store, so that is the only thing consulted. + let idle_but_live = vec![ + vol("triple-c-home-site-builder", 8_400_000_000, 0), + vol("triple-c-claude-config-site-builder", 427_000_000, 0), + vol("triple-c-home-cal-dav-mcp", 1_200_000_000, 0), + vol("triple-c-claude-config-cal-dav-mcp", 44_000_000, 0), + vol("triple-c-home-really-gone", 900_000, 0), + ]; + let store = HashSet::from(["site-builder".to_string(), "cal-dav-mcp".to_string()]); + + let orphans = orphan_volumes(&idle_but_live, &store, true); + assert_eq!( + orphans.iter().map(|o| o.name.as_str()).collect::>(), + vec!["triple-c-home-really-gone"], + "an idle live project's volumes were offered for deletion" + ); + + // And nothing in the signature even *offers* container or image state, so a + // future change cannot quietly start inferring from it. + assert_eq!( + orphans[0].created_at.as_deref(), + Some("2026-03-14T09:00:00Z"), + "the creation date is the evidence a user recognises the project by" + ); +} + +#[test] +fn a_volume_with_a_container_attached_is_never_an_orphan() { + let volumes = vec![ + vol("triple-c-home-gone", 1_000, 1), + // -1 is "the daemon did not compute it", which must fail closed: an + // unknown ref count is not permission. + vol("triple-c-claude-config-gone", 2_000, -1), + vol("triple-c-home-other", 3_000, 0), + ]; + let orphans = orphan_volumes(&volumes, &HashSet::new(), true); + assert_eq!(orphans.len(), 1); + assert_eq!(orphans[0].name, "triple-c-home-other"); +} + +#[test] +fn orphan_detection_ignores_volumes_that_are_not_ours() { + let volumes = vec![ + vol("nfc-profile-mysql", 183_926_366, 0), + vol("postgres_data", 9_000_000, 0), + vol("triple-c-stt-model-cache", 900_000_000, 0), + vol("triple-c-gateway-config", 1_000, 0), + vol("triple-c-home-gone", 5_000, 0), + ]; + let orphans = orphan_volumes(&volumes, &HashSet::new(), true); + assert_eq!(orphans.len(), 1, "{:?}", orphans); + assert_eq!(orphans[0].name, "triple-c-home-gone"); + + // The STT model cache and the gateway config are ours by name but are not + // per-project volumes; they belong to features, not projects, and nothing + // here may reach them. + assert!(parse_project_volume_name("triple-c-stt-model-cache").is_none()); + assert!(parse_project_volume_name("triple-c-gateway-config").is_none()); +} + +#[test] +fn a_volume_name_splits_into_the_right_project_and_role() { + assert_eq!( + parse_project_volume_name("triple-c-home-abc-123"), + Some(("abc-123", "home")) + ); + assert_eq!( + parse_project_volume_name("triple-c-claude-config-abc-123"), + Some(("abc-123", "config")) + ); + // A bare prefix names no project, so it is not ours to delete. + assert!(parse_project_volume_name("triple-c-home-").is_none()); + assert!(parse_project_volume_name("triple-c-claude-config-").is_none()); + assert!(parse_project_volume_name("triple-c-").is_none()); + assert!(parse_project_volume_name("").is_none()); +} + +#[test] +fn the_config_role_is_reported_because_it_is_the_one_holding_credentials() { + let orphans = orphan_volumes( + &[vol("triple-c-claude-config-gone", 7, 0)], + &HashSet::new(), + true, + ); + assert_eq!(orphans[0].role, "config"); + assert_eq!(orphans[0].project_id, "gone"); +} + +// --------------------------------------------------------------------------- +// Throwaway-container predicates — these gate a `docker rm` +// --------------------------------------------------------------------------- + +fn summary(names: &[&str], labels: &[(&str, &str)]) -> ContainerSummary { + ContainerSummary { + names: Some(names.iter().map(|n| (*n).to_string()).collect()), + labels: Some( + labels + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + ), + ..Default::default() + } +} + +#[test] +fn a_scrub_container_is_matched_on_its_whole_name_not_a_substring() { + // Docker's `name` filter is a *substring* match, so the daemon happily + // returns a user's own container whose name merely contains ours. The + // predicate is what decides, and it anchors at the start. + assert!(is_scrub_container(&summary(&["/triple-c-scrub-abc123"], &[]))); + + assert!(!is_scrub_container(&summary(&["/my-triple-c-scrub-notes"], &[]))); + assert!(!is_scrub_container(&summary(&["/triple-c-scrubber"], &[]))); + assert!(!is_scrub_container(&summary(&["/triple-c-abc"], &[]))); + assert!(!is_scrub_container(&summary(&[], &[]))); +} + +#[test] +fn a_probe_container_is_matched_on_its_label_not_on_the_daemons_filter() { + // The `label=triple-c.probe=migration` filter is an exact match and would + // be enough on its own — but a filter is a string assembled elsewhere in + // the file, and "enough" is not the standard for something that runs + // `docker rm`. + assert!(is_migration_probe(&summary( + &["/nervous_curie"], + &[(migration::LABEL_PROBE, migration::PROBE_LABEL_MIGRATION)] + ))); + + // A different probe kind, a truncated value, and no label at all. + assert!(!is_migration_probe(&summary( + &["/x"], + &[(migration::LABEL_PROBE, "something-else")] + ))); + assert!(!is_migration_probe(&summary(&["/x"], &[]))); + assert!(!is_migration_probe(&summary( + &["/x"], + &[("triple-c.managed", "true")] + ))); +} + +// --------------------------------------------------------------------------- +// Store trust +// --------------------------------------------------------------------------- + +fn project(id: &str, name: &str) -> Project { + let mut p = Project::new(name.to_string(), Vec::new()); + p.id = id.to_string(); + p +} + +#[test] +fn an_unreadable_projects_json_is_never_trusted() { + let err = project_store_trust(&[project("a", "api")], true, false).unwrap_err(); + assert!(err.contains("could not be read"), "{}", err); +} + +#[test] +fn an_empty_list_from_an_existing_file_is_treated_as_a_failed_load() { + // `ProjectsStore::new()` swallows a corrupt projects.json: it backs the file + // up and starts empty. That is right for the app and catastrophic here, so + // the combination "empty list + file present" is refused rather than read as + // "the user has no projects". + let err = project_store_trust(&[], true, true).unwrap_err(); + assert!(err.contains("suppressed"), "{}", err); + + // No file at all is a genuine fresh install, and there is nothing on the + // daemon to mis-attribute in that state. + assert!(project_store_trust(&[], false, true).unwrap().is_empty()); +} + +#[test] +fn a_healthy_store_yields_its_ids() { + let ids = project_store_trust(&[project("a", "api"), project("b", "web")], true, true).unwrap(); + assert_eq!(ids, HashSet::from(["a".to_string(), "b".to_string()])); +} + +// --------------------------------------------------------------------------- +// Layer accounting — the number the whole UI exists to show +// --------------------------------------------------------------------------- + +#[test] +fn commit_layers_are_the_history_a_snapshot_has_beyond_its_base() { + // `image_history` returns newest first, and a snapshot's history is its + // base's history with the commits appended — so the commits are the head. + let snapshot = vec![0, 868_000_000, 500_000_000, 4_000_000_000, 0]; + let stats = layer_stats(&snapshot, Some(2)); + assert_eq!(stats.commit_layers, 3); + assert_eq!(stats.above_base_bytes, Some(1_368_000_000)); +} + +#[test] +fn a_missing_base_reports_a_count_but_refuses_to_split_the_bytes() { + // A base image that has been swept is common — the project keeps running + // from its own snapshot. The layer count is still useful; the byte split is + // not knowable, and a guess there would be the one number in this UI that + // is not measured. + let stats = layer_stats(&[10, 20, 0, 30], None); + assert_eq!(stats.commit_layers, 3, "zero-byte layers are metadata, not commits"); + assert_eq!(stats.above_base_bytes, None); +} + +#[test] +fn a_base_longer_than_the_snapshot_means_they_are_not_the_same_lineage() { + let stats = layer_stats(&[10, 20], Some(5)); + assert_eq!(stats.above_base_bytes, None); +} + +#[test] +fn a_snapshot_that_is_exactly_its_base_has_no_commits() { + let stats = layer_stats(&[10, 20, 30], Some(3)); + assert_eq!(stats.commit_layers, 0); + assert_eq!(stats.above_base_bytes, Some(0)); +} + +#[test] +fn compaction_is_bounded_and_the_floor_is_zero() { + // Verified on Docker 29.7.2: a stack with nothing superseded came out + // *larger* (29.8 MB -> 30.8 MB), because the merged layer recompresses on + // its own. So the floor is zero and never a fraction of the total. + let (floor, ceiling) = compaction_bounds(&[100, 100, 100]); + assert_eq!(floor, 0); + assert_eq!(ceiling, 200, "at most everything but the largest layer"); + + // One layer can supersede nothing, so there is no upside at all. + assert_eq!(compaction_bounds(&[500]), (0, 0)); + assert_eq!(compaction_bounds(&[]), (0, 0)); +} + +#[test] +fn the_ceiling_shown_in_the_plan_matches_the_bound() { + // With no shared base to re-duplicate, the bound is the superseded-bytes + // one: an even split approximating "everything but the largest layer". + assert_eq!(compaction_ceiling_for(300, 0, 3), 200); + assert_eq!(compaction_ceiling_for(300, 0, 1), 0, "one layer supersedes nothing"); + assert_eq!(compaction_ceiling_for(0, 0, 14), 0); + assert_eq!(compaction_ceiling_for(-5, 0, 3), 0, "never negative"); +} + +#[test] +fn compacting_a_thin_snapshot_over_a_fat_base_is_never_offered() { + // The bug this exists to stop, with the real numbers that exposed it. + // + // `FROM scratch` + `COPY --from` produces an image that shares nothing, so + // the flattened snapshot carries its own private copy of the base — which + // stays on disk regardless, because every other project is still built from + // it. Eight of ten projects on a real daemon had a unique delta of + // 0.10–1.32 GB over a 4.72 GB shared base: flattening any of them turns a + // sub-gigabyte cost into a ~4.7 GB one. + // + // A ceiling of zero keeps them out of the plan entirely, rather than + // offering a 4 GB loss as a saving. + let shared_base = 4_723_860_394; + for unique in [100_000_000i64, 630_000_000, 1_320_000_000] { + assert_eq!( + compaction_ceiling_for(unique, shared_base, 6), + 0, + "a {}-byte delta over a {}-byte base must not be offered", + unique, + shared_base + ); + } + + // The one project that *was* worth it: 8.44 GB unique across 14 layers over + // a 3.83 GB base. The base penalty still binds — 8.44 - 3.83 = 4.61 GB is + // smaller than the 7.84 GB the even split allows — so that is the figure. + let ceiling = compaction_ceiling_for(8_440_966_715, 3_832_425_659, 14); + assert_eq!(ceiling, 8_440_966_715 - 3_832_425_659); + assert!(ceiling < (8_440_966_715 / 14) * 13, "the base penalty must bind here"); +} + +#[test] +fn the_superseded_bound_still_binds_when_the_base_is_small() { + // With a tiny base, the limit on what can come back is how much the layers + // superseded, not the duplication cost. Both terms have to be live. + let ceiling = compaction_ceiling_for(300, 10, 3); + assert_eq!(ceiling, 200, "the even split binds, not 300 - 10"); +} + +// --------------------------------------------------------------------------- +// Build cache +// --------------------------------------------------------------------------- + +fn cache(size: i64, in_use: bool, age_hours: i64) -> BuildCacheFacts { + BuildCacheFacts { + size, + in_use, + last_used_at: Some(chrono::Utc::now() - chrono::Duration::hours(age_hours)), + } +} + +#[test] +fn the_age_filter_leaves_in_use_and_recent_records_alone() { + let now = chrono::Utc::now(); + let entries = vec![ + cache(1_000, false, 200), // old and free -> counted + cache(2_000, true, 200), // old but in use -> never + cache(4_000, false, 10), // free but recent -> not by this filter + BuildCacheFacts { + size: 8_000, + in_use: false, + // No timestamp at all: unknown age fails closed, same rule as an + // unknown volume ref count. + last_used_at: None, + }, + ]; + assert_eq!(stale_build_cache_bytes(&entries, 168, now), 1_000); +} + +#[test] +fn docker_sizes_parse_in_base_1000_because_that_is_what_docker_prints() { + // `units.HumanSize` is base 1000. Reading "28.0GB" as 1024-based would + // overstate the single biggest win in this panel by about 7%. + assert_eq!(parse_docker_size("0B"), Some(0)); + assert_eq!(parse_docker_size("28.0GB"), Some(28_000_000_000)); + assert_eq!(parse_docker_size("1.5MB"), Some(1_500_000)); + assert_eq!(parse_docker_size(" 46.88GB "), Some(46_880_000_000)); + assert_eq!(parse_docker_size("12kB"), Some(12_000)); + + // Anything unrecognised is None, so the caller falls back to `df()` rather + // than showing a wrong number. + assert_eq!(parse_docker_size("lots"), None); + assert_eq!(parse_docker_size(""), None); + assert_eq!(parse_docker_size("12GiB"), None); + + // A space before the unit is fine — `docker builder prune` uses a tab. + assert_eq!(parse_docker_size("1.5 kB"), Some(1_500)); + assert_eq!(parse_docker_size("\t20.59MB"), Some(20_590_000)); + + // A negative would subtract from the running freed total if it got through. + assert_eq!(parse_docker_size("-5GB"), None); +} + +#[test] +fn buildx_du_output_parses_into_total_and_reclaimable() { + // Real shape, taken from `docker buildx du` on Docker 29.7.2. + let output = "ID RECLAIMABLE SIZE LAST ACCESSED\n\ + abc123 true 29.78MB 36 seconds ago\n\ + Reclaimable:\t28.0GB\n\ + Total:\t\t33.57MB\n"; + assert_eq!(parse_buildx_du(output), Some((33_570_000, 28_000_000_000))); + + // An empty cache still reports both lines. + assert_eq!( + parse_buildx_du("Reclaimable:\t0B\nTotal:\t\t0B\n"), + Some((0, 0)) + ); + // No Total line means the output is not what we expect; fall back rather + // than invent. + assert_eq!(parse_buildx_du("nothing here"), None); +} + +#[test] +fn the_reclaimed_figure_comes_from_the_prunes_own_report() { + // `docker system prune` / `image prune` wording. + let output = "deleted: sha256:abc\ndeleted: sha256:def\nTotal reclaimed space: 12.3GB\n"; + assert_eq!(parse_reclaimed_space(output), 12_300_000_000); + assert_eq!(parse_reclaimed_space("Total reclaimed space: 0B"), 0); + + // `docker builder prune` wording — the one this module actually runs, and + // the one an earlier draft of the parser missed entirely, reporting every + // build-cache prune as having freed nothing. Verbatim from Docker 29.7.2. + let builder = "2zp7lsfz2me0jtqe8rio6s4eq*\ttrue\t\t8.192kB\tLess than a second ago\n\ + rmonzx1v6jrrlgxt783dmfb3k\ttrue\t16.79MB\t1 second ago\n\ + Total:\t20.59MB\n"; + assert_eq!(parse_reclaimed_space(builder), 20_590_000); + + // A filtered prune that matched nothing still prints the summary. + assert_eq!(parse_reclaimed_space("Total:\t0B\n"), 0); + + // A prune that printed nothing recognisable freed nothing we can claim. + assert_eq!(parse_reclaimed_space("nothing to do"), 0); +} + +// --------------------------------------------------------------------------- +// Scripts — shell strings, so pinned by test +// --------------------------------------------------------------------------- + +#[test] +fn the_compaction_dockerfile_reuses_the_one_scrub_list() { + let df = compaction_dockerfile( + "triple-c-snapshot-p1:latest", + &container::snapshot_scrub_script(), + ); + + assert!(df.starts_with("FROM triple-c-snapshot-p1:latest AS src\n")); + assert!( + df.contains("\nFROM scratch\nCOPY --from=src / /\n"), + "the flatten is the whole point: {}", + df + ); + + // Every path in the reviewed list has to appear, and it has to be *that* + // list rather than a second copy — a forked list is the failure mode a + // hardcoded set of `rm -rf` paths invites. + for path in container::SNAPSHOT_SCRUB_PATHS { + assert!(df.contains(path), "scrub path {} missing from {}", path, df); + } + + // The RUN must be one line: a Dockerfile instruction does not continue over + // a bare newline, and a script folded wrongly would silently truncate to + // its first statement. + let run_lines: Vec<&str> = df.lines().filter(|l| l.starts_with("RUN ")).collect(); + assert_eq!(run_lines.len(), 1, "{}", df); + assert!(!run_lines[0].contains('\n')); +} + +#[test] +fn the_compaction_dockerfile_never_reaches_a_bind_mount() { + let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script()); + // `/workspace/{mount_name}` subtrees are the user's real project + // directories, mounted from the host. Nothing in a scrub may name one, and + // the two read-only host mounts under /tmp are dot-prefixed so no glob + // reaches them either. + assert!(!df.contains("/workspace"), "{}", df); + assert!(!df.contains(".host-ca"), "{}", df); + assert!(!df.contains(".host-aws"), "{}", df); +} + +#[test] +fn the_cache_script_only_ever_names_paths_under_home() { + for include_rustup in [false, true] { + let script = cache_clear_script(include_rustup); + for line in script.lines() { + // Every deletion in this script is anchored to $HOME. A path that + // is not would be operating on the system layer, or worse on a + // bind mount. + if line.contains("rm -rf") { + assert!( + line.contains("$HOME") || line.contains("$d"), + "unanchored deletion: {}", + line + ); + } + } + assert!(!script.contains("/workspace"), "{}", script); + assert!(!script.contains(" / "), "{}", script); + } +} + +#[test] +fn rustup_is_only_cleared_when_it_is_asked_for() { + // Regenerable, but a re-download rather than a rebuild from a local cache — + // which is why it is a separate tick and not part of the set. + assert!(!cache_clear_script(false).contains(".rustup")); + assert!(cache_clear_script(true).contains("$HOME/.rustup/toolchains")); +} + +#[test] +fn the_cache_script_keeps_the_newest_playwright_revision() { + // Deleting the current revision turns a working browser-view project into + // one that downloads 400 MB on next use, so only superseded revisions go. + let script = cache_clear_script(false); + assert!(script.contains("keep=$("), "{}", script); + assert!(script.contains("= \"$keep\" ] && continue"), "{}", script); + // ...and it must not simply remove the whole directory. + assert!(!script.contains("rm -rf -- \"$HOME/.cache/ms-playwright\""), "{}", script); +} + +#[test] +fn the_cache_script_covers_every_documented_cache() { + let script = cache_clear_script(false); + for path in [ + "$HOME/.npm/_cacache", + "$HOME/.npm/_npx", + "$HOME/.cache/go-build", + "$HOME/.cache/pip", + "$HOME/.cache/uv", + "$HOME/.cache/act", + "$HOME/.cache/chrome-devtools-mcp", + "$HOME/go/pkg/mod", + "$HOME/.cache/ms-playwright", + ] { + assert!(script.contains(path), "{} missing from the cache script", path); + } +} + +#[test] +fn the_cache_script_reports_a_total_that_can_be_read_back() { + let script = cache_clear_script(false); + assert!(script.contains(CACHE_MARKER)); + assert_eq!( + parse_cache_total(&format!("noise\n{}6291456\nmore noise\n", CACHE_MARKER)), + Some(6_291_456) + ); + // No marker means the script never reached its last line — a killed exec, + // not a run that freed nothing. + assert_eq!(parse_cache_total("permission denied"), None); + assert_eq!(parse_cache_total(&format!("{}0", CACHE_MARKER)), Some(0)); +} + +// --------------------------------------------------------------------------- +// Confirmation +// --------------------------------------------------------------------------- + +#[test] +fn a_typed_confirmation_must_match_the_project_name_exactly() { + assert!(confirmation_matches("whp", "whp")); + // A trailing space from a paste is not a different intent. + assert!(confirmation_matches("whp", " whp ")); + + // Case is not negotiable: `Api` and `api` are different projects, and this + // is the only thing between a user and their transcripts. + assert!(!confirmation_matches("Api", "api")); + assert!(!confirmation_matches("whp", "wh")); + assert!(!confirmation_matches("whp", "")); + // An empty expected name would otherwise be satisfied by an empty box. + assert!(!confirmation_matches("", "")); +} + +// --------------------------------------------------------------------------- +// Host detection +// --------------------------------------------------------------------------- + +#[test] +fn the_vhdx_caveat_needs_both_windows_and_docker_desktop() { + assert!(vhdx_applies(true, "Docker Desktop")); + assert!(vhdx_applies(true, "Docker Desktop 4.30.0"), "matched loosely"); + + // macOS Docker Desktop has the same never-shrinks property but a different + // file and a different fix, so this note would be wrong there. + assert!(!vhdx_applies(false, "Docker Desktop")); + // A Windows host talking to a native or remote engine has neither. + assert!(!vhdx_applies(true, "Ubuntu 24.04.1 LTS")); +} + +#[test] +fn the_vhdx_note_spells_out_the_fix() { + // Users otherwise report "I pruned and C: did not change" as a bug, so both + // routes have to be on screen, not in a doc. + assert!(WSL2_VHDX_NOTE.contains("never shrinks")); + assert!(WSL2_VHDX_FIX[0].contains("wsl --shutdown")); + assert!(WSL2_VHDX_FIX[1].contains("Optimize-VHD")); + assert!(WSL2_VHDX_FIX[1].contains("docker_data.vhdx")); + assert!(WSL2_VHDX_FIX_GUI.contains("Purge data")); +} + +#[test] +fn base_images_are_recognised_by_reference_for_display_only() { + assert!(is_base_image_reference("ghcr.io/shadowdao/triple-c-sandbox:latest")); + assert!(is_base_image_reference("triple-c-sandbox:latest")); + assert!(is_base_image_reference("triple-c:latest")); + + // A project's own snapshot is not a base image, and neither is anything of + // the user's. + assert!(!is_base_image_reference("triple-c-snapshot-abc:latest")); + assert!(!is_base_image_reference("triple-c-gateway:latest")); + assert!(!is_base_image_reference("postgres:17-alpine")); +} + +// --------------------------------------------------------------------------- +// IPC contract +// --------------------------------------------------------------------------- + +#[test] +fn reclaim_targets_round_trip_through_the_wire_format() { + // The frontend ticks an item and hands the very same `target` object back, + // so the tagged representation has to survive the trip unchanged in both + // directions. + for target in all_reclaim_targets() { + let json = serde_json::to_string(&target).unwrap(); + let back: ReclaimTarget = serde_json::from_str(&json).unwrap(); + assert_eq!(target, back, "{}", json); + assert!(json.contains("\"kind\""), "{}", json); + } +} + +#[test] +fn destructive_targets_round_trip_too() { + let target = DestructiveTarget::RollbackPin { + project_id: "p1".to_string(), + tag: "pre-migration-20260101-101500".to_string(), + }; + let json = serde_json::to_string(&target).unwrap(); + assert!(json.contains("\"kind\":\"rollback_pin\""), "{}", json); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + target + ); +} + +#[test] +fn the_report_serialises_as_snake_case_like_every_other_ipc_struct() { + let report = DiskUsageReport { + projects: vec![ProjectDiskRow { + project_id: "p1".to_string(), + project_name: "whp".to_string(), + snapshot_commit_layers: 14, + container_writable_bytes: 868_000_000, + ..Default::default() + }], + ..Default::default() + }; + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["projects"][0]["snapshot_commit_layers"], 14); + assert_eq!(json["projects"][0]["container_writable_bytes"], 868_000_000i64); + assert!(json["orphan_volumes_unavailable"].is_null()); + // `Option` must reach the frontend as null, not be omitted — the TS + // type is `number | null`, matching every other optional in `types.ts`. + assert!(json["projects"][0]["snapshot_above_base_bytes"].is_null()); +} diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index 97113fe..ad2b5ac 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -1,6 +1,7 @@ pub mod ca_certs; pub mod client; pub mod container; +pub mod disk; pub mod image; pub mod exec; pub mod gateway; @@ -24,6 +25,10 @@ pub use exec::*; pub use legacy_cleanup::*; #[allow(unused_imports)] pub use migration::*; +// `disk` is also deliberately kept namespaced. Its `scan`, `reclaim` and +// `destroy` are meaningless as bare names, and `disk::destroy` reading as what +// it is at every call site is worth more than the brevity. + // Deliberately *not* re-exported flat: `ca_certs::resolve` and // `ca_certs::CA_MOUNT_DIR` are far clearer than bare `resolve` in a module that // already re-exports five other namespaces. diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9c7a064..42494b6 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -436,6 +436,12 @@ pub fn run() { commands::docker_commands::build_image, commands::docker_commands::get_container_info, commands::docker_commands::list_sibling_containers, + // Disk + commands::docker_commands::get_docker_disk_usage, + commands::docker_commands::list_reclaimable, + commands::docker_commands::reclaim, + commands::docker_commands::destroy_project_disk_object, + commands::docker_commands::sweep_orphaned_snapshots, // Projects commands::project_commands::list_projects, commands::project_commands::add_project, diff --git a/app/src/components/projects/home/format.ts b/app/src/components/projects/home/format.ts index 171c8e0..2f85c06 100644 --- a/app/src/components/projects/home/format.ts +++ b/app/src/components/projects/home/format.ts @@ -1,10 +1,16 @@ /** Shared formatting helpers for the Project Home views. */ +import { formatBytes as shared } from "../../../lib/formatBytes"; + +/** + * File sizes in Project Home, ÷1024 with `KB`/`MB`/`GB` labels. + * + * Kept as a named re-export rather than deleted: three modules import it from + * here, and the binary/decimal-label pairing is a Project Home convention + * rather than the app-wide default. The implementation is `lib/formatBytes`. + */ export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + return shared(bytes, { binary: true }); } /** "2h ago" / "3d ago". Returns null for unparseable timestamps. */ diff --git a/app/src/components/projects/migrationCopy.ts b/app/src/components/projects/migrationCopy.ts index 026d541..d5e8169 100644 --- a/app/src/components/projects/migrationCopy.ts +++ b/app/src/components/projects/migrationCopy.ts @@ -7,6 +7,7 @@ */ import type { PackageFailure } from "../../lib/types"; +import { formatBytes } from "../../lib/formatBytes"; /** * What re-attaches untouched. These are not copied, rebuilt or re-authenticated @@ -62,14 +63,7 @@ export const REPLAY_COST = /** `41.0 MB`. Sizes here are informational, so the friendlier decimal unit. */ export function formatDataSize(bytes: number): string { - const units = ["B", "KB", "MB", "GB", "TB"]; - let value = bytes; - let unit = 0; - while (value >= 1000 && unit < units.length - 1) { - value /= 1000; - unit += 1; - } - return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`; + return formatBytes(bytes); } /** `1 Mar` — short enough to sit inline in the banner sentence. */ diff --git a/app/src/components/settings/DiskProjectTable.tsx b/app/src/components/settings/DiskProjectTable.tsx new file mode 100644 index 0000000..891a200 --- /dev/null +++ b/app/src/components/settings/DiskProjectTable.tsx @@ -0,0 +1,168 @@ +import OverflowMenu from "../ui/OverflowMenu"; +import Tooltip from "../ui/Tooltip"; +import StatusIndicator from "../ui/StatusIndicator"; +import { formatBytes, formatBytesDelta } from "../../lib/formatBytes"; +import type { DestructiveItem, ProjectDiskRow } from "../../lib/types"; + +interface Props { + rows: ProjectDiskRow[]; + /** Per-project destructive objects, keyed off the same rows. */ + destructive: DestructiveItem[]; + onDestroy: (item: DestructiveItem) => void; +} + +/** `—` for a column with nothing in it, so an empty cell never reads as zero. */ +function cell(bytes: number, present: boolean) { + return present ? formatBytes(bytes) : "—"; +} + +/** + * The per-project table — the mental model users actually have of this app. + * + * ## Why "Layers" is a column and not a detail + * + * A total tells a user their disk is full. The layer count tells them *why*: + * every container recreation runs `docker commit`, a commit stacks a layer and + * never rewrites one, and 24 different settings changes trigger a recreation. + * A project sitting at 14 layers has paid for fourteen full copies of whatever + * changed, and no total on its own ever says that. + * + * "Next commit adds" is the same fact from the other end: it is the container's + * writable layer, i.e. exactly what the *next* recreation will bake in + * permanently. Seeing 868 MB there is what makes Compact worth doing before the + * next settings change rather than after it. + */ +export default function DiskProjectTable({ rows, destructive, onDestroy }: Props) { + if (rows.length === 0) { + return ( +

+ No projects to account for. +

+ ); + } + + return ( + // Wide content scrolls inside its own container; the panel itself must + // never scroll sideways. +
+ + + + + + + + + + + + + + + + {rows.map((row) => { + const mine = destructive.filter((d) => d.project_id === row.project_id); + return ( + + + + + + + + + + + ); + })} + +
+ Disk used by each project, largest first +
+ Project + + Snapshot + + Layers + + + Next commit adds + + + Home vol + + Config vol + + Total + + Actions +
+
+ {row.project_name} + {row.migrating && ( + + )} +
+ + {row.project_id} + +
+ {cell(row.snapshot_above_base_bytes ?? 0, row.snapshot_exists)} + {row.snapshot_exists && ( + + {/* The base is shared by every project, so charging it to + each row would show the same 4.7 GB eight times. The + headline figure is what is unique to this project; + the total is here for anyone reconciling against + `docker images`. */} + {formatBytes(row.snapshot_bytes)} with base + + )} + + {row.snapshot_exists ? ( + 5 + ? "text-[var(--warning)]" + : "text-[var(--text-primary)]" + } + > + {row.snapshot_commit_layers} + + ) : ( + "—" + )} + + {row.container_exists + ? formatBytesDelta(row.container_writable_bytes) + : "—"} + + {cell(row.home_volume_bytes, row.home_volume_present)} + + {cell(row.config_volume_bytes, row.config_volume_present)} + + {formatBytes(row.total_bytes)} + + {mine.length > 0 && ( + ({ + label: `Delete ${item.label.toLowerCase()} (${formatBytes(item.bytes)})…`, + onSelect: () => onDestroy(item), + danger: true, + disabled: item.blocked !== null, + }))} + /> + )} +
+
+ ); +} diff --git a/app/src/components/settings/DiskSettings.test.tsx b/app/src/components/settings/DiskSettings.test.tsx new file mode 100644 index 0000000..dad6168 --- /dev/null +++ b/app/src/components/settings/DiskSettings.test.tsx @@ -0,0 +1,510 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react"; +import DiskSettings from "./DiskSettings"; +import type { + DiskUsageReport, + ProjectDiskRow, + ReclaimItem, + ReclaimPlan, + ReclaimTarget, +} from "../../lib/types"; + +const getDockerDiskUsage = vi.fn(); +const listReclaimable = vi.fn(); +const reclaim = vi.fn(); +const destroyProjectDiskObject = vi.fn(); + +vi.mock("../../lib/tauri-commands", () => ({ + getDockerDiskUsage: () => getDockerDiskUsage(), + listReclaimable: (report: DiskUsageReport) => listReclaimable(report), + reclaim: (targets: ReclaimTarget[]) => reclaim(targets), + destroyProjectDiskObject: (target: unknown, confirmation: string) => + destroyProjectDiskObject(target, confirmation), + sweepOrphanedSnapshots: vi.fn(async () => ({})), +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const row = (over: Partial = {}): ProjectDiskRow => ({ + project_id: "p-whp", + project_name: "whp", + snapshot_image: "triple-c-snapshot-p-whp:latest", + snapshot_exists: true, + snapshot_bytes: 12_273_392_374, + snapshot_shared_bytes: 3_832_425_659, + snapshot_commit_layers: 14, + snapshot_above_base_bytes: 8_440_966_715, + container_exists: true, + container_running: false, + container_writable_bytes: 868_000_000, + home_volume_bytes: 4_860_000_000, + home_volume_present: true, + config_volume_bytes: 427_000_000, + config_volume_present: true, + total_bytes: 14_595_966_715, + migrating: false, + ...over, +}); + +const report = (over: Partial = {}): DiskUsageReport => ({ + scanned_at: "2026-08-23T10:00:00Z", + projects: [row()], + base_images: [ + { + reference: "ghcr.io/shadowdao/triple-c-sandbox:latest", + bytes: 4_724_062_366, + shared_bytes: 4_723_860_396, + containers: 2, + is_labelled_base: true, + }, + ], + base_images_bytes: 4_724_062_366, + orphan_image_bytes: 11_900_000_000, + orphan_image_count: 3, + orphan_volumes: [], + orphan_volume_bytes: 0, + orphan_volumes_unavailable: null, + build_cache: { + total_bytes: 28_000_000_000, + reclaimable_bytes: 28_000_000_000, + stale_bytes: 20_000_000_000, + source: "buildx du", + cli_error: null, + }, + images_total_bytes: 104_500_000_000, + containers_total_bytes: 7_497_000_000, + volumes_total_bytes: 72_890_000_000, + triple_c_total_bytes: 116_000_000_000, + host: { + docker_root_dir: "/var/lib/docker", + operating_system: "Docker Desktop", + is_docker_desktop: true, + is_windows_host: false, + vhdx_applies: false, + vhdx_note: "", + vhdx_fix: [], + vhdx_fix_gui: "", + }, + ...over, +}); + +const item = (over: Partial = {}): ReclaimItem => ({ + target: { kind: "dangling_snapshots" }, + safety: "safe", + daemon_wide: false, + label: "Superseded snapshot layers (3 images)", + detail: "Untagged images left behind by past container recreations.", + bytes: 11_900_000_000, + bytes_are_exact: true, + bytes_floor: null, + blocked: null, + ...over, +}); + +const plan = (over: Partial = {}): ReclaimPlan => ({ + items: [item()], + destructive: [], + store_error: null, + ...over, +}); + +async function renderAndScan() { + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Scan" })); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + getDockerDiskUsage.mockResolvedValue(report()); + listReclaimable.mockResolvedValue(plan()); + reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 }); +}); + +// --------------------------------------------------------------------------- + +describe("DiskSettings", () => { + it("never scans until the button is pressed", async () => { + // `df()` walks the whole daemon and takes seconds on a large store, and + // AccordionSection unmounts its body when collapsed — so a scan on mount + // would re-run every time the section was opened. + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(getDockerDiskUsage).not.toHaveBeenCalled(); + expect(screen.getByText(/never done for you/)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Scan" })); + }); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + }); + + it("says it is scanning in words, not only in colour", async () => { + let resolve: (value: DiskUsageReport) => void = () => {}; + getDockerDiskUsage.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + render(); + fireEvent.click(screen.getByRole("button", { name: "Scan" })); + expect(screen.getByText("Scanning")).toBeInTheDocument(); + await act(async () => { + resolve(report()); + }); + await waitFor(() => expect(screen.getByText(/^Scanned /)).toBeInTheDocument()); + }); + + it("shows the layer count and the cost of the next commit", async () => { + // The two numbers that explain the growth mechanism. A total alone never + // says why the disk filled up. + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("14")).toBeInTheDocument(); + expect(within(projectRow).getByText("+868.0 MB")).toBeInTheDocument(); + expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument(); + }); + + it("charges the shared base to the globals, not to every project row", async () => { + // The base is one 4.7 GB image every project descends from. Counting it per + // row would show it eight times and make the column meaningless. + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("8.4 GB")).toBeInTheDocument(); + expect(within(projectRow).getByText(/12\.3 GB with base/)).toBeInTheDocument(); + }); + + it("plans from the report it already has rather than scanning twice", async () => { + await renderAndScan(); + await waitFor(() => expect(listReclaimable).toHaveBeenCalledTimes(1)); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + expect(listReclaimable).toHaveBeenCalledWith(expect.objectContaining({ projects: expect.any(Array) })); + }); + + // ------------------------------------------------------------------------- + // Selection plumbing + // ------------------------------------------------------------------------- + + it("sends exactly the ticked targets and nothing else", async () => { + listReclaimable.mockResolvedValue( + plan({ + items: [ + item(), + item({ + target: { kind: "migration_staging" }, + label: "Migration staging files", + bytes: 500_000_000, + }), + ], + }), + ); + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + + const boxes = screen.getAllByRole("checkbox"); + await act(async () => { + fireEvent.click(boxes[1]); + }); + expect(screen.getByText(/1 selected, 500\.0 MB/)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + }); + expect(reclaim).toHaveBeenCalledWith([{ kind: "migration_staging" }]); + }); + + it("cannot reclaim with nothing ticked", async () => { + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + expect(screen.getByRole("button", { name: "Reclaim" })).toBeDisabled(); + expect(screen.getByText("Nothing ticked.")).toBeInTheDocument(); + }); + + it("refuses to tick a blocked item", async () => { + listReclaimable.mockResolvedValue( + plan({ + items: [item({ blocked: "A base-image migration is in flight for this project." })], + }), + ); + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + const box = screen.getByRole("checkbox"); + expect(box).toBeDisabled(); + expect( + screen.getByText("A base-image migration is in flight for this project."), + ).toBeInTheDocument(); + }); + + it("keeps semi-safe work out of the one-button bucket", async () => { + // Compaction is a rewrite and cache clearing costs a re-download. Neither + // may be swept up by a Reclaim press aimed at the free wins. + listReclaimable.mockResolvedValue( + plan({ + items: [ + item(), + item({ + target: { kind: "compact_snapshot", project_id: "p-whp" }, + safety: "semi_safe", + label: "Compact whp's snapshot", + bytes: 5_100_000_000, + bytes_are_exact: false, + bytes_floor: 0, + }), + ], + }), + ); + await renderAndScan(); + + const safe = await screen.findByTestId("disk-safe-bucket"); + expect(within(safe).getAllByRole("checkbox")).toHaveLength(1); + expect(within(safe).queryByText(/Compact whp/)).not.toBeInTheDocument(); + + const semi = screen.getByTestId("disk-semi-bucket"); + expect(within(semi).getByText("Compact whp's snapshot")).toBeInTheDocument(); + expect(within(semi).queryByRole("checkbox")).not.toBeInTheDocument(); + }); + + it("marks a compaction's yield as a bound, never as a measurement", async () => { + listReclaimable.mockResolvedValue( + plan({ + items: [ + item({ + target: { kind: "compact_snapshot", project_id: "p-whp" }, + safety: "semi_safe", + label: "Compact whp's snapshot", + bytes: 5_100_000_000, + bytes_are_exact: false, + bytes_floor: 0, + }), + ], + }), + ); + await renderAndScan(); + const semi = await screen.findByTestId("disk-semi-bucket"); + expect(within(semi).getByText("up to 5.1 GB")).toBeInTheDocument(); + }); + + it("says out loud when an action reaches the whole daemon", async () => { + // The user's daemon also holds unrelated postgres and site-builder work, + // and a build-cache prune takes their warm cache with ours. + listReclaimable.mockResolvedValue( + plan({ + items: [ + item({ + target: { kind: "build_cache", all: true }, + daemon_wide: true, + label: "Build cache, all of it", + bytes: 28_000_000_000, + }), + ], + }), + ); + await renderAndScan(); + const safe = await screen.findByTestId("disk-safe-bucket"); + expect(within(safe).getByText("whole daemon")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Orphan copy — the correction that matters most + // ------------------------------------------------------------------------- + + it("says what a 'no matching project' volume is derived from", async () => { + // An idle live project has volumes, no container and possibly no image — + // indistinguishable from a deleted one unless you consult the project + // store. The copy must not invite the inference that made that mistake. + getDockerDiskUsage.mockResolvedValue( + report({ + orphan_volumes: [ + { + name: "triple-c-home-gone", + project_id: "gone", + bytes: 900_000, + role: "home", + created_at: "2026-03-14T09:00:00Z", + }, + ], + orphan_volume_bytes: 900_000, + }), + ); + await renderAndScan(); + const globals = await screen.findByTestId("disk-globals"); + expect( + within(globals).getByText(/Volumes with no matching project in Triple-C/), + ).toBeInTheDocument(); + // The sentence is split by an , so match on the container's text. + expect(globals.textContent).toMatch(/is not inferred from a project being stopped/i); + expect(globals.textContent).toMatch( + /A project you have not opened in a while has no container and no snapshot either, and that is normal/i, + ); + }); + + it("explains a suppressed orphan list instead of showing an empty one", async () => { + // With the project store unreadable every project's volumes look + // unclaimed. Showing nothing is right; showing nothing *silently* is not. + getDockerDiskUsage.mockResolvedValue( + report({ + orphan_volumes: [], + orphan_volumes_unavailable: + "projects.json could not be read, so there is no way to tell an orphaned volume from a live project's.", + }), + ); + await renderAndScan(); + const banner = await screen.findByTestId("disk-store-error"); + expect(within(banner).getByText("Could not read the project list")).toBeInTheDocument(); + expect(within(banner).getByText(/no way to tell/)).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Windows / WSL2 + // ------------------------------------------------------------------------- + + it("spells out that pruning will not shrink C: on Docker Desktop for Windows", async () => { + getDockerDiskUsage.mockResolvedValue( + report({ + host: { + docker_root_dir: "/var/lib/docker", + operating_system: "Docker Desktop", + is_docker_desktop: true, + is_windows_host: true, + vhdx_applies: true, + vhdx_note: "Docker Desktop keeps this daemon inside ext4.vhdx on C:.", + vhdx_fix: ["wsl --shutdown", 'Optimize-VHD -Path "…docker_data.vhdx" -Mode Full'], + vhdx_fix_gui: "Docker Desktop → Settings → Resources → Advanced → Clean up / Purge data", + }, + }), + ); + await renderAndScan(); + const note = await screen.findByTestId("disk-vhdx-note"); + expect( + within(note).getByText("Reclaiming here will not shrink your C: drive"), + ).toBeInTheDocument(); + expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument(); + expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument(); + expect(within(note).getByText(/Purge data/)).toBeInTheDocument(); + }); + + it("keeps the vhdx note off a host it does not apply to", async () => { + await renderAndScan(); + await screen.findByTestId("disk-globals"); + expect(screen.queryByTestId("disk-vhdx-note")).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Destructive path + // ------------------------------------------------------------------------- + + it("needs the project name typed before it will delete a config volume", async () => { + listReclaimable.mockResolvedValue( + plan({ + destructive: [ + { + target: { kind: "config_volume", project_id: "p-whp" }, + project_id: "p-whp", + project_name: "whp", + label: "Claude config volume", + loses: "The Claude login credential, plugins, and EVERY conversation transcript.", + bytes: 427_000_000, + blocked: null, + }, + ], + }), + ); + destroyProjectDiskObject.mockResolvedValue({ + target: { kind: "orphan_volume", name: "triple-c-claude-config-p-whp" }, + ok: true, + freed_bytes: 427_000_000, + projected_bytes: null, + message: "Removed volume.", + }); + + await renderAndScan(); + await screen.findByTestId("disk-row-p-whp"); + + fireEvent.click(screen.getByRole("button", { name: "Delete whp data" })); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: /Delete claude config volume/ })); + }); + + const dialog = screen.getByRole("dialog"); + const confirm = within(dialog).getByRole("button", { name: "Delete claude config volume" }); + expect(confirm).toBeDisabled(); + expect(within(dialog).getByText(/EVERY conversation transcript/)).toBeInTheDocument(); + + // The wrong name does not open the gate. + fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "who" } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } }); + expect(confirm).toBeEnabled(); + await act(async () => { + fireEvent.click(confirm); + }); + expect(destroyProjectDiskObject).toHaveBeenCalledWith( + { kind: "config_volume", project_id: "p-whp" }, + "whp", + ); + }); + + it("never routes a destructive object through the bulk Reclaim button", async () => { + listReclaimable.mockResolvedValue( + plan({ + destructive: [ + { + target: { kind: "home_volume", project_id: "p-whp" }, + project_id: "p-whp", + project_name: "whp", + label: "Home volume", + loses: "Shell history, dotfiles, toolchains.", + bytes: 4_860_000_000, + blocked: null, + }, + ], + }), + ); + await renderAndScan(); + const safe = await screen.findByTestId("disk-safe-bucket"); + // One tick, for the dangling images — the home volume is not in this list + // at any price. + expect(within(safe).getAllByRole("checkbox")).toHaveLength(1); + expect(within(safe).queryByText(/Home volume/)).not.toBeInTheDocument(); + }); + + it("reports what was actually freed against what was projected", async () => { + reclaim.mockResolvedValue({ + results: [ + { + target: { kind: "compact_snapshot", project_id: "p-whp" }, + ok: true, + freed_bytes: 5_100_000_000, + projected_bytes: 7_000_000_000, + message: "Rewrote the snapshot into a single layer.", + }, + ], + total_freed_bytes: 5_100_000_000, + }); + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + }); + + const outcome = await screen.findByTestId("disk-outcome"); + expect(within(outcome).getByText("Reclaimed 5.1 GB")).toBeInTheDocument(); + expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument(); + }); + + it("surfaces a scan failure rather than showing stale numbers", async () => { + getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host"); + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Scan" })); + }); + expect(screen.getByRole("alert")).toHaveTextContent(/no such host/); + }); +}); diff --git a/app/src/components/settings/DiskSettings.tsx b/app/src/components/settings/DiskSettings.tsx new file mode 100644 index 0000000..e3a3992 --- /dev/null +++ b/app/src/components/settings/DiskSettings.tsx @@ -0,0 +1,512 @@ +import { useState } from "react"; +import Button from "../ui/Button"; +import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator"; +import Modal from "../ui/Modal"; +import TypedConfirmModal from "../ui/TypedConfirmModal"; +import DiskProjectTable from "./DiskProjectTable"; +import { useDiskUsage } from "../../hooks/useDiskUsage"; +import { formatBytes, formatBytesCeiling } from "../../lib/formatBytes"; +import type { DestructiveItem, ReclaimItem, ReclaimTarget } from "../../lib/types"; + +/** A stable key for a target, so ticks survive a re-plan. */ +function targetKey(target: ReclaimTarget): string { + return JSON.stringify(target); +} + +/** + * Where the disk went, and how to get it back. + * + * ## Why the scan is a button + * + * `getDockerDiskUsage` is `GET /system/df`, which walks every image, container + * and volume on the daemon computing shared-layer sizes — seconds on a 100 GB + * store, and the only call that produces those numbers at all. So nothing here + * runs on open, on a timer, or on a re-render. + * + * ## Why the buckets are separated the way they are + * + * Safe work (dangling images, ownerless pins, build cache, volumes whose + * project id is not in the project store) gets one list of ticks and one + * button, because none of it can lose anything a user has. Note what the last + * of those is derived from: membership in Triple-C's own project list, never + * "this project has no container" — an idle live project looks exactly like a + * deleted one from the daemon's side, and mistaking the two would delete + * credentials and transcripts. Semi-safe work (compaction, cache clearing) is a rewrite or a + * re-download and is confirmed one at a time. Destructive work — a live + * project's volumes, its snapshot, a live rollback pin — is not in either list: + * it is reached only from that project's own row, behind a typed confirmation, + * and the backend refuses it in bulk by taking a different type entirely. + */ +export default function DiskSettings() { + const { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy } = + useDiskUsage(); + const [ticked, setTicked] = useState>(new Set()); + const [confirming, setConfirming] = useState(null); + const [destroying, setDestroying] = useState(null); + + const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? []; + const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? []; + const selected = safeItems.filter( + (i) => i.blocked === null && ticked.has(targetKey(i.target)), + ); + const selectedBytes = selected.reduce((sum, i) => sum + i.bytes, 0); + + const toggle = (item: ReclaimItem) => { + setTicked((prev) => { + const next = new Set(prev); + const key = targetKey(item.target); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off"; + const statusLabel = scanning + ? "Scanning" + : report + ? `Scanned ${new Date(report.scanned_at).toLocaleTimeString()}` + : "Not scanned"; + + return ( +
+ {/* --- Why this section exists ------------------------------------- */} +

+ Every time a container is recreated, Triple-C commits it — and a commit{" "} + stacks a new layer rather + than rewriting the old one. Deleting a file afterwards writes a whiteout; the + bytes underneath stay forever. Twenty-four different settings changes trigger a + recreation, so a project can quietly accumulate a dozen multi-gigabyte layers it + no longer uses any of. +

+ + {/* --- Scan --------------------------------------------------------- */} +
+ + + + Reads the whole Docker store; takes a few seconds on a large one. + +
+ + {error && ( +

+ {error} +

+ )} + + {!report && !scanning && ( +

+ Nothing has been measured yet. Scanning is the only thing here that costs + anything, so it is never done for you. +

+ )} + + {report && ( + <> + {/* --- Windows / WSL2, mandatory when it applies ----------------- */} + {report.host.vhdx_applies && ( +
+ +

+ {report.host.vhdx_note} +

+

+ To actually give the space back to C:, run these in PowerShell as + administrator after reclaiming: +

+
+                {report.host.vhdx_fix.join("\n")}
+              
+

+ Or, without Hyper-V: {report.host.vhdx_fix_gui}. +

+
+ )} + + {/* --- Per-project table ---------------------------------------- */} +
+

+ By project +

+ +
+ + {/* --- Globals --------------------------------------------------- */} +
+

+ Shared and left over +

+
+
+ Base images ({report.base_images.length}) — shared by every project +
+
+ {formatBytes(report.base_images_bytes)} +
+ +
+ Superseded images from past recreations ({report.orphan_image_count}) +
+
+ {formatBytes(report.orphan_image_bytes)} +
+ +
+ Volumes with no matching project in Triple-C ( + {report.orphan_volumes.length}) +
+
+ {formatBytes(report.orphan_volume_bytes)} +
+ +
+ Build cache — whole daemon, + not just Triple-C{" "} + + (via {report.build_cache.source}) + +
+
+ {formatBytes(report.build_cache.reclaimable_bytes)} of{" "} + {formatBytes(report.build_cache.total_bytes)} +
+ +
+ Attributable to Triple-C +
+
+ {formatBytes(report.triple_c_total_bytes)} +
+ +
+ Everything on this daemon, yours included +
+
+ {formatBytes( + report.images_total_bytes + + report.containers_total_bytes + + report.volumes_total_bytes, + )} +
+
+ {report.orphan_volumes.length > 0 && ( +

+ That last figure means only that the volume’s project id is not in + your project list — it is not inferred from a project + being stopped or having no image. A project you have not opened in a + while has no container and no snapshot either, and that is normal, so + each of these is ticked individually and shows the date Docker created + it. +

+ )} +

+ Docker stores this at{" "} + {report.host.docker_root_dir || "an unknown path"} + {report.host.is_docker_desktop && " — a path inside the Docker Desktop VM, not on your filesystem"}. +

+
+ + {/* --- Store failure, if any ------------------------------------ */} + {report.orphan_volumes_unavailable && ( +
+ +

+ {report.orphan_volumes_unavailable} +

+
+ )} + + {/* --- Safe reclaim ---------------------------------------------- */} +
+

+ Safe to reclaim +

+ {safeItems.length === 0 ? ( +

+ Nothing here — no leftovers were found. +

+ ) : ( + <> +

+ None of this is reachable any more, or all of it regenerates on demand. + Nothing you have made is in this list. +

+
    + {safeItems.map((item) => { + const key = targetKey(item.target); + return ( +
  • + +
  • + ); + })} +
+
+ + + {selected.length === 0 + ? "Nothing ticked." + : `${selected.length} selected, ${formatBytes(selectedBytes)}.`} + +
+ + )} +
+ + {/* --- Semi-safe -------------------------------------------------- */} + {semiItems.length > 0 && ( +
+

+ Worth doing, one at a time +

+

+ Nothing here loses anything you have installed. Compacting rewrites a + project’s stacked layers into one; clearing caches deletes files + that refill themselves. Both take a moment and both are confirmed + separately. +

+
    + {semiItems.map((item) => ( +
  • + + {item.label} + + {item.detail} + + {item.blocked && ( + + {item.blocked} + + )} + + + + {/* A bound, not a measurement — rendered through a + different helper so it cannot read as a promise. */} + {item.bytes_are_exact + ? formatBytes(item.bytes) + : formatBytesCeiling(item.bytes)} + + + +
  • + ))} +
+
+ )} + + {/* --- Sweep ------------------------------------------------------ */} +
+ + + The same sweep that runs at startup and after every recreation — here you + can see what it found. + +
+ + )} + + {/* --- Outcome ------------------------------------------------------- */} + {outcome && ( +
+ r.ok) ? "ok" : "error"} + label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`} + className="text-xs" + /> +
    + {outcome.results.map((result, index) => ( +
  • + {result.message} + {result.projected_bytes !== null && ( + <> + {" "} + + (projected {formatBytesCeiling(result.projected_bytes)}, actually{" "} + {formatBytes(result.freed_bytes)}) + + + )} +
  • + ))} +
+
+ )} + + {/* --- Semi-safe confirmation ---------------------------------------- */} + {confirming && ( + setConfirming(null)} + widthClassName="w-[30rem]" + footer={ + <> + + + + } + > +
+

{confirming.detail}

+ {confirming.target.kind === "compact_snapshot" && ( + <> +

+ The snapshot is rebuilt into a single layer while the old one is left + in place, so a failure at any point leaves this project exactly as it + is now. +

+

+ How much comes back depends on how much of those layers a later one + already replaced — it could be{" "} + {formatBytesCeiling(confirming.bytes)}, and it could be nothing at all. + You will be told the real figure when it finishes. +

+

+ One thing worth knowing: the rewritten image no longer shares the base + image with your other projects, so it carries its own copy of it. That + cost is already subtracted from the figure above, and if the rewrite + turns out not to come out ahead it is thrown away and the snapshot is + left exactly as it is. +

+ + )} + {confirming.target.kind === "clear_caches" && + confirming.target.include_rustup && ( +

+ Rust toolchains are included in this one. They are regenerable, but + getting them back is a download rather than a rebuild. +

+ )} +
+
+ )} + + {/* --- Destructive confirmation --------------------------------------- */} + {destroying && ( + setDestroying(null)} + onConfirm={(typed) => { + const target = destroying.target; + setDestroying(null); + void destroy(target, typed); + }} + > +

+ This removes{" "} + + {destroying.project_name} + + ’s {destroying.label.toLowerCase()}, freeing{" "} + {formatBytes(destroying.bytes)}. +

+

{destroying.loses}

+

+ Your mounted project folders live on the host and are not affected by this. +

+
+ )} +
+ ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 062a9f4..8dce472 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -19,6 +19,7 @@ import WebTerminalSettings from "./WebTerminalSettings"; import SttSettings from "./SttSettings"; import SharedAuthSettings from "./SharedAuthSettings"; import CertificateSettings from "./CertificateSettings"; +import DiskSettings from "./DiskSettings"; export default function SettingsPanel() { const { appSettings, saveSettings } = useSettings(); @@ -173,6 +174,10 @@ export default function SettingsPanel() { + + + + diff --git a/app/src/components/ui/TypedConfirmModal.test.tsx b/app/src/components/ui/TypedConfirmModal.test.tsx new file mode 100644 index 0000000..bacaec3 --- /dev/null +++ b/app/src/components/ui/TypedConfirmModal.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import TypedConfirmModal from "./TypedConfirmModal"; + +const onConfirm = vi.fn(); +const onCancel = vi.fn(); + +function renderModal(props: Partial> = {}) { + render( + +

Everything goes.

+
, + ); + return { + input: screen.getByLabelText(/Type/), + confirm: screen.getByRole("button", { name: "Delete config volume" }), + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("TypedConfirmModal", () => { + it("is a real dialog, from the Modal primitive", () => { + renderModal(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("keeps the confirm button shut until the name is typed exactly", () => { + const { input, confirm } = renderModal(); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: "wh" } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: "whp" } }); + expect(confirm).toBeEnabled(); + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledWith("whp"); + }); + + it("is case-sensitive, because Api and api are different projects", () => { + // This gate is the only thing between a misclick on a sorted table of + // numbers and a project's transcripts, so a near-miss is a miss. + const { input, confirm } = renderModal({ expected: "Api" }); + fireEvent.change(input, { target: { value: "api" } }); + expect(confirm).toBeDisabled(); + fireEvent.change(input, { target: { value: "Api" } }); + expect(confirm).toBeEnabled(); + }); + + it("forgives surrounding whitespace from a paste", () => { + const { input, confirm } = renderModal(); + fireEvent.change(input, { target: { value: " whp " } }); + expect(confirm).toBeEnabled(); + }); + + it("announces the gate's state in words rather than only by the button fill", () => { + const { input } = renderModal(); + expect(screen.getByRole("status")).toHaveTextContent( + "Waiting for the exact project name.", + ); + fireEvent.change(input, { target: { value: "whp" } }); + expect(screen.getByRole("status")).toHaveTextContent("Name matches."); + }); + + it("spells out what is lost, from the caller's copy", () => { + renderModal(); + expect(screen.getByText("Everything goes.")).toBeInTheDocument(); + }); + + it("locks itself while the deletion is running", () => { + render( + +

Everything goes.

+
, + ); + // The confirm button reports the work in a word rather than only going + // grey, so it is found by its busy label, not its idle one. + expect(screen.getByLabelText(/Type/)).toBeDisabled(); + expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + }); + + it("cancels without confirming", () => { + renderModal(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("cannot be satisfied by an empty box when there is no name to type", () => { + const { confirm } = renderModal({ expected: "" }); + expect(confirm).toBeDisabled(); + }); +}); diff --git a/app/src/components/ui/TypedConfirmModal.tsx b/app/src/components/ui/TypedConfirmModal.tsx new file mode 100644 index 0000000..5a78278 --- /dev/null +++ b/app/src/components/ui/TypedConfirmModal.tsx @@ -0,0 +1,113 @@ +import { useRef, useState, type ReactNode } from "react"; +import Modal from "./Modal"; +import Button from "./Button"; +import { inputClass } from "./Field"; + +interface Props { + title: string; + /** What must be typed, verbatim, before the confirm button enables. */ + expected: string; + /** The verb on the confirm button. Repeat the action — never "OK". */ + confirmLabel: string; + /** What is about to be lost, in full. */ + children: ReactNode; + onConfirm: (typed: string) => void; + onCancel: () => void; + busy?: boolean; +} + +/** + * The confirmation gate for something that has no other copy. + * + * ## Why this exists when `ConfirmResetModal` already did + * + * Reset and Remove are reached from a project's own overflow menu, one project + * at a time, by a user who went looking for them. The Disk panel lists every + * project's volumes side by side in a table of numbers, sorted by size — which + * is exactly the layout that invites a misclick on the wrong row. A two-button + * dialog does not survive that, because the thing being confirmed (*which* + * project) is the thing the user got wrong. + * + * Typing the name fixes the failure mode rather than adding friction to it: the + * gate is not "are you sure", it is "name the project you mean". + * + * The comparison is `expected.trim() === typed.trim()` and **case-sensitive** — + * mirroring `confirmation_matches` in `docker/disk.rs`, which is the check that + * actually holds, since this one is only a UI affordance. The backend refuses a + * mismatch on its own. + */ +export default function TypedConfirmModal({ + title, + expected, + confirmLabel, + children, + onConfirm, + onCancel, + busy = false, +}: Props) { + const [typed, setTyped] = useState(""); + const inputRef = useRef(null); + const matches = expected.trim().length > 0 && typed.trim() === expected.trim(); + + return ( + + + + + } + > +
+ {children} +
+ + setTyped(e.target.value)} + disabled={busy} + autoComplete="off" + spellCheck={false} + className={`${inputClass} font-mono`} + /> + {/* Announced rather than only coloured — the gate's state has to be + readable without relying on the button's fill. */} +

+ {matches ? ( + Name matches. + ) : ( + + Waiting for the exact project name. + + )} +

+
+
+
+ ); +} diff --git a/app/src/hooks/useDiskUsage.test.tsx b/app/src/hooks/useDiskUsage.test.tsx new file mode 100644 index 0000000..0c21429 --- /dev/null +++ b/app/src/hooks/useDiskUsage.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useDiskUsage } from "./useDiskUsage"; +import type { DiskUsageReport } from "../lib/types"; + +const getDockerDiskUsage = vi.fn(); +const listReclaimable = vi.fn(); +const reclaim = vi.fn(); +const destroyProjectDiskObject = vi.fn(); + +vi.mock("../lib/tauri-commands", () => ({ + getDockerDiskUsage: () => getDockerDiskUsage(), + listReclaimable: (report: DiskUsageReport) => listReclaimable(report), + reclaim: (targets: unknown) => reclaim(targets), + destroyProjectDiskObject: (target: unknown, confirmation: string) => + destroyProjectDiskObject(target, confirmation), + sweepOrphanedSnapshots: vi.fn(), +})); + +const report = (scanned_at: string): DiskUsageReport => + ({ scanned_at, projects: [] }) as unknown as DiskUsageReport; + +const plan = { items: [], destructive: [], store_error: null }; + +beforeEach(() => { + vi.clearAllMocks(); + listReclaimable.mockResolvedValue(plan); + reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 }); +}); + +describe("useDiskUsage", () => { + it("holds no report until a scan is asked for", () => { + const { result } = renderHook(() => useDiskUsage()); + expect(result.current.report).toBeNull(); + expect(result.current.plan).toBeNull(); + expect(getDockerDiskUsage).not.toHaveBeenCalled(); + }); + + it("scans, then plans off the same report rather than scanning again", async () => { + getDockerDiskUsage.mockResolvedValue(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + expect(listReclaimable).toHaveBeenCalledWith(report("first")); + expect(result.current.report?.scanned_at).toBe("first"); + expect(result.current.plan).toEqual(plan); + }); + + it("lets the newest scan win when two are in flight", async () => { + // A user pressing Scan twice can have two `df()` calls outstanding, and + // the second is not necessarily the slower one. A stale response must not + // overwrite a fresher one. + let resolveFirst: (value: DiskUsageReport) => void = () => {}; + getDockerDiskUsage + .mockReturnValueOnce( + new Promise((r) => { + resolveFirst = r; + }), + ) + .mockResolvedValueOnce(report("second")); + + const { result } = renderHook(() => useDiskUsage()); + let firstScan: Promise = Promise.resolve(); + act(() => { + firstScan = result.current.scan(); + }); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.report?.scanned_at).toBe("second"); + + // The slow first scan lands afterwards and is discarded. + await act(async () => { + resolveFirst(report("first")); + await firstScan; + }); + expect(result.current.report?.scanned_at).toBe("second"); + expect(result.current.scanning).toBe(false); + }); + + it("passes the ticked targets straight through", async () => { + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runReclaim([ + { kind: "dangling_snapshots" }, + { kind: "build_cache", all: false }, + ]); + }); + expect(reclaim).toHaveBeenCalledWith([ + { kind: "dangling_snapshots" }, + { kind: "build_cache", all: false }, + ]); + }); + + it("does not call the backend for an empty selection", async () => { + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runReclaim([]); + }); + expect(reclaim).not.toHaveBeenCalled(); + }); + + it("does not re-scan after a reclaim", async () => { + // Another `df()` costs seconds, and the outcome already carries measured + // bytes for every target. A user who wants fresh totals asks for them. + getDockerDiskUsage.mockResolvedValue(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + }); + + it("clears the previous outcome when a new scan starts", async () => { + getDockerDiskUsage.mockResolvedValue(report("first")); + reclaim.mockResolvedValue({ results: [], total_freed_bytes: 42 }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(result.current.outcome?.total_freed_bytes).toBe(42); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.outcome).toBeNull(); + }); + + it("forwards the typed confirmation verbatim", async () => { + destroyProjectDiskObject.mockResolvedValue({ + target: { kind: "dangling_snapshots" }, + ok: true, + freed_bytes: 100, + projected_bytes: null, + message: "gone", + }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.destroy({ kind: "config_volume", project_id: "p1" }, "whp"); + }); + expect(destroyProjectDiskObject).toHaveBeenCalledWith( + { kind: "config_volume", project_id: "p1" }, + "whp", + ); + expect(result.current.outcome?.total_freed_bytes).toBe(100); + }); + + it("surfaces a failure rather than leaving a stale report on screen", async () => { + getDockerDiskUsage.mockRejectedValue("daemon unreachable"); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/)); + expect(result.current.scanning).toBe(false); + }); +}); diff --git a/app/src/hooks/useDiskUsage.ts b/app/src/hooks/useDiskUsage.ts new file mode 100644 index 0000000..e3bf786 --- /dev/null +++ b/app/src/hooks/useDiskUsage.ts @@ -0,0 +1,114 @@ +import { useCallback, useRef, useState } from "react"; +import * as commands from "../lib/tauri-commands"; +import type { + DestructiveTarget, + DiskUsageReport, + ReclaimOutcome, + ReclaimPlan, + ReclaimTarget, +} from "../lib/types"; + +/** + * State for the Disk section. + * + * ## Why nothing here runs on mount + * + * A scan is `GET /system/df`, which walks every image, container and volume on + * the daemon and computes shared-layer sizes. On a 100 GB store that is + * seconds. `AccordionSection` unmounts its body when collapsed, so a + * `useEffect` scan would re-run every single time the user opened the section. + * The scan is therefore a `scan()` the Scan button calls and nothing else, and + * the result lives in this hook rather than in the component so that reopening + * the section shows the last result instead of paying again. + * + * ## The generation guard + * + * A user who hits Scan twice can have two `df()` calls in flight, and they can + * land out of order — the second one is not necessarily slower. Every async + * write checks it is still the newest before it lands, the same pattern + * `useContainerMigration` uses. + */ +export interface DiskUsageState { + report: DiskUsageReport | null; + plan: ReclaimPlan | null; + /** A scan is in flight. */ + scanning: boolean; + /** A reclaim or a destroy is in flight. */ + working: boolean; + error: string | null; + /** The outcome of the last reclaim, kept on screen until the next scan. */ + outcome: ReclaimOutcome | null; + scan: () => Promise; + runReclaim: (targets: ReclaimTarget[]) => Promise; + destroy: (target: DestructiveTarget, confirmation: string) => Promise; + clearOutcome: () => void; +} + +export function useDiskUsage(): DiskUsageState { + const [report, setReport] = useState(null); + const [plan, setPlan] = useState(null); + const [scanning, setScanning] = useState(false); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + const [outcome, setOutcome] = useState(null); + const generation = useRef(0); + + const scan = useCallback(async () => { + const mine = ++generation.current; + setScanning(true); + setError(null); + // The previous outcome describes a state that no longer holds once a new + // scan starts, so it goes rather than sitting beside fresh numbers. + setOutcome(null); + try { + const next = await commands.getDockerDiskUsage(); + if (generation.current !== mine) return; + setReport(next); + // Planning is cheap and always wanted: the classification is what makes + // the numbers actionable, and it reuses the report rather than scanning + // again. + const nextPlan = await commands.listReclaimable(next); + if (generation.current !== mine) return; + setPlan(nextPlan); + } catch (e) { + if (generation.current !== mine) return; + setError(String(e)); + } finally { + if (generation.current === mine) setScanning(false); + } + }, []); + + const runReclaim = useCallback(async (targets: ReclaimTarget[]) => { + if (targets.length === 0) return; + setWorking(true); + setError(null); + try { + const result = await commands.reclaim(targets); + setOutcome(result); + // Deliberately no automatic re-scan. It costs another `df()`, and the + // outcome already reports measured bytes for every target — a user who + // wants the new totals asks for them. + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); + } + }, []); + + const destroy = useCallback(async (target: DestructiveTarget, confirmation: string) => { + setWorking(true); + setError(null); + try { + const result = await commands.destroyProjectDiskObject(target, confirmation); + setOutcome({ results: [result], total_freed_bytes: result.freed_bytes }); + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); + } + }, []); + + const clearOutcome = useCallback(() => setOutcome(null), []); + + return { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy, clearOutcome }; +} diff --git a/app/src/lib/formatBytes.test.ts b/app/src/lib/formatBytes.test.ts new file mode 100644 index 0000000..f832838 --- /dev/null +++ b/app/src/lib/formatBytes.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes"; + +describe("formatBytes", () => { + it("defaults to base 1000, because that is what Docker prints", () => { + // The Disk panel exists to explain `docker system df`, which formats with + // `units.HumanSize` — base 1000. Showing 26.1 GB against a terminal saying + // 28.0 GB for the same build cache reads as a bug in the panel. + expect(formatBytes(28_000_000_000)).toBe("28.0 GB"); + expect(formatBytes(1_000)).toBe("1.0 KB"); + expect(formatBytes(1_500_000)).toBe("1.5 MB"); + expect(formatBytes(12_273_392_374)).toBe("12.3 GB"); + }); + + it("leaves whole bytes without a decimal point", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(999)).toBe("999 B"); + }); + + it("reproduces the Project Home convention exactly under `binary`", () => { + // Three modules import `projects/home/format.ts#formatBytes`, which is now + // this function. Its output had to be byte-identical or re-pointing it + // would have quietly changed every file listing in the app. + expect(formatBytes(1023, { binary: true })).toBe("1023 B"); + expect(formatBytes(1024, { binary: true })).toBe("1.0 KB"); + expect(formatBytes(1024 * 1024, { binary: true })).toBe("1.0 MB"); + expect(formatBytes(1024 * 1024 * 1024, { binary: true })).toBe("1.0 GB"); + expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB"); + }); + + it("reproduces the migration convention exactly by default", () => { + // `migrationCopy.formatDataSize` is now a call to this, and its output is + // asserted in MigrateContainerModal.test.tsx. + expect(formatBytes(41_000_000)).toBe("41.0 MB"); + expect(formatBytes(3_800_000_000)).toBe("3.8 GB"); + }); + + it("labels binary units honestly when asked to", () => { + expect(formatBytes(1024, { binary: true, iec: true })).toBe("1.0 KiB"); + expect(formatBytes(1024 ** 3, { binary: true, iec: true })).toBe("1.0 GiB"); + }); + + it("climbs to TB rather than showing five-digit gigabytes", () => { + expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB"); + }); + + it("renders an em dash for a size the daemon did not compute", () => { + // Docker reports -1 for "not calculated" on shared sizes and volume ref + // counts. `NaN GB` in the middle of a table is worse than nothing. + expect(formatBytes(-1)).toBe("—"); + expect(formatBytes(NaN)).toBe("—"); + expect(formatBytes(Infinity)).toBe("—"); + }); + + it("honours a requested precision", () => { + expect(formatBytes(1_234_567_890, { precision: 2 })).toBe("1.23 GB"); + expect(formatBytes(1_234_567_890, { precision: 0 })).toBe("1 GB"); + }); +}); + +describe("formatBytesDelta", () => { + it("signs a figure that is being added rather than measured", () => { + // "Next commit adds +868.0 MB" — the sign is what makes it read as a cost + // about to be incurred rather than a size already on disk. + expect(formatBytesDelta(868_000_000)).toBe("+868.0 MB"); + expect(formatBytesDelta(0)).toBe("+0 B"); + }); + + it("does not sign an unknown", () => { + expect(formatBytesDelta(-1)).toBe("—"); + }); +}); + +describe("formatBytesCeiling", () => { + it("says 'up to', because a compaction's yield is a bound not a promise", () => { + // Every other figure in the Disk panel is measured. This one cannot be + // known until the rewrite runs, and rendering it through a separate + // function is what stops it being read as a guarantee. + expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB"); + }); + + it("refuses to imply a saving when there is no bound to give", () => { + expect(formatBytesCeiling(0)).toBe("an unknown amount"); + expect(formatBytesCeiling(-1)).toBe("an unknown amount"); + }); +}); diff --git a/app/src/lib/formatBytes.ts b/app/src/lib/formatBytes.ts new file mode 100644 index 0000000..6c2b0a1 --- /dev/null +++ b/app/src/lib/formatBytes.ts @@ -0,0 +1,86 @@ +/** + * The one byte formatter. + * + * Before this existed the app had four of them — `projects/home/format.ts`, + * `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline + * `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the + * unit labels and the precision. They are now expressed in terms of this. + * + * ## Why the default is base 1000 + * + * The Disk panel exists to explain what `docker system df` reports, and Docker + * formats every size it prints with `units.HumanSize`, which is **base 1000**. + * A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the + * same build cache would read as a bug in the panel. So decimal is the default + * and binary is opt-in, rather than the other way round. + * + * Both existing conventions are preserved exactly, so re-pointing the old + * call sites changed no rendered string: + * + * - `{ }` → `41.0 MB` (decimal, what migration used) + * - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style + * labels, what Project Home used + * — technically a misnomer, but + * it is the app's convention and + * changing it is not this + * feature's business) + * - `{ binary: true, iec: true }` → `1.5 GiB` (÷1024 labelled honestly) + */ + +const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"]; +const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + +export interface FormatBytesOptions { + /** Divide by 1024 instead of 1000. */ + binary?: boolean; + /** Label binary units as `KiB`/`MiB`/`GiB` rather than `KB`/`MB`/`GB`. */ + iec?: boolean; + /** Decimal places above `B`. Bytes are always whole. */ + precision?: number; +} + +export function formatBytes(bytes: number, options: FormatBytesOptions = {}): string { + const { binary = false, iec = false, precision = 1 } = options; + + // A negative or non-finite size is a bug upstream, not something to render as + // `NaN GB` in the middle of a table. Docker reports -1 for "not computed", + // and that is the case this actually catches. + if (!Number.isFinite(bytes) || bytes < 0) return "—"; + + const step = binary ? 1024 : 1000; + const units = binary && iec ? IEC_UNITS : DECIMAL_UNITS; + + let value = bytes; + let unit = 0; + while (value >= step && unit < units.length - 1) { + value /= step; + unit += 1; + } + // Whole bytes never get a decimal point: `512 B`, not `512.0 B`. + return unit === 0 + ? `${Math.round(bytes)} ${units[0]}` + : `${value.toFixed(precision)} ${units[unit]}`; +} + +/** + * `12.3 GB` → `+12.3 GB`, for a figure that is being *added* rather than + * measured. Used for "next commit adds …", which is the number that explains + * why a snapshot grows. + */ +export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string { + const formatted = formatBytes(bytes, options); + return formatted === "—" ? formatted : `+${formatted}`; +} + +/** + * `up to 12.3 GB` / `nothing` — for a bound rather than a measurement. + * + * The Disk panel is careful about this distinction: every figure it shows is + * measured except a compaction's yield, which cannot be known until it runs. + * Rendering that one through a different function is what stops it being read + * as a promise. + */ +export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount"; + return `up to ${formatBytes(bytes, options)}`; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 27010f4..1a61bf3 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, DiskUsageReport, ReclaimPlan, ReclaimTarget, ReclaimOutcome, ReclaimResult, DestructiveTarget, SnapshotSweepReport } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -349,3 +349,34 @@ export const rollbackMigration = (projectId: string) => * app crash shows up here as phase "interrupted". */ export const getMigrationState = (projectId: string) => invoke("get_migration_state", { projectId }); + +// Disk + +/** Measure where the daemon's bytes have gone. + * + * **Expensive — keep it behind an explicit Scan button.** This is + * `GET /system/df`, which walks every image, container and volume on the + * daemon to compute shared-layer sizes, plus an `image_history` per image. + * Seconds on a 100 GB store. Never call it on mount and never poll it. */ +export const getDockerDiskUsage = () => invoke("get_docker_disk_usage"); + +/** Classify what could be reclaimed, with measured bytes. Takes the report + * from `getDockerDiskUsage` so re-planning costs no second scan. */ +export const listReclaimable = (report: DiskUsageReport) => + invoke("list_reclaimable", { report }); + +/** Run the ticked targets. `ReclaimTarget` cannot name a destructive action, + * so no selection built here can delete a live project's data. */ +export const reclaim = (targets: ReclaimTarget[]) => + invoke("reclaim", { targets }); + +/** Delete one object that has no other copy. `confirmation` must be the + * project's name, typed by the user. One target per call, never bulk. */ +export const destroyProjectDiskObject = (target: DestructiveTarget, confirmation: string) => + invoke("destroy_project_disk_object", { target, confirmation }); + +/** Run the orphaned-snapshot sweep on demand and see its report — the same + * sweep that runs at startup and after every recreation, whose result every + * existing caller throws away. */ +export const sweepOrphanedSnapshots = () => + invoke("sweep_orphaned_snapshots"); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 59813b7..5af5033 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -817,3 +817,198 @@ export interface MigrationState { options: MigrationOptions; plan: MigrationPlan | null; } + +// --------------------------------------------------------------------------- +// Disk +// --------------------------------------------------------------------------- +// +// Mirrors `app/src-tauri/src/docker/disk.rs`. Plain snake_case, like every +// other IPC struct in this app. + +/** One row of the per-project disk table. */ +export interface ProjectDiskRow { + project_id: string; + project_name: string; + snapshot_image: string; + snapshot_exists: boolean; + /** Total size of the snapshot image, base image included. */ + snapshot_bytes: number; + /** Bytes shared with another image — almost always the base. */ + snapshot_shared_bytes: number; + /** Layers stacked above the base image: **one per container recreation**. + * This is the number that explains why a snapshot grows. */ + snapshot_commit_layers: number; + /** Bytes those layers account for. `null` when the base image is gone and + * the split cannot be measured — never a guess. */ + snapshot_above_base_bytes: number | null; + container_exists: boolean; + container_running: boolean; + /** The writable layer, i.e. exactly what the next commit will add. */ + container_writable_bytes: number; + home_volume_bytes: number; + home_volume_present: boolean; + config_volume_bytes: number; + config_volume_present: boolean; + total_bytes: number; + migrating: boolean; +} + +export interface BaseImageRow { + reference: string; + bytes: number; + shared_bytes: number; + containers: number; + is_labelled_base: boolean; +} + +/** Where the daemon keeps its bytes, and the Windows/WSL2 caveat if it applies. + * The vhdx copy comes from Rust so the wording cannot drift from the + * constants its tests pin. */ +export interface HostStorage { + docker_root_dir: string; + operating_system: string; + is_docker_desktop: boolean; + is_windows_host: boolean; + vhdx_applies: boolean; + /** Empty unless `vhdx_applies`. */ + vhdx_note: string; + vhdx_fix: string[]; + vhdx_fix_gui: string; +} + +export interface BuildCacheUsage { + total_bytes: number; + reclaimable_bytes: number; + /** What a `--filter until=168h` prune would reach. */ + stale_bytes: number; + /** `"buildx du"` or `"system df"` — `docker system df` under-reports build + * cache, so which one produced the number is worth showing. */ + source: string; + cli_error: string | null; +} + +/** A per-project volume whose project id is not in Triple-C's project store. + * + * **Not "a volume with no container".** From the daemon's side an idle live + * project and a deleted one look identical — volumes present, no container, + * nothing running — so only the project store can tell them apart. */ +export interface OrphanVolume { + name: string; + project_id: string; + bytes: number; + /** `"home"` or `"config"`. */ + role: string; + /** When Docker created it. Evidence a user can recognise a project by; a + * size and a UUID identify nothing. From `df()` metadata — volumes are + * never mounted to inspect them, because `docker run -v` *creates* a + * volume that does not exist. */ + created_at: string | null; +} + +/** The result of one Scan. Expensive to produce — see `getDockerDiskUsage`. */ +export interface DiskUsageReport { + scanned_at: string; + projects: ProjectDiskRow[]; + base_images: BaseImageRow[]; + base_images_bytes: number; + orphan_image_bytes: number; + orphan_image_count: number; + orphan_volumes: OrphanVolume[]; + orphan_volume_bytes: number; + /** Why orphan detection was suppressed, when it was. */ + orphan_volumes_unavailable: string | null; + build_cache: BuildCacheUsage; + images_total_bytes: number; + containers_total_bytes: number; + volumes_total_bytes: number; + triple_c_total_bytes: number; + host: HostStorage; +} + +/** Mirrors Rust `Safety` (serde snake_case). */ +export type ReclaimSafety = "safe" | "semi_safe"; + +/** Mirrors Rust `ReclaimTarget`, an internally tagged enum. + * + * This type **cannot express a destructive action** — that is + * `DestructiveTarget`, and the Rust `reclaim` command cannot be handed one. + * The separation is structural on both sides on purpose. */ +export type ReclaimTarget = + | { kind: "dangling_snapshots" } + | { kind: "superseded_base_images" } + | { kind: "build_cache"; all: boolean } + | { kind: "migration_pins" } + | { kind: "migration_staging" } + | { kind: "probe_containers" } + | { kind: "scrub_containers" } + | { kind: "orphan_volume"; name: string } + | { kind: "compact_snapshot"; project_id: string } + | { kind: "clear_caches"; project_id: string; include_rustup: boolean }; + +/** Mirrors Rust `DestructiveTarget`. Every one of these deletes something with + * no other copy, and needs the project's name typed to confirm. */ +export type DestructiveTarget = + | { kind: "home_volume"; project_id: string } + | { kind: "config_volume"; project_id: string } + | { kind: "snapshot_image"; project_id: string } + | { kind: "rollback_pin"; project_id: string; tag: string }; + +export interface ReclaimItem { + target: ReclaimTarget; + safety: ReclaimSafety; + /** Reaches beyond Triple-C's own objects — true only for the build cache, + * and the UI must say so. */ + daemon_wide: boolean; + label: string; + detail: string; + bytes: number; + /** `false` means `bytes` is a bound, not a measurement. Render it as + * "up to …" — only snapshot compaction sets this. */ + bytes_are_exact: boolean; + bytes_floor: number | null; + /** Why this cannot run right now. */ + blocked: string | null; +} + +export interface DestructiveItem { + target: DestructiveTarget; + project_id: string; + project_name: string; + label: string; + /** Spelled out in full — this is the confirmation copy. */ + loses: string; + bytes: number; + blocked: string | null; +} + +export interface ReclaimPlan { + items: ReclaimItem[]; + /** Display only. `reclaim` cannot act on these. */ + destructive: DestructiveItem[]; + store_error: string | null; +} + +export interface ReclaimResult { + target: ReclaimTarget; + ok: boolean; + freed_bytes: number; + /** What was projected beforehand, for the one action that projects. */ + projected_bytes: number | null; + message: string; +} + +export interface ReclaimOutcome { + results: ReclaimResult[]; + total_freed_bytes: number; +} + +/** Mirrors Rust `SnapshotSweepReport`. Note `failed` is a list of + * `[reference, error]` pairs — a Rust tuple serialises as an array. */ +export interface SnapshotSweepReport { + removed: string[]; + reclaimed_bytes: number; + /** Refused because a container is still built from them. Normal. */ + in_use: number; + failed: [string, string][]; + unavailable: string | null; +} From 611f67cca746a907fc964247b8cf20dbf4e295e8 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 09:51:09 -0700 Subject: [PATCH 2/2] Fix what review found in the Disk section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safety: - `destroy`'s rollback-pin arm took a tag over IPC and interpolated it straight into an image reference it then removed. `tag: "latest"` named the project's live snapshot, deleted under a dialog saying "rollback pin". It is the one destructive variant carrying a free-form string, so it now goes through `parse_rollback_tag`. - The compaction's scratch container was named `triple-c-scrub-*`, which is what the scrub reclaim bucket hunts and force-removes. A reclaim from a second window would have destroyed the container a running compaction was about to commit. It gets `triple-c-compact-*`, swept at the start of the next compaction rather than from a bucket anything else can fire. - Deleting a home or config volume only refused a *running* container, but a stopped one still pins its volumes — the resting state of every project ever started — so the user typed the project name and met a raw 409. The container is now removed first and `loses` says so. Correctness: - The compaction Dockerfile emitted no `LABEL`, so the flattened intermediate could never match the sweep's `dangling` + `triple-c.managed` filter that three cleanup paths rely on. Verified on Docker 29.7.2 that the label lands on the final stage, the build still yields one layer, and untagging the staging tag after the commit leaves the committed snapshot intact and startable. - `snapshot_commit_layers` silently meant something else when `triple-c.base-image-id` was absent — the normal case for a pre-label project — counting the base's own layers and letting a never-recreated project qualify for compaction. `base_lineage_known` now carries that, the column says "unknown", and the plan does not offer the rewrite. - `destroy` returned a `ReclaimResult` wearing a `ReclaimTarget` that named work it had not done (a home-volume deletion came back as `OrphanVolume`). Split into `target` / `destroyed`, exactly one set. - `formatBytes` ran `toFixed` after the divide loop, so 999,999 rendered as "1000.0 KB" — in the app's only byte formatter, in a panel full of near-boundary sizes. - `is_base_image_reference` split on the first colon, so a registry port ate the repo name. UI: - `snapshot_above_base_bytes: null` — deliberately unmeasurable — rendered as "0 B", the one guessed number in the table. - Layer count was flagged by colour alone; it now says "stacked". - The tick list survived a reclaim, so the same call could be re-fired at objects that no longer existed. The plan is dropped after any action and the panel says the totals predate it. - `setReport` landed before the plan call was awaited, so a plan failure rendered fresh totals above the previous scan's rows. - Both confirmation modals unmounted before awaiting, making the entire busy path dead code during multi-second work. - `buildx du` failures silently showed `docker system df`'s under-reported build-cache figure with no explanation. - Tooltip text reached no assistive tech, so two headers announced as "Help"; hardcoded input id; error-toned glyph in warning-toned panels; `sweepOrphanedSnapshots` and `clearOutcome` had no callers. - Four docstrings claimed things the code did not do, and two tests were named for behaviour they did not assert. Tests: 513 frontend (was 502), 370 Rust (was 365). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- app/src-tauri/src/docker/disk.rs | 229 +++++++++++++++--- app/src-tauri/src/docker/disk_tests.rs | 115 +++++++++ .../components/settings/DiskProjectTable.tsx | 56 +++-- .../components/settings/DiskSettings.test.tsx | 131 +++++++++- app/src/components/settings/DiskSettings.tsx | 109 ++++++--- app/src/components/ui/TypedConfirmModal.tsx | 9 +- app/src/hooks/useDiskUsage.test.tsx | 89 ++++++- app/src/hooks/useDiskUsage.ts | 100 +++++++- app/src/lib/formatBytes.test.ts | 18 ++ app/src/lib/formatBytes.ts | 29 ++- app/src/lib/types.ts | 14 +- 11 files changed, 799 insertions(+), 100 deletions(-) diff --git a/app/src-tauri/src/docker/disk.rs b/app/src-tauri/src/docker/disk.rs index ef0d818..43d7b65 100644 --- a/app/src-tauri/src/docker/disk.rs +++ b/app/src-tauri/src/docker/disk.rs @@ -97,7 +97,19 @@ pub struct ProjectDiskRow { pub snapshot_shared_bytes: i64, /// How many layers the snapshot has stacked **above its base image**. This /// is the number that explains the growth: one per recreation. + /// + /// Only means that when [`Self::base_lineage_known`] is true. Otherwise it + /// is every layer carrying bytes, base included — an upper bound, and a + /// misleading one to present as a recreation count. pub snapshot_commit_layers: u32, + /// Whether the base image this snapshot descends from could be identified. + /// + /// False when `triple-c.base-image-id` is absent, which is the **normal** + /// case for a project created before that label existed. The UI must not + /// present `snapshot_commit_layers` as a recreation count in that state, + /// and compaction is not offered, because a never-recreated project would + /// otherwise report its base's ~15 layers and qualify. + pub base_lineage_known: bool, /// Bytes those stacked layers account for. `None` when the base image the /// snapshot descends from is no longer on the daemon, so the split cannot /// be measured and must not be guessed. @@ -422,7 +434,18 @@ pub struct ReclaimOutcome { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ReclaimResult { - pub target: ReclaimTarget, + /// The reclaim target this reports on, or `None` when it reports a + /// [`destroy`]. + /// + /// Deliberately not reused to carry a destructive action: an earlier + /// version returned `OrphanVolume { name }` for a home-volume deletion, + /// which named a volume that was never an orphan and would attribute the + /// outcome to a plan row the user never ticked. `destroyed` carries it + /// instead, and exactly one of the two is ever set. + pub target: Option, + /// The destructive action this reports on, when it is one. + #[serde(default)] + pub destroyed: Option, pub ok: bool, /// Bytes actually freed, measured after the fact. pub freed_bytes: i64, @@ -763,6 +786,14 @@ pub fn parse_reclaimed_space(output: &str) -> i64 { /// [`restore_image_config`], which is why this function does not try to emit it /// as Dockerfile instructions. A multi-line `CLAUDE_INSTRUCTIONS` env var alone /// makes that escaping a bad bet. +/// +/// The one label it *does* emit is `triple-c.managed=true`, and it is not +/// decoration. Everything that cleans up after this build — the discard path +/// when the result is not smaller, the untag after a successful commit — relies +/// on `sweep_orphaned_snapshots` collecting the intermediate, and that sweep +/// filters on `dangling=true` **and** this label. Without it the sweep can +/// never match, and the flattened intermediate is left to whatever `untag_image` +/// happens to delete on its own. pub fn compaction_dockerfile(snapshot_ref: &str, scrub_script: &str) -> String { // The scrub script is multi-line shell. `RUN` takes it verbatim only if the // newlines are escaped, so it is folded onto one line with `;` separators — @@ -773,7 +804,8 @@ pub fn compaction_dockerfile(snapshot_ref: &str, scrub_script: &str) -> String { "FROM {snapshot_ref} AS src\n\ RUN {folded}\n\ FROM scratch\n\ - COPY --from=src / /\n" + COPY --from=src / /\n\ + LABEL {LABEL_MANAGED}=true\n" ) } @@ -1000,7 +1032,13 @@ fn projects_json_health() -> (bool, bool) { /// `LABEL` lines carries neither label, and a globals block that could not name /// the 4.7 GB image every project sits on would be missing the obvious. fn is_base_image_reference(reference: &str) -> bool { - let repo = reference.split(':').next().unwrap_or(reference); + // Split on the *tag*, not the first colon: `localhost:5000/triple-c-sandbox:latest` + // has a registry port, and splitting on the first colon would yield + // `localhost`. A tag never contains `/`, which is what tells the two apart. + let repo = match reference.rsplit_once(':') { + Some((repo, tag)) if !tag.contains('/') => repo, + _ => reference, + }; repo == "triple-c" || repo.ends_with("/triple-c-sandbox") || repo == "triple-c-sandbox" @@ -1081,6 +1119,7 @@ pub async fn scan(projects: &[Project]) -> Result { }; let mut stats = LayerStats::default(); + let mut base_lineage_known = false; if let Some(image) = snapshot { let history = docker .image_history(&image.id) @@ -1112,6 +1151,7 @@ pub async fn scan(projects: &[Project]) -> Result { }, None => None, }; + base_lineage_known = base_len.is_some(); stats = layer_stats(&history, base_len); } @@ -1140,6 +1180,7 @@ pub async fn scan(projects: &[Project]) -> Result { snapshot_bytes, snapshot_shared_bytes, snapshot_commit_layers: stats.commit_layers, + base_lineage_known, // Prefer the daemon's own measurement of what is unique to this // image over layer arithmetic; fall back to the layer sum when // `df()` did not compute a shared size. @@ -1209,6 +1250,12 @@ pub async fn scan(projects: &[Project]) -> Result { } } base_images.sort_by(|a, b| b.bytes.cmp(&a.bytes)); + // Full size per base, not `size - shared_size`: a base's shared bytes are + // shared with *its own snapshots*, so netting them out would report the + // 4.7 GB image every project sits on as ~0. The residual imprecision is two + // *different* bases that share lower layers with each other, whose common + // layers are counted twice here — worth knowing before treating this total + // as exact. let base_images_bytes = base_images.iter().map(|b| b.bytes).sum(); let (json_exists, json_parsed) = projects_json_health(); @@ -1663,7 +1710,11 @@ pub async fn list_reclaimable( ) }) .unwrap_or(0); - if row.snapshot_exists && row.snapshot_commit_layers > 1 && ceiling > 0 { + if row.snapshot_exists + && row.base_lineage_known + && row.snapshot_commit_layers > 1 + && ceiling > 0 + { items.push({ // Safety and reach are read off the target, never restated: a literal // here that disagreed with the classifier is exactly the drift this @@ -1763,7 +1814,9 @@ pub async fn list_reclaimable( project_name: row.project_name.clone(), label: "Home volume".to_string(), loses: "Shell history, dotfiles, every toolchain installed under $HOME, and any \ - Playwright browsers. Not recoverable." + Playwright browsers. Not recoverable. The project's container is removed \ + too, because a stopped container still holds its volumes open — it is \ + rebuilt from the snapshot on the next start." .to_string(), bytes: row.home_volume_bytes, blocked: blocked.clone(), @@ -1778,7 +1831,9 @@ pub async fn list_reclaimable( project_name: row.project_name.clone(), label: "Claude config volume".to_string(), loses: "The Claude login credential, installed plugins and skills, and EVERY \ - conversation transcript for this project. Not recoverable." + conversation transcript for this project. Not recoverable. The project's \ + container is removed too, because a stopped container still holds its \ + volumes open — it is rebuilt from the snapshot on the next start." .to_string(), bytes: row.config_volume_bytes, blocked: blocked.clone(), @@ -2112,7 +2167,8 @@ fn find_project<'a>(projects: &'a [Project], project_id: &str) -> Result<&'a Pro fn failed(target: ReclaimTarget, message: String) -> ReclaimResult { ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: false, freed_bytes: 0, projected_bytes: None, @@ -2196,7 +2252,8 @@ async fn reclaim_dangling(target: &ReclaimTarget) -> ReclaimResult { message.push_str(&format!(" {} could not be removed; see the log.", errors)); } ReclaimResult { - target: target.clone(), + target: Some(target.clone()), + destroyed: None, ok: errors == 0, freed_bytes: freed, projected_bytes: None, @@ -2222,7 +2279,8 @@ async fn reclaim_build_cache(all: bool) -> ReclaimResult { } match docker_cli(&args).await { Ok(output) => ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: parse_reclaimed_space(&output), projected_bytes: None, @@ -2302,7 +2360,8 @@ async fn reclaim_migration_pins() -> ReclaimResult { sweep.reclaimed_bytes ); ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: sweep.reclaimed_bytes, projected_bytes: None, @@ -2352,7 +2411,8 @@ fn reclaim_migration_staging(projects: &[Project]) -> ReclaimResult { } } ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: freed, projected_bytes: None, @@ -2407,7 +2467,8 @@ async fn reclaim_containers( } } ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: errors == 0, freed_bytes: freed, projected_bytes: None, @@ -2459,7 +2520,8 @@ async fn reclaim_orphan_volume(name: &str, projects: &[Project]) -> ReclaimResul match docker.remove_volume(name, None).await { Ok(()) => ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: volume.bytes, projected_bytes: None, @@ -2606,7 +2668,8 @@ pub async fn compact_snapshot(project: &Project) -> ReclaimResult { let _ = migration::untag_image(&staging_ref).await; let _ = container::sweep_orphaned_snapshots().await; return ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: 0, projected_bytes: projected, @@ -2647,7 +2710,8 @@ pub async fn compact_snapshot(project: &Project) -> ReclaimResult { sweep.removed.len() ); ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: freed, projected_bytes: projected, @@ -2726,6 +2790,59 @@ async fn build_from_dockerfile(dockerfile: &str, tag: &str) -> Result<(), String Ok(()) } +/// Name prefix for the throwaway container that replays a compacted image's +/// config. Distinct from `triple-c-scrub-*` on purpose — see +/// [`restore_image_config`]. +const COMPACTION_CONTAINER_PREFIX: &str = "triple-c-compact-"; + +/// Remove any container left behind by an interrupted compaction. +/// +/// Runs at the start of a compaction rather than from a reclaim bucket, so +/// nothing can ever remove the container of a compaction that is still running: +/// by the time this is called, this task owns the compaction path. +async fn remove_stale_compaction_containers() { + let Ok(docker) = get_docker() else { + return; + }; + let containers = docker + .list_containers(Some(ListContainersOptions { + all: true, + size: false, + filters: HashMap::from([( + "name".to_string(), + vec![COMPACTION_CONTAINER_PREFIX.to_string()], + )]), + ..Default::default() + })) + .await + .unwrap_or_default(); + for summary in containers { + // Docker's `name` filter is a substring match; the full name decides. + if !is_compaction_container(&summary) { + continue; + } + if let Some(id) = summary.id.as_deref() { + match container::remove_container(id).await { + Ok(()) => log::info!("Removed stale compaction container {}", id), + Err(e) => log::warn!("Could not remove stale compaction container {}: {}", id, e), + } + } + } +} + +/// Whether a container is one of ours from an interrupted compaction. +fn is_compaction_container(summary: &ContainerSummary) -> bool { + summary + .names + .as_deref() + .unwrap_or(&[]) + .iter() + .any(|name| { + name.trim_start_matches('/') + .starts_with(COMPACTION_CONTAINER_PREFIX) + }) +} + /// Put a captured image config back onto a flattened image, under the original /// tag. /// @@ -2748,12 +2865,20 @@ async fn restore_image_config( use bollard::image::CommitContainerOptions; let docker = get_docker()?; - // Deliberately the same `triple-c-scrub-*` name `rewrite_image_without_secrets` - // uses. If this process dies between the create and the remove below, the - // leftover is already covered by the "secret-scrub scratch containers" - // bucket in this very panel rather than needing a second reaper. The two - // never run at once: `reclaim` executes its targets in sequence. - let scratch_name = format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()); + + // **Its own prefix, not `triple-c-scrub-*`.** An earlier version reused the + // secret-rewrite name on the grounds that the existing reclaim bucket would + // then collect any leftover. It would — including the live one: that bucket + // removes with `force: true`, so a "remove scrub containers" reclaim fired + // from a second window while a compaction was mid-flight would destroy the + // container the commit is about to run against. Sequential execution inside + // one `reclaim` call is not a guarantee when two can be in flight. + // + // Stale ones are instead swept here, at the start of the next compaction — + // a created-but-never-started container has no writable layer, so a + // leftover costs almost nothing until then. + remove_stale_compaction_containers().await; + let scratch_name = format!("{}{}", COMPACTION_CONTAINER_PREFIX, uuid::Uuid::new_v4().simple()); // `image` is the flat build; everything else is copied from the original so // the committed image is byte-for-byte the same configuration. @@ -2877,7 +3002,8 @@ pub async fn clear_caches(project: &Project, include_rustup: bool) -> ReclaimRes match super::exec::exec_oneshot_as(&container_id, "claude", cmd, Vec::new()).await { Ok((output, _exit)) => match parse_cache_total(&output) { Some(bytes) => ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: bytes as i64, projected_bytes: None, @@ -2934,8 +3060,9 @@ pub async fn destroy( // A running container holds all three of these open, and Docker's refusal // is not something to lean on for the volumes: it would happily leave a // half-removed project behind. - if let Ok(Some(container_id)) = container::find_existing_container(project).await { - if container::is_container_running(&container_id) + let existing_container = container::find_existing_container(project).await.ok().flatten(); + if let Some(container_id) = existing_container.as_deref() { + if container::is_container_running(container_id) .await .unwrap_or(false) { @@ -2953,13 +3080,37 @@ pub async fn destroy( }; // Size it before it goes, so the report is a measurement. let bytes = volume_size(&name).await; + + // **A stopped container still pins its volumes.** Docker refuses + // `remove_volume` with a 409 while any container references one, + // and every project that has ever been started has exactly that — + // a stopped container is the resting state, not an edge case. So + // the container is removed first rather than letting the user type + // a project name and then meet a raw 409. It is regenerable from + // the snapshot; `DestructiveItem::loses` says so. + if let Some(container_id) = existing_container.as_deref() { + container::remove_container(container_id).await.map_err(|e| { + format!( + "Could not remove this project's container, which still holds the volume \ + open: {}. Nothing was removed.", + e + ) + })?; + log::info!( + "Removed container {} so {} could be deleted", + container_id, + name + ); + } + docker .remove_volume(&name, None) .await .map_err(|e| format!("Could not remove volume {}: {}", name, e))?; log::info!("Removed volume {} on explicit confirmation", name); Ok(ReclaimResult { - target: ReclaimTarget::OrphanVolume { name: name.clone() }, + target: None, + destroyed: Some(target.clone()), ok: true, freed_bytes: bytes, projected_bytes: None, @@ -2974,7 +3125,8 @@ pub async fn destroy( container::remove_snapshot_image(project).await?; let sweep = container::sweep_orphaned_snapshots().await; Ok(ReclaimResult { - target: ReclaimTarget::DanglingSnapshots, + target: None, + destroyed: Some(target.clone()), ok: true, freed_bytes: bytes + sweep.reclaimed_bytes, projected_bytes: None, @@ -2985,6 +3137,19 @@ pub async fn destroy( }) } DestructiveTarget::RollbackPin { tag, .. } => { + // **The one destructive variant carrying a free-form string.** + // Every other arm builds its target from constants; this one takes + // a tag over IPC and interpolates it into an image reference that + // is then removed. Unvalidated, `tag: "latest"` names the project's + // live snapshot — deleted under a dialog that says "rollback pin". + // `parse_rollback_tag` accepts only `pre-migration-`, + // which is exactly what `rollback_tag` produces and nothing else. + if migration::parse_rollback_tag(tag).is_none() { + return Err(format!( + "{:?} is not a rollback pin tag. Nothing was removed.", + tag + )); + } let reference = format!("triple-c-snapshot-{}:{}", project.id, tag); migration::untag_image(&reference).await?; // Untagging only makes the image dangling. Whatever came back came @@ -2992,7 +3157,8 @@ pub async fn destroy( let sweep = container::sweep_orphaned_snapshots().await; log::info!("Dropped rollback pin {} on explicit confirmation", reference); Ok(ReclaimResult { - target: ReclaimTarget::MigrationPins, + target: None, + destroyed: Some(target.clone()), ok: true, freed_bytes: sweep.reclaimed_bytes, projected_bytes: None, @@ -3011,8 +3177,13 @@ pub async fn destroy( /// The plain `Size` from `inspect_image` includes the base, which several other /// projects are still built from and which is not going anywhere. Reporting it /// as freed would overstate a snapshot removal by ~4.7 GB every time. Only -/// `df()` computes `SharedSize`, so this costs one — acceptable on the -/// destructive path, which handles exactly one object per call. +/// `df()` computes `SharedSize`, so each call costs a full daemon walk. +/// +/// That is three `df()`s on a compaction (before, after, and the scan that +/// planned it) and one per destructive removal. Acceptable because both are +/// single-object, user-initiated actions that already take seconds to minutes — +/// but it is why nothing in the *scan* path calls this: `scan` gets shared +/// sizes from the one `df()` it already makes. async fn image_unique_bytes(reference: &str) -> i64 { let Ok(docker) = get_docker() else { return 0; diff --git a/app/src-tauri/src/docker/disk_tests.rs b/app/src-tauri/src/docker/disk_tests.rs index 0aae0b1..8ec636a 100644 --- a/app/src-tauri/src/docker/disk_tests.rs +++ b/app/src-tauri/src/docker/disk_tests.rs @@ -338,6 +338,26 @@ fn a_scrub_container_is_matched_on_its_whole_name_not_a_substring() { assert!(!is_scrub_container(&summary(&[], &[]))); } +#[test] +fn a_compaction_container_is_never_matched_by_the_scrub_bucket() { + // These had the same `triple-c-scrub-*` prefix once. The scrub bucket + // removes with `force: true`, so a reclaim fired from a second window while + // a compaction was mid-flight would have destroyed the container the commit + // was about to run against. Separate prefixes, and neither predicate may + // reach the other's containers. + let compaction = summary(&["/triple-c-compact-abc123"], &[]); + let scrub = summary(&["/triple-c-scrub-abc123"], &[]); + + assert!(is_compaction_container(&compaction)); + assert!(!is_scrub_container(&compaction), "the scrub bucket must not reach it"); + + assert!(is_scrub_container(&scrub)); + assert!(!is_compaction_container(&scrub)); + + // Same substring-filter hazard applies to the new prefix. + assert!(!is_compaction_container(&summary(&["/my-triple-c-compact-notes"], &[]))); +} + #[test] fn a_probe_container_is_matched_on_its_label_not_on_the_daemons_filter() { // The `label=triple-c.probe=migration` filter is an exact match and would @@ -627,6 +647,24 @@ fn the_compaction_dockerfile_reuses_the_one_scrub_list() { assert!(!run_lines[0].contains('\n')); } +#[test] +fn the_compaction_build_is_labelled_so_the_sweep_can_collect_it() { + // Everything that cleans up after this build — the discard path when the + // result is not smaller, the untag after a successful commit — leans on + // `sweep_orphaned_snapshots`, and that sweep filters on `dangling=true` + // AND `triple-c.managed=true`. Without the label it can never match, and + // the flattened intermediate is stranded. + let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script()); + assert!( + df.contains("LABEL triple-c.managed=true"), + "the sweep filters on this label and would never match: {}", + df + ); + // It has to be on the *final* stage, not the discarded `src` one. + let after_scratch = df.split("FROM scratch").nth(1).expect("no final stage"); + assert!(after_scratch.contains("LABEL triple-c.managed=true"), "{}", df); +} + #[test] fn the_compaction_dockerfile_never_reaches_a_bind_mount() { let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script()); @@ -730,6 +768,77 @@ fn a_typed_confirmation_must_match_the_project_name_exactly() { assert!(!confirmation_matches("", "")); } +#[test] +fn only_a_real_rollback_tag_can_name_an_image_to_delete() { + // `DestructiveTarget::RollbackPin` is the one destructive variant carrying + // a free-form string from the frontend, and `destroy` interpolates it into + // an image reference it then removes. Unguarded, `tag: "latest"` names the + // project's *live snapshot* — deleted under a dialog that says "rollback + // pin". The guard is `parse_rollback_tag`, so this pins what it accepts. + assert!(migration::parse_rollback_tag("pre-migration-20260101-101500").is_some()); + + for hostile in [ + "latest", + "", + "pre-migration-", + "pre-migration-notatimestamp", + "../latest", + "latest\npre-migration-20260101-101500", + ] { + assert!( + migration::parse_rollback_tag(hostile).is_none(), + "{:?} must not be accepted as a rollback pin tag", + hostile + ); + } +} + +#[test] +fn a_destroy_result_never_claims_to_be_reclaim_work() { + // An earlier version returned `OrphanVolume { name }` for a home-volume + // deletion — naming a volume that was never an orphan, and attributing the + // outcome to a plan row the user never ticked. Exactly one of the two + // fields is ever set. + let reclaim_shaped = ReclaimResult { + target: Some(ReclaimTarget::DanglingSnapshots), + destroyed: None, + ok: true, + freed_bytes: 1, + projected_bytes: None, + message: String::new(), + }; + let destroy_shaped = ReclaimResult { + target: None, + destroyed: Some(DestructiveTarget::HomeVolume { + project_id: "p1".to_string(), + }), + ..reclaim_shaped.clone() + }; + assert!(reclaim_shaped.target.is_some() != reclaim_shaped.destroyed.is_some()); + assert!(destroy_shaped.target.is_some() != destroy_shaped.destroyed.is_some()); + + // And both shapes survive the wire. + let json = serde_json::to_string(&destroy_shaped).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), destroy_shaped); +} + +#[test] +fn a_snapshot_with_no_known_base_is_not_offered_for_compaction() { + // With `triple-c.base-image-id` absent — the normal case for a project + // created before that label existed — `layer_stats` counts every layer that + // carries bytes, base included. A never-recreated project then reports ~15 + // "commit layers" and would sail past a `> 1` check. `base_lineage_known` + // is what stops the plan offering a rewrite sized from a number that does + // not mean what its name says. + let unknown = layer_stats(&[10, 20, 30, 40], None); + assert_eq!(unknown.commit_layers, 4); + assert_eq!(unknown.above_base_bytes, None, "the split must not be guessed"); + + let known = layer_stats(&[10, 20, 30, 40], Some(3)); + assert_eq!(known.commit_layers, 1); + assert_eq!(known.above_base_bytes, Some(10)); +} + // --------------------------------------------------------------------------- // Host detection // --------------------------------------------------------------------------- @@ -763,9 +872,14 @@ fn base_images_are_recognised_by_reference_for_display_only() { assert!(is_base_image_reference("triple-c-sandbox:latest")); assert!(is_base_image_reference("triple-c:latest")); + // A registry port must not be mistaken for a tag separator. + assert!(is_base_image_reference("localhost:5000/triple-c-sandbox:latest")); + assert!(is_base_image_reference("registry.example.com:8443/triple-c-sandbox")); + // A project's own snapshot is not a base image, and neither is anything of // the user's. assert!(!is_base_image_reference("triple-c-snapshot-abc:latest")); + assert!(!is_base_image_reference("localhost:5000/postgres:17")); assert!(!is_base_image_reference("triple-c-gateway:latest")); assert!(!is_base_image_reference("postgres:17-alpine")); } @@ -815,6 +929,7 @@ fn the_report_serialises_as_snake_case_like_every_other_ipc_struct() { }; let json = serde_json::to_value(&report).unwrap(); assert_eq!(json["projects"][0]["snapshot_commit_layers"], 14); + assert_eq!(json["projects"][0]["base_lineage_known"], false); assert_eq!(json["projects"][0]["container_writable_bytes"], 868_000_000i64); assert!(json["orphan_volumes_unavailable"].is_null()); // `Option` must reach the frontend as null, not be omitted — the TS diff --git a/app/src/components/settings/DiskProjectTable.tsx b/app/src/components/settings/DiskProjectTable.tsx index 891a200..0c9b9e3 100644 --- a/app/src/components/settings/DiskProjectTable.tsx +++ b/app/src/components/settings/DiskProjectTable.tsx @@ -11,6 +11,12 @@ interface Props { onDestroy: (item: DestructiveItem) => void; } +const LAYERS_HELP = + "Commit layers stacked above the base image — one for every time this project's container was recreated. Nothing merges them, so each one is paid for permanently until the snapshot is compacted."; + +const NEXT_COMMIT_HELP = + "The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that."; + /** `—` for a column with nothing in it, so an empty cell never reads as zero. */ function cell(bytes: number, present: boolean) { return present ? formatBytes(bytes) : "—"; @@ -59,11 +65,18 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props Layers - + {/* `Tooltip` renders a portalled div with no `role` and no + `aria-describedby`, so its text reaches no assistive tech and + the trigger announces as "Help". These two headers are + meaningless without their explanation, so it is also emitted + as screen-reader-only text. */} + + — {LAYERS_HELP} Next commit adds - + + — {NEXT_COMMIT_HELP} Home vol @@ -104,7 +117,12 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props - {cell(row.snapshot_above_base_bytes ?? 0, row.snapshot_exists)} + {/* `null` means the split could not be measured. Rendering it + as 0 B would be the one guessed number in this table. */} + {cell( + row.snapshot_above_base_bytes ?? -1, + row.snapshot_exists && row.snapshot_above_base_bytes !== null, + )} {row.snapshot_exists && ( {/* The base is shared by every project, so charging it to @@ -117,18 +135,28 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props )} - {row.snapshot_exists ? ( - 5 - ? "text-[var(--warning)]" - : "text-[var(--text-primary)]" - } - > - {row.snapshot_commit_layers} - - ) : ( + {!row.snapshot_exists ? ( "—" + ) : !row.base_lineage_known ? ( + // The base this descends from is unknown, so the count + // includes the base's own layers and does not mean + // "recreations". Saying so beats printing a wrong number. + + unknown + + ) : ( + + {row.snapshot_commit_layers} + {/* Never colour alone: a count worth acting on says so in + a word, which is also what a screen reader gets. */} + {row.snapshot_commit_layers > 5 && ( + + stacked + + )} + )} diff --git a/app/src/components/settings/DiskSettings.test.tsx b/app/src/components/settings/DiskSettings.test.tsx index dad6168..07ec58d 100644 --- a/app/src/components/settings/DiskSettings.test.tsx +++ b/app/src/components/settings/DiskSettings.test.tsx @@ -35,6 +35,7 @@ const row = (over: Partial = {}): ProjectDiskRow => ({ snapshot_bytes: 12_273_392_374, snapshot_shared_bytes: 3_832_425_659, snapshot_commit_layers: 14, + base_lineage_known: true, snapshot_above_base_bytes: 8_440_966_715, container_exists: true, container_running: false, @@ -170,6 +171,36 @@ describe("DiskSettings", () => { expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument(); }); + it("refuses to present a layer count that does not mean recreations", async () => { + // Without `triple-c.base-image-id` — the normal case for a project created + // before that label existed — the count includes the base's own ~15 layers. + // Printing it beside a header that says "one per recreation" would be a + // wrong number in the column the table exists for. + getDockerDiskUsage.mockResolvedValue( + report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }), + ); + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("unknown")).toBeInTheDocument(); + expect(within(projectRow).queryByText("17")).not.toBeInTheDocument(); + }); + + it("renders an unmeasurable snapshot split as a dash, never as zero", async () => { + getDockerDiskUsage.mockResolvedValue( + report({ projects: [row({ snapshot_above_base_bytes: null })] }), + ); + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument(); + expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0); + }); + + it("marks a heavily stacked snapshot with a word, not just a colour", async () => { + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("stacked")).toBeInTheDocument(); + }); + it("charges the shared base to the globals, not to every project row", async () => { // The base is one 4.7 GB image every project descends from. Counting it per // row would show it eight times and make the column meaningless. @@ -218,6 +249,45 @@ describe("DiskSettings", () => { expect(reclaim).toHaveBeenCalledWith([{ kind: "migration_staging" }]); }); + it("clears the tick list once the reclaim has run", async () => { + // The plan's rows describe objects the reclaim just removed; leaving them + // ticked lets the user fire the same call again against nothing. + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + expect(screen.getByText(/1 selected/)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + }); + expect(screen.queryByTestId("disk-safe-bucket")).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + // And it says why the list is gone rather than claiming nothing was found. + expect(screen.getByTestId("disk-plan-stale").textContent).toMatch( + /measured before that last action/, + ); + }); + + it("says why the build-cache figure is the under-reporting one", async () => { + // Without this, a `buildx du` failure silently shows `docker system df`'s + // number, which under-reports what a prune would free. + getDockerDiskUsage.mockResolvedValue( + report({ + build_cache: { + total_bytes: 28_000_000_000, + reclaimable_bytes: 1_000_000, + stale_bytes: 0, + source: "system df", + cli_error: "`docker buildx du` failed: executable not found", + }, + }), + ); + await renderAndScan(); + const globals = await screen.findByTestId("disk-globals"); + expect(globals.textContent).toMatch(/under-reports what a prune would free/); + expect(globals.textContent).toMatch(/executable not found/); + }); + it("cannot reclaim with nothing ticked", async () => { await renderAndScan(); await screen.findByTestId("disk-safe-bucket"); @@ -380,9 +450,7 @@ describe("DiskSettings", () => { ); await renderAndScan(); const note = await screen.findByTestId("disk-vhdx-note"); - expect( - within(note).getByText("Reclaiming here will not shrink your C: drive"), - ).toBeInTheDocument(); + expect(note.textContent).toMatch(/Warning: reclaiming here will not shrink your C: drive/); expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument(); expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument(); expect(within(note).getByText(/Purge data/)).toBeInTheDocument(); @@ -415,7 +483,8 @@ describe("DiskSettings", () => { }), ); destroyProjectDiskObject.mockResolvedValue({ - target: { kind: "orphan_volume", name: "triple-c-claude-config-p-whp" }, + target: null, + destroyed: { kind: "config_volume", project_id: "p-whp" }, ok: true, freed_bytes: 427_000_000, projected_bytes: null, @@ -450,6 +519,57 @@ describe("DiskSettings", () => { ); }); + it("keeps the confirmation open and busy while the deletion runs", async () => { + // The modal used to be unmounted before the call was awaited, which made + // its whole busy path dead code and left a multi-second volume removal with + // no indication it was happening. + listReclaimable.mockResolvedValue( + plan({ + destructive: [ + { + target: { kind: "home_volume", project_id: "p-whp" }, + project_id: "p-whp", + project_name: "whp", + label: "Home volume", + loses: "Shell history and toolchains.", + bytes: 4_860_000_000, + blocked: null, + }, + ], + }), + ); + let finish: (value: unknown) => void = () => {}; + destroyProjectDiskObject.mockReturnValue(new Promise((r) => (finish = r))); + + await renderAndScan(); + await screen.findByTestId("disk-row-p-whp"); + fireEvent.click(screen.getByRole("button", { name: "Delete whp data" })); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: /Delete home volume/ })); + }); + + const dialog = screen.getByRole("dialog"); + fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" })); + + // Still open, and saying so. + await waitFor(() => + expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(), + ); + + await act(async () => { + finish({ + target: null, + destroyed: { kind: "home_volume", project_id: "p-whp" }, + ok: true, + freed_bytes: 4_860_000_000, + projected_bytes: null, + message: "Removed volume.", + }); + }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + it("never routes a destructive object through the bulk Reclaim button", async () => { listReclaimable.mockResolvedValue( plan({ @@ -479,6 +599,7 @@ describe("DiskSettings", () => { results: [ { target: { kind: "compact_snapshot", project_id: "p-whp" }, + destroyed: null, ok: true, freed_bytes: 5_100_000_000, projected_bytes: 7_000_000_000, @@ -499,7 +620,7 @@ describe("DiskSettings", () => { expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument(); }); - it("surfaces a scan failure rather than showing stale numbers", async () => { + it("surfaces a scan failure as an alert", async () => { getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host"); render(); await act(async () => { diff --git a/app/src/components/settings/DiskSettings.tsx b/app/src/components/settings/DiskSettings.tsx index e3a3992..720fcfc 100644 --- a/app/src/components/settings/DiskSettings.tsx +++ b/app/src/components/settings/DiskSettings.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import Button from "../ui/Button"; import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator"; import Modal from "../ui/Modal"; @@ -38,12 +38,29 @@ function targetKey(target: ReclaimTarget): string { * and the backend refuses it in bulk by taking a different type entirely. */ export default function DiskSettings() { - const { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy } = - useDiskUsage(); + const { + report, + plan, + scanning, + working, + error, + outcome, + scan, + runReclaim, + destroy, + runSweep, + clearOutcome, + } = useDiskUsage(); const [ticked, setTicked] = useState>(new Set()); const [confirming, setConfirming] = useState(null); const [destroying, setDestroying] = useState(null); + // The plan is dropped after any reclaim, so a tick can never outlive the row + // it was made against and be re-fired at an object that is already gone. + useEffect(() => { + if (!plan) setTicked(new Set()); + }, [plan]); + const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? []; const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? []; const selected = safeItems.filter( @@ -112,11 +129,14 @@ export default function DiskSettings() { className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2" data-testid="disk-vhdx-note" > - + {/* `StatusIndicator` has no warning tone — `error` would put a + red glyph in a warning-toned panel. This is advisory, so it + carries its own glyph beside the words rather than relying on + the panel's colour. */} +

+ Warning: reclaiming here will not + shrink your C: drive +

{report.host.vhdx_note}

@@ -203,11 +223,20 @@ export default function DiskSettings() { )} + {report.build_cache.cli_error && ( +

+ {/* Without this the panel silently shows `docker system df`'s + under-reported build-cache figure and the user has no way + to know why it disagrees with their terminal. */} + Build-cache figures fell back to docker system df, which + under-reports what a prune would free: {report.build_cache.cli_error} +

+ )} {report.orphan_volumes.length > 0 && (

- That last figure means only that the volume’s project id is not in - your project list — it is not inferred from a project - being stopped or having no image. A project you have not opened in a + “Volumes with no matching project” above means only that the + volume’s project id is not in your project list — it is{" "} + not inferred from a project being stopped or having no image. A project you have not opened in a while has no container and no snapshot either, and that is normal, so each of these is ticked individually and shows the date Docker created it. @@ -223,7 +252,7 @@ export default function DiskSettings() { {/* --- Store failure, if any ------------------------------------ */} {report.orphan_volumes_unavailable && (

)} + {/* --- The plan was dropped by a reclaim -------------------------- */} + {!plan && ( +

+ The totals above were measured before that last action. Scan again to see + what is left to reclaim. +

+ )} + {/* --- Safe reclaim ---------------------------------------------- */} + {plan && (

Safe to reclaim @@ -260,7 +298,10 @@ export default function DiskSettings() {

+ )} {/* --- Semi-safe -------------------------------------------------- */} {semiItems.length > 0 && ( @@ -371,16 +413,13 @@ export default function DiskSettings() { {/* --- Sweep ------------------------------------------------------ */}
- - The same sweep that runs at startup and after every recreation — here you - can see what it found. + The same sweep that runs at startup and after every recreation. Unlike the + tick above it also reports what it refused to remove, which is how + a superseded image pinned by a stopped project shows itself.
@@ -394,11 +433,16 @@ export default function DiskSettings() { aria-live="polite" data-testid="disk-outcome" > - r.ok) ? "ok" : "error"} - label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`} - className="text-xs" - /> +
+ r.ok) ? "ok" : "error"} + label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`} + className="text-xs" + /> + +
    {outcome.results.map((result, index) => (
  • @@ -433,10 +477,11 @@ export default function DiskSettings() { size="md" variant="primary" disabled={working} - onClick={() => { - const target = confirming.target; + onClick={async () => { + // Same reasoning as the destructive modal: a compaction takes + // minutes, and the dialog reporting it beats it vanishing. + await runReclaim([confirming.target]); setConfirming(null); - void runReclaim([target]); }} > {working ? "Working…" : "Run it"} @@ -487,10 +532,12 @@ export default function DiskSettings() { confirmLabel={`Delete ${destroying.label.toLowerCase()}`} busy={working} onCancel={() => setDestroying(null)} - onConfirm={(typed) => { - const target = destroying.target; + onConfirm={async (typed) => { + // The modal stays mounted until the call settles, so its `busy` + // state is what the user sees while a multi-second volume removal + // runs. Clearing it first made the whole busy path dead code. + await destroy(destroying.target, typed); setDestroying(null); - void destroy(target, typed); }} >

    diff --git a/app/src/components/ui/TypedConfirmModal.tsx b/app/src/components/ui/TypedConfirmModal.tsx index 5a78278..8c7f036 100644 --- a/app/src/components/ui/TypedConfirmModal.tsx +++ b/app/src/components/ui/TypedConfirmModal.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type ReactNode } from "react"; +import { useId, useRef, useState, type ReactNode } from "react"; import Modal from "./Modal"; import Button from "./Button"; import { inputClass } from "./Field"; @@ -47,6 +47,9 @@ export default function TypedConfirmModal({ }: Props) { const [typed, setTyped] = useState(""); const inputRef = useRef(null); + // Every other `ui/` component uses `useId`; a hardcoded id breaks the + // label association as soon as two of these are mounted at once. + const inputId = useId(); const matches = expected.trim().length > 0 && typed.trim() === expected.trim(); return ( @@ -80,13 +83,13 @@ export default function TypedConfirmModal({ {children}

    setTyped(e.target.value)} diff --git a/app/src/hooks/useDiskUsage.test.tsx b/app/src/hooks/useDiskUsage.test.tsx index 0c21429..bda6e72 100644 --- a/app/src/hooks/useDiskUsage.test.tsx +++ b/app/src/hooks/useDiskUsage.test.tsx @@ -14,9 +14,11 @@ vi.mock("../lib/tauri-commands", () => ({ reclaim: (targets: unknown) => reclaim(targets), destroyProjectDiskObject: (target: unknown, confirmation: string) => destroyProjectDiskObject(target, confirmation), - sweepOrphanedSnapshots: vi.fn(), + sweepOrphanedSnapshots: () => sweepOrphanedSnapshots(), })); +const sweepOrphanedSnapshots = vi.fn(); + const report = (scanned_at: string): DiskUsageReport => ({ scanned_at, projects: [] }) as unknown as DiskUsageReport; @@ -149,13 +151,94 @@ describe("useDiskUsage", () => { expect(result.current.outcome?.total_freed_bytes).toBe(100); }); - it("surfaces a failure rather than leaving a stale report on screen", async () => { - getDockerDiskUsage.mockRejectedValue("daemon unreachable"); + it("reports a scan failure and keeps the last good measurement", async () => { + // The old report is still an accurate measurement of an earlier moment, + // and the error says the refresh failed. Blanking it would leave the panel + // with nothing while telling the user nothing more. + getDockerDiskUsage.mockResolvedValueOnce(report("first")); const { result } = renderHook(() => useDiskUsage()); await act(async () => { await result.current.scan(); }); + + getDockerDiskUsage.mockRejectedValueOnce("daemon unreachable"); + await act(async () => { + await result.current.scan(); + }); await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/)); + expect(result.current.report?.scanned_at).toBe("first"); expect(result.current.scanning).toBe(false); }); + + it("never shows fresh totals beside a stale tick list", async () => { + // `setReport` used to land before the plan call was awaited, so a plan + // failure rendered this scan's numbers above the previous scan's rows. + getDockerDiskUsage.mockResolvedValueOnce(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + + getDockerDiskUsage.mockResolvedValueOnce(report("second")); + listReclaimable.mockRejectedValueOnce("planner exploded"); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.error).toMatch(/planner exploded/); + expect(result.current.report?.scanned_at).toBe("first"); + }); + + it("drops the plan after a reclaim so ticks cannot be re-fired at nothing", async () => { + getDockerDiskUsage.mockResolvedValue(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.plan).toEqual(plan); + + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(result.current.plan).toBeNull(); + // The totals stay — they were measured before the reclaim and the outcome + // says what changed. + expect(result.current.report?.scanned_at).toBe("first"); + }); + + it("runs the sweep through its own command and reports what it refused", async () => { + // The sweep's `in_use` count — orphans Docker refused to delete because a + // stopped project still needs them — is invisible everywhere else in the + // app, because every other caller throws the report away. + sweepOrphanedSnapshots.mockResolvedValue({ + removed: ["sha256:a", "sha256:b"], + reclaimed_bytes: 11_900_000_000, + in_use: 3, + failed: [], + unavailable: null, + }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runSweep(); + }); + expect(sweepOrphanedSnapshots).toHaveBeenCalled(); + expect(result.current.outcome?.total_freed_bytes).toBe(11_900_000_000); + expect(result.current.outcome?.results[0].message).toMatch(/Swept 2 superseded image/); + expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/); + }); + + it("treats an unreachable daemon in the sweep report as an error", async () => { + sweepOrphanedSnapshots.mockResolvedValue({ + removed: [], + reclaimed_bytes: 0, + in_use: 0, + failed: [], + unavailable: "Could not reach the Docker engine", + }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runSweep(); + }); + expect(result.current.error).toMatch(/Could not reach the Docker engine/); + expect(result.current.outcome).toBeNull(); + }); }); diff --git a/app/src/hooks/useDiskUsage.ts b/app/src/hooks/useDiskUsage.ts index e3bf786..f82eb19 100644 --- a/app/src/hooks/useDiskUsage.ts +++ b/app/src/hooks/useDiskUsage.ts @@ -17,16 +17,24 @@ import type { * the daemon and computes shared-layer sizes. On a 100 GB store that is * seconds. `AccordionSection` unmounts its body when collapsed, so a * `useEffect` scan would re-run every single time the user opened the section. - * The scan is therefore a `scan()` the Scan button calls and nothing else, and - * the result lives in this hook rather than in the component so that reopening - * the section shows the last result instead of paying again. + * The scan is therefore only ever what the Scan button calls. + * + * Note what that does *not* buy: this hook lives inside `DiskSettings`, which + * the accordion unmounts on collapse, so its state goes with it and reopening + * the section shows an unscanned panel again. That is the honest behaviour — + * a stale total is worse than an absent one — but it means collapsing and + * reopening discards a scan the user paid for. Lifting the report into + * `appState` would fix that and is deliberately not done here: it would put a + * multi-megabyte, rapidly-stale blob into the app-wide store for one panel. * * ## The generation guard * * A user who hits Scan twice can have two `df()` calls in flight, and they can * land out of order — the second one is not necessarily slower. Every async - * write checks it is still the newest before it lands, the same pattern - * `useContainerMigration` uses. + * write in `scan` checks it is still the newest before it lands, the same + * pattern `useContainerMigration` uses. `runReclaim` and `destroy` do not need + * it: the UI disables their buttons while `working` is set, so there is never + * a second one to race. */ export interface DiskUsageState { report: DiskUsageReport | null; @@ -41,6 +49,8 @@ export interface DiskUsageState { scan: () => Promise; runReclaim: (targets: ReclaimTarget[]) => Promise; destroy: (target: DestructiveTarget, confirmation: string) => Promise; + /** Run the orphaned-snapshot sweep and report what it found *and refused*. */ + runSweep: () => Promise; clearOutcome: () => void; } @@ -63,16 +73,24 @@ export function useDiskUsage(): DiskUsageState { try { const next = await commands.getDockerDiskUsage(); if (generation.current !== mine) return; - setReport(next); // Planning is cheap and always wanted: the classification is what makes // the numbers actionable, and it reuses the report rather than scanning // again. const nextPlan = await commands.listReclaimable(next); if (generation.current !== mine) return; + // Both land together, or neither does. Setting the report before + // awaiting the plan would render this scan's totals above the *previous* + // scan's still-clickable tick list if the plan call failed. + setReport(next); setPlan(nextPlan); } catch (e) { if (generation.current !== mine) return; setError(String(e)); + // The old report is left on screen deliberately — it is still an + // accurate measurement of an earlier moment, and the error says the + // refresh failed. What must not survive is a plan describing a scan the + // user can no longer see the totals for, but that cannot happen: the two + // only ever move together. } finally { if (generation.current === mine) setScanning(false); } @@ -85,9 +103,16 @@ export function useDiskUsage(): DiskUsageState { try { const result = await commands.reclaim(targets); setOutcome(result); - // Deliberately no automatic re-scan. It costs another `df()`, and the + // **The plan is now stale and must not stay clickable.** Its rows + // describe objects this call just removed, so leaving them ticked lets + // the user fire the same reclaim again against nothing. Dropping the plan + // (not the report) leaves the totals on screen, marked as measured before + // the reclaim, with the tick list gone. + // + // Deliberately no automatic re-scan: it costs another `df()`, and the // outcome already reports measured bytes for every target — a user who // wants the new totals asks for them. + setPlan(null); } catch (e) { setError(String(e)); } finally { @@ -101,6 +126,53 @@ export function useDiskUsage(): DiskUsageState { try { const result = await commands.destroyProjectDiskObject(target, confirmation); setOutcome({ results: [result], total_freed_bytes: result.freed_bytes }); + // Same reasoning as `runReclaim`: the destructive list named an object + // that is now gone. + setPlan(null); + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); + } + }, []); + + /** + * The startup sweep, on demand. + * + * Not the same as ticking "superseded snapshot layers", even though both end + * up removing the same images: this reports `in_use` — the orphans Docker + * *refused* to delete because a stopped project's container still needs + * them. That refusal is the sweep's third safety net and it is invisible + * everywhere else in the app, because every existing caller throws the + * report away. + */ + const runSweep = useCallback(async () => { + setWorking(true); + setError(null); + try { + const sweep = await commands.sweepOrphanedSnapshots(); + if (sweep.unavailable) { + setError(sweep.unavailable); + return; + } + const refused = + sweep.in_use > 0 + ? ` ${sweep.in_use} were left alone because a container is still built from them — start and stop, or recreate, that project and a later sweep gets them.` + : ""; + setOutcome({ + results: [ + { + target: { kind: "dangling_snapshots" }, + destroyed: null, + ok: sweep.failed.length === 0, + freed_bytes: sweep.reclaimed_bytes, + projected_bytes: null, + message: `Swept ${sweep.removed.length} superseded image(s).${refused}`, + }, + ], + total_freed_bytes: sweep.reclaimed_bytes, + }); + setPlan(null); } catch (e) { setError(String(e)); } finally { @@ -110,5 +182,17 @@ export function useDiskUsage(): DiskUsageState { const clearOutcome = useCallback(() => setOutcome(null), []); - return { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy, clearOutcome }; + return { + report, + plan, + scanning, + working, + error, + outcome, + scan, + runReclaim, + destroy, + runSweep, + clearOutcome, + }; } diff --git a/app/src/lib/formatBytes.test.ts b/app/src/lib/formatBytes.test.ts index f832838..aabd3d1 100644 --- a/app/src/lib/formatBytes.test.ts +++ b/app/src/lib/formatBytes.test.ts @@ -45,6 +45,24 @@ describe("formatBytes", () => { expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB"); }); + it("promotes the unit when rounding lands on a whole step", () => { + // `toFixed` runs after the divide loop, so a value just under a boundary + // rounds up into a unit the loop had already ruled out. This is the app's + // only byte formatter and the panel is full of near-boundary sizes. + expect(formatBytes(999_999)).toBe("1.0 MB"); + expect(formatBytes(999_999_999)).toBe("1.0 GB"); + expect(formatBytes(999_999_999_999)).toBe("1.0 TB"); + expect(formatBytes(1_048_575, { binary: true })).toBe("1.0 MB"); + + // Just below the rounding threshold it must NOT promote. + expect(formatBytes(999_949)).toBe("999.9 KB"); + expect(formatBytes(999_400, { precision: 0 })).toBe("999 KB"); + + // The top unit has nowhere to go: it renders a whole step rather than + // running off the end of the unit array. + expect(formatBytes(999_999_999_999_999_999)).toBe("1000.0 PB"); + }); + it("renders an em dash for a size the daemon did not compute", () => { // Docker reports -1 for "not calculated" on shared sizes and volume ref // counts. `NaN GB` in the middle of a table is worse than nothing. diff --git a/app/src/lib/formatBytes.ts b/app/src/lib/formatBytes.ts index 6c2b0a1..a66cb11 100644 --- a/app/src/lib/formatBytes.ts +++ b/app/src/lib/formatBytes.ts @@ -1,10 +1,14 @@ /** * The one byte formatter. * - * Before this existed the app had four of them — `projects/home/format.ts`, + * The app had four of them — `projects/home/format.ts`, * `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline * `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the - * unit labels and the precision. They are now expressed in terms of this. + * unit labels and the precision. The first two now delegate here. + * + * The other two deliberately do not, yet: `UpdateDialog` renders KB at + * `toFixed(0)`, so re-pointing it would change what a download size reads as, + * and neither is on the Disk panel's path. They are the remaining copies. * * ## Why the default is base 1000 * @@ -14,8 +18,12 @@ * same build cache would read as a bug in the panel. So decimal is the default * and binary is opt-in, rather than the other way round. * - * Both existing conventions are preserved exactly, so re-pointing the old - * call sites changed no rendered string: + * Both existing conventions are preserved for every size either call site can + * realistically produce — a file size or a payload size, i.e. a non-negative + * finite number below a terabyte. Outside that range this deliberately differs + * from what it replaced: a negative or `NaN` input now renders `—` rather than + * `-1 B` or `NaN GB`, and the unit ladder continues past GB instead of + * stopping there. * * - `{ }` → `41.0 MB` (decimal, what migration used) * - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style @@ -56,6 +64,17 @@ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): st value /= step; unit += 1; } + + // **Promote again if rounding pushed the value back up to a whole step.** + // `toFixed` runs after the loop, so 999,999 B divides to 999.999 KB and then + // renders as "1000.0 KB" — a unit the loop had already decided against. The + // same happens at every boundary (999,999,999 → "1000.0 MB", and 1,048,575 + // → "1024.0 KB" in binary). + if (unit < units.length - 1 && Number(value.toFixed(precision)) >= step) { + value /= step; + unit += 1; + } + // Whole bytes never get a decimal point: `512 B`, not `512.0 B`. return unit === 0 ? `${Math.round(bytes)} ${units[0]}` @@ -73,7 +92,7 @@ export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): s } /** - * `up to 12.3 GB` / `nothing` — for a bound rather than a measurement. + * `up to 12.3 GB` — for a bound rather than a measurement. * * The Disk panel is careful about this distinction: every figure it shows is * measured except a compaction's yield, which cannot be known until it runs. diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 5af5033..fa97cd5 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -836,8 +836,14 @@ export interface ProjectDiskRow { /** Bytes shared with another image — almost always the base. */ snapshot_shared_bytes: number; /** Layers stacked above the base image: **one per container recreation**. - * This is the number that explains why a snapshot grows. */ + * This is the number that explains why a snapshot grows — but only when + * `base_lineage_known` is true. Otherwise it counts the base's layers too. */ snapshot_commit_layers: number; + /** Whether the base image this snapshot descends from could be identified. + * False is the normal case for a project created before the + * `triple-c.base-image-id` label existed; the layer count must not be + * presented as a recreation count then. */ + base_lineage_known: boolean; /** Bytes those layers account for. `null` when the base image is gone and * the split cannot be measured — never a guess. */ snapshot_above_base_bytes: number | null; @@ -989,7 +995,11 @@ export interface ReclaimPlan { } export interface ReclaimResult { - target: ReclaimTarget; + /** The reclaim target this reports on, or `null` when it reports a destroy. + * Exactly one of `target` / `destroyed` is ever set — a destroy used to come + * back wearing a `ReclaimTarget` that named work it had not done. */ + target: ReclaimTarget | null; + destroyed: DestructiveTarget | null; ok: boolean; freed_bytes: number; /** What was projected beforehand, for the one action that projects. */