diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index e1ba914..46ecbf9 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -81,12 +81,19 @@ pub async fn remove_project( state: State<'_, AppState>, ) -> Result<(), String> { // Stop and remove container if it exists - if let Some(project) = state.projects_store.get(&project_id) { + if let Some(ref project) = state.projects_store.get(&project_id) { if let Some(ref container_id) = project.container_id { state.exec_manager.close_sessions_for_container(container_id).await; let _ = docker::stop_container(container_id).await; let _ = docker::remove_container(container_id).await; } + // Clean up the snapshot image + volumes + if let Err(e) = docker::remove_snapshot_image(project).await { + log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); + } + if let Err(e) = docker::remove_project_volumes(project).await { + log::warn!("Failed to remove project volumes for project {}: {}", project_id, e); + } } // Clean up keychain secrets for this project @@ -153,25 +160,37 @@ pub async fn start_project_container( // AWS config path from global settings let aws_config_path = settings.global_aws.aws_config_path.clone(); - // Check for existing container let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { - let needs_recreation = docker::container_needs_recreation( - &existing_id, - &project, - settings.global_claude_instructions.as_deref(), - &settings.global_custom_env_vars, - settings.timezone.as_deref(), - ) - .await - .unwrap_or(false); - if needs_recreation { - log::info!("Container config changed, recreating container for project {}", project.id); + // Check if config changed — if so, snapshot + recreate + let needs_recreate = docker::container_needs_recreation( + &existing_id, + &project, + settings.global_claude_instructions.as_deref(), + &settings.global_custom_env_vars, + settings.timezone.as_deref(), + ).await.unwrap_or(false); + + if needs_recreate { + log::info!("Container config changed for project {} — committing snapshot and recreating", project.id); + // Snapshot the filesystem before destroying + if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await { + log::warn!("Failed to snapshot container before recreation: {}", e); + } let _ = docker::stop_container(&existing_id).await; docker::remove_container(&existing_id).await?; + + // Create from snapshot image (preserves system-level changes) + let snapshot_image = docker::get_snapshot_image_name(&project); + let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { + snapshot_image + } else { + image_name.clone() + }; + let new_id = docker::create_container( &project, &docker_socket, - &image_name, + &create_image, aws_config_path.as_deref(), &settings.global_aws, settings.global_claude_instructions.as_deref(), @@ -185,10 +204,21 @@ pub async fn start_project_container( existing_id } } else { + // Container doesn't exist (first start, or Docker pruned it). + // Check for a snapshot image first — it preserves system-level + // changes (apt/pip/npm installs) from the previous session. + let snapshot_image = docker::get_snapshot_image_name(&project); + let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { + log::info!("Creating container from snapshot image for project {}", project.id); + snapshot_image + } else { + image_name.clone() + }; + let new_id = docker::create_container( &project, &docker_socket, - &image_name, + &create_image, aws_config_path.as_deref(), &settings.global_aws, settings.global_claude_instructions.as_deref(), @@ -260,6 +290,14 @@ pub async fn rebuild_project_container( state.projects_store.set_container_id(&project_id, None)?; } + // Remove snapshot image + volumes so Reset creates from the clean base image + if let Err(e) = docker::remove_snapshot_image(&project).await { + log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); + } + if let Err(e) = docker::remove_project_volumes(&project).await { + log::warn!("Failed to remove project volumes for project {}: {}", project_id, e); + } + // Start fresh start_project_container(project_id, state).await } diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 02a850a..e9a108a 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -2,6 +2,7 @@ use bollard::container::{ Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, StartContainerOptions, StopContainerOptions, }; +use bollard::image::{CommitContainerOptions, RemoveImageOptions}; use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding}; use std::collections::HashMap; use std::collections::hash_map::DefaultHasher; @@ -367,7 +368,19 @@ pub async fn create_container( }); } - // Named volume for claude config persistence + // Named volume for the entire home directory — preserves ~/.claude.json, + // ~/.local (pip/npm globals), and any other user-level state across + // container stop/start cycles. + mounts.push(Mount { + target: Some("/home/claude".to_string()), + source: Some(format!("triple-c-home-{}", project.id)), + typ: Some(MountTypeEnum::VOLUME), + read_only: Some(false), + ..Default::default() + }); + + // Named volume for claude config persistence — mounted as a nested volume + // 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)), @@ -538,6 +551,83 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> { .map_err(|e| format!("Failed to remove container: {}", e)) } +/// Return the snapshot image name for a project. +pub fn get_snapshot_image_name(project: &Project) -> String { + format!("triple-c-snapshot-{}:latest", project.id) +} + +/// Commit the container's filesystem to a snapshot image so that system-level +/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container +/// removal. The Config is left empty so that secrets injected as env vars are +/// NOT baked into the image. +pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> { + let docker = get_docker()?; + let image_name = get_snapshot_image_name(project); + + // Parse repo:tag + let (repo, tag) = match image_name.rsplit_once(':') { + Some((r, t)) => (r.to_string(), t.to_string()), + None => (image_name.clone(), "latest".to_string()), + }; + + let options = CommitContainerOptions { + container: container_id.to_string(), + repo: repo.clone(), + tag: tag.clone(), + pause: true, + ..Default::default() + }; + + // Empty config — no env vars / cmd baked in + let config = Config:: { + ..Default::default() + }; + + docker + .commit_container(options, config) + .await + .map_err(|e| format!("Failed to commit container snapshot: {}", e))?; + + log::info!("Committed container {} as snapshot {}:{}", container_id, repo, tag); + Ok(()) +} + +/// Remove the snapshot image for a project (used on Reset / project removal). +pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> { + let docker = get_docker()?; + let image_name = get_snapshot_image_name(project); + + docker + .remove_image( + &image_name, + Some(RemoveImageOptions { + force: true, + noprune: false, + }), + None, + ) + .await + .map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?; + + log::info!("Removed snapshot image {}", image_name); + Ok(()) +} + +/// Remove both named volumes for a project (used on Reset / project removal). +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), + ] { + match docker.remove_volume(&vol, None).await { + Ok(_) => log::info!("Removed volume {}", vol), + Err(e) => log::warn!("Failed to remove volume {} (may not exist): {}", vol, e), + } + } + Ok(()) +} + /// Check whether the existing container's configuration still matches the /// current project settings. Returns `true` when the container must be /// recreated (mounts or env vars differ).