diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 03b4c0a..0524519 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -7,6 +7,252 @@ use std::time::UNIX_EPOCH; use crate::session::{Session, SessionError}; +/// Per-worktree session store that namespaces on-disk session files by +/// workspace fingerprint so that parallel `opencode serve` instances never +/// collide. +/// +/// Create via [`SessionStore::from_cwd`] (derives the store path from the +/// server's working directory) or [`SessionStore::from_data_dir`] (honours an +/// explicit `--data-dir` flag). Both constructors produce a directory layout +/// of `/sessions//` where `` is a +/// stable hex digest of the canonical workspace root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionStore { + /// Resolved root of the session namespace, e.g. + /// `/home/user/project/.claw/sessions/a1b2c3d4e5f60718/`. + sessions_root: PathBuf, + /// The canonical workspace path that was fingerprinted. + workspace_root: PathBuf, +} + +impl SessionStore { + /// Build a store from the server's current working directory. + /// + /// The on-disk layout becomes `/.claw/sessions//`. + pub fn from_cwd(cwd: impl AsRef) -> Result { + let cwd = cwd.as_ref(); + let sessions_root = cwd + .join(".claw") + .join("sessions") + .join(workspace_fingerprint(cwd)); + fs::create_dir_all(&sessions_root)?; + Ok(Self { + sessions_root, + workspace_root: cwd.to_path_buf(), + }) + } + + /// Build a store from an explicit `--data-dir` flag. + /// + /// The on-disk layout becomes `/sessions//` + /// where `` is derived from `workspace_root`. + pub fn from_data_dir( + data_dir: impl AsRef, + workspace_root: impl AsRef, + ) -> Result { + let workspace_root = workspace_root.as_ref(); + let sessions_root = data_dir + .as_ref() + .join("sessions") + .join(workspace_fingerprint(workspace_root)); + fs::create_dir_all(&sessions_root)?; + Ok(Self { + sessions_root, + workspace_root: workspace_root.to_path_buf(), + }) + } + + /// The fully resolved sessions directory for this namespace. + #[must_use] + pub fn sessions_dir(&self) -> &Path { + &self.sessions_root + } + + /// The workspace root this store is bound to. + #[must_use] + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub fn create_handle(&self, session_id: &str) -> SessionHandle { + let id = session_id.to_string(); + let path = self + .sessions_root + .join(format!("{id}.{PRIMARY_SESSION_EXTENSION}")); + SessionHandle { id, path } + } + + pub fn resolve_reference(&self, reference: &str) -> Result { + if is_session_reference_alias(reference) { + let latest = self.latest_session()?; + return Ok(SessionHandle { + id: latest.id, + path: latest.path, + }); + } + + let direct = PathBuf::from(reference); + let candidate = if direct.is_absolute() { + direct.clone() + } else { + self.workspace_root.join(&direct) + }; + let looks_like_path = direct.extension().is_some() || direct.components().count() > 1; + let path = if candidate.exists() { + candidate + } else if looks_like_path { + return Err(SessionControlError::Format( + format_missing_session_reference(reference), + )); + } else { + self.resolve_managed_path(reference)? + }; + + Ok(SessionHandle { + id: session_id_from_path(&path).unwrap_or_else(|| reference.to_string()), + path, + }) + } + + pub fn resolve_managed_path(&self, session_id: &str) -> Result { + for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] { + let path = self.sessions_root.join(format!("{session_id}.{extension}")); + if path.exists() { + return Ok(path); + } + } + Err(SessionControlError::Format( + format_missing_session_reference(session_id), + )) + } + + pub fn list_sessions(&self) -> Result, SessionControlError> { + let mut sessions = Vec::new(); + let read_result = fs::read_dir(&self.sessions_root); + let entries = match read_result { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(sessions), + Err(err) => return Err(err.into()), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !is_managed_session_file(&path) { + continue; + } + let metadata = entry.metadata()?; + let modified_epoch_millis = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let (id, message_count, parent_session_id, branch_name) = + match Session::load_from_path(&path) { + Ok(session) => { + let parent_session_id = session + .fork + .as_ref() + .map(|fork| fork.parent_session_id.clone()); + let branch_name = session + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()); + ( + session.session_id, + session.messages.len(), + parent_session_id, + branch_name, + ) + } + Err(_) => ( + path.file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_string(), + 0, + None, + None, + ), + }; + sessions.push(ManagedSessionSummary { + id, + path, + modified_epoch_millis, + message_count, + parent_session_id, + branch_name, + }); + } + sessions.sort_by(|left, right| { + right + .modified_epoch_millis + .cmp(&left.modified_epoch_millis) + .then_with(|| right.id.cmp(&left.id)) + }); + Ok(sessions) + } + + pub fn latest_session(&self) -> Result { + self.list_sessions()? + .into_iter() + .next() + .ok_or_else(|| SessionControlError::Format(format_no_managed_sessions())) + } + + pub fn load_session( + &self, + reference: &str, + ) -> Result { + let handle = self.resolve_reference(reference)?; + let session = Session::load_from_path(&handle.path)?; + Ok(LoadedManagedSession { + handle: SessionHandle { + id: session.session_id.clone(), + path: handle.path, + }, + session, + }) + } + + pub fn fork_session( + &self, + session: &Session, + branch_name: Option, + ) -> Result { + let parent_session_id = session.session_id.clone(); + let forked = session.fork(branch_name); + let handle = self.create_handle(&forked.session_id); + let branch_name = forked + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()); + let forked = forked.with_persistence_path(handle.path.clone()); + forked.save_to_path(&handle.path)?; + Ok(ForkedManagedSession { + parent_session_id, + handle, + session: forked, + branch_name, + }) + } +} + +/// Stable hex fingerprint of a workspace path. +/// +/// Uses FNV-1a (64-bit) to produce a 16-char hex string that partitions the +/// on-disk session directory per workspace root. +#[must_use] +pub fn workspace_fingerprint(workspace_root: &Path) -> String { + let input = workspace_root.to_string_lossy(); + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in input.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0100_0000_01b3); + } + format!("{hash:016x}") +} + pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; pub const LEGACY_SESSION_EXTENSION: &str = "json"; pub const LATEST_SESSION_REFERENCE: &str = "latest"; @@ -333,7 +579,7 @@ mod tests { use super::{ create_managed_session_handle_for, fork_managed_session_for, is_session_reference_alias, list_managed_sessions_for, load_managed_session_for, resolve_session_reference_for, - ManagedSessionSummary, LATEST_SESSION_REFERENCE, + workspace_fingerprint, ManagedSessionSummary, SessionStore, LATEST_SESSION_REFERENCE, }; use crate::session::Session; use std::fs; @@ -456,4 +702,172 @@ mod tests { ); fs::remove_dir_all(root).expect("temp dir should clean up"); } + + // ------------------------------------------------------------------ + // Per-worktree session isolation (SessionStore) tests + // ------------------------------------------------------------------ + + fn persist_session_via_store(store: &SessionStore, text: &str) -> Session { + let mut session = Session::new(); + session + .push_user_text(text) + .expect("session message should save"); + let handle = store.create_handle(&session.session_id); + let session = session.with_persistence_path(handle.path.clone()); + session + .save_to_path(&handle.path) + .expect("session should persist"); + session + } + + #[test] + fn workspace_fingerprint_is_deterministic_and_differs_per_path() { + // given + let path_a = Path::new("/tmp/worktree-alpha"); + let path_b = Path::new("/tmp/worktree-beta"); + + // when + let fp_a1 = workspace_fingerprint(path_a); + let fp_a2 = workspace_fingerprint(path_a); + let fp_b = workspace_fingerprint(path_b); + + // then + assert_eq!(fp_a1, fp_a2, "same path must produce the same fingerprint"); + assert_ne!( + fp_a1, fp_b, + "different paths must produce different fingerprints" + ); + assert_eq!(fp_a1.len(), 16, "fingerprint must be a 16-char hex string"); + } + + #[test] + fn session_store_from_cwd_isolates_sessions_by_workspace() { + // given + let base = temp_dir(); + let workspace_a = base.join("repo-alpha"); + let workspace_b = base.join("repo-beta"); + fs::create_dir_all(&workspace_a).expect("workspace a should exist"); + fs::create_dir_all(&workspace_b).expect("workspace b should exist"); + + let store_a = SessionStore::from_cwd(&workspace_a).expect("store a should build"); + let store_b = SessionStore::from_cwd(&workspace_b).expect("store b should build"); + + // when + let session_a = persist_session_via_store(&store_a, "alpha work"); + let _session_b = persist_session_via_store(&store_b, "beta work"); + + // then — each store only sees its own sessions + let list_a = store_a.list_sessions().expect("list a"); + let list_b = store_b.list_sessions().expect("list b"); + assert_eq!(list_a.len(), 1, "store a should see exactly one session"); + assert_eq!(list_b.len(), 1, "store b should see exactly one session"); + assert_eq!(list_a[0].id, session_a.session_id); + assert_ne!( + store_a.sessions_dir(), + store_b.sessions_dir(), + "session directories must differ across workspaces" + ); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + + #[test] + fn session_store_from_data_dir_namespaces_by_workspace() { + // given + let base = temp_dir(); + let data_dir = base.join("global-data"); + let workspace_a = PathBuf::from("/tmp/project-one"); + let workspace_b = PathBuf::from("/tmp/project-two"); + fs::create_dir_all(&data_dir).expect("data dir should exist"); + + let store_a = + SessionStore::from_data_dir(&data_dir, &workspace_a).expect("store a should build"); + let store_b = + SessionStore::from_data_dir(&data_dir, &workspace_b).expect("store b should build"); + + // when + persist_session_via_store(&store_a, "work in project-one"); + persist_session_via_store(&store_b, "work in project-two"); + + // then + assert_ne!( + store_a.sessions_dir(), + store_b.sessions_dir(), + "data-dir stores must namespace by workspace" + ); + assert_eq!(store_a.list_sessions().expect("list a").len(), 1); + assert_eq!(store_b.list_sessions().expect("list b").len(), 1); + assert_eq!(store_a.workspace_root(), workspace_a.as_path()); + assert_eq!(store_b.workspace_root(), workspace_b.as_path()); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + + #[test] + fn session_store_create_and_load_round_trip() { + // given + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + let session = persist_session_via_store(&store, "round-trip message"); + + // when + let loaded = store + .load_session(&session.session_id) + .expect("session should load via store"); + + // then + assert_eq!(loaded.handle.id, session.session_id); + assert_eq!(loaded.session.messages.len(), 1); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + + #[test] + fn session_store_latest_and_resolve_reference() { + // given + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + let _older = persist_session_via_store(&store, "older"); + wait_for_next_millisecond(); + let newer = persist_session_via_store(&store, "newer"); + + // when + let latest = store.latest_session().expect("latest should resolve"); + let handle = store + .resolve_reference("latest") + .expect("latest alias should resolve"); + + // then + assert_eq!(latest.id, newer.session_id); + assert_eq!(handle.id, newer.session_id); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + + #[test] + fn session_store_fork_stays_in_same_namespace() { + // given + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + let source = persist_session_via_store(&store, "parent work"); + + // when + let forked = store + .fork_session(&source, Some("bugfix".to_string())) + .expect("fork should succeed"); + let sessions = store.list_sessions().expect("list sessions"); + + // then + assert_eq!( + sessions.len(), + 2, + "forked session must land in the same namespace" + ); + assert_eq!(forked.parent_session_id, source.session_id); + assert_eq!(forked.branch_name.as_deref(), Some("bugfix")); + assert!( + forked.handle.path.starts_with(store.sessions_dir()), + "forked session path must be inside the store namespace" + ); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } }