From cc973c8291c56d3f346e319fabe78d1f94acaa58 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 13 Nov 2026 11:14:58 -0800 Subject: [PATCH 1/3] Move the container exit watcher's Docker calls behind host::watch container_watch keeps all of its logic (sweep, compare-and-set settle, backoff, cancellation) but no longer touches bollard: reachability, the death stream and the positive-exit check come from host::watch::ExitSource and ContainerTarget::has_exited, implemented for Docker in docker/events.rs. An AWS host can supply deaths by polling. container_watch.rs leaves the boundary test's allowlist. Verified with the live end-to-end watcher test against a real daemon. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/container_watch.rs | 124 ++++++--------------------- app/src-tauri/src/docker/events.rs | 114 ++++++++++++++++++++++++ app/src-tauri/src/docker/mod.rs | 1 + app/src-tauri/src/host/mod.rs | 3 +- app/src-tauri/src/host/watch.rs | 55 ++++++++++++ 5 files changed, 199 insertions(+), 98 deletions(-) create mode 100644 app/src-tauri/src/docker/events.rs create mode 100644 app/src-tauri/src/host/watch.rs diff --git a/app/src-tauri/src/container_watch.rs b/app/src-tauri/src/container_watch.rs index 52136f2..9647681 100644 --- a/app/src-tauri/src/container_watch.rs +++ b/app/src-tauri/src/container_watch.rs @@ -8,9 +8,10 @@ //! disconnected, while the sidebar, Start/Stop and Project Home all still //! described a live container. //! -//! One subscription to Docker's event stream, filtered daemon-side to container -//! `die` events, so an idle daemon costs nothing and the app hears about a death -//! as it happens. No polling loop. Each `die` naming a project's container is +//! One subscription to the host's death reports (`host::watch`; for Docker, +//! the daemon's event stream filtered to container `die` events, so an idle +//! daemon costs nothing and the app hears about a death as it happens — no +//! polling loop). Each `die` naming a project's container is //! confirmed with one inspect before anything changes (an event can be //! replayed, and a restart can reuse the id), and the flip itself is //! [`ProjectsStore::mark_exited`]'s compare-and-set, which never touches a @@ -22,20 +23,17 @@ //! the only window that replay cannot cover is a daemon restart, which forgets //! its event history, and that is exactly when containers go away. -use std::collections::HashMap; use std::future::Future; use std::sync::Arc; use std::time::{Duration, Instant}; -use bollard::errors::Error as DockerError; -use bollard::models::{EventMessage, EventMessageTypeEnum}; -use bollard::system::EventsOptions; -use bollard::Docker; use futures_util::StreamExt; use serde::Serialize; use tauri::{AppHandle, Emitter}; use tokio::sync::watch; +use crate::host::watch::ExitSource; +use crate::host::ContainerTarget; use crate::models::{Project, ProjectStatus}; use crate::storage::projects_store::{ExitMark, ProjectsStore}; @@ -72,46 +70,28 @@ pub struct ContainerExited<'a> { pub reason: ExitReason, } -/// The container id a Docker event reports as having died, if it is one. -pub(crate) fn died_container(msg: &EventMessage) -> Option<&str> { - if msg.typ != Some(EventMessageTypeEnum::CONTAINER) || msg.action.as_deref() != Some("die") { - return None; - } - msg.actor.as_ref()?.id.as_deref().filter(|id| !id.is_empty()) -} - -/// `true` only when Docker positively says the container is not running: it -/// inspects as stopped, or it no longer exists. Any other failure is "don't -/// know" — `docker::is_container_running` maps every error to `false`, which -/// is right for its callers and wrong here, where it would mark every project -/// Stopped the moment the daemon hiccups. -async fn has_exited(docker: &Docker, container_id: &str) -> bool { - match docker.inspect_container(container_id, None).await { - Ok(info) => !info.state.and_then(|s| s.running).unwrap_or(false), - Err(DockerError::DockerResponseServerError { status_code: 404, .. }) => true, - Err(_) => false, - } -} - fn project_busy(project_id: &str) -> bool { crate::project_lock::held(project_id).is_some() } -/// Confirm `container_id` really stopped and, if a project was Running on it, +/// Confirm `container` really stopped and, if a project was Running on it, /// flip that project. Returns what changed. -async fn settle(docker: &Docker, store: &ProjectsStore, container_id: &str) -> Option { +async fn settle(store: &ProjectsStore, container: &ContainerTarget) -> Option { + let container_id = container.id(); for _ in 0..SETTLE_ATTEMPTS { // Cheap and allocation-free: most containers that die are not ours // (probe and scratch containers, other software on the daemon), and // those cost no inspect at all. let seen = store.running_on(container_id)?; - if !has_exited(docker, container_id).await { + // Positive exit only: a host that can't answer must not mark every + // project Stopped. + if !container.has_exited().await { return None; } match store.mark_exited(container_id, &seen, project_busy) { ExitMark::Flipped(p) => return Some(*p), ExitMark::NotApplicable => return None, - // The store moved while Docker was answering — possibly a stop and + // The store moved while the host was answering — possibly a stop and // start of this very container. The inspect is stale; ask again. ExitMark::Changed => continue, } @@ -120,27 +100,20 @@ async fn settle(docker: &Docker, store: &ProjectsStore, container_id: &str) -> O } /// Re-check every Running project once. Used on each (re)subscription. -async fn sweep(docker: &Docker, store: &ProjectsStore) -> Vec { +async fn sweep(store: &ProjectsStore) -> Vec { let mut changed = Vec::new(); for p in store.list() { if p.status != ProjectStatus::Running { continue; } - let Some(cid) = p.container_id.as_deref() else { continue }; - if let Some(c) = settle(docker, store, cid).await { + let Some(container) = ContainerTarget::for_project(&p) else { continue }; + if let Some(c) = settle(store, &container).await { changed.push(c); } } changed } -fn die_filter() -> HashMap> { - HashMap::from([ - ("type".to_string(), vec!["container".to_string()]), - ("event".to_string(), vec!["die".to_string()]), - ]) -} - /// The wait before connection attempt `n + 1`, after `n` consecutive /// unproductive ones: 5 s, 10 s, 20 s, 40 s, then 60 s for good. fn retry_delay(consecutive_failures: u32) -> Duration { @@ -148,7 +121,7 @@ fn retry_delay(consecutive_failures: u32) -> Duration { (RETRY_MIN * 2u32.pow(doublings)).min(RETRY_MAX) } -/// `fut`, unless the app starts shutting down first. Every Docker call in here +/// `fut`, unless the app starts shutting down first. Every host call in here /// goes through this: a wedged daemon must not hold shutdown for its full /// budget. async fn unless_cancelled( @@ -174,32 +147,26 @@ async fn watch_once( sweep_reason: ExitReason, on_change: &impl Fn(&Project, ExitReason), ) -> Option { - let Ok(docker) = crate::docker::get_docker() else { return Some(false) }; - if unless_cancelled(cancel, docker.ping()).await?.is_err() { + let source = ExitSource::Docker; + if !unless_cancelled(cancel, source.reachable()).await? { return Some(false); } - // A second's overlap: the daemon replays from `since`, so a death during + // A second's overlap: the host replays from `since`, so a death during // the sweep below is still delivered. Duplicates are harmless — `settle` // confirms with an inspect every time. - let since = (chrono::Utc::now().timestamp() - 1).to_string(); - for p in unless_cancelled(cancel, sweep(&docker, store)).await? { + let since = chrono::Utc::now().timestamp() - 1; + for p in unless_cancelled(cancel, sweep(store)).await? { on_change(&p, sweep_reason); } - let mut events = docker.events(Some(EventsOptions:: { - since: Some(since), - until: None, - filters: die_filter(), - })); + let mut deaths = source.deaths(since); let connected = Instant::now(); let mut delivered = false; loop { - match unless_cancelled(cancel, events.next()).await? { - Some(Ok(msg)) => { + match unless_cancelled(cancel, deaths.next()).await? { + Some(Ok(container)) => { delivered = true; - if let Some(cid) = died_container(&msg) { - if let Some(p) = unless_cancelled(cancel, settle(&docker, store, cid)).await? { - on_change(&p, ExitReason::Exited); - } + if let Some(p) = unless_cancelled(cancel, settle(store, &container)).await? { + on_change(&p, ExitReason::Exited); } } Some(Err(e)) => { @@ -265,41 +232,6 @@ pub fn spawn( #[cfg(test)] mod tests { use super::*; - use bollard::models::EventActor; - - fn event(typ: EventMessageTypeEnum, action: &str, id: Option<&str>) -> EventMessage { - EventMessage { - typ: Some(typ), - action: Some(action.to_string()), - actor: Some(EventActor { id: id.map(str::to_string), attributes: None }), - ..Default::default() - } - } - - #[test] - fn only_a_container_die_names_a_container() { - let die = event(EventMessageTypeEnum::CONTAINER, "die", Some("abc")); - assert_eq!(died_container(&die), Some("abc")); - - for (typ, action, id) in [ - (EventMessageTypeEnum::CONTAINER, "stop", Some("abc")), - (EventMessageTypeEnum::CONTAINER, "start", Some("abc")), - (EventMessageTypeEnum::CONTAINER, "exec_die", Some("abc")), - (EventMessageTypeEnum::NETWORK, "die", Some("abc")), - (EventMessageTypeEnum::CONTAINER, "die", None), - (EventMessageTypeEnum::CONTAINER, "die", Some("")), - ] { - assert_eq!(died_container(&event(typ, action, id)), None, "{typ:?} {action} {id:?}"); - } - } - - #[test] - fn the_subscription_is_filtered_to_container_deaths_daemon_side() { - let f = die_filter(); - assert_eq!(f.get("type"), Some(&vec!["container".to_string()])); - assert_eq!(f.get("event"), Some(&vec!["die".to_string()])); - assert_eq!(f.len(), 2); - } #[test] fn retries_back_off_to_a_minute_and_stay_there() { @@ -321,7 +253,7 @@ mod tests { assert_eq!(CONTAINER_EXITED_EVENT, "project-container-exited"); } - /// The shutdown path: a Docker call that never answers (a wedged daemon) + /// The shutdown path: a host call that never answers (a wedged daemon) /// must give way to cancellation at once, not hold teardown. #[tokio::test] async fn a_hung_docker_call_yields_to_cancellation() { diff --git a/app/src-tauri/src/docker/events.rs b/app/src-tauri/src/docker/events.rs new file mode 100644 index 0000000..da12339 --- /dev/null +++ b/app/src-tauri/src/docker/events.rs @@ -0,0 +1,114 @@ +//! The Docker half of `container_watch`: reachability, the daemon's container +//! `die` event stream, and the "has it positively exited" check. Everything +//! about *what to do* with a death (confirm, compare-and-set the store, tell +//! the frontend, back off) is host-neutral and stays in `container_watch.rs`, +//! which reaches these through `host::watch`. + +use std::collections::HashMap; +use std::pin::Pin; + +use bollard::errors::Error as DockerError; +use bollard::models::{EventMessage, EventMessageTypeEnum}; +use bollard::system::EventsOptions; +use futures_util::{Stream, StreamExt}; + +use super::client::get_docker; + +/// Whether the daemon answers at all. +pub async fn ping() -> bool { + match get_docker() { + Ok(docker) => docker.ping().await.is_ok(), + Err(_) => false, + } +} + +/// The container id a Docker event reports as having died, if it is one. +pub(crate) fn died_container(msg: &EventMessage) -> Option<&str> { + if msg.typ != Some(EventMessageTypeEnum::CONTAINER) || msg.action.as_deref() != Some("die") { + return None; + } + msg.actor.as_ref()?.id.as_deref().filter(|id| !id.is_empty()) +} + +fn die_filter() -> HashMap> { + HashMap::from([ + ("type".to_string(), vec!["container".to_string()]), + ("event".to_string(), vec!["die".to_string()]), + ]) +} + +/// Container ids as Docker reports them dying, replayed from `since` (unix +/// seconds). Filtered daemon-side to container `die` events, so an idle daemon +/// costs nothing. Anything else that comes through is dropped here. An `Err` +/// item, or the stream ending, means the connection is gone. +pub fn container_deaths(since: i64) -> Pin> + Send>> { + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return Box::pin(futures_util::stream::once(async move { Err(e) })), + }; + let events = docker.events(Some(EventsOptions:: { + since: Some(since.to_string()), + until: None, + filters: die_filter(), + })); + Box::pin(events.filter_map(|item| async move { + match item { + Ok(msg) => died_container(&msg).map(|id| Ok(id.to_string())), + Err(e) => Some(Err(e.to_string())), + } + })) +} + +/// `true` only when Docker positively says the container is not running: it +/// inspects as stopped, or it no longer exists. Any other failure is "don't +/// know" — `is_container_running` maps every error to `false`, which is right +/// for its callers and wrong for the watcher, where it would mark every project +/// Stopped the moment the daemon hiccups. +pub async fn has_exited(container_id: &str) -> bool { + let Ok(docker) = get_docker() else { return false }; + match docker.inspect_container(container_id, None).await { + Ok(info) => !info.state.and_then(|s| s.running).unwrap_or(false), + Err(DockerError::DockerResponseServerError { status_code: 404, .. }) => true, + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bollard::models::EventActor; + + fn event(typ: EventMessageTypeEnum, action: &str, id: Option<&str>) -> EventMessage { + EventMessage { + typ: Some(typ), + action: Some(action.to_string()), + actor: Some(EventActor { id: id.map(str::to_string), attributes: None }), + ..Default::default() + } + } + + #[test] + fn only_a_container_die_names_a_container() { + let die = event(EventMessageTypeEnum::CONTAINER, "die", Some("abc")); + assert_eq!(died_container(&die), Some("abc")); + + for (typ, action, id) in [ + (EventMessageTypeEnum::CONTAINER, "stop", Some("abc")), + (EventMessageTypeEnum::CONTAINER, "start", Some("abc")), + (EventMessageTypeEnum::CONTAINER, "exec_die", Some("abc")), + (EventMessageTypeEnum::NETWORK, "die", Some("abc")), + (EventMessageTypeEnum::CONTAINER, "die", None), + (EventMessageTypeEnum::CONTAINER, "die", Some("")), + ] { + assert_eq!(died_container(&event(typ, action, id)), None, "{typ:?} {action} {id:?}"); + } + } + + #[test] + fn the_subscription_is_filtered_to_container_deaths_daemon_side() { + let f = die_filter(); + assert_eq!(f.get("type"), Some(&vec!["container".to_string()])); + assert_eq!(f.get("event"), Some(&vec!["die".to_string()])); + assert_eq!(f.len(), 2); + } +} diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index 2bcc6a5..0a15d89 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -1,5 +1,6 @@ pub mod ca_certs; pub mod client; +pub mod events; pub mod container; pub mod image; pub mod exec; diff --git a/app/src-tauri/src/host/mod.rs b/app/src-tauri/src/host/mod.rs index 1350610..a259dc6 100644 --- a/app/src-tauri/src/host/mod.rs +++ b/app/src-tauri/src/host/mod.rs @@ -27,6 +27,7 @@ //! may still do so. pub mod sessions; +pub mod watch; pub use crate::docker::exec::{ MAX_DROP_BYTES, OUTPUT_LIMIT_MARKER, PROC_NET_OUTPUT_LIMIT, @@ -432,8 +433,6 @@ mod tests { const ALLOWED: &[&str] = &[ // Docker-only: base-image migration replays apt / restores a payload. "commands/migration_commands.rs", - // Phase 1b: lifecycle and the `die` event stream. - "container_watch.rs", ]; let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut offenders = Vec::new(); diff --git a/app/src-tauri/src/host/watch.rs b/app/src-tauri/src/host/watch.rs new file mode 100644 index 0000000..7ba6ea5 --- /dev/null +++ b/app/src-tauri/src/host/watch.rs @@ -0,0 +1,55 @@ +//! Where `container_watch` learns that a project container stopped on its +//! own. Host-neutral shape: a reachability check, a stream of containers +//! reported dead (replayable from a point in time), and a positive-exit check +//! used to confirm each report before anything changes. +//! +//! Docker pushes `die` events. An AWS host will have no push stream from the +//! app's point of view; its [`deaths`] will be a `DescribeTasks` poll that +//! yields stopped tasks, which `container_watch` handles the same way because +//! every report is confirmed with [`ContainerTarget::has_exited`] anyway. + +use futures_util::{Stream, StreamExt}; +use std::pin::Pin; + +use super::ContainerTarget; +use crate::docker; + +/// Which host's deaths to watch. One variant today. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitSource { + Docker, +} + +pub type DeathStream = Pin> + Send>>; + +impl ExitSource { + /// Whether the host answers at all. A `false` here is a failed connection + /// attempt for the watcher's backoff. + pub async fn reachable(&self) -> bool { + match self { + ExitSource::Docker => docker::events::ping().await, + } + } + + /// Containers reported dead, replayed from `since` (unix seconds). An `Err` + /// item, or the stream ending, means the connection is gone. + pub fn deaths(&self, since: i64) -> DeathStream { + match self { + ExitSource::Docker => Box::pin( + docker::events::container_deaths(since).map(|r| r.map(ContainerTarget::docker)), + ), + } + } +} + +impl ContainerTarget { + /// `true` only when the host positively says the container is not running + /// (stopped, or gone). Any failure to find out is `false` — unlike + /// [`ContainerTarget::is_running`], whose callers want errors to read as + /// "not running". + pub async fn has_exited(&self) -> bool { + match self { + ContainerTarget::Docker { container_id } => docker::events::has_exited(container_id).await, + } + } +} -- 2.52.0 From 83d63e756db19b35dcc9e0a7816abb367cc4b8a3 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 13 Nov 2026 11:18:03 -0800 Subject: [PATCH 2/3] Host phase 1a follow-up: pass ContainerTarget instead of container ids Functions that took a bare container id and built `ContainerTarget::docker(id)` internally now take a target, and their callers resolve one from the project (`ContainerTarget::for_project`) or carry the one they already hold. Long-lived holders (auth bridge poller, browser-view proxy/session, file viewer commands) store the target. Collapses the string/target wrapper pairs: `detect::detect` now takes a target (`detect_in` removed) and `tunnel_connection_with_prelude` takes a target (`tunnel_into_with_prelude` removed). No behaviour change. Two temporary Docker constructions remain on the container-start path, marked "phase 1b": the auth bridge start in `start_project_container_locked` and `marketplace::spawn_project_sync`. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/auth_bridge/mod.rs | 6 +-- app/src-tauri/src/auth_bridge/tunnel.rs | 24 +-------- app/src-tauri/src/browser_view/commands.rs | 52 +++++++++---------- app/src-tauri/src/browser_view/detect.rs | 7 +-- app/src-tauri/src/browser_view/install.rs | 26 +++++----- app/src-tauri/src/browser_view/mod.rs | 7 ++- app/src-tauri/src/browser_view/page.rs | 44 ++++++++-------- app/src-tauri/src/browser_view/popout.rs | 6 +-- app/src-tauri/src/browser_view/proxy.rs | 15 +++--- .../src/commands/auth_bridge_commands.rs | 10 ++-- app/src-tauri/src/commands/file_commands.rs | 25 ++++----- .../src/commands/file_viewer_commands.rs | 31 ++++++----- .../src/commands/marketplace_commands.rs | 11 ++-- .../src/commands/project_commands.rs | 7 +-- app/src-tauri/src/file_viewer/poll.rs | 7 ++- app/src-tauri/src/file_viewer/resolve.rs | 4 +- app/src-tauri/src/file_viewer/write.rs | 5 +- app/src-tauri/src/marketplace/mod.rs | 17 ++++-- app/src-tauri/src/marketplace/sync.rs | 8 +-- 19 files changed, 143 insertions(+), 169 deletions(-) diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs index cd55876..43618af 100644 --- a/app/src-tauri/src/auth_bridge/mod.rs +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -177,7 +177,7 @@ impl AuthBridgeManager { pub async fn start( &self, project_id: String, - container_id: String, + container: ContainerTarget, app: AppHandle, store: Arc, ) { @@ -199,12 +199,12 @@ impl AuthBridgeManager { log::info!( "Auth bridge: starting for project {} (container {})", project_id, - &container_id[..container_id.len().min(12)] + &container.id()[..container.id().len().min(12)] ); let poller = tokio::spawn(poll_loop( project_id.clone(), - ContainerTarget::docker(container_id), + container, epoch, app, store, diff --git a/app/src-tauri/src/auth_bridge/tunnel.rs b/app/src-tauri/src/auth_bridge/tunnel.rs index d40588d..4c8ad96 100644 --- a/app/src-tauri/src/auth_bridge/tunnel.rs +++ b/app/src-tauri/src/auth_bridge/tunnel.rs @@ -322,7 +322,7 @@ async fn tunnel_connection( } // The bytes already off the socket go back on the wire first, byte-exact. - tunnel_into_with_prelude(container, target, stream, port, head.into_buffer()).await + tunnel_connection_with_prelude(container, target, stream, port, head.into_buffer()).await } /// What the first bytes of an accepted connection turned out to be. @@ -514,29 +514,7 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict { /// head off the socket to check a token, and then has to put those same bytes /// back on the wire. Passing them here keeps the byte stream exact, rather than /// re-serialising a parsed request. -/// -/// Takes a Docker container id because that is what the browser-view proxy -/// holds; [`tunnel_into_with_prelude`] is the same thing for a -/// [`ContainerTarget`] already in hand. pub async fn tunnel_connection_with_prelude( - container_id: String, - target: String, - stream: TcpStream, - port: u16, - prelude: Vec, -) { - tunnel_into_with_prelude( - ContainerTarget::docker(container_id), - target, - stream, - port, - prelude, - ) - .await -} - -/// [`tunnel_connection_with_prelude`] for a [`ContainerTarget`]. -pub async fn tunnel_into_with_prelude( container: ContainerTarget, target: String, stream: TcpStream, diff --git a/app/src-tauri/src/browser_view/commands.rs b/app/src-tauri/src/browser_view/commands.rs index 1b4a5af..258b02e 100644 --- a/app/src-tauri/src/browser_view/commands.rs +++ b/app/src-tauri/src/browser_view/commands.rs @@ -6,6 +6,7 @@ use tauri::{AppHandle, State}; use crate::browser_view::install::{self, BrowserSetupOutcome}; use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus}; +use crate::host::ContainerTarget; use crate::AppState; /// Turn the pane on or off for a project. @@ -57,12 +58,12 @@ pub async fn set_browser_view_enabled( return Ok(manager().status(&project_id, false).await); } - let container_id = running_container(&state, &project_id, "opening the browser view").await?; + let container = running_container(&state, &project_id, "opening the browser view").await?; manager() .start( project_id, - container_id, + container, app_handle, state.projects_store.clone(), ) @@ -106,8 +107,8 @@ pub async fn check_browser_view_support( project_id: String, state: State<'_, AppState>, ) -> Result { - let container_id = running_container(&state, &project_id, "checking for Playwright").await?; - crate::browser_view::detect::detect(&container_id).await + let container = running_container(&state, &project_id, "checking for Playwright").await?; + crate::browser_view::detect::detect(&container).await } /// Install `playwright` and `@playwright/cli` into the container. @@ -125,8 +126,8 @@ pub async fn install_browser_view_support( app_handle: AppHandle, state: State<'_, AppState>, ) -> Result { - let container_id = running_container(&state, &project_id, "installing Playwright").await?; - install::install_packages(&app_handle, &project_id, &container_id).await + let container = running_container(&state, &project_id, "installing Playwright").await?; + install::install_packages(&app_handle, &project_id, &container).await } /// Install a browser — `chromium` (Playwright's own build, for scripts that @@ -143,8 +144,8 @@ pub async fn install_browser_view_browser( state: State<'_, AppState>, ) -> Result { let target = install::BrowserTarget::parse(&browser)?; - let container_id = running_container(&state, &project_id, "installing a browser").await?; - install::install_browser(&app_handle, &project_id, &container_id, target).await + let container = running_container(&state, &project_id, "installing a browser").await?; + install::install_browser(&app_handle, &project_id, &container, target).await } /// Detach the view into a window of its own, or raise the one already open. @@ -240,17 +241,17 @@ pub async fn open_page_in_container_browser( if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { return Err("Only http:// and https:// URLs can be opened in the browser.".to_string()); } - let container_id = running_container(&state, &project_id, "opening a page").await?; + let container = running_container(&state, &project_id, "opening a page").await?; crate::commands::project_commands::emit_progress( &app_handle, &project_id, "Checking the container for Playwright…", ); - let detection = crate::browser_view::detect::detect(&container_id).await?; + let detection = crate::browser_view::detect::detect(&container).await?; let opened = page::open( &app_handle, &project_id, - &container_id, + &container, &detection, trimmed, page::Viewport::sane(width, height), @@ -273,7 +274,7 @@ pub async fn open_page_in_container_browser( manager() .start( project_id.clone(), - container_id, + container, app_handle.clone(), state.projects_store.clone(), ) @@ -309,8 +310,8 @@ pub async fn set_container_page_viewport( height: u32, state: State<'_, AppState>, ) -> Result<(), String> { - let container_id = running_container(&state, &project_id, "resizing the page").await?; - page::set_viewport(&container_id, page::Viewport::sane(width, height)).await + let container = running_container(&state, &project_id, "resizing the page").await?; + page::set_viewport(&container, page::Viewport::sane(width, height)).await } /// State of the page this opened, if any. Never fails: "no page" is an answer. @@ -319,10 +320,10 @@ pub async fn get_container_page_state( project_id: String, state: State<'_, AppState>, ) -> Result { - let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else { + let Ok(container) = running_container(&state, &project_id, "reading the page").await else { return Ok(page::PageState::default()); }; - Ok(page::state(&container_id).await) + Ok(page::state(&container).await) } /// Close the page this opened, leaving the view itself running. @@ -331,8 +332,8 @@ pub async fn close_container_page( project_id: String, state: State<'_, AppState>, ) -> Result<(), String> { - let container_id = running_container(&state, &project_id, "closing the page").await?; - page::close(&container_id).await; + let container = running_container(&state, &project_id, "closing the page").await?; + page::close(&container).await; Ok(()) } @@ -356,8 +357,8 @@ pub async fn set_browser_view_match_window( let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else { return Ok(()); }; - let container_id = running_container(&state, &project_id, "matching the window").await?; - page::set_viewport(&container_id, page::Viewport::sane(width, height)).await + let container = running_container(&state, &project_id, "matching the window").await?; + page::set_viewport(&container, page::Viewport::sane(width, height)).await } /// Whether match-window mode is on. Read on mount, like the rest of the @@ -391,28 +392,25 @@ async fn running_container( state: &State<'_, AppState>, project_id: &str, action: &str, -) -> Result { +) -> Result { let project = state .projects_store .get(project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; - let Some(container_id) = project.container_id.clone() else { + let Some(container) = ContainerTarget::for_project(&project) else { return Err(format!( "This project has no container yet. Start it before {}.", action )); }; - if !crate::docker::container::is_container_running(&container_id) - .await - .unwrap_or(false) - { + if !container.is_running().await.unwrap_or(false) { return Err(format!( "The container for “{}” isn't running. Start it before {}.", project.name, action )); } - Ok(container_id) + Ok(container) } #[cfg(test)] diff --git a/app/src-tauri/src/browser_view/detect.rs b/app/src-tauri/src/browser_view/detect.rs index eb88a06..adc5876 100644 --- a/app/src-tauri/src/browser_view/detect.rs +++ b/app/src-tauri/src/browser_view/detect.rs @@ -277,12 +277,7 @@ impl PlaywrightDetection { /// Playwright installed with `npm i -g`, or merely *run* once through /// `npx @playwright/mcp`, is found as readily as one in /// `/workspace/node_modules`. -pub async fn detect(container_id: &str) -> Result { - detect_in(&ContainerTarget::docker(container_id)).await -} - -/// [`detect`] against a target already in hand. -pub async fn detect_in(container: &ContainerTarget) -> Result { +pub async fn detect(container: &ContainerTarget) -> Result { let output = container .exec_oneshot(vec![ "node".to_string(), diff --git a/app/src-tauri/src/browser_view/install.rs b/app/src-tauri/src/browser_view/install.rs index a6c149d..40ea69d 100644 --- a/app/src-tauri/src/browser_view/install.rs +++ b/app/src-tauri/src/browser_view/install.rs @@ -238,16 +238,15 @@ pub struct BrowserSetupOutcome { pub async fn install_packages( app: &AppHandle, project_id: &str, - container_id: &str, + container: &ContainerTarget, ) -> Result { - let container = ContainerTarget::docker(container_id); emit_progress( app, project_id, &format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR), ); - let mut step = npm_install(app, project_id, &container, &[VIEWER_PACKAGE]).await?; + let mut step = npm_install(app, project_id, container, &[VIEWER_PACKAGE]).await?; if step.exit_code != 0 { return Err(format!( "npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}", @@ -264,9 +263,9 @@ pub async fn install_packages( // see the note on `npm_install`. The pin can only be read after the first // install has written the manifest, which is why this stays two commands // rather than one. - let spec = pinned_playwright_spec(&container).await; + let spec = pinned_playwright_spec(container).await; emit_progress(app, project_id, &format!("Installing {}…", spec)); - let second = npm_install(app, project_id, &container, &[VIEWER_PACKAGE, &spec]).await?; + let second = npm_install(app, project_id, container, &[VIEWER_PACKAGE, &spec]).await?; if second.exit_code != 0 { return Err(format!( "npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}", @@ -278,7 +277,7 @@ pub async fn install_packages( step.log = merge_logs(step.log, second.log); emit_progress(app, project_id, "Re-checking what the container has…"); - let detection = detect::detect_in(&container).await?; + let detection = detect::detect(container).await?; // A probe that still finds something missing after a successful npm run is // the interesting case, so it is surfaced rather than swallowed. And a @@ -410,11 +409,10 @@ fn merge_logs(first: String, second: String) -> String { pub async fn install_browser( app: &AppHandle, project_id: &str, - container_id: &str, + container: &ContainerTarget, target: BrowserTarget, ) -> Result { - let container = ContainerTarget::docker(container_id); - let before = detect::detect_in(&container).await?; + let before = detect::detect(container).await?; let Some(cli) = before.playwright_cli.clone() else { return Err( "Playwright isn't installed in this container yet, so there is nothing to install a \ @@ -458,7 +456,7 @@ pub async fn install_browser( project_id, "Step 1/3 — checking whether this container already has the browser system libraries…", ); - let state = check_libraries(&container, &cli, target).await; + let state = check_libraries(container, &cli, target).await; match &state { LibraryState::Present => { emit_progress( @@ -482,7 +480,7 @@ pub async fn install_browser( let deps = run_step( app, project_id, - &container, + container, "root", "/tmp", vec![ @@ -530,7 +528,7 @@ pub async fn install_browser( let dl = run_step( app, project_id, - &container, + container, user, workdir, vec![ @@ -559,7 +557,7 @@ pub async fn install_browser( project_id, &format!("Step 3/3 — checking that {} actually launches…", target.cli_name()), ); - let verdict = verify_launch(&container, &before, target).await; + let verdict = verify_launch(container, &before, target).await; push_section(&mut log, &verdict.detail); if !verdict.ok { warning = merge( @@ -590,7 +588,7 @@ pub async fn install_browser( } emit_progress(app, project_id, "Re-checking what the container has…"); - let detection = detect::detect_in(&container).await?; + let detection = detect::detect(container).await?; Ok(BrowserSetupOutcome { detection, diff --git a/app/src-tauri/src/browser_view/mod.rs b/app/src-tauri/src/browser_view/mod.rs index 374acb3..624bf2c 100644 --- a/app/src-tauri/src/browser_view/mod.rs +++ b/app/src-tauri/src/browser_view/mod.rs @@ -281,7 +281,7 @@ impl BrowserViewManager { pub async fn start( &self, project_id: String, - container_id: String, + container: ContainerTarget, app: AppHandle, store: Arc, ) -> Result { @@ -299,8 +299,7 @@ impl BrowserViewManager { return Ok(self.status(&project_id, true).await); } - let container = ContainerTarget::docker(container_id.as_str()); - let detection = detect::detect_in(&container).await?; + let detection = detect::detect(&container).await?; if !detection.is_usable() { let blocker = detection.blocker().unwrap_or_else(|| { "Playwright is present but incomplete in this container.".to_string() @@ -328,7 +327,7 @@ impl BrowserViewManager { // `--host 127.0.0.1` is ours to set, so the family is known and there is // no need to go back to /proc/net to work it out. let proxy = match BrowserViewProxy::bind( - container_id.clone(), + container.clone(), container_port, PortFamily::V4, token.clone(), diff --git a/app/src-tauri/src/browser_view/page.rs b/app/src-tauri/src/browser_view/page.rs index c2d134a..f30c389 100644 --- a/app/src-tauri/src/browser_view/page.rs +++ b/app/src-tauri/src/browser_view/page.rs @@ -91,7 +91,7 @@ pub struct PageState { pub async fn open( app: &AppHandle, project_id: &str, - container_id: &str, + container: &ContainerTarget, detection: &PlaywrightDetection, url: &str, viewport: Viewport, @@ -115,17 +115,17 @@ pub async fn open( // Reuse a helper that is already up. Relaunching would throw away the // browser's cookies and storage — which for the auth case means signing in // again to reach the second page, having just signed in on the first. - if state(container_id).await.ready { + if state(container).await.ready { emit_progress(app, project_id, "Navigating the container's browser…"); - set_viewport(container_id, viewport).await?; - navigate(container_id, url).await?; - if let Some(state) = wait_for_url(container_id, url).await { + set_viewport(container, viewport).await?; + navigate(container, url).await?; + if let Some(state) = wait_for_url(container, url).await { return Ok(state); } // It stopped answering; fall through and start a fresh one. } - close(container_id).await; + close(container).await; // Cold start: a browser launch plus a page load, which is the several // seconds the user would otherwise spend wondering whether the click // registered. @@ -150,7 +150,7 @@ pub async fn open( shell_quote(&script), HELPER_LOG ); - ContainerTarget::docker(container_id) + container .exec_oneshot_as( "claude", vec!["sh".to_string(), "-c".to_string(), launcher], @@ -160,36 +160,36 @@ pub async fn open( .map_err(|e| format!("Could not start the browser helper: {}", e))?; emit_progress(app, project_id, "Waiting for the page to load…"); - wait_until_ready(container_id).await + wait_until_ready(container).await } /// Resize the open page. Cheap enough to call from a window-resize handler. -pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> { +pub async fn set_viewport(container: &ContainerTarget, viewport: Viewport) -> Result<(), String> { write_control( - container_id, + container, serde_json::json!({ "viewport": viewport }).to_string(), ) .await } /// Navigate the open page without relaunching the browser. -pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> { - write_control(container_id, serde_json::json!({ "url": url }).to_string()).await +pub async fn navigate(container: &ContainerTarget, url: &str) -> Result<(), String> { + write_control(container, serde_json::json!({ "url": url }).to_string()).await } /// Ask the helper to shut down. Best effort: a container that has none is the /// normal case, and the caller is usually about to start one anyway. -pub async fn close(container_id: &str) { - let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await; +pub async fn close(container: &ContainerTarget) { + let _ = write_control(container, serde_json::json!({ "close": true }).to_string()).await; } /// Current state, or a default when no helper has ever run here. -pub async fn state(container_id: &str) -> PageState { +pub async fn state(container: &ContainerTarget) -> PageState { let script = format!( "try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}", STATE_PATH ); - let Ok((out, _)) = ContainerTarget::docker(container_id) + let Ok((out, _)) = container .exec_oneshot_as( "claude", vec!["node".to_string(), "-e".to_string(), script], @@ -204,12 +204,12 @@ pub async fn state(container_id: &str) -> PageState { /// Write the control file through Node rather than a shell redirect, so a URL /// is never interpreted by `sh`. -async fn write_control(container_id: &str, json: String) -> Result<(), String> { +async fn write_control(container: &ContainerTarget, json: String) -> Result<(), String> { let script = format!( "require('fs').writeFileSync('{}',process.argv[1]);", CONTROL_PATH ); - ContainerTarget::docker(container_id) + container .exec_oneshot_as( "claude", vec!["node".to_string(), "-e".to_string(), script, json], @@ -225,10 +225,10 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> { /// Bounded much tighter than a cold start: the browser is already up, so this /// is one navigation. `None` means it stopped answering, and the caller starts /// a fresh helper rather than reporting a page that isn't there. -async fn wait_for_url(container_id: &str, url: &str) -> Option { +async fn wait_for_url(container: &ContainerTarget, url: &str) -> Option { let deadline = std::time::Instant::now() + REUSE_TIMEOUT; loop { - let state = state(container_id).await; + let state = state(container).await; if state.ready && state.url.as_deref() == Some(url) { return Some(state); } @@ -240,10 +240,10 @@ async fn wait_for_url(container_id: &str, url: &str) -> Option { } /// Poll the state file until the helper says the page is up, or says why not. -async fn wait_until_ready(container_id: &str) -> Result { +async fn wait_until_ready(container: &ContainerTarget) -> Result { let deadline = std::time::Instant::now() + READY_TIMEOUT; loop { - let state = state(container_id).await; + let state = state(container).await; if let Some(error) = state.error.clone() { return Err(error); } diff --git a/app/src-tauri/src/browser_view/popout.rs b/app/src-tauri/src/browser_view/popout.rs index 1b84cf3..0b5bb8f 100644 --- a/app/src-tauri/src/browser_view/popout.rs +++ b/app/src-tauri/src/browser_view/popout.rs @@ -255,15 +255,15 @@ fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) { } let state = app.state::(); - let Some(container_id) = state + let Some(container) = state .projects_store .get(&project_id) - .and_then(|p| p.container_id) + .and_then(|p| crate::host::ContainerTarget::for_project(&p)) else { return; }; if let Err(e) = super::page::set_viewport( - &container_id, + &container, super::page::Viewport::sane(width, height), ) .await diff --git a/app/src-tauri/src/browser_view/proxy.rs b/app/src-tauri/src/browser_view/proxy.rs index 522f6dd..8d4c42c 100644 --- a/app/src-tauri/src/browser_view/proxy.rs +++ b/app/src-tauri/src/browser_view/proxy.rs @@ -62,6 +62,7 @@ use tokio::task::{JoinHandle, JoinSet}; use crate::auth_bridge::proc_net::PortFamily; use crate::auth_bridge::tunnel::tunnel_connection_with_prelude; +use crate::host::ContainerTarget; /// Host loopback ports the pane may be served on, and therefore the exact set of /// origins enumerated in the app's `frame-src`. Keep the two in sync: adding a @@ -103,9 +104,9 @@ impl Drop for BrowserViewProxy { impl BrowserViewProxy { /// Take the first free port in [`PROXY_PORTS`] on the host loopback and - /// start gating connections into `container_id`'s `container_port`. + /// start gating connections into `container`'s `container_port`. pub async fn bind( - container_id: String, + container: ContainerTarget, container_port: u16, family: PortFamily, token: String, @@ -121,7 +122,7 @@ impl BrowserViewProxy { Ok(listener) => { let task = tokio::spawn(accept_loop( listener, - container_id, + container, family.socat_target(container_port), container_port, token, @@ -172,7 +173,7 @@ fn host_authorities(port: u16) -> Vec { #[allow(clippy::too_many_arguments)] async fn accept_loop( listener: TcpListener, - container_id: String, + container: ContainerTarget, target: String, container_port: u16, token: String, @@ -195,7 +196,7 @@ async fn accept_loop( let _ = stream.set_nodelay(true); conns.spawn(serve_connection( stream, - container_id.clone(), + container.clone(), target.clone(), container_port, token.clone(), @@ -214,7 +215,7 @@ async fn accept_loop( #[allow(clippy::too_many_arguments)] async fn serve_connection( mut stream: TcpStream, - container_id: String, + container: ContainerTarget, target: String, container_port: u16, token: String, @@ -249,7 +250,7 @@ async fn serve_connection( // Authorized: hand the socket to the same socat-over-Docker-exec tunnel the // auth bridge uses, replaying the head we had to buffer to make the call. - tunnel_connection_with_prelude(container_id, target, stream, container_port, head).await; + tunnel_connection_with_prelude(container, target, stream, container_port, head).await; } /// Read bytes until the end of the HTTP request head (`\r\n\r\n`), or fail. diff --git a/app/src-tauri/src/commands/auth_bridge_commands.rs b/app/src-tauri/src/commands/auth_bridge_commands.rs index dddff71..70bb354 100644 --- a/app/src-tauri/src/commands/auth_bridge_commands.rs +++ b/app/src-tauri/src/commands/auth_bridge_commands.rs @@ -5,6 +5,7 @@ use tauri::{AppHandle, State}; use crate::auth_bridge::AuthBridgeStatus; +use crate::host::ContainerTarget; use crate::AppState; /// Turn the bridge on or off for a project and return the resulting status. @@ -29,16 +30,13 @@ pub async fn set_auth_bridge_enabled( .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; - if let Some(container_id) = project.container_id { - if crate::docker::container::is_container_running(&container_id) - .await - .unwrap_or(false) - { + if let Some(container) = ContainerTarget::for_project(&project) { + if container.is_running().await.unwrap_or(false) { state .auth_bridge .start( project_id.clone(), - container_id, + container, app_handle, state.projects_store.clone(), ) diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 1a472df..27458f7 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -1202,15 +1202,12 @@ pub(crate) struct FetchedFile { /// file — or the whole *directory tree*, since the type check happens after the /// read — landed in host RAM twice. This function buffers, so every caller of /// it must name a ceiling. -/// -/// Takes a container id rather than a [`ContainerTarget`] because the file -/// viewer's commands call it with the id they already hold. pub(crate) async fn fetch_container_file( - container_id: &str, + container: &ContainerTarget, container_path: &str, max_bytes: u64, ) -> Result { - let mut stream = ContainerTarget::docker(container_id).download_archive(container_path); + let mut stream = container.download_archive(container_path); // A tar member is a 512-byte header plus payload padded to 512. 8 KiB of // slack past the payload cap guarantees the header and the whole capped @@ -1297,7 +1294,7 @@ pub async fn read_container_file( .ok_or_else(|| "Container not running".to_string())?; let cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES); - let fetched = fetch_container_file(target.id(), &path, cap).await?; + let fetched = fetch_container_file(&target, &path, cap).await?; Ok(FileContents { contents_base64: BASE64.encode(&fetched.bytes), @@ -1451,14 +1448,14 @@ pub(crate) fn not_running_message(action: &str, why: &str) -> String { /// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath` /// is the first thing to touch the container, so a stopped project fails inside /// path *validation* and reads like the path was the problem. -/// -/// Takes a container id rather than a [`ContainerTarget`] because the file -/// viewer's commands call it with the id they already hold. -pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> { +pub(crate) async fn require_running( + container: &ContainerTarget, + action: &str, +) -> Result<(), String> { // An unreachable daemon is an error; an inspect failure (container gone) // reads as "not running" — `is_running`'s contract, and what this did // when it inspected the container itself. - let running = ContainerTarget::docker(container_id).is_running().await?; + let running = container.is_running().await?; if running { return Ok(()); } @@ -1532,7 +1529,7 @@ pub async fn download_container_file( let target = ContainerTarget::for_project(&project) .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; - require_running(target.id(), "saving a file to the host").await?; + require_running(&target, "saving a file to the host").await?; let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else { // Dismissed. Not an error, and deliberately distinguishable from one: @@ -1887,7 +1884,7 @@ pub async fn upload_files_to_container( // Both checks before the dialog, so a project that cannot receive files // says so instead of asking the user to choose some first. - require_running(target.id(), "uploading files").await?; + require_running(&target, "uploading files").await?; resolve_container_dir(&target, "Upload", &container_dir).await?; let Some(picked) = pick_files_to_upload(&window).await else { @@ -2241,7 +2238,7 @@ pub async fn download_container_backup( let target = ContainerTarget::for_project(&project) .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; - require_running(target.id(), "backing up").await?; + require_running(&target, "backing up").await?; let path = container_path.unwrap_or_else(|| "/workspace".to_string()); // Read-only source: `tar -C` it, so absoluteness and `..` are what matter. diff --git a/app/src-tauri/src/commands/file_viewer_commands.rs b/app/src-tauri/src/commands/file_viewer_commands.rs index dc9f65f..8e8fb11 100644 --- a/app/src-tauri/src/commands/file_viewer_commands.rs +++ b/app/src-tauri/src/commands/file_viewer_commands.rs @@ -18,6 +18,7 @@ use crate::file_viewer::registry::{ use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates}; use crate::file_viewer::window::open_viewer_window; use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES}; +use crate::host::ContainerTarget; use crate::models::Project; use crate::AppState; @@ -113,13 +114,11 @@ fn project_of(state: &AppState, project_id: &str) -> Result { } /// `action` completes "Start the project before …", e.g. "saving this file". -async fn running_container_of(project: &Project, action: &str) -> Result { - let container_id = project - .container_id - .clone() +async fn running_container_of(project: &Project, action: &str) -> Result { + let container = ContainerTarget::for_project(project) .ok_or_else(|| not_running_message(action, "files live in its container"))?; - require_running(&container_id, action).await?; - Ok(container_id) + require_running(&container, action).await?; + Ok(container) } /// The container of the project a viewer window belongs to, if it is running. @@ -127,7 +126,7 @@ async fn running_container_for( state: &AppState, target: &ViewerTarget, action: &str, -) -> Result { +) -> Result { running_container_of(&project_of(state, &target.project_id)?, action).await } @@ -156,17 +155,17 @@ pub async fn open_file_viewer( ) -> Result<(), String> { require_main(window.label())?; let project = project_of(&state, &project_id)?; - let container_id = running_container_of(&project, "opening files").await?; + let container = running_container_of(&project, "opening files").await?; let mounts: Vec = project.paths.iter().map(|p| p.mount_name.clone()).collect(); let mut candidates = candidate_paths(&path, &mounts)?; - let mut matches = probe_candidates(&container_id, &candidates).await?; + let mut matches = probe_candidates(&container, &candidates).await?; // Not in the project: try home and /tmp, as a second exec so a project file // never becomes a chooser because a namesake sits in /tmp. if matches.is_empty() { let fallbacks = fallback_candidate_paths(&path); if !fallbacks.is_empty() { - matches = probe_candidates(&container_id, &fallbacks).await?; + matches = probe_candidates(&container, &fallbacks).await?; candidates.extend(fallbacks); } } @@ -253,9 +252,9 @@ pub async fn viewer_read_file( ) -> Result { let (_label, target) = own_target(&window, ®istry)?; let path = resolved_path(&target)?; - let container_id = running_container_for(&state, &target, "opening files").await?; + let container = running_container_for(&state, &target, "opening files").await?; let cap = max_bytes.clamp(1, MAX_READ_BYTES); - let fetched = fetch_container_file(&container_id, &path, cap).await?; + let fetched = fetch_container_file(&container, &path, cap).await?; let (editable, readonly_reason) = match validate_container_write_path("File", &path) { Ok(()) => (true, None), Err(reason) => (false, Some(reason)), @@ -278,8 +277,8 @@ pub async fn viewer_poll_file( ) -> Result { let (_label, target) = own_target(&window, ®istry)?; let path = resolved_path(&target)?; - let container_id = running_container_for(&state, &target, "checking this file for changes").await?; - poll_file(&container_id, &path).await + let container = running_container_for(&state, &target, "checking this file for changes").await?; + poll_file(&container, &path).await } /// Errors from `write_file` pass through unchanged: the frontend matches the @@ -302,8 +301,8 @@ pub async fn viewer_write_file( let bytes = BASE64 .decode(contents_base64.as_bytes()) .map_err(|_| "The editor sent malformed content.".to_string())?; - let container_id = running_container_for(&state, &target, "saving this file").await?; - write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await + let container = running_container_for(&state, &target, "saving this file").await?; + write_file(&container, &state.exec_manager, &path, &bytes, &base_hash).await } #[cfg(test)] diff --git a/app/src-tauri/src/commands/marketplace_commands.rs b/app/src-tauri/src/commands/marketplace_commands.rs index c346224..c59a127 100644 --- a/app/src-tauri/src/commands/marketplace_commands.rs +++ b/app/src-tauri/src/commands/marketplace_commands.rs @@ -945,10 +945,10 @@ pub async fn apply_marketplace_now( let mut results = Vec::new(); for p in projects { let running = match ContainerTarget::for_project(&p) { - Some(target) => target.is_running().await.unwrap_or(false), - None => false, + Some(target) if target.is_running().await.unwrap_or(false) => Some(target), + _ => None, }; - if !running { + let Some(target) = running else { if project_id.is_some() { return Err(format!( "\"{}\" is not running. Its marketplace items are applied when it starts.", @@ -956,9 +956,8 @@ pub async fn apply_marketplace_now( )); } continue; - } - let cid = p.container_id.clone().unwrap_or_default(); - let report = mk::sync_project(&state.marketplace, &settings, &p, &cid).await; + }; + let report = mk::sync_project(&state.marketplace, &settings, &p, &target).await; let _ = app_handle.emit( mk::SYNC_FINISHED_EVENT, serde_json::json!({ "project_id": p.id, "report": report }), diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index a52a17b..becd18c 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1491,7 +1491,8 @@ async fn start_project_container_locked( .auth_bridge .start( project_id.clone(), - container_id.clone(), + // phase 1b: lifecycle will hand out the target + crate::host::ContainerTarget::docker(container_id.clone()), app_handle.clone(), state.projects_store.clone(), ) @@ -1695,12 +1696,12 @@ pub async fn reconcile_project_statuses( // bridge lives in this process, so re-arm it here. `start` is // idempotent, so a bridge that is already polling is untouched. if project.auth_bridge_enabled { - if let Some(ref container_id) = project.container_id { + if let Some(target) = crate::host::ContainerTarget::for_project(&project) { state .auth_bridge .start( project.id.clone(), - container_id.clone(), + target, app_handle.clone(), state.projects_store.clone(), ) diff --git a/app/src-tauri/src/file_viewer/poll.rs b/app/src-tauri/src/file_viewer/poll.rs index 28dd359..905e172 100644 --- a/app/src-tauri/src/file_viewer/poll.rs +++ b/app/src-tauri/src/file_viewer/poll.rs @@ -40,7 +40,10 @@ pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll { ViewerPoll { exists: true, hash, size } } -pub async fn poll_file(container_id: &str, container_path: &str) -> Result { +pub async fn poll_file( + container: &ContainerTarget, + container_path: &str, +) -> Result { let cmd = vec![ "sh".to_string(), "-c".to_string(), @@ -48,7 +51,7 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result Vec { } pub async fn probe_candidates( - container_id: &str, + container: &ContainerTarget, candidates: &[String], ) -> Result, String> { let mut cmd: Vec = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()]; cmd.extend(candidates.iter().cloned()); - let (stdout, _stderr, _code) = ContainerTarget::docker(container_id) + let (stdout, _stderr, _code) = container .exec_oneshot_streams_as("claude", cmd, Vec::new()) .await?; Ok(parse_probe_output(&stdout)) diff --git a/app/src-tauri/src/file_viewer/write.rs b/app/src-tauri/src/file_viewer/write.rs index d11315c..28172f2 100644 --- a/app/src-tauri/src/file_viewer/write.rs +++ b/app/src-tauri/src/file_viewer/write.rs @@ -172,7 +172,7 @@ fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result } pub async fn write_file( - container_id: &str, + container: &ContainerTarget, exec_manager: &ExecSessionManager, target: &str, bytes: &[u8], @@ -180,9 +180,8 @@ pub async fn write_file( ) -> Result { check_write_input(bytes.len(), base_hash)?; let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple()); - let container = ContainerTarget::docker(container_id); let tmp_path = exec_manager - .write_file_to_container(&container, &tmp_name, bytes) + .write_file_to_container(container, &tmp_name, bytes) .await?; let cmd = write_command(target, &tmp_path, base_hash); let (stdout, stderr, code) = container diff --git a/app/src-tauri/src/marketplace/mod.rs b/app/src-tauri/src/marketplace/mod.rs index 010b130..bec3b98 100644 --- a/app/src-tauri/src/marketplace/mod.rs +++ b/app/src-tauri/src/marketplace/mod.rs @@ -26,6 +26,7 @@ use crate::models::marketplace::{ effective_installs, CatalogItem, ItemKind, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport, }; +use crate::host::ContainerTarget; use crate::models::{AppSettings, Project}; use catalog::{item_fingerprint, parse_catalog}; use tree::GitTree; @@ -505,7 +506,7 @@ pub async fn sync_project( mgr: &MarketplaceManager, settings: &AppSettings, project: &Project, - container_id: &str, + container: &ContainerTarget, ) -> SyncReport { let lock = mgr.sync_lock(&project.id); let _sync_guard = lock.lock().await; @@ -533,7 +534,7 @@ pub async fn sync_project( items, p.skipped.len() ); - let result = sync::sync_container(container_id, &p).await; + let result = sync::sync_container(container, &p).await; sync::with_payload_skips(sync::report_from_result(result), &p.skipped) } Err(e) => sync::report_from_result(Err(e)), @@ -561,8 +562,13 @@ pub fn spawn_project_sync( if !should_sync(&mgr, &settings, &project) { return; } + // phase 1b: lifecycle will hand out the target. Until then the start path + // only has the fresh container's id: `project.container_id` is written + // after this is called, so `ContainerTarget::for_project` could name a + // container that was just replaced, or none at all. + let container = ContainerTarget::docker(container_id); tauri::async_runtime::spawn(async move { - let report = sync_project(&mgr, &settings, &project, &container_id).await; + let report = sync_project(&mgr, &settings, &project, &container).await; if !report.errors.is_empty() { log::warn!( "Marketplace sync for project {} reported errors: {:?}", @@ -1132,9 +1138,10 @@ mod tests { let lock = mgr.sync_lock(&project.id); let guard = lock.lock().await; + let nowhere = ContainerTarget::docker("no-such-container"); let blocked = tokio::time::timeout( std::time::Duration::from_millis(300), - sync_project(&mgr, &settings, &project, "no-such-container"), + sync_project(&mgr, &settings, &project, &nowhere), ) .await; assert!(blocked.is_err(), "sync must wait while another sync holds the lock"); @@ -1143,7 +1150,7 @@ mod tests { // No Docker (or no such container) here: the failure becomes a stored // report instead of an error. - let report = sync_project(&mgr, &settings, &project, "no-such-container").await; + let report = sync_project(&mgr, &settings, &project, &nowhere).await; assert_eq!(report.errors.len(), 1, "{report:?}"); assert!(!report.finished_at.is_empty()); assert_eq!(mgr.report(&project.id), Some(report)); diff --git a/app/src-tauri/src/marketplace/sync.rs b/app/src-tauri/src/marketplace/sync.rs index 7c7352b..0c84a08 100644 --- a/app/src-tauri/src/marketplace/sync.rs +++ b/app/src-tauri/src/marketplace/sync.rs @@ -92,9 +92,11 @@ fn tail(text: &str, max: usize) -> &str { /// Wait for readiness, upload the payload and the script, run the script as /// `claude`, and return its report. -pub async fn sync_container(container_id: &str, payload: &Payload) -> Result { - let target = ContainerTarget::docker(container_id); - wait_until_ready(&target).await?; +pub async fn sync_container( + target: &ContainerTarget, + payload: &Payload, +) -> Result { + wait_until_ready(target).await?; let (out, code) = target .exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![]) -- 2.52.0 From 7c004fed46a2fd86416ea56974cd3a1cbdbdd919 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 13 Nov 2026 11:22:24 -0800 Subject: [PATCH 3/3] Move the project container lifecycle behind host::lifecycle::ProjectHost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1b of the AWS host work. ProjectHost (one Docker variant) owns ensure_started (find → snapshot-and-recreate → create → start, moved verbatim from start_project_container_locked), find_current, stop, remove, discard_project_state and remove_legacy_leftovers. Start, stop, Reset, remove and reconcile keep only host-neutral steps and ask the host for the rest; the started ContainerTarget now flows to the auth bridge, marketplace sync and Bedrock credential sync. - create_container_for_project and default_docker_socket move to docker/ (migration used both; its duplicate socket helper is gone). - sync_bedrock_credentials and marketplace::spawn_project_sync take a ContainerTarget. - New boundary test: no lifecycle calls or ContainerTarget::docker(id) in non-test code outside host/ and docker/ (migration excepted). - Ignored live test round-trips a throwaway project container through the host; passes against Docker 29.9.3 and leaves nothing behind. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 +- .../src/commands/migration_commands.rs | 11 +- .../src/commands/project_commands.rs | 264 +++----------- app/src-tauri/src/docker/client.rs | 10 + app/src-tauri/src/docker/container.rs | 54 ++- app/src-tauri/src/host/lifecycle.rs | 324 ++++++++++++++++++ app/src-tauri/src/host/mod.rs | 98 ++++-- app/src-tauri/src/marketplace/mod.rs | 10 +- 8 files changed, 517 insertions(+), 266 deletions(-) create mode 100644 app/src-tauri/src/host/lifecycle.rs diff --git a/CLAUDE.md b/CLAUDE.md index ca0ad90..439ac46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,10 +287,14 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`. **Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or `get_docker()`** — `host::tests::no_container_interior_bollard_outside_the_host_layer` fails - the build on it. Its allowlist is Docker-only code (migration) plus lifecycle/`die` events, - which phase 1b moves; shrink it, never grow it. Prefer `ContainerTarget::for_project` or a - target a session already holds (`ExecSessionManager::get_target`) over - `ContainerTarget::docker(id)`, which hard-codes the host. + the build on it. `lifecycle.rs`'s `ProjectHost` owns create/start/stop/find/remove and + discarding a project's persistent state; the project commands keep only host-neutral steps + (validation, status, sessions, auth bridge, syncs). `watch.rs` gives `container_watch` its + death reports and positive-exit check. `no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer` + fails on lifecycle calls or `ContainerTarget::docker(id)` in non-test code outside `host/` and + `docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session + (`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception + (`DOCKER_ONLY`); shrink it, never grow it. - **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server: - `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades - `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect diff --git a/app/src-tauri/src/commands/migration_commands.rs b/app/src-tauri/src/commands/migration_commands.rs index 5117479..6771e37 100644 --- a/app/src-tauri/src/commands/migration_commands.rs +++ b/app/src-tauri/src/commands/migration_commands.rs @@ -55,7 +55,8 @@ use tauri::State; -use crate::commands::project_commands::{create_container_for_project, emit_progress}; +use crate::commands::project_commands::emit_progress; +use crate::docker::{create_container_for_project, default_docker_socket}; use crate::docker; use crate::docker::migration::{self as mig, Recovery}; use crate::models::{ @@ -1810,14 +1811,6 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) { migration_store::clear_ownerless_for_project(project_id); } -fn default_docker_socket() -> String { - if cfg!(target_os = "windows") { - "//./pipe/docker_engine".to_string() - } else { - "/var/run/docker.sock".to_string() - } -} - fn human_bytes(n: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; let mut v = n as f64; diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index becd18c..412b17c 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -2,7 +2,9 @@ use tauri::{Emitter, State}; use crate::commands::aws_commands; use crate::docker; -use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus}; +use crate::host::lifecycle::{DiscardLeftovers, ProjectHost}; +use crate::host::ContainerTarget; +use crate::models::{Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus}; use crate::storage::secure; use crate::AppState; @@ -114,48 +116,6 @@ fn store_secrets_for_project(project: &Project, explicitly_cleared: &[&str]) -> Ok(()) } -/// Create the project's container, threading every global setting through. -/// -/// Exists so that the two ordinary create paths below and base-image migration -/// cannot drift apart — a container created by a migration must be -/// indistinguishable from one created by a normal start, or the next -/// `container_needs_recreation` would immediately throw it away. -/// -/// `create_image` is what to create *from* (the snapshot or the base); -/// `base_image_name` is the configured base, which `create_container` needs in -/// order to tell those two apart when it stamps the lineage labels. -pub(crate) async fn create_container_for_project( - project: &Project, - settings: &AppSettings, - docker_socket: &str, - aws_config_path: Option<&str>, - create_image: &str, - base_image_name: &str, - extras: docker::CreateExtras<'_>, -) -> Result { - docker::create_container( - project, - docker_socket, - create_image, - base_image_name, - extras, - aws_config_path, - &settings.global_aws, - &settings.global_ollama, - &settings.global_llamacpp, - &settings.global_openai_compatible, - settings.global_claude_instructions.as_deref(), - &settings.global_custom_env_vars, - settings.timezone.as_deref(), - settings.global_claude_code_settings.as_ref(), - settings.default_ssh_key_path.as_deref(), - settings.ca_cert_path.as_deref(), - settings.default_git_user_name.as_deref(), - settings.default_git_user_email.as_deref(), - ) - .await -} - /// Populate secret fields on a project struct from the OS keychain. pub(crate) fn load_secrets_for_project(project: &mut Project) { project.git_token = secure::get_project_secret(&project.id, "git-token") @@ -774,7 +734,8 @@ pub async fn remove_project( if let Some(ref stored_id) = project.container_id { state.exec_manager.close_sessions_for_container(stored_id).await; } - let container_ref = match docker::find_existing_container(project).await { + let host = ProjectHost::for_project(project); + let container_ref = match host.find_current(project).await { Ok(found) => found, Err(e) => { log::warn!( @@ -785,15 +746,14 @@ pub async fn remove_project( None } }; - if let Some(ref container_id) = container_ref { - if project.container_id.as_deref() != Some(container_id.as_str()) { - state.exec_manager.close_sessions_for_container(container_id).await; + if let Some(ref container) = container_ref { + if project.container_id.as_deref() != Some(container.id()) { + state.exec_manager.close_sessions_for_container(container.id()).await; } - let _ = docker::stop_container(container_id).await; - if let Err(e) = docker::remove_container(container_id).await { + if let Err(e) = host.remove(container).await { log::warn!( "Failed to remove container {} for project {}: {}", - container_id, project_id, e + container.id(), project_id, e ); // Recorded by name, not id: the name is the stable handle a // later retry can still resolve (Docker's remove-container @@ -805,15 +765,12 @@ pub async fn remove_project( // Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP // containers first, then the per-project network they were attached to. - docker::remove_legacy_mcp_containers(&project.id).await; - docker::remove_legacy_project_network(&project.id).await; + host.remove_legacy_leftovers(project).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); - report.image = Some(docker::get_snapshot_image_name(project)); - } - report.volumes = docker::remove_project_volumes(project).await; + let left = host.discard_project_state(project).await; + report.image = left.image; + report.volumes = left.volumes; } // Clean up keychain secrets for this project @@ -1204,9 +1161,9 @@ async fn start_project_container_locked( // in memory when building environment variables for the container. load_secrets_for_project(&mut project); - // Load settings for image resolution and global AWS + // Load settings: backend validation below, and the host's start (image + // resolution, global AWS, …). let settings = state.settings_store.get(); - let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); // Validate backend requirements if project.backend == Backend::Bedrock { @@ -1322,140 +1279,18 @@ async fn start_project_container_locked( } // Wrap container operations so that any failure resets status to Stopped. - let result: Result = async { - // Ensure image exists - emit_progress(&app_handle, &project_id, "Checking image..."); - if !docker::image_exists(&image_name).await? { - return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name)); - } - - // Determine docker socket path - let docker_socket = settings.docker_socket_path - .as_deref() - .map(|s| s.to_string()) - .unwrap_or_else(|| default_docker_socket()); - - // AWS config path from global settings - let aws_config_path = settings.global_aws.aws_config_path.clone(); - - // What we would create this container from *right now*: the project's - // snapshot when one exists, else the configured base. This is the value - // `container_needs_recreation` compares against the container's - // `triple-c.create-image` label — the check that replaced the old - // tautological one. It is resolved *before* the commit below, so it - // describes the pre-commit world the existing container was born into. - let snapshot_image = docker::get_snapshot_image_name(&project); - let expected_create_image = - if docker::image_exists(&snapshot_image).await.unwrap_or(false) { - snapshot_image.clone() - } else { - image_name.clone() - }; - - let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { - // Check if config changed — if so, snapshot + recreate - let needs_recreate = docker::container_needs_recreation( - &existing_id, - &project, - &expected_create_image, - &settings.global_aws, - &settings.global_ollama, - &settings.global_llamacpp, - &settings.global_openai_compatible, - settings.global_claude_instructions.as_deref(), - &settings.global_custom_env_vars, - settings.timezone.as_deref(), - settings.global_claude_code_settings.as_ref(), - settings.default_ssh_key_path.as_deref(), - settings.ca_cert_path.as_deref(), - settings.default_git_user_name.as_deref(), - settings.default_git_user_email.as_deref(), - ).await.unwrap_or(false); - - if needs_recreate { - log::info!("Container config changed for project {} — committing snapshot and recreating", project.id); - // Snapshot the filesystem before destroying - 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; - docker::remove_container(&existing_id).await?; - - // Legacy MCP cleanup: the old container may have been attached to - // `triple-c-net-`. Tear down leftover MCP containers and - // that network now, before the replacement is created without it. - docker::remove_legacy_mcp_containers(&project.id).await; - docker::remove_legacy_project_network(&project.id).await; - - // Create from snapshot image (preserves system-level changes). - // Re-resolved after the commit above: when no snapshot existed - // before, one does now, and creating from the base instead - // would throw away the state that was just saved. - let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { - snapshot_image.clone() - } else { - image_name.clone() - }; - - let new_id = create_container_for_project( - &project, - &settings, - &docker_socket, - aws_config_path.as_deref(), - &create_image, - &image_name, - docker::CreateExtras::default(), - ).await?; - emit_progress(&app_handle, &project_id, "Starting container..."); - docker::start_container(&new_id).await?; - - // The commit above moved `:latest` and orphaned the image it - // used to point at; the container holding that image open was - // removed a few lines up, so now is when Docker will actually - // let it go. Detached because this is housekeeping and the - // project is already running — and it sweeps every orphan, not - // just this one, so recreations that happened before the sweep - // existed are cleaned up too. - tauri::async_runtime::spawn(async { - docker::sweep_orphaned_snapshots_logged("after recreation").await; - }); - - new_id - } else { - emit_progress(&app_handle, &project_id, "Starting container..."); - docker::start_container(&existing_id).await?; - 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. - if expected_create_image == snapshot_image { - log::info!("Creating container from snapshot image for project {}", project.id); - } - let create_image = expected_create_image.clone(); - - emit_progress(&app_handle, &project_id, "Creating container..."); - let new_id = create_container_for_project( - &project, - &settings, - &docker_socket, - aws_config_path.as_deref(), - &create_image, - &image_name, - docker::CreateExtras::default(), - ).await?; - emit_progress(&app_handle, &project_id, "Starting container..."); - docker::start_container(&new_id).await?; - new_id - }; + let host = ProjectHost::for_project(&project); + let result: Result = async { + let target = host + .ensure_started(&project, &settings, &|msg: &str| { + emit_progress(&app_handle, &project_id, msg) + }) + .await?; // Sync Bedrock credentials on every start: refresh static/session creds // so rotated keys are picked up without a full container recreation, and // clear stale creds when the project no longer uses static-cred Bedrock. - if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await { + if let Err(e) = docker::sync_bedrock_credentials(&target, &project).await { log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e); } @@ -1466,10 +1301,10 @@ async fn start_project_container_locked( state.marketplace.clone(), state.settings_store.get(), project.clone(), - container_id.clone(), + target.clone(), ); - Ok(container_id) + Ok(target) }.await; // On failure, reset status to Stopped so the project doesn't get stuck. @@ -1478,7 +1313,8 @@ async fn start_project_container_locked( let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped); } - let container_id = result?; + let target = result?; + let container_id = target.id().to_string(); // Update project with container info using granular methods (Issue 14: TOCTOU) state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?; @@ -1491,8 +1327,7 @@ async fn start_project_container_locked( .auth_bridge .start( project_id.clone(), - // phase 1b: lifecycle will hand out the target - crate::host::ContainerTarget::docker(container_id.clone()), + target.clone(), app_handle.clone(), state.projects_store.clone(), ) @@ -1528,13 +1363,13 @@ pub async fn stop_project_container( // Drop host listeners first: they only make sense while the container runs. state.auth_bridge.stop(&project_id).await; - if let Some(ref container_id) = project.container_id { + if let Some(container) = ContainerTarget::for_project(&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; - 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); + if let Err(e) = ProjectHost::for_project(&project).stop(&container).await { + log::warn!("Stop failed for container {} (project {}): {} — resetting to Stopped anyway", container.id(), project_id, e); } } @@ -1589,13 +1424,13 @@ pub async fn rebuild_project_container( if let Some(ref stored_id) = project.container_id { state.exec_manager.close_sessions_for_container(stored_id).await; } - let container_ref = docker::find_existing_container(&project).await?; - if let Some(ref container_id) = container_ref { - if project.container_id.as_deref() != Some(container_id.as_str()) { - state.exec_manager.close_sessions_for_container(container_id).await; + let host = ProjectHost::for_project(&project); + let container_ref = host.find_current(&project).await?; + if let Some(ref container) = container_ref { + if project.container_id.as_deref() != Some(container.id()) { + state.exec_manager.close_sessions_for_container(container.id()).await; } - let _ = docker::stop_container(container_id).await; - docker::remove_container(container_id).await?; + host.remove(container).await?; state.projects_store.set_container_id(&project_id, None)?; } @@ -1608,12 +1443,10 @@ pub async fn rebuild_project_container( // discard. No pending-cleanup record for either: unlike `remove_project`, // Reset keeps the project record, so a later Reset attempt can retry // these itself rather than needing startup housekeeping to do it. - let mut leftover_image = None; - if let Err(e) = docker::remove_snapshot_image(&project).await { - log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); - leftover_image = Some(docker::get_snapshot_image_name(&project)); - } - let leftover_volumes = docker::remove_project_volumes(&project).await; + let DiscardLeftovers { + image: leftover_image, + volumes: leftover_volumes, + } = host.discard_project_state(&project).await; if leftover_image.is_some() || !leftover_volumes.is_empty() { log::warn!( "Reset for project {} could not fully clean up — image: {:?}, volumes: {:?} — the \ @@ -1680,8 +1513,8 @@ pub async fn reconcile_project_statuses( continue; } - let is_running = if let Some(ref container_id) = project.container_id { - docker::is_container_running(container_id).await.unwrap_or(false) + let is_running = if let Some(container) = ContainerTarget::for_project(project) { + container.is_running().await.unwrap_or(false) } else { false }; @@ -1721,13 +1554,6 @@ pub async fn reconcile_project_statuses( Ok(state.projects_store.list()) } -fn default_docker_socket() -> String { - if cfg!(target_os = "windows") { - "//./pipe/docker_engine".to_string() - } else { - "/var/run/docker.sock".to_string() - } -} #[cfg(test)] mod tests { diff --git a/app/src-tauri/src/docker/client.rs b/app/src-tauri/src/docker/client.rs index 49c4e5b..e64eaf3 100644 --- a/app/src-tauri/src/docker/client.rs +++ b/app/src-tauri/src/docker/client.rs @@ -26,3 +26,13 @@ pub async fn check_docker_available() -> Result { } } } + +/// The daemon socket a project container gets bind-mounted when Docker access +/// is enabled and no path is configured. +pub fn default_docker_socket() -> String { + if cfg!(target_os = "windows") { + "//./pipe/docker_engine".to_string() + } else { + "/var/run/docker.sock".to_string() + } +} diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 3d0ced1..ef39fbb 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -9,6 +9,7 @@ use sha2::{Sha256, Digest}; use super::ca_certs; use super::client::get_docker; +use crate::models::AppSettings; use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath}; const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks @@ -2015,7 +2016,7 @@ pub const CONFIG_VOLUME_PREFIX: &str = "triple-c-claude-config-"; /// `aws_config_path` is configured), since the entrypoint already refreshes /// `~/.aws` from the host on every start in that case. pub async fn sync_bedrock_credentials( - container_id: &str, + target: &crate::host::ContainerTarget, project: &Project, ) -> Result<(), String> { let static_bedrock = if project.backend == Backend::Bedrock { @@ -2038,10 +2039,10 @@ pub async fn sync_bedrock_credentials( let script = r#"if [ ! -d /tmp/.host-aws ]; then rm -f "$HOME/.aws/credentials"; fi"#; let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; let env = vec!["HOME=/home/claude".to_string()]; - if let Err(e) = crate::docker::exec::exec_oneshot_env(container_id, cmd, env).await { + if let Err(e) = target.exec_oneshot_env(cmd, env).await { log::warn!( "Failed to clear stale AWS credentials in container {}: {}", - container_id, + target.id(), e ); } @@ -2093,7 +2094,7 @@ chmod 600 "$HOME/.aws/credentials""#; let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; let (output, exit_code) = - crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env) + target.exec_oneshot_env_status(cmd, env) .await .map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?; if exit_code != 0 { @@ -2104,7 +2105,7 @@ chmod 600 "$HOME/.aws/credentials""#; )); } - log::info!("Wrote Bedrock static credentials into container {}", container_id); + log::info!("Wrote Bedrock static credentials into container {}", target.id()); Ok(()) } @@ -4143,6 +4144,49 @@ pub async fn is_container_running(container_id: &str) -> Result { } + +/// Create the project's container, threading every global setting through. +/// +/// Exists so that the ordinary create paths (`host::lifecycle`) and base-image migration +/// cannot drift apart — a container created by a migration must be +/// indistinguishable from one created by a normal start, or the next +/// `container_needs_recreation` would immediately throw it away. +/// +/// `create_image` is what to create *from* (the snapshot or the base); +/// `base_image_name` is the configured base, which `create_container` needs in +/// order to tell those two apart when it stamps the lineage labels. +pub async fn create_container_for_project( + project: &Project, + settings: &AppSettings, + docker_socket: &str, + aws_config_path: Option<&str>, + create_image: &str, + base_image_name: &str, + extras: CreateExtras<'_>, +) -> Result { + create_container( + project, + docker_socket, + create_image, + base_image_name, + extras, + aws_config_path, + &settings.global_aws, + &settings.global_ollama, + &settings.global_llamacpp, + &settings.global_openai_compatible, + settings.global_claude_instructions.as_deref(), + &settings.global_custom_env_vars, + settings.timezone.as_deref(), + settings.global_claude_code_settings.as_ref(), + settings.default_ssh_key_path.as_deref(), + settings.ca_cert_path.as_deref(), + settings.default_git_user_name.as_deref(), + settings.default_git_user_email.as_deref(), + ) + .await +} + #[cfg(test)] mod tests { use super::*; diff --git a/app/src-tauri/src/host/lifecycle.rs b/app/src-tauri/src/host/lifecycle.rs new file mode 100644 index 0000000..1196d57 --- /dev/null +++ b/app/src-tauri/src/host/lifecycle.rs @@ -0,0 +1,324 @@ +//! Bringing a project's container into existence, starting, stopping and +//! removing it — the host-specific half of the project lifecycle commands. +//! +//! The commands in `commands/project_commands.rs` keep everything that is the +//! same on every host: validating the LLM backend config, the Bedrock SSO +//! check, project status transitions, the project lock, closing terminal +//! sessions, the auth bridge, marketplace and credential syncs. They ask a +//! [`ProjectHost`] for the rest. For Docker that is the +//! find → maybe-snapshot-and-recreate → create → start sequence, which used to +//! sit inline in `start_project_container_locked` and is moved here unchanged. +//! +//! Docker-only operations with no host-neutral meaning — base-image migration, +//! snapshot sweeps, legacy cleanup at startup — keep calling `crate::docker` +//! directly; see the boundary test in `host/mod.rs`. + +use super::ContainerTarget; +use crate::docker; +use crate::models::{AppSettings, Project}; +use crate::models::container_config; + +/// The host a project runs on. One variant today; an AWS variant will carry +/// the connection, region and environment (see the spec's data model). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProjectHost { + Docker, +} + +/// What a host could not clean up when asked to discard a project's state. +/// Docker: the snapshot image and the named volumes. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct DiscardLeftovers { + pub image: Option, + pub volumes: Vec, +} + +impl ProjectHost { + pub fn for_project(_project: &Project) -> Self { + ProjectHost::Docker + } + + /// Make sure the project's container exists, matches the project's + /// current configuration, and is running. Returns where it runs. + /// + /// `progress` receives the same human-readable steps the start view has + /// always shown. Failure leaves the caller to reset the project status. + pub async fn ensure_started( + &self, + project: &Project, + settings: &AppSettings, + progress: &(dyn Fn(&str) + Send + Sync), + ) -> Result { + match self { + ProjectHost::Docker => docker_ensure_started(project, settings, progress) + .await + .map(ContainerTarget::docker), + } + } + + /// The container the host currently has for this project, found by asking + /// the host rather than trusting `project.container_id` — that field can + /// be stale (a crash between remove and the store update, a hand-edited + /// `projects.json`). + pub async fn find_current(&self, project: &Project) -> Result, String> { + match self { + ProjectHost::Docker => Ok(docker::find_existing_container(project) + .await? + .map(ContainerTarget::docker)), + } + } + + pub async fn stop(&self, target: &ContainerTarget) -> Result<(), String> { + match target { + ContainerTarget::Docker { container_id } => docker::stop_container(container_id).await, + } + } + + /// Stop (errors ignored — a stopped container is the goal) and remove. + pub async fn remove(&self, target: &ContainerTarget) -> Result<(), String> { + match target { + ContainerTarget::Docker { container_id } => { + let _ = docker::stop_container(container_id).await; + docker::remove_container(container_id).await + } + } + } + + /// Delete what outlives the container — for Docker the snapshot image and + /// both named volumes (`~/.claude`, the OAuth login and transcripts go with + /// them). Best-effort; reports what it could not remove. + pub async fn discard_project_state(&self, project: &Project) -> DiscardLeftovers { + match self { + ProjectHost::Docker => { + let mut left = DiscardLeftovers::default(); + if let Err(e) = docker::remove_snapshot_image(project).await { + log::warn!("Failed to remove snapshot image for project {}: {}", project.id, e); + left.image = Some(docker::get_snapshot_image_name(project)); + } + left.volumes = docker::remove_project_volumes(project).await; + left + } + } + } + + /// Remove leftovers of features this host no longer has (Docker: the + /// deleted MCP feature's containers and network). + pub async fn remove_legacy_leftovers(&self, project: &Project) { + match self { + ProjectHost::Docker => { + docker::remove_legacy_mcp_containers(&project.id).await; + docker::remove_legacy_project_network(&project.id).await; + } + } + } +} + +/// The Docker start sequence, moved here verbatim from +/// `start_project_container_locked`. Returns the running container's id. +async fn docker_ensure_started( + project: &Project, + settings: &AppSettings, + progress: &(dyn Fn(&str) + Send + Sync), +) -> Result { + let image_name = + container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); + + // Ensure image exists + progress("Checking image..."); + if !docker::image_exists(&image_name).await? { + return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name)); + } + + // Determine docker socket path + let docker_socket = settings.docker_socket_path + .as_deref() + .map(|s| s.to_string()) + .unwrap_or_else(|| docker::default_docker_socket()); + + // AWS config path from global settings + let aws_config_path = settings.global_aws.aws_config_path.clone(); + + // What we would create this container from *right now*: the project's + // snapshot when one exists, else the configured base. This is the value + // `container_needs_recreation` compares against the container's + // `triple-c.create-image` label — the check that replaced the old + // tautological one. It is resolved *before* the commit below, so it + // describes the pre-commit world the existing container was born into. + let snapshot_image = docker::get_snapshot_image_name(project); + let expected_create_image = + if docker::image_exists(&snapshot_image).await.unwrap_or(false) { + snapshot_image.clone() + } else { + image_name.clone() + }; + + let container_id = if let Some(existing_id) = docker::find_existing_container(project).await? { + // Check if config changed — if so, snapshot + recreate + let needs_recreate = docker::container_needs_recreation( + &existing_id, + project, + &expected_create_image, + &settings.global_aws, + &settings.global_ollama, + &settings.global_llamacpp, + &settings.global_openai_compatible, + settings.global_claude_instructions.as_deref(), + &settings.global_custom_env_vars, + settings.timezone.as_deref(), + settings.global_claude_code_settings.as_ref(), + settings.default_ssh_key_path.as_deref(), + settings.ca_cert_path.as_deref(), + settings.default_git_user_name.as_deref(), + settings.default_git_user_email.as_deref(), + ).await.unwrap_or(false); + + if needs_recreate { + log::info!("Container config changed for project {} — committing snapshot and recreating", project.id); + // Snapshot the filesystem before destroying + progress("Saving container state..."); + if let Err(e) = docker::commit_container_snapshot(&existing_id, project).await { + log::warn!("Failed to snapshot container before recreation: {}", e); + } + progress("Recreating container..."); + let _ = docker::stop_container(&existing_id).await; + docker::remove_container(&existing_id).await?; + + // Legacy MCP cleanup: the old container may have been attached to + // `triple-c-net-`. Tear down leftover MCP containers and + // that network now, before the replacement is created without it. + docker::remove_legacy_mcp_containers(&project.id).await; + docker::remove_legacy_project_network(&project.id).await; + + // Create from snapshot image (preserves system-level changes). + // Re-resolved after the commit above: when no snapshot existed + // before, one does now, and creating from the base instead + // would throw away the state that was just saved. + let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { + snapshot_image.clone() + } else { + image_name.clone() + }; + + let new_id = docker::create_container_for_project( + project, + settings, + &docker_socket, + aws_config_path.as_deref(), + &create_image, + &image_name, + docker::CreateExtras::default(), + ).await?; + progress("Starting container..."); + docker::start_container(&new_id).await?; + + // The commit above moved `:latest` and orphaned the image it + // used to point at; the container holding that image open was + // removed a few lines up, so now is when Docker will actually + // let it go. Detached because this is housekeeping and the + // project is already running — and it sweeps every orphan, not + // just this one, so recreations that happened before the sweep + // existed are cleaned up too. + tauri::async_runtime::spawn(async { + docker::sweep_orphaned_snapshots_logged("after recreation").await; + }); + + new_id + } else { + progress("Starting container..."); + docker::start_container(&existing_id).await?; + 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. + if expected_create_image == snapshot_image { + log::info!("Creating container from snapshot image for project {}", project.id); + } + let create_image = expected_create_image.clone(); + + progress("Creating container..."); + let new_id = docker::create_container_for_project( + project, + settings, + &docker_socket, + aws_config_path.as_deref(), + &create_image, + &image_name, + docker::CreateExtras::default(), + ).await?; + progress("Starting container..."); + docker::start_container(&new_id).await?; + new_id + }; + + Ok(container_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// End to end against a real daemon and the registry sandbox image: a + /// throwaway project (fresh UUID, so its container, volumes and snapshot + /// name are its own) is started, reached, stopped, started again on the + /// *same* container, then removed and its state discarded. + #[tokio::test] + #[ignore = "needs a Docker daemon and the triple-c-sandbox image; creates and removes a throwaway project container"] + async fn a_project_container_round_trips_through_the_host() { + let project = Project::new("lifecycle-live-test".to_string(), Vec::new()); + let settings = AppSettings::default(); + let host = ProjectHost::for_project(&project); + let steps = std::sync::Mutex::new(Vec::::new()); + let progress = |m: &str| steps.lock().unwrap().push(m.to_string()); + // Failures inside the round trip return instead of panicking, so the + // cleanup below always runs. + fn check(ok: bool, what: &str) -> Result<(), String> { + if ok { Ok(()) } else { Err(format!("check failed: {what}")) } + } + + let outcome = async { + check(host.find_current(&project).await?.is_none(), "no container before the first start")?; + + let first = host.ensure_started(&project, &settings, &progress).await?; + check(first.is_running().await?, "running after start")?; + let (out, code) = first + .exec_oneshot_env_status(vec!["id".into(), "-un".into()], vec![]) + .await?; + check(out.trim() == "claude" && code == 0, "exec runs as claude")?; + check(host.find_current(&project).await? == Some(first.clone()), "find_current names it")?; + + host.stop(&first).await?; + check(first.has_exited().await, "exited after stop")?; + + // Same config, so the existing container is started, not recreated. + let second = host.ensure_started(&project, &settings, &progress).await?; + check(second == first, "restart reuses the container")?; + check(second.is_running().await?, "running after restart")?; + + host.remove(&second).await?; + check(host.find_current(&project).await?.is_none(), "gone after remove")?; + Ok::<_, String>(()) + } + .await; + + // Always clean up, even after a failed assertion above. + if let Ok(Some(t)) = host.find_current(&project).await { + let _ = host.remove(&t).await; + } + let left = host.discard_project_state(&project).await; + outcome.expect("round trip"); + assert_eq!(left, DiscardLeftovers::default(), "nothing left behind"); + let steps = steps.into_inner().unwrap(); + assert_eq!( + steps, + [ + "Checking image...", + "Creating container...", + "Starting container...", + "Checking image...", + "Starting container...", + ] + ); + } +} diff --git a/app/src-tauri/src/host/mod.rs b/app/src-tauri/src/host/mod.rs index a259dc6..86359d4 100644 --- a/app/src-tauri/src/host/mod.rs +++ b/app/src-tauri/src/host/mod.rs @@ -26,6 +26,7 @@ //! `no_container_interior_bollard_outside_the_host_layer` test pins which files //! may still do so. +pub mod lifecycle; pub mod sessions; pub mod watch; @@ -422,20 +423,11 @@ mod tests { assert_eq!(ContainerTarget::docker("abc").id(), "abc"); } - /// Phase 1's boundary, pinned: outside `docker/` and `host/`, only the - /// files listed here may name bollard or `docker::exec`. Each entry is - /// Docker-only by nature (migration, snapshots, sidecars) or is scheduled - /// for phase 1b (lifecycle, the `die` event stream). Shrink this list; do - /// not grow it — new container-interior code goes through - /// [`ContainerTarget`]. - #[test] - fn no_container_interior_bollard_outside_the_host_layer() { - const ALLOWED: &[&str] = &[ - // Docker-only: base-image migration replays apt / restores a payload. - "commands/migration_commands.rs", - ]; + /// Every non-`docker/`, non-`host/` source file, with its path relative to + /// `src/`, minus the files in `allowed`. + fn sources_outside_the_host_layer(allowed: &[&str]) -> Vec<(String, String)> { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut offenders = Vec::new(); + let mut out = Vec::new(); let mut stack = vec![src.clone()]; while let Some(dir) = stack.pop() { for entry in std::fs::read_dir(&dir).unwrap() { @@ -451,23 +443,83 @@ mod tests { } continue; } - if !rel.ends_with(".rs") || ALLOWED.contains(&rel.as_str()) { - continue; - } - let text = std::fs::read_to_string(&path).unwrap(); - if text.contains("bollard::") - || text.contains("docker::exec") - || text.contains("get_docker") - { - offenders.push(rel); + if rel.ends_with(".rs") && !allowed.contains(&rel.as_str()) { + out.push((rel, std::fs::read_to_string(&path).unwrap())); } } } - offenders.sort(); + out.sort(); + out + } + + /// Docker-only by nature: base-image migration replays apt, restores a + /// payload, commits and swaps containers. It has no AWS meaning. + const DOCKER_ONLY: &[&str] = &["commands/migration_commands.rs"]; + + /// The host boundary, pinned: outside `docker/` and `host/`, nothing names + /// bollard or `docker::exec`. Container-interior code goes through + /// [`ContainerTarget`]. Shrink [`DOCKER_ONLY`]; do not grow it. + #[test] + fn no_container_interior_bollard_outside_the_host_layer() { + let offenders: Vec = sources_outside_the_host_layer(DOCKER_ONLY) + .into_iter() + .filter(|(_, text)| { + text.contains("bollard::") + || text.contains("docker::exec") + || text.contains("get_docker") + }) + .map(|(rel, _)| rel) + .collect(); assert!( offenders.is_empty(), "these files reach into the container without going through host::ContainerTarget: {:?}", offenders ); } + + /// The lifecycle half of the boundary: outside `docker/` and `host/`, no + /// non-test code creates, starts, stops, finds or recreates a container + /// itself (that is [`lifecycle::ProjectHost`]'s job), and none builds a + /// [`ContainerTarget::docker`] from a bare id, which would hard-code the + /// host. Comments and `#[cfg(test)]` code are not checked. + /// + /// `docker::remove_container` is deliberately absent: startup's + /// pending-cleanup retry removes Docker leftovers it recorded *by name*, + /// which is Docker housekeeping, not lifecycle. + #[test] + fn no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer() { + const BANNED: &[&str] = &[ + "docker::create_container(", + "docker::start_container(", + "docker::stop_container(", + "docker::find_existing_container(", + "docker::container_needs_recreation(", + "docker::commit_container_snapshot(", + "docker::remove_snapshot_image(", + "docker::remove_project_volumes(", + "docker::is_container_running(", + "container::is_container_running(", + "ContainerTarget::docker(", + ]; + let mut offenders = Vec::new(); + for (rel, text) in sources_outside_the_host_layer(DOCKER_ONLY) { + let code = text.split("#[cfg(test)]").next().unwrap_or(""); + for (n, line) in code.lines().enumerate() { + let line = line.trim_start(); + if line.starts_with("//") { + continue; + } + for pat in BANNED { + if line.contains(pat) { + offenders.push(format!("{}:{}: {}", rel, n + 1, pat)); + } + } + } + } + assert!( + offenders.is_empty(), + "container lifecycle / bare Docker targets outside host::lifecycle:\n{}", + offenders.join("\n") + ); + } } diff --git a/app/src-tauri/src/marketplace/mod.rs b/app/src-tauri/src/marketplace/mod.rs index bec3b98..a7d4a5c 100644 --- a/app/src-tauri/src/marketplace/mod.rs +++ b/app/src-tauri/src/marketplace/mod.rs @@ -557,16 +557,14 @@ pub fn spawn_project_sync( mgr: Arc, settings: AppSettings, project: Project, - container_id: String, + // The target the start just produced — not `ContainerTarget::for_project`: + // `project.container_id` is written after this is called, so it could name + // a container that was just replaced, or none at all. + container: ContainerTarget, ) { if !should_sync(&mgr, &settings, &project) { return; } - // phase 1b: lifecycle will hand out the target. Until then the start path - // only has the fresh container's id: `project.container_id` is written - // after this is called, so `ContainerTarget::for_project` could name a - // container that was just replaced, or none at all. - let container = ContainerTarget::docker(container_id); tauri::async_runtime::spawn(async move { let report = sync_project(&mgr, &settings, &project, &container).await; if !report.errors.is_empty() { -- 2.52.0