Add a Disk section: see where the bytes went, and get them back
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
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user