Report and retry Docker resources remove_project could not delete
Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-linux (pull_request) Successful in 6m5s
Build App (Preview) / build-macos (pull_request) Successful in 2m45s
Build App (Preview) / build-windows (pull_request) Successful in 5m42s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-linux (pull_request) Successful in 6m5s
Build App (Preview) / build-macos (pull_request) Successful in 2m45s
Build App (Preview) / build-windows (pull_request) Successful in 5m42s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
remove_project_volumes always returned Ok(()) regardless of what actually happened, making the `if let Err(e)` guarding it at every call site dead code. remove_project then dropped the project record unconditionally, so a volume, image or container that failed to delete became permanently unreachable — confirmed against a real orphaned volume pair found in the wild (fixes #31). remove_project_volumes/remove_snapshot_image/remove_container now report what they could not remove (treating "already gone" as success rather than a leftover), remove_project surfaces this to the user via a toast, and before dropping the project record it writes a pending-cleanup record that startup housekeeping retries automatically on the next launch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
This commit is contained in:
@@ -2,7 +2,7 @@ use tauri::{Emitter, State};
|
|||||||
|
|
||||||
use crate::commands::aws_commands;
|
use crate::commands::aws_commands;
|
||||||
use crate::docker;
|
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::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -696,7 +696,7 @@ pub async fn add_project(
|
|||||||
pub async fn remove_project(
|
pub async fn remove_project(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<ProjectRemovalReport, String> {
|
||||||
// **H-2: the only writer of these three categories that held nothing.**
|
// **H-2: the only writer of these three categories that held nothing.**
|
||||||
// This purges migration artifacts, removes `triple-c-snapshot-{id}` and
|
// This purges migration artifacts, removes `triple-c-snapshot-{id}` and
|
||||||
// both named volumes — and a compaction resolves that same tag when its
|
// 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.
|
// holding an entire snapshot image that nothing will ever reference again.
|
||||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||||
|
|
||||||
// Stop and remove container if it exists
|
// Stop and remove container if it exists. Everything named in `report`
|
||||||
if let Some(ref project) = state.projects_store.get(&project_id) {
|
// 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 {
|
if let Some(ref container_id) = project.container_id {
|
||||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
let _ = docker::stop_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
|
// 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
|
// Clean up the snapshot image + volumes
|
||||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
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 {
|
report.volumes = 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
|
// 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);
|
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]
|
#[tauri::command]
|
||||||
@@ -1265,8 +1380,19 @@ pub async fn rebuild_project_container(
|
|||||||
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||||
}
|
}
|
||||||
if let Err(e) = docker::remove_project_volumes(&project).await {
|
let leftover_volumes = docker::remove_project_volumes(&project).await;
|
||||||
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
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
|
// Start fresh. The locked variant, because `_guard` above is this project's
|
||||||
|
|||||||
@@ -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)",
|
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||||
container_id
|
container_id
|
||||||
);
|
);
|
||||||
docker
|
match docker
|
||||||
.remove_container(
|
.remove_container(
|
||||||
container_id,
|
container_id,
|
||||||
Some(RemoveContainerOptions {
|
Some(RemoveContainerOptions {
|
||||||
@@ -1945,7 +1945,17 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.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.
|
/// 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).
|
/// 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> {
|
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
remove_image_by_name(&get_snapshot_image_name(project)).await
|
||||||
let image_name = get_snapshot_image_name(project);
|
}
|
||||||
|
|
||||||
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(
|
.remove_image(
|
||||||
&image_name,
|
image_name,
|
||||||
Some(RemoveImageOptions {
|
Some(RemoveImageOptions {
|
||||||
force: true,
|
force: true,
|
||||||
noprune: false,
|
noprune: false,
|
||||||
@@ -3510,25 +3534,69 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
|
{
|
||||||
|
Ok(_) => {
|
||||||
log::info!("Removed snapshot image {}", image_name);
|
log::info!("Removed snapshot image {}", image_name);
|
||||||
Ok(())
|
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).
|
/// 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()?;
|
/// Returns the names of volumes that still exist afterwards — empty means
|
||||||
for vol in [
|
/// 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<String> {
|
||||||
|
remove_volumes_by_name(&[
|
||||||
home_volume_name(&project.id),
|
home_volume_name(&project.id),
|
||||||
config_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<String> {
|
||||||
|
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),
|
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
|
/// Check whether the existing container's configuration still matches the
|
||||||
|
|||||||
@@ -257,6 +257,10 @@ pub fn run() {
|
|||||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||||
}
|
}
|
||||||
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
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
|
// Auto-start web terminal server if enabled in settings
|
||||||
|
|||||||
@@ -422,6 +422,31 @@ pub enum ProjectStatus {
|
|||||||
Error,
|
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<String>,
|
||||||
|
/// The `triple-c-snapshot-{id}` image, if it could not be removed.
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// Named volumes (home, claude config) that could not be removed.
|
||||||
|
pub volumes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// Which AI model backend/provider the project uses.
|
||||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||||
@@ -650,6 +675,25 @@ impl Project {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 ─────────────────────────────────
|
// ── Custom environment variable names ─────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod migration_store;
|
pub mod migration_store;
|
||||||
|
pub mod pending_cleanup;
|
||||||
pub mod projects_store;
|
pub mod projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
pub mod settings_store;
|
pub mod settings_store;
|
||||||
|
|||||||
@@ -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 `<data_dir>/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<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub volumes: Vec<String>,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<data_dir>/triple-c/pending-cleanup`, created on demand.
|
||||||
|
fn 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("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<PathBuf, String> {
|
||||||
|
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<PendingCleanup> {
|
||||||
|
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::<PendingCleanup>(&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::<PendingCleanup>(&data) {
|
||||||
|
found.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
assert_eq!(found[0].project_id, "proj-2");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import type { ProjectRemovalReport } from "../../../lib/types";
|
||||||
import { useAppState } from "../../../store/appState";
|
import { useAppState } from "../../../store/appState";
|
||||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
import { useProjects } from "../../../hooks/useProjects";
|
import { useProjects } from "../../../hooks/useProjects";
|
||||||
@@ -30,6 +31,16 @@ const TABS = [
|
|||||||
|
|
||||||
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
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 {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -282,7 +293,14 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
setConfirmRemove(false);
|
setConfirmRemove(false);
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
useAppState.getState().pushToast({
|
useAppState.getState().pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ export function useProjects() {
|
|||||||
|
|
||||||
const remove = useCallback(
|
const remove = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await commands.removeProject(id);
|
const report = await commands.removeProject(id);
|
||||||
removeProjectFromList(id);
|
removeProjectFromList(id);
|
||||||
|
return report;
|
||||||
},
|
},
|
||||||
[removeProjectFromList],
|
[removeProjectFromList],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -13,7 +13,7 @@ export const listProjects = () => invoke<Project[]>("list_projects");
|
|||||||
export const addProject = (name: string, paths: ProjectPath[]) =>
|
export const addProject = (name: string, paths: ProjectPath[]) =>
|
||||||
invoke<Project>("add_project", { name, paths });
|
invoke<Project>("add_project", { name, paths });
|
||||||
export const removeProject = (projectId: string) =>
|
export const removeProject = (projectId: string) =>
|
||||||
invoke<void>("remove_project", { projectId });
|
invoke<ProjectRemovalReport>("remove_project", { projectId });
|
||||||
export const updateProject = (project: Project) =>
|
export const updateProject = (project: Project) =>
|
||||||
invoke<Project>("update_project", { project });
|
invoke<Project>("update_project", { project });
|
||||||
export const startProjectContainer = (projectId: string) =>
|
export const startProjectContainer = (projectId: string) =>
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ export type ProjectStatus =
|
|||||||
| "stopping"
|
| "stopping"
|
||||||
| "error";
|
| "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 =
|
export type Backend =
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "bedrock"
|
| "bedrock"
|
||||||
|
|||||||
Reference in New Issue
Block a user