Compare commits

...

3 Commits

Author SHA1 Message Date
1aced2d860 feat: add progress feedback during slow container starts
All checks were successful
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m17s
Build App / build-linux (push) Successful in 7m23s
Sync Release to GitHub / sync-release (release) Successful in 2s
Emit container-progress events from Rust at key milestones (checking
image, saving state, recreating, starting, stopping) and display them
in ProjectCard instead of the static "starting.../stopping..." text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:43:01 -08:00
652e451afe fix: prevent projects from getting stuck in starting/stopping state
All checks were successful
Build App / build-macos (push) Successful in 2m21s
Build App / build-linux (push) Successful in 5m46s
Sync Release to GitHub / sync-release (release) Successful in 1s
Build App / build-windows (push) Successful in 6m35s
Reconcile stale transient statuses on app startup, add Force Stop button
for transient states, and harden stop_project_container error handling so
Docker failures don't leave projects permanently stuck.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:12:49 -08:00
eb86aa95b7 fix: persist full container state across stop/start and config-change recreation
All checks were successful
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 2m29s
Build App / build-linux (push) Successful in 4m34s
Sync Release to GitHub / sync-release (release) Successful in 1s
- Add home volume (triple-c-home-{id}) for /home/claude to persist
  .claude.json, .local, and other user-level state across restarts
- Add docker commit before recreation: when container_needs_recreation()
  triggers, snapshot the container to preserve system-level changes
  (apt/pip/npm installs), then create the new container from that snapshot
