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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc