From 4c3b487e836196232d2258e1530d9d0eb30dbf87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:56:14 +0000 Subject: [PATCH] feat(workspace): add lifecycle controller --- crates/splice-core/src/lib.rs | 2 + crates/splice-core/src/workspace.rs | 21 + crates/splice-core/src/workspace_lifecycle.rs | 356 +++++++++ .../splice-core/tests/workspace_lifecycle.rs | 683 ++++++++++++++++++ 4 files changed, 1062 insertions(+) create mode 100644 crates/splice-core/src/workspace_lifecycle.rs create mode 100644 crates/splice-core/tests/workspace_lifecycle.rs diff --git a/crates/splice-core/src/lib.rs b/crates/splice-core/src/lib.rs index 6ff639d..606a44c 100644 --- a/crates/splice-core/src/lib.rs +++ b/crates/splice-core/src/lib.rs @@ -1,5 +1,7 @@ mod workspace; +mod workspace_lifecycle; pub use workspace::*; +pub use workspace_lifecycle::*; #[derive(Debug, Clone, PartialEq, Eq)] pub enum PastePayload { diff --git a/crates/splice-core/src/workspace.rs b/crates/splice-core/src/workspace.rs index fc2e446..7ee00bc 100644 --- a/crates/splice-core/src/workspace.rs +++ b/crates/splice-core/src/workspace.rs @@ -20,6 +20,10 @@ impl WorkspaceId { .then_some(Self(value)) .ok_or(WorkspaceError::InvalidWorkspaceId) } + + pub fn as_str(&self) -> &str { + &self.0 + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentDescriptor { @@ -67,6 +71,10 @@ pub struct WorkspaceProfile { pub environment: EnvironmentMetadata, pub agent: AgentDescriptor, pub session_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lifecycle_tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lifecycle_closing_session_id: Option, } impl WorkspaceProfile { pub fn new( @@ -93,6 +101,8 @@ impl WorkspaceProfile { environment, agent, session_ids: session_ids.into_iter().collect(), + lifecycle_tab_id: None, + lifecycle_closing_session_id: None, }) } } @@ -159,6 +169,13 @@ impl WorkspaceStore { .and_then(|mut database| database.profiles.remove(id))) } + pub fn list(&self) -> Result, WorkspaceError> { + Ok(self + .read()? + .map(|database| database.profiles.into_values().collect()) + .unwrap_or_default()) + } + fn path(&self) -> PathBuf { self.root.join(STORE_FILE) } @@ -266,6 +283,10 @@ fn valid_profile(profile: &WorkspaceProfile) -> bool { && valid_label(&profile.agent.id) && valid_command(&profile.agent.command) && profile.session_ids.iter().all(|id| *id != 0) + && profile.lifecycle_tab_id.as_deref().is_none_or(valid_label) + && profile + .lifecycle_closing_session_id + .is_none_or(|id| id != 0 && profile.session_ids.contains(&id)) && profile.session_ids.iter().collect::>().len() == profile.session_ids.len() } fn valid_database(database: &Database) -> bool { diff --git a/crates/splice-core/src/workspace_lifecycle.rs b/crates/splice-core/src/workspace_lifecycle.rs new file mode 100644 index 0000000..aa8a0e5 --- /dev/null +++ b/crates/splice-core/src/workspace_lifecycle.rs @@ -0,0 +1,356 @@ +use crate::{WorkspaceError, WorkspaceId, WorkspaceProfile, WorkspaceStore}; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct SessionId(u64); + +impl SessionId { + pub fn new(value: u64) -> Result { + (value != 0) + .then_some(Self(value)) + .ok_or(WorkspaceLifecycleError::InvalidSessionId) + } + + pub fn get(self) -> u64 { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TabId(String); + +impl TabId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + (!value.is_empty() && value.len() <= 64) + .then_some(Self(value)) + .ok_or(WorkspaceLifecycleError::InvalidTabId) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SessionLifecycleError { + pub code: String, + pub message: String, + pub platform: Option, + pub retryable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LifecycleError { + pub code: String, + pub message: String, + pub platform: Option, + pub retryable: bool, +} + +impl SessionLifecycleError { + pub fn contract(&self) -> LifecycleError { + LifecycleError { + code: self.code.clone(), + message: self.message.clone(), + platform: self.platform.clone(), + retryable: self.retryable, + } + } + + fn closes_convergently(&self) -> bool { + matches!(self.code.as_str(), "already-closed" | "session-not-found") + } +} + +pub trait SessionLifecyclePort { + fn start(&mut self, profile: &WorkspaceProfile) -> Result; + fn close(&mut self, session: SessionId) -> Result<(), SessionLifecycleError>; +} + +#[derive(Debug)] +pub enum WorkspaceLifecycleError { + InvalidSessionId, + InvalidTabId, + NotFound(WorkspaceId), + Conflict(WorkspaceId), + Store(WorkspaceError), + Session(SessionLifecycleError), + StartRollback { + store: WorkspaceError, + close: SessionLifecycleError, + }, +} + +impl From for WorkspaceLifecycleError { + fn from(error: WorkspaceError) -> Self { + Self::Store(error) + } +} + +impl WorkspaceLifecycleError { + pub fn contract(&self) -> LifecycleError { + match self { + Self::InvalidSessionId => { + contract("invalid-session-id", "Session ID must be non-zero.", false) + } + Self::InvalidTabId => contract("invalid-tab-id", "Tab ID is invalid.", false), + Self::NotFound(_) => contract("workspace-not-found", "Workspace was not found.", false), + Self::Conflict(_) => contract("workspace-conflict", "Workspace already exists.", false), + Self::Store(error) => LifecycleError { + code: "workspace-store-failure".to_owned(), + message: "Workspace persistence failed.".to_owned(), + platform: None, + retryable: matches!(error, WorkspaceError::Io), + }, + Self::Session(error) => error.contract(), + Self::StartRollback { store, close } => LifecycleError { + code: "workspace-start-rollback-failed".to_owned(), + message: "Workspace persistence and session rollback failed.".to_owned(), + platform: close.platform.clone(), + retryable: matches!(store, WorkspaceError::Io) || close.retryable, + }, + } + } +} + +fn contract(code: &str, message: &str, retryable: bool) -> LifecycleError { + LifecycleError { + code: code.to_owned(), + message: message.to_owned(), + platform: None, + retryable, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceBinding { + pub workspace_id: WorkspaceId, + pub tab_id: TabId, + pub session_id: SessionId, +} + +pub struct WorkspaceController