- On Reset/removal: delete snapshot image + both volumes for clean slate
- Remove commit from stop_project_container (stop/start preserves the
  writable layer naturally; no commit needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:16:19 -08:00
4 changed files with 225 additions and 28 deletions

View File

@@ -1,10 +1,20 @@
use tauri::State; use tauri::{Emitter, State};
use crate::docker; use crate::docker;
use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus}; use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
use crate::storage::secure; use crate::storage::secure;
use crate::AppState; use crate::AppState;
fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
let _ = app_handle.emit(
"container-progress",
serde_json::json!({
"project_id": project_id,
"message": message,
}),
);
}
/// Extract secret fields from a project and store them in the OS keychain. /// Extract secret fields from a project and store them in the OS keychain.
fn store_secrets_for_project(project: &Project) -> Result<(), String> { fn store_secrets_for_project(project: &Project) -> Result<(), String> {
if let Some(ref token) = project.git_token { if let Some(ref token) = project.git_token {
@@ -81,12 +91,19 @@ pub async fn remove_project(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
// Stop and remove container if it exists // 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 { 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; 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 // Clean up keychain secrets for this project
@@ -109,6 +126,7 @@ pub async fn update_project(
#[tauri::command] #[tauri::command]
pub async fn start_project_container( pub async fn start_project_container(
project_id: String, project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Project, String> { ) -> Result<Project, String> {
let mut project = state let mut project = state
@@ -140,6 +158,7 @@ pub async fn start_project_container(
// Wrap container operations so that any failure resets status to Stopped. // Wrap container operations so that any failure resets status to Stopped.
let result: Result<String, String> = async { let result: Result<String, String> = async {
// Ensure image exists // Ensure image exists
emit_progress(&app_handle, &project_id, "Checking image...");
if !docker::image_exists(&image_name).await? { if !docker::image_exists(&image_name).await? {
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name)); return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
} }
@@ -153,48 +172,77 @@ pub async fn start_project_container(
// AWS config path from global settings // AWS config path from global settings
let aws_config_path = settings.global_aws.aws_config_path.clone(); 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 container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
let needs_recreation = docker::container_needs_recreation( // Check if config changed — if so, snapshot + recreate
&existing_id, let needs_recreate = docker::container_needs_recreation(
&project, &existing_id,
settings.global_claude_instructions.as_deref(), &project,
&settings.global_custom_env_vars, settings.global_claude_instructions.as_deref(),
settings.timezone.as_deref(), &settings.global_custom_env_vars,
) settings.timezone.as_deref(),
.await ).await.unwrap_or(false);
.unwrap_or(false);
if needs_recreation { if needs_recreate {
log::info!("Container config changed, recreating container for project {}", project.id); log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
// Snapshot the filesystem before destroying
emit_progress(&app_handle, &project_id, "Saving container state...");
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
log::warn!("Failed to snapshot container before recreation: {}", e);
}
emit_progress(&app_handle, &project_id, "Recreating container...");
let _ = docker::stop_container(&existing_id).await; let _ = docker::stop_container(&existing_id).await;
docker::remove_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( let new_id = docker::create_container(
&project, &project,
&docker_socket, &docker_socket,
&image_name, &create_image,
aws_config_path.as_deref(), aws_config_path.as_deref(),
&settings.global_aws, &settings.global_aws,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
).await?; ).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
new_id new_id
} else { } else {
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&existing_id).await?; docker::start_container(&existing_id).await?;
existing_id existing_id
} }
} else { } 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()
};
emit_progress(&app_handle, &project_id, "Creating container...");
let new_id = docker::create_container( let new_id = docker::create_container(
&project, &project,
&docker_socket, &docker_socket,
&image_name, &create_image,
aws_config_path.as_deref(), aws_config_path.as_deref(),
&settings.global_aws, &settings.global_aws,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
).await?; ).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
new_id new_id
}; };
@@ -222,6 +270,7 @@ pub async fn start_project_container(
#[tauri::command] #[tauri::command]
pub async fn stop_project_container( pub async fn stop_project_container(
project_id: String, project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
let project = state let project = state
@@ -229,22 +278,26 @@ pub async fn stop_project_container(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
if let Some(ref container_id) = project.container_id { state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
if let Some(ref container_id) = project.container_id {
// Close exec sessions for this project // Close exec sessions for this project
emit_progress(&app_handle, &project_id, "Stopping container...");
state.exec_manager.close_sessions_for_container(container_id).await; state.exec_manager.close_sessions_for_container(container_id).await;
docker::stop_container(container_id).await?; if let Err(e) = docker::stop_container(container_id).await {
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
}
} }
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn rebuild_project_container( pub async fn rebuild_project_container(
project_id: String, project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Project, String> { ) -> Result<Project, String> {
let project = state let project = state
@@ -260,8 +313,16 @@ pub async fn rebuild_project_container(
state.projects_store.set_container_id(&project_id, None)?; 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 fresh
start_project_container(project_id, state).await start_project_container(project_id, app_handle, state).await
} }
fn default_docker_socket() -> String { fn default_docker_socket() -> String {

View File

@@ -2,6 +2,7 @@ use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions, StartContainerOptions, StopContainerOptions,
}; };
use bollard::image::{CommitContainerOptions, RemoveImageOptions};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding}; use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher; 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 { mounts.push(Mount {
target: Some("/home/claude/.claude".to_string()), target: Some("/home/claude/.claude".to_string()),
source: Some(format!("triple-c-claude-config-{}", project.id)), 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)) .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::<String> {
..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 /// Check whether the existing container's configuration still matches the
/// current project settings. Returns `true` when the container must be /// current project settings. Returns `true` when the container must be
/// recreated (mounts or env vars differ). /// recreated (mounts or env vars differ).

View File

@@ -70,17 +70,38 @@ impl ProjectsStore {
(Vec::new(), false) (Vec::new(), false)
}; };
// Reconcile stale transient statuses: on a cold app start no Docker
// operations can be in flight, so Starting/Stopping are always stale.
let mut projects = projects;
let mut needs_save = needs_save;
for p in projects.iter_mut() {
match p.status {
crate::models::ProjectStatus::Starting | crate::models::ProjectStatus::Stopping => {
log::warn!(
"Reconciling stale '{}' status for project '{}' ({}) → Stopped",
serde_json::to_string(&p.status).unwrap_or_default().trim_matches('"'),
p.name,
p.id
);
p.status = crate::models::ProjectStatus::Stopped;
p.updated_at = chrono::Utc::now().to_rfc3339();
needs_save = true;
}
_ => {}
}
}
let store = Self { let store = Self {
projects: Mutex::new(projects), projects: Mutex::new(projects),
file_path, file_path,
}; };
// Persist migrated format back to disk // Persist migrated/reconciled format back to disk
if needs_save { if needs_save {
log::info!("Migrated projects.json from single-path to multi-path format"); log::info!("Saving reconciled/migrated projects.json to disk");
let projects = store.lock(); let projects = store.lock();
if let Err(e) = store.save(&projects) { if let Err(e) = store.save(&projects) {
log::error!("Failed to save migrated projects: {}", e); log::error!("Failed to save projects: {}", e);
} }
} }

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event";
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types"; import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
@@ -23,6 +24,7 @@ export default function ProjectCard({ project }: Props) {
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false); const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false); const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
const [progressMsg, setProgressMsg] = useState<string | null>(null);
const isSelected = selectedProjectId === project.id; const isSelected = selectedProjectId === project.id;
const isStopped = project.status === "stopped" || project.status === "error"; const isStopped = project.status === "stopped" || project.status === "error";
@@ -64,6 +66,26 @@ export default function ProjectCard({ project }: Props) {
setBedrockModelId(project.bedrock_config?.model_id ?? ""); setBedrockModelId(project.bedrock_config?.model_id ?? "");
}, [project]); }, [project]);
// Listen for container progress events
useEffect(() => {
const unlisten = listen<{ project_id: string; message: string }>(
"container-progress",
(event) => {
if (event.payload.project_id === project.id) {
setProgressMsg(event.payload.message);
}
}
);
return () => { unlisten.then((f) => f()); };
}, [project.id]);
// Clear progress when status settles
useEffect(() => {
if (project.status === "running" || project.status === "stopped" || project.status === "error") {
setProgressMsg(null);
}
}, [project.status]);
const handleStart = async () => { const handleStart = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -315,9 +337,12 @@ export default function ProjectCard({ project }: Props) {
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent /> <ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
</> </>
) : ( ) : (
<span className="text-xs text-[var(--text-secondary)]"> <>
{project.status}... <span className="text-xs text-[var(--text-secondary)]">
</span> {progressMsg ?? `${project.status}...`}
</span>
<ActionButton onClick={handleStop} disabled={loading} label="Force Stop" danger />
</>
)} )}
<ActionButton <ActionButton
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }} onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}