Compare commits
2 Commits
v0.1.72-ma
...
v0.1.74-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| 652e451afe | |||
| eb86aa95b7 |
@@ -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(
|
||||
// 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_recreation {
|
||||
log::info!("Container config changed, recreating container for project {}", project.id);
|
||||
).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(),
|
||||
@@ -229,16 +259,18 @@ pub async fn stop_project_container(
|
||||
.get(&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)?;
|
||||
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
// Close exec sessions for this project
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
|
||||
docker::stop_container(container_id).await?;
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||
if let Err(e) = docker::stop_container(container_id).await {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -260,6 +292,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
|
||||
}
|
||||
|
||||
@@ -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::<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
|
||||
/// current project settings. Returns `true` when the container must be
|
||||
/// recreated (mounts or env vars differ).
|
||||
|
||||
@@ -70,17 +70,38 @@ impl ProjectsStore {
|
||||
(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 {
|
||||
projects: Mutex::new(projects),
|
||||
file_path,
|
||||
};
|
||||
|
||||
// Persist migrated format back to disk
|
||||
// Persist migrated/reconciled format back to disk
|
||||
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();
|
||||
if let Err(e) = store.save(&projects) {
|
||||
log::error!("Failed to save migrated projects: {}", e);
|
||||
log::error!("Failed to save projects: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -315,9 +315,12 @@ export default function ProjectCard({ project }: Props) {
|
||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{project.status}...
|
||||
</span>
|
||||
<ActionButton onClick={handleStop} disabled={loading} label="Force Stop" danger />
|
||||
</>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
|
||||
|
||||
Reference in New Issue
Block a user