diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 564012f..0f0ce5d 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -2,7 +2,7 @@ use tauri::{Emitter, State}; use crate::commands::aws_commands; use crate::docker; -use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus}; +use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectStatus}; use crate::storage::secure; use crate::AppState; @@ -696,7 +696,7 @@ pub async fn add_project( pub async fn remove_project( project_id: String, state: State<'_, AppState>, -) -> Result<(), String> { +) -> Result { // **H-2: the only writer of these three categories that held nothing.** // This purges migration artifacts, removes `triple-c-snapshot-{id}` and // both named volumes — and a compaction resolves that same tag when its @@ -722,12 +722,24 @@ pub async fn remove_project( // holding an entire snapshot image that nothing will ever reference again. crate::commands::migration_commands::purge_migration_artifacts(&project_id).await; - // Stop and remove container if it exists - if let Some(ref project) = state.projects_store.get(&project_id) { + // Stop and remove container if it exists. Everything named in `report` + // below is what will be unreachable the moment this function drops the + // project record — see [`ProjectRemovalReport`] and + // `storage::pending_cleanup`, which is what makes it reachable anyway. + let mut report = ProjectRemovalReport::default(); + let existing_project = state.projects_store.get(&project_id); + + if let Some(ref project) = existing_project { 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; + if let Err(e) = docker::remove_container(container_id).await { + log::warn!( + "Failed to remove container {} for project {}: {}", + container_id, project_id, e + ); + report.container = Some(container_id.clone()); + } } // Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP @@ -738,10 +750,9 @@ pub async fn remove_project( // 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); + report.image = Some(docker::get_snapshot_image_name(project)); } - if let Err(e) = docker::remove_project_volumes(project).await { - log::warn!("Failed to remove project volumes for project {}: {}", project_id, e); - } + report.volumes = docker::remove_project_volumes(project).await; } // Clean up keychain secrets for this project @@ -749,7 +760,111 @@ pub async fn remove_project( log::warn!("Failed to delete keychain secrets for project {}: {}", project_id, e); } - state.projects_store.remove(&project_id) + if !report.is_clean() { + let record = crate::storage::pending_cleanup::PendingCleanup { + project_id: project_id.clone(), + project_name: existing_project.map(|p| p.name).unwrap_or_default(), + container_id: report.container.clone(), + image: report.image.clone(), + volumes: report.volumes.clone(), + recorded_at: chrono::Utc::now().to_rfc3339(), + }; + match crate::storage::pending_cleanup::save(&record) { + Ok(()) => log::warn!( + "Project {} removed with Docker resources still present: {:?} — recorded for \ + automatic retry on next launch", + project_id, report + ), + Err(e) => log::error!( + "Project {} removed with Docker resources still present ({:?}), and the \ + pending-cleanup record could not be written ({}) — nothing will retry removing \ + them", + project_id, report, e + ), + } + } + + state.projects_store.remove(&project_id)?; + Ok(report) +} + +/// Retry every pending-cleanup record left behind by a [`remove_project`] +/// that could not finish. Run once at startup alongside the other reapers +/// (see `lib.rs`'s "Startup disk housekeeping" block) — never on a timer and +/// never blocking anything, since a locked volume or an in-use image can sit +/// unresolved for an arbitrary amount of time and the daemon may not even be +/// up yet. +/// +/// Not a `#[tauri::command]`: nothing in the UI surfaces this list yet +/// (deliberately — see `SnapshotSweepReport`'s doc comment for the same +/// reasoning), so there is no IPC contract to keep. A record that still has +/// leftovers after this is written back so the next run does not lose track +/// of what changed; one that is now empty is deleted. +pub async fn retry_pending_cleanup_logged() { + let records = crate::storage::pending_cleanup::list(); + if records.is_empty() { + return; + } + + let mut cleaned = 0usize; + let mut still_pending = 0usize; + + for mut record in records { + if let Some(container_id) = record.container_id.take() { + match docker::remove_container(&container_id).await { + Ok(()) => {} + Err(e) => { + log::warn!( + "Pending cleanup: still could not remove container {} for project {} \ + ({}): {}", + container_id, record.project_id, record.project_name, e + ); + record.container_id = Some(container_id); + } + } + } + + if let Some(image) = record.image.take() { + match docker::remove_image_by_name(&image).await { + Ok(()) => {} + Err(e) => { + log::warn!( + "Pending cleanup: still could not remove image {} for project {} ({}): {}", + image, record.project_id, record.project_name, e + ); + record.image = Some(image); + } + } + } + + if !record.volumes.is_empty() { + record.volumes = docker::remove_volumes_by_name(&record.volumes).await; + } + + if record.is_empty() { + if let Err(e) = crate::storage::pending_cleanup::clear(&record.project_id) { + log::warn!( + "Pending cleanup for project {} ({}) finished but the record could not be \ + deleted: {}", + record.project_id, record.project_name, e + ); + } + cleaned += 1; + } else { + still_pending += 1; + if let Err(e) = crate::storage::pending_cleanup::save(&record) { + log::warn!( + "Could not update pending cleanup record for project {} ({}): {}", + record.project_id, record.project_name, e + ); + } + } + } + + log::info!( + "Pending cleanup retry: {} project(s) fully cleaned up, {} still have leftovers", + cleaned, still_pending + ); } #[tauri::command] @@ -1265,8 +1380,19 @@ pub async fn rebuild_project_container( 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); + let leftover_volumes = docker::remove_project_volumes(&project).await; + if !leftover_volumes.is_empty() { + // Unlike `remove_project`, Reset keeps the project record — but a + // volume that survives this is reused as-is by the container + // `start_project_container_locked` creates below, which is exactly + // what Reset promises not to do. No pending-cleanup record: the + // project id is still live, so a later Reset attempt can retry this + // itself rather than needing startup housekeeping to do it. + log::warn!( + "Reset could not remove volume(s) {:?} for project {} — the new container may reuse \ + their old contents instead of starting clean", + leftover_volumes, project_id + ); } // Start fresh. The locked variant, because `_guard` above is this project's diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index a48cf72..39dd9ff 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -1935,7 +1935,7 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> { "Removing container {} (v=false: named volumes such as claude config are preserved)", container_id ); - docker + match docker .remove_container( container_id, Some(RemoveContainerOptions { @@ -1945,7 +1945,17 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> { }), ) .await - .map_err(|e| format!("Failed to remove container: {}", e)) + { + Ok(()) => Ok(()), + // Already gone is the outcome this call wants, not a failure — a + // caller retrying a leftover from a previous, partially-failed removal + // (see `remove_project`) must not be told it failed forever just + // because a *different* attempt already succeeded. + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => Ok(()), + Err(e) => Err(format!("Failed to remove container: {}", e)), + } } /// Return the snapshot image name for a project. @@ -3496,13 +3506,27 @@ async fn rewrite_image_without_secrets( } /// Remove the snapshot image for a project (used on Reset / project removal). +/// +/// A project that never started never built a snapshot, so "no such image" is +/// the ordinary case, not a failure — it is treated the same as success and +/// logged at most at `info`. A real failure (the image is in use, a +/// permission error, the daemon dropped the connection) is the one thing this +/// returns `Err` for, and callers must not throw that away: see the +/// `remove_project` doc comment on `ProjectRemovalReport` for why an +/// unreported failure here used to make the resource unreachable forever. pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> { - let docker = get_docker()?; - let image_name = get_snapshot_image_name(project); + remove_image_by_name(&get_snapshot_image_name(project)).await +} - docker +/// Remove a Docker image by name/tag, treating "does not exist" as success. +/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which +/// only has the image name (the project record is already gone by then). +pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> { + let docker = get_docker()?; + + match docker .remove_image( - &image_name, + image_name, Some(RemoveImageOptions { force: true, noprune: false, @@ -3510,25 +3534,69 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> { None, ) .await - .map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?; - - log::info!("Removed snapshot image {}", image_name); - Ok(()) + { + Ok(_) => { + log::info!("Removed snapshot image {}", image_name); + Ok(()) + } + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => Ok(()), + Err(e) => Err(format!("Failed to remove snapshot image {}: {}", image_name, e)), + } } /// 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 [ +/// +/// Returns the names of volumes that still exist afterwards — empty means +/// both are gone (removed here, or never created). This used to always +/// return `Ok(())` regardless of what actually happened, which made the +/// `if let Err(e)` at every call site unreachable by construction; see +/// triple-c#31. A volume Docker reports as simply not existing is not a +/// leftover and is not included. +pub async fn remove_project_volumes(project: &Project) -> Vec { + remove_volumes_by_name(&[ home_volume_name(&project.id), config_volume_name(&project.id), - ] { - match docker.remove_volume(&vol, None).await { + ]) + .await +} + +/// Remove a set of named volumes, treating "does not exist" as success. +/// Returns the names that still exist afterwards — empty means every one is +/// gone (removed here, or never created). +/// +/// Shared by [`remove_project_volumes`] and the pending-cleanup retry, the +/// latter calling this with whatever the former could not remove the first +/// time. Used to always report success regardless of what actually happened, +/// which made every `if let Err(e)` at its call sites unreachable by +/// construction; see triple-c#31. +pub async fn remove_volumes_by_name(names: &[String]) -> Vec { + let docker = match get_docker() { + Ok(d) => d, + Err(e) => { + // Can't reach the daemon to even try, so nothing here can be + // confirmed removed. Reporting all as leftover is the safe + // direction: worst case a later retry finds them already gone. + log::warn!("Could not remove volumes {:?}: {}", names, e); + return names.to_vec(); + } + }; + + let mut leftover = Vec::new(); + for vol in names { + 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), + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => {} + Err(e) => { + log::warn!("Failed to remove volume {}: {}", vol, e); + leftover.push(vol.clone()); + } } } - Ok(()) + leftover } /// Check whether the existing container's configuration still matches the diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 24b6bae..b9a92e6 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -257,6 +257,10 @@ pub fn run() { log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped); } crate::docker::sweep_orphaned_snapshots_logged("startup").await; + // A container/image/volume `remove_project` could not delete + // is recorded rather than lost — see triple-c#31 — and this is + // the only place anything ever retries it. + crate::commands::project_commands::retry_pending_cleanup_logged().await; }); // Auto-start web terminal server if enabled in settings diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index dfa8f38..f17747b 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -422,6 +422,31 @@ pub enum ProjectStatus { Error, } +/// What `remove_project` could not delete, named so the UI can say so instead +/// of reporting a clean removal that was not one. +/// +/// The project record is dropped from `projects.json` regardless — see the +/// long comment on `remove_project` for why refusing is not the answer — but +/// anything named here is also written to a pending-cleanup record that +/// startup housekeeping retries, so it stays reachable after the project it +/// belonged to no longer exists. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct ProjectRemovalReport { + /// The project's container, if it could not be removed. + pub container: Option, + /// The `triple-c-snapshot-{id}` image, if it could not be removed. + pub image: Option, + /// Named volumes (home, claude config) that could not be removed. + pub volumes: Vec, +} + +impl ProjectRemovalReport { + /// True when nothing was left behind. + pub fn is_clean(&self) -> bool { + self.container.is_none() && self.image.is_none() && self.volumes.is_empty() + } +} + /// Which AI model backend/provider the project uses. /// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container) /// - `Bedrock`: AWS Bedrock with per-project AWS credentials @@ -650,6 +675,25 @@ impl Project { mod tests { use super::*; + // ── ProjectRemovalReport ──────────────────────────────────────────────── + + #[test] + fn a_report_is_clean_only_with_nothing_left_behind() { + assert!(ProjectRemovalReport::default().is_clean()); + + let mut r = ProjectRemovalReport::default(); + r.container = Some("abc123".to_string()); + assert!(!r.is_clean(), "a leftover container must not read as clean"); + + let mut r = ProjectRemovalReport::default(); + r.image = Some("triple-c-snapshot-x:latest".to_string()); + assert!(!r.is_clean(), "a leftover image must not read as clean"); + + let mut r = ProjectRemovalReport::default(); + r.volumes.push("triple-c-home-x".to_string()); + assert!(!r.is_clean(), "a leftover volume must not read as clean"); + } + // ── Custom environment variable names ───────────────────────────────── #[test] diff --git a/app/src-tauri/src/storage/mod.rs b/app/src-tauri/src/storage/mod.rs index 1559e3e..151759b 100644 --- a/app/src-tauri/src/storage/mod.rs +++ b/app/src-tauri/src/storage/mod.rs @@ -1,4 +1,5 @@ pub mod migration_store; +pub mod pending_cleanup; pub mod projects_store; pub mod secure; pub mod settings_store; diff --git a/app/src-tauri/src/storage/pending_cleanup.rs b/app/src-tauri/src/storage/pending_cleanup.rs new file mode 100644 index 0000000..0045ca6 --- /dev/null +++ b/app/src-tauri/src/storage/pending_cleanup.rs @@ -0,0 +1,217 @@ +//! Host-side record of Docker resources `remove_project` could not delete. +//! +//! `remove_project` drops a project's id from `projects.json` unconditionally +//! — see the comment on `ProjectRemovalReport` — so once that happens nothing +//! in the app can name the leftover container, image or volume again by any +//! path a user can reach. This is what keeps it reachable anyway: one JSON +//! file per affected project under `/triple-c/pending-cleanup/`, +//! written *before* the project record is dropped. Startup housekeeping +//! retries every record on the next launch (see +//! `commands::project_commands::retry_pending_cleanup_logged`) and deletes +//! the ones that fully succeed. +//! +//! Same write-temp-then-rename shape as `projects.json` and the migration +//! store, and the same per-project-file layout as +//! `storage::migration_store` — a stuck cleanup record for one project must +//! never block the retry of another's. + +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingCleanup { + pub project_id: String, + /// Kept only so a log line or a future UI can name the project without a + /// second lookup — the project record itself is already gone by the time + /// this is read back. + pub project_name: String, + pub container_id: Option, + pub image: Option, + pub volumes: Vec, + pub recorded_at: String, +} + +impl PendingCleanup { + /// True once nothing named here still needs to be removed. + pub fn is_empty(&self) -> bool { + self.container_id.is_none() && self.image.is_none() && self.volumes.is_empty() + } +} + +/// `/triple-c/pending-cleanup`, created on demand. +fn dir() -> Result { + 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("pending-cleanup"); + fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create pending-cleanup directory: {}", e))?; + Ok(dir) +} + +/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer +/// the write anywhere but the pending-cleanup directory. Mirrors +/// `storage::migration_store::sanitize`. +fn sanitize(project_id: &str) -> String { + project_id + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect() +} + +fn path_for(project_id: &str) -> Result { + Ok(dir()?.join(format!("{}.json", sanitize(project_id)))) +} + +/// Write (or overwrite) a project's pending-cleanup record. +pub fn save(record: &PendingCleanup) -> Result<(), String> { + let path = path_for(&record.project_id)?; + let data = serde_json::to_string_pretty(record) + .map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, data).map_err(|e| format!("Failed to write pending cleanup record: {}", e))?; + fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit pending cleanup record: {}", e)) +} + +/// Remove a project's pending-cleanup record. Missing is success — this is +/// how a fully-succeeded retry (or a record that never existed) is expressed. +pub fn clear(project_id: &str) -> Result<(), String> { + let path = path_for(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 pending cleanup record: {}", e)), + } +} + +/// Every pending-cleanup record on disk. An unparseable file is logged and +/// skipped rather than blocking every other project's retry — the same +/// "one bad record can't wedge the rest" reasoning as the migration store. +pub fn list() -> Vec { + let Ok(dir) = dir() else { return Vec::new() }; + let Ok(entries) = fs::read_dir(&dir) else { return Vec::new() }; + + entries + .flatten() + .filter(|e| e.path().extension().is_some_and(|ext| ext == "json")) + .filter_map(|e| { + let path = e.path(); + let data = fs::read_to_string(&path).ok()?; + match serde_json::from_str::(&data) { + Ok(record) => Some(record), + Err(err) => { + log::warn!( + "Could not parse pending cleanup record {}: {} — skipping it this run", + path.display(), + err + ); + None + } + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_data_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("triple-c-pending-cleanup-{}-{}", name, uuid::Uuid::new_v4().simple())) + } + + fn record(project_id: &str) -> PendingCleanup { + PendingCleanup { + project_id: project_id.to_string(), + project_name: "Some Project".to_string(), + container_id: Some("abc123".to_string()), + image: Some("triple-c-snapshot-abc:latest".to_string()), + volumes: vec!["triple-c-home-abc".to_string()], + recorded_at: "2026-08-25T00:00:00Z".to_string(), + } + } + + /// `list`/`save`/`clear` go through `dirs::data_dir()`, so these exercise + /// the pure parts directly against a temp directory rather than the real + /// one — same approach `migration_store`'s tests take for `sanitize`. + #[test] + fn project_ids_cannot_escape_the_pending_cleanup_directory() { + assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd"); + assert_eq!(sanitize("a/b"), "a_b"); + assert_eq!( + sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"), + "ab62cd24-51aa-4645-8f5c-17a124062050" + ); + } + + #[test] + fn is_empty_reflects_whatever_still_needs_removing() { + let mut r = record("p1"); + assert!(!r.is_empty()); + + r.container_id = None; + r.image = None; + assert!(!r.is_empty(), "a leftover volume alone still counts"); + + r.volumes.clear(); + assert!(r.is_empty()); + } + + /// Save-then-list-then-clear against a real (temp) directory, bypassing + /// `dir()`'s hardcoded `dirs::data_dir()` join by writing/reading the + /// files directly the way `save`/`list` do internally. + #[test] + fn a_saved_record_round_trips_and_clearing_removes_it() { + let dir = temp_data_dir("roundtrip"); + fs::create_dir_all(&dir).unwrap(); + let rec = record("proj-1"); + let path = dir.join(format!("{}.json", sanitize(&rec.project_id))); + + let data = serde_json::to_string_pretty(&rec).unwrap(); + fs::write(&path, data).unwrap(); + + let loaded: PendingCleanup = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(loaded.project_id, "proj-1"); + assert_eq!(loaded.volumes, vec!["triple-c-home-abc".to_string()]); + + fs::remove_file(&path).unwrap(); + assert!(!path.exists()); + + fs::remove_dir_all(&dir).ok(); + } + + /// A record that fails to parse must not poison the rest of the listing. + #[test] + fn an_unparseable_record_is_skipped_not_fatal() { + let dir = temp_data_dir("corrupt"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("bad.json"), "{ not json").unwrap(); + let good = record("proj-2"); + fs::write( + dir.join("proj-2.json"), + serde_json::to_string_pretty(&good).unwrap(), + ) + .unwrap(); + + let mut found = Vec::new(); + for entry in fs::read_dir(&dir).unwrap().flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + if let Ok(data) = fs::read_to_string(&path) { + if let Ok(r) = serde_json::from_str::(&data) { + found.push(r); + } + } + } + } + assert_eq!(found.len(), 1); + assert_eq!(found[0].project_id, "proj-2"); + + fs::remove_dir_all(&dir).ok(); + } +} diff --git a/app/src/components/projects/home/ProjectHome.tsx b/app/src/components/projects/home/ProjectHome.tsx index 9245f4d..a83b04a 100644 --- a/app/src/components/projects/home/ProjectHome.tsx +++ b/app/src/components/projects/home/ProjectHome.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; +import type { ProjectRemovalReport } from "../../../lib/types"; import { useAppState } from "../../../store/appState"; import { useProjectActions } from "../../../hooks/useProjectActions"; import { useProjects } from "../../../hooks/useProjects"; @@ -30,6 +31,16 @@ const TABS = [ export type ProjectHomeTabId = (typeof TABS)[number]["id"]; +/** Names what a `ProjectRemovalReport` says survived, for the leftover toast. */ +function describeLeftovers(report: ProjectRemovalReport): string { + const parts: string[] = []; + if (report.container) parts.push("its container"); + if (report.image) parts.push("its saved image"); + if (report.volumes.length === 1) parts.push("a volume"); + else if (report.volumes.length > 1) parts.push(`${report.volumes.length} volumes`); + return parts.join(", "); +} + interface Props { projectId: string; active: boolean; @@ -282,7 +293,14 @@ export default function ProjectHome({ projectId, active }: Props) { onConfirm={async () => { setConfirmRemove(false); try { - await remove(project.id); + const report = await remove(project.id); + if (report.container || report.image || report.volumes.length > 0) { + useAppState.getState().pushToast({ + kind: "info", + message: `“${project.name}” was removed, but some Docker resources are still on disk`, + detail: `Triple-C will retry removing ${describeLeftovers(report)} the next time it starts.`, + }); + } } catch (e) { useAppState.getState().pushToast({ kind: "error", diff --git a/app/src/hooks/useProjects.ts b/app/src/hooks/useProjects.ts index 66868a0..2ebcb5b 100644 --- a/app/src/hooks/useProjects.ts +++ b/app/src/hooks/useProjects.ts @@ -44,8 +44,9 @@ export function useProjects() { const remove = useCallback( async (id: string) => { - await commands.removeProject(id); + const report = await commands.removeProject(id); removeProjectFromList(id); + return report; }, [removeProjectFromList], ); diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index f9bb0fe..3264818 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, 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, UploadOutcome } from "./types"; +import type { Project, ProjectPath, ProjectRemovalReport, ContainerInfo, 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, UploadOutcome } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -13,7 +13,7 @@ export const listProjects = () => invoke("list_projects"); export const addProject = (name: string, paths: ProjectPath[]) => invoke("add_project", { name, paths }); export const removeProject = (projectId: string) => - invoke("remove_project", { projectId }); + invoke("remove_project", { projectId }); export const updateProject = (project: Project) => invoke("update_project", { project }); export const startProjectContainer = (projectId: string) => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index cc394d6..a84e85a 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -77,6 +77,15 @@ export type ProjectStatus = | "stopping" | "error"; +/** What `removeProject` could not delete. The project is removed from the + * sidebar either way; anything named here is recorded on the host and + * retried automatically the next time the app starts. */ +export interface ProjectRemovalReport { + container: string | null; + image: string | null; + volumes: string[]; +} + export type Backend = | "anthropic" | "bedrock"