{ + store: WorkspaceStore, + sessions: P, + bindings: BTreeMap, + selected: Option, +} + +impl WorkspaceController

{ + pub fn new(store: WorkspaceStore, sessions: P) -> Self { + Self { + store, + sessions, + bindings: BTreeMap::new(), + selected: None, + } + } + + pub fn list(&self) -> Result, WorkspaceLifecycleError> { + Ok(self.store.list()?) + } + + pub fn selected(&self) -> Option<&WorkspaceId> { + self.selected.as_ref() + } + + pub fn bindings(&self) -> Vec { + self.bindings.values().cloned().collect() + } + + pub fn create( + &mut self, + mut profile: WorkspaceProfile, + tab_id: TabId, + ) -> Result { + if let Some(existing) = self.store.load(&profile.id)? { + let retrying_live_binding = self + .bindings + .get(&existing.id) + .is_some_and(|binding| binding.tab_id == tab_id); + if existing.lifecycle_tab_id.as_deref() != Some(tab_id.as_str()) + && !retrying_live_binding + { + return Err(WorkspaceLifecycleError::Conflict(profile.id)); + } + return self.start(&existing.id, tab_id); + } + profile.session_ids.clear(); + profile.lifecycle_tab_id = Some(tab_id.as_str().to_owned()); + self.store.save(&profile)?; + let binding = self.start(&profile.id, tab_id)?; + self.selected = Some(binding.workspace_id.clone()); + Ok(binding) + } + + pub fn select(&mut self, id: &WorkspaceId) -> Result<(), WorkspaceLifecycleError> { + self.require_profile(id)?; + self.selected = Some(id.clone()); + Ok(()) + } + + pub fn update(&mut self, mut profile: WorkspaceProfile) -> Result<(), WorkspaceLifecycleError> { + let current = self.require_profile(&profile.id)?; + profile.session_ids = current.session_ids; + profile.lifecycle_tab_id = current.lifecycle_tab_id; + profile.lifecycle_closing_session_id = current.lifecycle_closing_session_id; + self.store.save(&profile)?; + Ok(()) + } + + pub fn close(&mut self, id: &WorkspaceId) -> Result<(), WorkspaceLifecycleError> { + let mut profile = self.require_profile(id)?; + let closing_session = profile + .lifecycle_closing_session_id + .map(SessionId::new) + .transpose()? + .or_else(|| self.bindings.get(id).map(|binding| binding.session_id)); + if let Some(session_id) = closing_session { + if profile.lifecycle_closing_session_id.is_none() { + let binding = self.bindings.get(id).expect("a live session has a binding"); + profile.session_ids = vec![session_id.get()]; + profile.lifecycle_tab_id = Some(binding.tab_id.as_str().to_owned()); + profile.lifecycle_closing_session_id = Some(session_id.get()); + self.store.save(&profile)?; + } + if let Err(error) = self.sessions.close(session_id) { + if !error.closes_convergently() { + return Err(WorkspaceLifecycleError::Session(error)); + } + } + profile.session_ids.clear(); + profile.lifecycle_tab_id = None; + profile.lifecycle_closing_session_id = None; + self.store.save(&profile)?; + self.bindings.remove(id); + } + self.reconcile_close(id) + } + + fn reconcile_close(&mut self, id: &WorkspaceId) -> Result<(), WorkspaceLifecycleError> { + let mut profile = self.require_profile(id)?; + if !profile.session_ids.is_empty() + && profile.lifecycle_closing_session_id.is_none() + && !self.bindings.contains_key(id) + { + profile.session_ids.clear(); + profile.lifecycle_tab_id = None; + self.store.save(&profile)?; + } + if self.selected.as_ref() == Some(id) { + self.selected = None; + } + Ok(()) + } + + pub fn restart( + &mut self, + id: &WorkspaceId, + ) -> Result { + let profile = self.require_profile(id)?; + let tab_id = self + .bindings + .get(id) + .map(|binding| binding.tab_id.clone()) + .or_else(|| { + profile + .lifecycle_tab_id + .as_deref() + .map(TabId::new) + .transpose() + .ok() + .flatten() + }) + .ok_or_else(|| WorkspaceLifecycleError::NotFound(id.clone()))?; + let selected = self.selected.as_ref() == Some(id); + if self.bindings.contains_key(id) || profile.lifecycle_closing_session_id.is_some() { + self.close(id)?; + } + let mut profile = self.require_profile(id)?; + profile.lifecycle_tab_id = Some(tab_id.as_str().to_owned()); + self.store.save(&profile)?; + let binding = self.start(id, tab_id)?; + if selected { + self.selected = Some(id.clone()); + } + Ok(binding) + } + + pub fn recover(&mut self) -> Result, WorkspaceLifecycleError> { + let mut first_error = None; + for profile in self.store.list()? { + let result = if profile.lifecycle_closing_session_id.is_some() { + self.close(&profile.id) + } else if profile.session_ids.is_empty() && profile.lifecycle_tab_id.is_none() { + Ok(()) + } else { + let tab_id = profile + .lifecycle_tab_id + .as_deref() + .unwrap_or(profile.id.as_str()); + self.start(&profile.id, TabId::new(tab_id)?).map(drop) + }; + if let Err(error) = result { + first_error.get_or_insert(error); + } + } + first_error.map_or_else(|| Ok(self.bindings()), Err) + } + + fn start( + &mut self, + id: &WorkspaceId, + tab_id: TabId, + ) -> Result { + if let Some(binding) = self.bindings.get(id) { + let mut profile = self.require_profile(id)?; + if !profile.session_ids.contains(&binding.session_id.get()) { + profile.session_ids = vec![binding.session_id.get()]; + profile.lifecycle_tab_id = Some(binding.tab_id.as_str().to_owned()); + profile.lifecycle_closing_session_id = Some(binding.session_id.get()); + self.store.save(&profile)?; + } + return Ok(binding.clone()); + } + let mut profile = self.require_profile(id)?; + let session_id = self + .sessions + .start(&profile) + .map_err(WorkspaceLifecycleError::Session)?; + profile.session_ids = vec![session_id.get()]; + profile.lifecycle_tab_id = None; + if let Err(error) = self.store.save(&profile) { + if let Err(close) = self.sessions.close(session_id) { + let binding = WorkspaceBinding { + workspace_id: id.clone(), + tab_id, + session_id, + }; + self.bindings.insert(id.clone(), binding); + return Err(WorkspaceLifecycleError::StartRollback { + store: error, + close, + }); + } + return Err(error.into()); + } + let binding = WorkspaceBinding { + workspace_id: id.clone(), + tab_id, + session_id, + }; + self.bindings.insert(id.clone(), binding.clone()); + Ok(binding) + } + + fn require_profile( + &self, + id: &WorkspaceId, + ) -> Result { + self.store + .load(id)? + .ok_or_else(|| WorkspaceLifecycleError::NotFound(id.clone())) + } +} diff --git a/crates/splice-core/tests/workspace_lifecycle.rs b/crates/splice-core/tests/workspace_lifecycle.rs new file mode 100644 index 0000000..29846e6 --- /dev/null +++ b/crates/splice-core/tests/workspace_lifecycle.rs @@ -0,0 +1,683 @@ +use splice_core::{ + AgentDescriptor, EnvironmentMetadata, SessionId, SessionLifecycleError, SessionLifecyclePort, + TabId, WorkspaceController, WorkspaceId, WorkspaceProfile, WorkspaceStore, +}; +use std::{ + collections::BTreeMap, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +#[derive(Default)] +struct FakeState { + next_session: u64, + running: BTreeMap, + closed: Vec, + start_failure: Option, + close_failure: Option, + remove_directory_on_start: Option, + remove_directory_on_close: Option, + non_idempotent_close: bool, +} + +#[derive(Clone, Default)] +struct FakeSessions(Arc>); + +impl SessionLifecyclePort for FakeSessions { + fn start(&mut self, profile: &WorkspaceProfile) -> Result { + let mut state = self.0.lock().expect("fake state remains available"); + if let Some(error) = state.start_failure.clone() { + return Err(error); + } + if let Some(directory) = state.remove_directory_on_start.take() { + std::fs::remove_dir_all(directory).expect("test directory can be removed"); + } + state.next_session += 1; + let session = SessionId::new(state.next_session).expect("fake session id is non-zero"); + state.running.insert(session, profile.id.clone()); + Ok(session) + } + + fn close(&mut self, session: SessionId) -> Result<(), SessionLifecycleError> { + let mut state = self.0.lock().expect("fake state remains available"); + if let Some(error) = state.close_failure.clone() { + return Err(error); + } + if state.running.remove(&session).is_none() && state.non_idempotent_close { + return Err(retryable_error("session-not-found")); + } + state.closed.push(session); + if let Some(directory) = state.remove_directory_on_close.take() { + std::fs::remove_dir_all(directory).unwrap(); + } + Ok(()) + } +} + +fn root(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "splice-core-lifecycle-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("workspace root exists"); + root +} + +fn profile(id: &str, directory: PathBuf, sessions: Vec) -> WorkspaceProfile { + WorkspaceProfile::new( + WorkspaceId::new(id).expect("workspace id is valid"), + id, + directory, + EnvironmentMetadata::new("development", ["PATH"]).expect("metadata is valid"), + AgentDescriptor::new("codex", "codex").expect("agent is valid"), + sessions, + ) + .expect("profile is valid") +} + +fn session_error( + code: &str, + message: &str, + platform: Option<&str>, + retryable: bool, +) -> SessionLifecycleError { + SessionLifecycleError { + code: code.to_owned(), + message: message.to_owned(), + platform: platform.map(str::to_owned), + retryable, + } +} + +fn retryable_error(code: &str) -> SessionLifecycleError { + session_error(code, "Injected lifecycle failure.", None, true) +} + +#[test] +fn lifecycle_errors_serialize_stably_for_invalid_and_injected_failures() { + let invalid = SessionId::new(0) + .expect_err("zero session IDs are rejected") + .contract(); + assert_eq!( + serde_json::to_value(invalid).unwrap(), + serde_json::json!({ + "code": "invalid-session-id", + "message": "Session ID must be non-zero.", + "platform": null, + "retryable": false, + }) + ); + let invalid_tab = TabId::new("") + .expect_err("empty tab IDs are rejected") + .contract(); + assert_eq!( + serde_json::to_value(invalid_tab).unwrap(), + serde_json::json!({ + "code": "invalid-tab-id", + "message": "Tab ID is invalid.", + "platform": null, + "retryable": false, + }) + ); + + let root = root("structured-session-error"); + let fake = FakeSessions::default(); + let expected = session_error( + "session-start-failed", + "Session service is unavailable.", + Some("linux"), + true, + ); + fake.0.lock().unwrap().start_failure = Some(expected.clone()); + let mut controller = WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake); + let error = controller + .create( + profile("alpha", root, vec![]), + TabId::new("tab-alpha").unwrap(), + ) + .expect_err("injected start failure is returned"); + + assert_eq!( + serde_json::to_value(error.contract()).unwrap(), + serde_json::json!({ + "code": "session-start-failed", + "message": "Session service is unavailable.", + "platform": "linux", + "retryable": true, + }) + ); +} + +#[test] +fn failed_create_is_retryable_after_controller_reconstruction() { + let root = root("create-start-retry"); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + state.lock().unwrap().start_failure = Some(retryable_error("session-start-failed")); + let store = WorkspaceStore::new(&root).unwrap(); + WorkspaceController::new(store, fake.clone()) + .create( + profile("alpha", root.clone(), vec![]), + TabId::new("tab").unwrap(), + ) + .unwrap_err(); + + state.lock().unwrap().start_failure = None; + let mut controller = WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake); + controller + .create(profile("alpha", root, vec![]), TabId::new("tab").unwrap()) + .expect("the same create request resumes"); +} + +#[test] +fn failed_restart_start_recovers_tab_identity_after_reconstruction() { + let root = root("restart-start-recovery"); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + let mut controller = + WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake.clone()); + let original = controller + .create( + profile("alpha", root.clone(), vec![]), + TabId::new("stable-tab").unwrap(), + ) + .unwrap(); + state.lock().unwrap().start_failure = Some(retryable_error("session-start-failed")); + controller + .restart(&original.workspace_id) + .expect_err("replacement start fails"); + + state.lock().unwrap().start_failure = None; + let mut recovered = WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake); + let bindings = recovered.recover().unwrap(); + assert_eq!(bindings[0].tab_id, TabId::new("stable-tab").unwrap()); + let state = state.lock().unwrap(); + assert_eq!(state.closed, vec![original.session_id]); + assert_eq!( + state.running, + BTreeMap::from([(bindings[0].session_id, original.workspace_id)]) + ); +} + +#[test] +fn failed_restart_start_retries_with_the_durable_tab_intent() { + let root = root("restart-start-direct-retry"); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + let mut controller = + WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake.clone()); + let original = controller + .create( + profile("alpha", root, vec![]), + TabId::new("stable-tab").unwrap(), + ) + .unwrap(); + state.lock().unwrap().start_failure = Some(retryable_error("session-start-failed")); + controller + .restart(&original.workspace_id) + .expect_err("replacement start fails"); + + state.lock().unwrap().start_failure = None; + let replacement = controller + .restart(&original.workspace_id) + .expect("direct restart uses the durable tab intent"); + let state = state.lock().unwrap(); + assert_eq!(replacement.tab_id, TabId::new("stable-tab").unwrap()); + assert_eq!(state.closed, vec![original.session_id]); + assert_eq!( + state.running, + BTreeMap::from([(replacement.session_id, original.workspace_id)]) + ); +} + +#[test] +fn failed_start_save_and_rollback_keeps_live_session_addressable() { + let root = root("start-save-rollback-failure"); + let working = root.join("working"); + std::fs::create_dir_all(&working).unwrap(); + let store = WorkspaceStore::new(&root).unwrap(); + store + .save(&profile("alpha", working.clone(), vec![9])) + .unwrap(); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + { + let mut state = state.lock().unwrap(); + state.remove_directory_on_start = Some(working.clone()); + state.close_failure = Some(retryable_error("session-close-failed")); + } + let mut controller = WorkspaceController::new(store, fake.clone()); + let failure = controller + .recover() + .expect_err("save and rollback close fail"); + assert_eq!(failure.contract().code, "workspace-start-rollback-failed"); + assert!(failure.contract().retryable); + let binding = controller.bindings().pop().unwrap(); + + std::fs::create_dir_all(&working).unwrap(); + state.lock().unwrap().close_failure = None; + let recovered = controller + .create( + profile("alpha", working.clone(), vec![]), + TabId::new("alpha").unwrap(), + ) + .unwrap(); + assert_eq!(recovered, binding); + assert_eq!( + controller.list().unwrap()[0].lifecycle_closing_session_id, + Some(binding.session_id.get()) + ); + drop(controller); + let mut reconstructed = WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake); + let replacement = reconstructed.recover().unwrap(); + assert!(replacement.is_empty()); + assert!(state.lock().unwrap().running.is_empty()); + assert_eq!(state.lock().unwrap().closed, vec![binding.session_id]); +} + +#[test] +fn update_preserves_pending_start_and_close_intents() { + let root = root("update-intents"); + let store = WorkspaceStore::new(&root).unwrap(); + let mut pending_start = profile("alpha", root.clone(), vec![]); + pending_start.lifecycle_tab_id = Some("stable-tab".into()); + store.save(&pending_start).unwrap(); + let mut pending_close = profile("beta", root.clone(), vec![7]); + pending_close.lifecycle_closing_session_id = Some(7); + store.save(&pending_close).unwrap(); + let mut controller = WorkspaceController::new(store, FakeSessions::default()); + + controller + .update(profile("alpha", root.clone(), vec![])) + .unwrap(); + controller.update(profile("beta", root, vec![])).unwrap(); + let profiles = controller.list().unwrap(); + assert_eq!(profiles[0].lifecycle_tab_id.as_deref(), Some("stable-tab")); + assert_eq!(profiles[1].session_ids, vec![7]); + assert_eq!(profiles[1].lifecycle_closing_session_id, Some(7)); +} + +#[test] +fn reconstructed_close_converges_after_clear_save_failure() { + let root = root("close-clear-reconstruction"); + let working = root.join("working"); + std::fs::create_dir_all(&working).unwrap(); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + state.lock().unwrap().non_idempotent_close = true; + let mut controller = + WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake.clone()); + let binding = controller + .create( + profile("alpha", working.clone(), vec![]), + TabId::new("tab").unwrap(), + ) + .unwrap(); + state.lock().unwrap().remove_directory_on_close = Some(working.clone()); + controller.close(&binding.workspace_id).unwrap_err(); + + std::fs::create_dir_all(&working).unwrap(); + let mut reconstructed = WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake); + assert!(reconstructed.recover().unwrap().is_empty()); + assert_eq!( + reconstructed.list().unwrap(), + vec![profile("alpha", working, vec![])] + ); + assert_eq!(state.lock().unwrap().closed, vec![binding.session_id]); +} + +#[test] +fn recovery_continues_after_an_earlier_profile_fails() { + let root = root("recovery-aggregate"); + let store = WorkspaceStore::new(&root).unwrap(); + let mut alpha = profile("alpha", root.clone(), vec![7]); + alpha.lifecycle_closing_session_id = Some(7); + store.save(&alpha).unwrap(); + store.save(&profile("beta", root, vec![9])).unwrap(); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + state.lock().unwrap().close_failure = Some(retryable_error("session-close-failed")); + let mut controller = WorkspaceController::new(store, fake); + + let failure = controller.recover().unwrap_err(); + assert_eq!(failure.contract().code, "session-close-failed"); + assert_eq!( + controller.bindings()[0].workspace_id, + WorkspaceId::new("beta").unwrap() + ); +} + +#[test] +fn close_failure_persists_cleanup_intent_for_reconstructed_recovery() { + let root = root("close-reconstruction"); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + let mut controller = + WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake.clone()); + let alpha = controller + .create( + profile("alpha", root.clone(), vec![]), + TabId::new("tab-alpha").unwrap(), + ) + .unwrap(); + state.lock().unwrap().close_failure = Some(retryable_error("session-close-failed")); + controller.close(&alpha.workspace_id).unwrap_err(); + + assert_eq!( + controller.list().unwrap(), + vec![WorkspaceProfile { + session_ids: vec![alpha.session_id.get()], + lifecycle_tab_id: Some("tab-alpha".to_owned()), + lifecycle_closing_session_id: Some(alpha.session_id.get()), + ..profile("alpha", root.clone(), vec![]) + }] + ); + + let mut reconstructed = WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake); + reconstructed + .recover() + .expect_err("reconstructed recovery retries cleanup before any replacement start"); + assert_eq!( + state.lock().unwrap().running, + BTreeMap::from([(alpha.session_id, alpha.workspace_id.clone())]) + ); + assert!(state.lock().unwrap().closed.is_empty()); + + state.lock().unwrap().close_failure = None; + assert!(reconstructed.recover().unwrap().is_empty()); + assert_eq!(state.lock().unwrap().closed, vec![alpha.session_id]); + assert!(state.lock().unwrap().running.is_empty()); + assert_eq!( + reconstructed.list().unwrap(), + vec![profile("alpha", root, vec![])] + ); +} + +#[test] +fn duplicate_workspace_conflict_uses_the_controller_error_contract() { + let root = root("workspace-conflict"); + let mut controller = + WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), FakeSessions::default()); + controller + .create( + profile("alpha", root.clone(), vec![]), + TabId::new("tab-alpha").unwrap(), + ) + .unwrap(); + + let conflict = controller + .create( + profile("alpha", root, vec![]), + TabId::new("tab-retry").unwrap(), + ) + .expect_err("duplicate profile conflicts"); + assert_eq!( + serde_json::to_value(conflict.contract()).unwrap(), + serde_json::json!({ + "code": "workspace-conflict", + "message": "Workspace already exists.", + "platform": null, + "retryable": false, + }) + ); +} + +#[test] +fn close_failure_preserves_the_binding_until_the_port_succeeds() { + let root = root("close-failure"); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + let mut controller = WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake); + let alpha = controller + .create( + profile("alpha", root, vec![]), + TabId::new("tab-alpha").unwrap(), + ) + .unwrap(); + state.lock().unwrap().close_failure = Some(session_error( + "session-close-failed", + "Session service is busy.", + Some("windows"), + true, + )); + + let failure = controller + .close(&alpha.workspace_id) + .expect_err("close failure propagates"); + assert_eq!(failure.contract().code, "session-close-failed"); + assert_eq!(controller.bindings(), vec![alpha.clone()]); + assert_eq!( + controller.list().unwrap()[0].session_ids, + vec![alpha.session_id.get()] + ); + assert_eq!( + controller.list().unwrap()[0].lifecycle_closing_session_id, + Some(alpha.session_id.get()) + ); + + state.lock().unwrap().close_failure = None; + controller + .close(&alpha.workspace_id) + .expect("retry closes once"); + let state = state.lock().unwrap(); + assert_eq!(state.closed, vec![alpha.session_id]); + assert!(state.running.is_empty()); +} + +#[test] +fn close_persistence_failure_does_not_close_and_keeps_other_workspaces_isolated() { + let root = root("close-persistence-retry"); + let working_directory = root.join("working"); + std::fs::create_dir_all(&working_directory).unwrap(); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + let mut controller = WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake); + let alpha = controller + .create( + profile("alpha", working_directory.clone(), vec![]), + TabId::new("tab-alpha").unwrap(), + ) + .unwrap(); + let beta = controller + .create( + profile("beta", working_directory.clone(), vec![]), + TabId::new("tab-beta").unwrap(), + ) + .unwrap(); + + std::fs::remove_dir_all(&working_directory).unwrap(); + let failure = controller + .close(&alpha.workspace_id) + .expect_err("persistence fails before port close"); + assert_eq!(failure.contract().code, "workspace-store-failure"); + assert!(state.lock().unwrap().closed.is_empty()); + assert_eq!(controller.bindings(), vec![alpha.clone(), beta.clone()]); + + std::fs::create_dir_all(&working_directory).unwrap(); + controller + .close(&alpha.workspace_id) + .expect("close retries safely"); + assert_eq!(state.lock().unwrap().closed, vec![alpha.session_id]); + assert_eq!( + controller.list().unwrap(), + vec![ + profile("alpha", working_directory.clone(), vec![]), + profile("beta", working_directory, vec![beta.session_id.get()]), + ] + ); +} + +#[test] +fn restart_reconciles_a_pending_close_without_double_closing_or_leaking() { + let root = root("restart-persistence-retry"); + let working_directory = root.join("working"); + std::fs::create_dir_all(&working_directory).unwrap(); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + let mut controller = WorkspaceController::new(WorkspaceStore::new(&root).unwrap(), fake); + let alpha = controller + .create( + profile("alpha", working_directory.clone(), vec![]), + TabId::new("tab-alpha").unwrap(), + ) + .unwrap(); + let beta = controller + .create( + profile("beta", working_directory.clone(), vec![]), + TabId::new("tab-beta").unwrap(), + ) + .unwrap(); + + std::fs::remove_dir_all(&working_directory).unwrap(); + controller + .restart(&alpha.workspace_id) + .expect_err("restart stops at failed close persistence"); + std::fs::create_dir_all(&working_directory).unwrap(); + let restarted = controller + .restart(&alpha.workspace_id) + .expect("retry reconciles then starts a replacement"); + + let state = state.lock().unwrap(); + assert_eq!(state.closed, vec![alpha.session_id]); + assert_eq!( + state.running, + BTreeMap::from([ + (restarted.session_id, alpha.workspace_id), + (beta.session_id, beta.workspace_id.clone()), + ]) + ); + assert_eq!(controller.bindings(), vec![restarted, beta]); +} + +#[test] +fn recovery_cleans_up_a_failed_start_and_retries_after_persistence_recovers() { + let root = root("start-persistence-retry"); + let working_directory = root.join("working"); + std::fs::create_dir_all(&working_directory).unwrap(); + let store = WorkspaceStore::new(&root).unwrap(); + store + .save(&profile("alpha", working_directory.clone(), vec![9])) + .unwrap(); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + state.lock().unwrap().remove_directory_on_start = Some(working_directory.clone()); + let mut controller = WorkspaceController::new(store, fake); + + controller + .recover() + .expect_err("failed save cleans up the started session"); + assert_eq!( + state.lock().unwrap().closed, + vec![SessionId::new(1).unwrap()] + ); + assert!(controller.bindings().is_empty()); + + std::fs::create_dir_all(&working_directory).unwrap(); + let recovered = controller + .recover() + .expect("recovery retries after storage heals"); + assert_eq!(recovered.len(), 1); + let state = state.lock().unwrap(); + assert_eq!(state.closed, vec![SessionId::new(1).unwrap()]); + assert_eq!( + state.running, + BTreeMap::from([(recovered[0].session_id, WorkspaceId::new("alpha").unwrap())]) + ); +} + +#[test] +fn lifecycle_keeps_workspace_tab_and_session_identities_isolated() { + let root = root("identity-isolation"); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + let store = WorkspaceStore::new(&root).expect("store root is absolute"); + let mut controller = WorkspaceController::new(store, fake); + + let alpha = controller + .create( + profile("alpha", root.clone(), vec![]), + TabId::new("tab-alpha").unwrap(), + ) + .expect("alpha starts"); + let beta = controller + .create( + profile("beta", root.clone(), vec![]), + TabId::new("tab-beta").unwrap(), + ) + .expect("beta starts"); + controller.select(&beta.workspace_id).expect("beta selects"); + let mut updated_beta = profile("beta", root.clone(), vec![]); + updated_beta.name = "Beta renamed".to_owned(); + controller + .update(updated_beta.clone()) + .expect("profile update keeps its session"); + controller.close(&alpha.workspace_id).expect("alpha closes"); + controller + .close(&alpha.workspace_id) + .expect("repeat close is idempotent"); + let restarted_beta = controller + .restart(&beta.workspace_id) + .expect("restart replaces only beta's session"); + let missing = WorkspaceId::new("missing").unwrap(); + + assert_ne!(alpha.workspace_id, beta.workspace_id); + assert_ne!(alpha.tab_id, beta.tab_id); + assert_ne!(alpha.session_id, beta.session_id); + assert_eq!(beta.workspace_id, restarted_beta.workspace_id); + assert_eq!(beta.tab_id, restarted_beta.tab_id); + assert_ne!(beta.session_id, restarted_beta.session_id); + assert_eq!(controller.selected(), Some(&beta.workspace_id)); + assert_eq!(controller.bindings(), vec![restarted_beta.clone()]); + assert!( + matches!(controller.select(&missing), Err(splice_core::WorkspaceLifecycleError::NotFound(id)) if id == missing) + ); + assert_eq!( + controller.list().expect("profiles list"), + vec![ + profile("alpha", root.clone(), vec![]), + WorkspaceProfile { + session_ids: vec![restarted_beta.session_id.get()], + ..updated_beta + }, + ] + ); + let state = state.lock().expect("fake state remains available"); + assert_eq!(state.closed, vec![alpha.session_id, beta.session_id]); + assert_eq!( + state.running, + BTreeMap::from([(restarted_beta.session_id, beta.workspace_id)]) + ); +} + +#[test] +fn recovery_replaces_stale_sessions_and_is_idempotent() { + let root = root("recovery"); + let store = WorkspaceStore::new(&root).expect("store root is absolute"); + store + .save(&profile("alpha", root.clone(), vec![9])) + .unwrap(); + store + .save(&profile("beta", root.clone(), vec![11])) + .unwrap(); + let fake = FakeSessions::default(); + let state = fake.0.clone(); + let mut controller = WorkspaceController::new(store, fake); + + let recovered = controller.recover().expect("stale sessions recover"); + assert_eq!(recovered.len(), 2); + assert_ne!(recovered[0].session_id.get(), 9); + assert_ne!(recovered[1].session_id.get(), 11); + assert_eq!( + controller.recover().expect("recovery is idempotent"), + recovered + ); + assert_eq!( + controller.list().expect("profiles list"), + vec![ + profile("alpha", root.clone(), vec![recovered[0].session_id.get()]), + profile("beta", root, vec![recovered[1].session_id.get()]), + ] + ); + assert_eq!(state.lock().unwrap().running.len(), 2); +}