Two independent sets of fixes.
## Disk: stop the growth, no UI this round
The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.
* **Scrub the writable layer before every commit** (`docker/container.rs`,
`SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
are still free to drop is before the commit that captures them. Measured on
one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
`/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
`/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
mounts. Failure is a log line — a scrub must never block a snapshot.
* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
anywhere, so containers ran on the daemon's unbounded `json-file` default.
Deliberately *not* wired into `container_needs_recreation`: participating
would recreate every project once, and a recreation costs a commit, which is
the thing being fixed. Picked up on the next natural recreation.
* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
pair provably could not match one — ~11.9 GB observed stranded. Stamping
`triple-c.managed=true` is the whole fix; the sweep needed no change.
`create_container` writes the new `triple-c.base` key explicitly empty, or
Docker's label inheritance plus `docker commit` would make every snapshot
claim to be a base image. `force: false` stays, and now says why.
* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
(a probe pins an image the unforced sweep then refuses), pins second, sweep
last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
the report away — `reclaimed_bytes`, `failed` and `unavailable` included.
* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
age-reaped by scanning the tag pattern rather than trusting the state file —
`migration_store::load` reports an unparseable record as absent, which
stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
record aside so `has_record` is trustworthy. A pin whose migration is still
awaiting confirmation is never reaped at any age. The probe container's
removal was a plain statement after an await, so a dropped future (an app quit
mid-migration) leaked a container pinning a multi-gigabyte image; it is a
`Drop` guard now, with `reap_probe_containers` for the case where the process
itself dies.
* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
directory, and the task runner appended uncapped `claude -p` output.
* **Fix the delete copy.** It said "the container, config volume, and stored
credentials"; it removes *both* volumes and the snapshot image.
No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.
## Claude Code settings: two invented keys, one inverted default, one sticky bug
Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.
* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
`CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
could never turn the recap *off*. The field is renamed to
`session_recap_disabled` rather than reused: reusing the name with the
opposite meaning would have read every stored `enable_session_recap: false` —
which is every project that never touched the control — as "the user turned
this off".
* **The stickiness, which is the important one.** Keys were emitted only when
non-default, and the entrypoint *merges* into a settings.json on a persisted
volume, so switching a setting off omitted its key, the merge preserved the
stale on-value, and the setting stayed on until a destructive Reset. The fix
already existed in the same file — the sandbox block is emitted
unconditionally for exactly this reason — and is now applied to all five keys.
A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
`awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
because a stand-in value is not neutral: `tui: "default"` pins the classic
renderer where unset lets Claude Code choose, and `viewMode: "default"`
overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
bakes container env into the snapshot image, so a `=1` written once rode it
forever. All four are now emitted on every create, extracted into
`claude_code_env_vars` and unit tested. Two use an empty value for "off"
rather than `0`, because they outrank a setting the user can change from
inside their own container and Triple-C's default must not overrule a
`/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
which the always-emitted key makes both necessary and possible.
`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.
Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
147 lines
5.9 KiB
Rust
147 lines
5.9 KiB
Rust
//! Host-side persistence for in-flight container base-image migrations.
|
||
//!
|
||
//! One JSON file per project under `<data_dir>/triple-c/migrations/`, written
|
||
//! with the same write-temp-then-rename dance as `projects.json` so a crash can
|
||
//! never leave a half-written state file. The staged verbatim payload tar lives
|
||
//! in the same directory.
|
||
//!
|
||
//! This is deliberately *not* part of `projects.json`: a migration is transient
|
||
//! and a migration record must survive independently of a project save racing
|
||
//! it. It is also the crash record — see
|
||
//! [`crate::models::MigrationState`] for the phase table.
|
||
|
||
use std::fs;
|
||
use std::path::PathBuf;
|
||
|
||
use crate::models::MigrationState;
|
||
|
||
/// `<data_dir>/triple-c/migrations`, created on demand.
|
||
pub fn migrations_dir() -> Result<PathBuf, String> {
|
||
let dir = dirs::data_dir()
|
||
.ok_or_else(|| {
|
||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||
})?
|
||
.join("triple-c")
|
||
.join("migrations");
|
||
fs::create_dir_all(&dir)
|
||
.map_err(|e| format!("Failed to create migrations directory: {}", e))?;
|
||
Ok(dir)
|
||
}
|
||
|
||
fn state_path(project_id: &str) -> Result<PathBuf, String> {
|
||
Ok(migrations_dir()?.join(format!("{}.json", sanitize(project_id))))
|
||
}
|
||
|
||
/// Host path for a project's staged verbatim payload.
|
||
pub fn staging_path(project_id: &str) -> Result<PathBuf, String> {
|
||
Ok(migrations_dir()?.join(format!("{}-payload.tar", sanitize(project_id))))
|
||
}
|
||
|
||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||
/// the write anywhere but the migrations directory.
|
||
fn sanitize(project_id: &str) -> String {
|
||
project_id
|
||
.chars()
|
||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||
.collect()
|
||
}
|
||
|
||
/// Read a project's migration state. `Ok(None)` means no migration is in
|
||
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||
/// blocking every future migration on a corrupt record.
|
||
///
|
||
/// **A corrupt record is moved aside, not merely ignored.** Reporting "absent"
|
||
/// while leaving the file in place strands the rollback pin it describes: the
|
||
/// `:pre-migration-*` tag holding a 4–12 GB image stays on disk, no code path
|
||
/// can find it again (every one of them starts here and is told there is no
|
||
/// migration), and the leftover file goes on making the project look like it
|
||
/// has a migration in flight to anything that checks for the file rather than
|
||
/// parsing it — including [`has_record`], which the pin reaper relies on.
|
||
/// Renaming to `.bak` follows the same convention as `projects.json`: the
|
||
/// user's bytes are kept, but they stop pinning gigabytes.
|
||
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||
let path = state_path(project_id)?;
|
||
if !path.exists() {
|
||
return Ok(None);
|
||
}
|
||
let data = fs::read_to_string(&path)
|
||
.map_err(|e| format!("Failed to read migration state: {}", e))?;
|
||
match serde_json::from_str::<MigrationState>(&data) {
|
||
Ok(state) => Ok(Some(state)),
|
||
Err(e) => {
|
||
let backup = path.with_extension("json.bak");
|
||
let moved = fs::rename(&path, &backup);
|
||
log::error!(
|
||
"Failed to parse migration state for project {}: {} — treating as absent{}",
|
||
project_id,
|
||
e,
|
||
match moved {
|
||
Ok(()) => format!(" and moved the record to {}", backup.display()),
|
||
Err(ref e) => format!(" (could not move the record aside: {})", e),
|
||
}
|
||
);
|
||
Ok(None)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether a project has a migration record on disk *at all*, without parsing
|
||
/// it.
|
||
///
|
||
/// The pin reaper needs "is this project's rollback image still somebody's only
|
||
/// copy?" and must answer it conservatively. [`load`] cannot be used for that
|
||
/// question on its own — it deliberately reports a corrupt record as absent —
|
||
/// so this asks the filesystem instead. `load` moving a corrupt record aside is
|
||
/// what keeps the two answers from disagreeing forever.
|
||
pub fn has_record(project_id: &str) -> Result<bool, String> {
|
||
Ok(state_path(project_id)?.exists())
|
||
}
|
||
|
||
/// Atomically write a project's migration state.
|
||
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||
let path = state_path(project_id)?;
|
||
let data = serde_json::to_string_pretty(state)
|
||
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
||
let tmp = path.with_extension("json.tmp");
|
||
fs::write(&tmp, data).map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Remove a project's migration state file. Missing is success.
|
||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||
let path = state_path(project_id)?;
|
||
match fs::remove_file(&path) {
|
||
Ok(()) => Ok(()),
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||
Err(e) => Err(format!("Failed to remove migration state: {}", e)),
|
||
}
|
||
}
|
||
|
||
/// Remove a project's staged payload. Missing is success.
|
||
pub fn clear_staging(project_id: &str) -> Result<(), String> {
|
||
let path = staging_path(project_id)?;
|
||
match fs::remove_file(&path) {
|
||
Ok(()) => Ok(()),
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||
Err(e) => Err(format!("Failed to remove staged migration payload: {}", e)),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn project_ids_cannot_escape_the_migrations_directory() {
|
||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||
assert_eq!(sanitize("a/b"), "a_b");
|
||
// The real shape — a UUID — must survive untouched, or state files
|
||
// would move the first time this function changed.
|
||
assert_eq!(
|
||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||
);
|
||
}
|
||
}
|