From 5fc914945c22be9849f65010fa70942724add029 Mon Sep 17 00:00:00 2001 From: robcholz <84130577+robcholz@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:21:37 -0500 Subject: [PATCH] feat: wire up SessionManager. --- docs/tasks.md | 7 +- src/bin/vibebox-cli.rs | 8 +- src/instance.rs | 6 +- src/session_manager.rs | 266 +++++++++++++++++++++++++++-------------- src/vm_manager.rs | 6 +- 5 files changed, 189 insertions(+), 104 deletions(-) diff --git a/docs/tasks.md b/docs/tasks.md index 22120a9..c88f256 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -39,16 +39,17 @@ 2. [x] Use ssh to connect to vm. 3. [x] allow multi vibebox to connect to the same vm. 4. [x] use vm.lock to ensure process concurrency safety. -5. [ ] wire up SessionManager. +5. [x] wire up SessionManager. 6. [x] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself). 7. [ ] setup vibebox commands 8. [ ] setup cli commands. 9. [ ] fix ui overlap. +10. [ ] intensive integration test. ## Publish -1. [ ] write the docs -2. [ ] setup quick link. +1. [ ] write the docs. +2. [ ] setup quick install link. 3. [ ] setup website. ## Stage 2 diff --git a/src/bin/vibebox-cli.rs b/src/bin/vibebox-cli.rs index 312c667..61a3fd5 100644 --- a/src/bin/vibebox-cli.rs +++ b/src/bin/vibebox-cli.rs @@ -54,6 +54,10 @@ fn main() -> Result<()> { }; let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; tracing::info!(cwd = %cwd.display(), "starting vibebox cli"); + let auto_shutdown_ms = config::load_config(&cwd) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))? + .auto_shutdown_ms + .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS); if let Ok(manager) = SessionManager::new() { if let Err(err) = manager.update_global_sessions(&cwd) { tracing::warn!(error = %err, "failed to update global session list"); @@ -73,10 +77,6 @@ fn main() -> Result<()> { stdout.flush()?; } - let auto_shutdown_ms = config::load_config(&cwd) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))? - .auto_shutdown_ms - .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS); tracing::info!(auto_shutdown_ms, "auto shutdown config"); let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; diff --git a/src/instance.rs b/src/instance.rs index b8956d5..287ec80 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -1,4 +1,3 @@ -use crate::session_manager::INSTANCE_TOML_FILENAME; use std::{ env, fs, io::{self, Write}, @@ -20,7 +19,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use uuid::Uuid; use crate::{ - session_manager::INSTANCE_DIR_NAME, + session_manager::{INSTANCE_DIR_NAME, INSTANCE_TOML_FILENAME}, tui::{self, AppState}, vm::{self, LoginAction, VmInput}, }; @@ -367,7 +366,8 @@ fn spawn_ssh_io( let log_path = instance_dir.join(SERIAL_LOG_NAME); let log_file = fs::OpenOptions::new() .create(true) - .append(true) + .write(true) + .truncate(true) .open(&log_path) .ok() .map(|file| Arc::new(Mutex::new(file))); diff --git a/src/session_manager.rs b/src/session_manager.rs index c32a1d8..d840611 100644 --- a/src/session_manager.rs +++ b/src/session_manager.rs @@ -6,28 +6,27 @@ use std::{ use serde::{Deserialize, Serialize}; +use crate::config::CONFIG_FILENAME; + pub const INSTANCE_DIR_NAME: &str = ".vibebox"; pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox"; pub const GLOBAL_DIR_NAME: &str = ".vibebox"; -pub const GLOBAL_SESSION_FILENAME: &str = "session.toml"; +pub const INSTANCE_TOML_FILENAME: &str = "instance.toml"; pub const SESSION_TEMP_PREFIX: &str = "sessions"; pub const SESSION_TOML_SUFFIX: &str = ".toml"; -use crate::config::CONFIG_FILENAME; -pub const INSTANCE_TOML_FILENAME: &str = "instance.toml"; +const SESSIONS_DIR_NAME: &str = "sessions"; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionRecord { pub directory: PathBuf, - #[serde(default)] - pub id: Option, - #[serde(default)] + pub id: String, pub last_active: Option, } -#[derive(Debug, Default, Serialize, Deserialize)] -struct GlobalSessionIndex { - #[serde(default)] - directories: Vec, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct SessionEntry { + pub directory: PathBuf, + pub id: String, } #[derive(Debug, Default, Deserialize)] @@ -40,8 +39,7 @@ struct InstanceMetadata { #[derive(Debug)] pub struct SessionManager { - global_dir: PathBuf, - session_path: PathBuf, + sessions_dir: PathBuf, } #[derive(Debug, thiserror::Error)] @@ -69,55 +67,84 @@ impl SessionManager { } pub fn with_global_dir(global_dir: PathBuf) -> Self { - let session_path = global_dir.join(GLOBAL_SESSION_FILENAME); - Self { - global_dir, - session_path, - } + let sessions_dir = global_dir.join(SESSIONS_DIR_NAME); + Self { sessions_dir } } pub fn index_path(&self) -> &Path { - &self.session_path + &self.sessions_dir } pub fn update_global_sessions(&self, directory: &Path) -> Result, SessionError> { let directory = self.normalize_directory(directory)?; - let mut index = self.read_global_index()?; - let mut changed = false; + fs::create_dir_all(&self.sessions_dir)?; - let removed = prune_invalid_dirs(&mut index); - if removed > 0 { - changed = true; + let (mut sessions, removed) = self.prune_stale_sessions()?; + let has_config = is_vibebox_dir(&directory); + let mut added = false; + + if has_config { + let meta = read_instance_metadata(&directory)?; + if let Some(id) = meta.id { + let record = SessionEntry { + directory: directory.clone(), + id: id.clone(), + }; + self.write_session_record(&record)?; + if let Some(existing) = sessions.iter_mut().find(|s| s.id == id) { + *existing = record; + } else { + sessions.push(record); + } + added = true; + } else { + tracing::warn!( + directory = %directory.display(), + "missing session id in instance.toml" + ); + } } - if is_vibebox_dir(&directory) && !index.directories.iter().any(|dir| dir == &directory) { - index.directories.push(directory); - changed = true; + if removed > 0 || added { + tracing::info!( + path = %self.sessions_dir.display(), + removed, + added, + entries = sessions.len(), + "updated global sessions" + ); + } else { + tracing::debug!( + path = %self.sessions_dir.display(), + entries = sessions.len(), + has_config, + "global sessions unchanged" + ); } - if changed { - self.write_global_index(&index)?; - } - - Ok(index.directories) + Ok(sessions.into_iter().map(|s| s.directory).collect()) } pub fn list_sessions(&self) -> Result, SessionError> { - let mut index = self.read_global_index()?; - let removed = prune_invalid_dirs(&mut index); + let (sessions, removed) = self.prune_stale_sessions()?; if removed > 0 { - self.write_global_index(&index)?; + tracing::info!( + path = %self.sessions_dir.display(), + removed, + entries = sessions.len(), + "pruned stale sessions" + ); } - let mut sessions = Vec::with_capacity(index.directories.len()); - for directory in index.directories { - let meta = read_instance_metadata(&directory)?; - sessions.push(SessionRecord { - directory, - id: meta.id, + let mut records = Vec::with_capacity(sessions.len()); + for session in sessions { + let meta = read_instance_metadata(&session.directory)?; + records.push(SessionRecord { + directory: session.directory, + id: session.id, last_active: meta.last_active, }); } - Ok(sessions) + Ok(records) } fn normalize_directory(&self, directory: &Path) -> Result { @@ -130,20 +157,55 @@ impl SessionManager { Ok(directory.canonicalize()?) } - fn read_global_index(&self) -> Result { - if !self.session_path.exists() { - return Ok(GlobalSessionIndex::default()); - } - let content = fs::read_to_string(&self.session_path)?; - Ok(toml::from_str(&content)?) + fn session_path_for(&self, id: &str) -> PathBuf { + let filename = format!("{id}.toml"); + self.sessions_dir.join(filename) } - fn write_global_index(&self, index: &GlobalSessionIndex) -> Result<(), SessionError> { - fs::create_dir_all(&self.global_dir)?; - let content = toml::to_string_pretty(index)?; - atomic_write(&self.session_path, content.as_bytes())?; + fn write_session_record(&self, record: &SessionEntry) -> Result<(), SessionError> { + fs::create_dir_all(&self.sessions_dir)?; + let path = self.session_path_for(&record.id); + let content = toml::to_string_pretty(record)?; + atomic_write(&path, content.as_bytes())?; + tracing::info!( + path = %path.display(), + "wrote session record" + ); Ok(()) } + + fn prune_stale_sessions(&self) -> Result<(Vec, usize), SessionError> { + if !self.sessions_dir.exists() { + return Ok((Vec::new(), 0)); + } + + let mut sessions = Vec::new(); + let mut removed = 0usize; + + for entry in fs::read_dir(&self.sessions_dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let record = read_session_file(&path)?; + if !is_vibebox_dir(&record.directory) { + let _ = fs::remove_file(&path); + removed += 1; + continue; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + if stem != record.id { + let _ = fs::remove_file(&path); + removed += 1; + continue; + } + } + sessions.push(record); + } + + Ok((sessions, removed)) + } } fn is_vibebox_dir(directory: &Path) -> bool { @@ -153,10 +215,13 @@ fn is_vibebox_dir(directory: &Path) -> bool { directory.join(CONFIG_FILENAME).is_file() } -fn prune_invalid_dirs(index: &mut GlobalSessionIndex) -> usize { - let before = index.directories.len(); - index.directories.retain(|dir| is_vibebox_dir(dir)); - before - index.directories.len() +fn read_session_file(path: &Path) -> Result { + let raw = fs::read_to_string(path)?; + let record: SessionEntry = toml::from_str(&raw)?; + if record.id.trim().is_empty() { + return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into()); + } + Ok(record) } fn read_instance_metadata(directory: &Path) -> Result { @@ -216,31 +281,35 @@ mod tests { dir } + fn write_instance(project_dir: &Path, id: &str, last_active: &str) { + let instance_dir = project_dir.join(INSTANCE_DIR_NAME); + fs::create_dir_all(&instance_dir).unwrap(); + let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n"); + fs::write(instance_dir.join(INSTANCE_TOML_FILENAME), content).unwrap(); + } + #[test] fn update_global_sessions_adds_directory() { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); let project_dir = create_project_dir(&temp); - fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap(); + fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap(); + write_instance( + &project_dir, + "019bf290-cccc-7c23-ba1d-dce7e6d40693", + "2026-02-07T05:00:00Z", + ); let dirs = mgr.update_global_sessions(&project_dir).unwrap(); assert_eq!(dirs.len(), 1); assert_eq!(dirs[0], project_dir.canonicalize().unwrap()); assert!(mgr.index_path().exists()); - } - #[test] - fn invalid_toml_returns_error() { - let temp = TempDir::new().unwrap(); - let mgr = manager(&temp); - let index_path = mgr.index_path(); - fs::create_dir_all(index_path.parent().unwrap()).unwrap(); - fs::write(index_path, "this is not toml").unwrap(); - - let err = mgr.list_sessions().unwrap_err(); - - assert!(matches!(err, SessionError::TomlDe(_))); + let session_path = mgr + .index_path() + .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml"); + assert!(session_path.exists()); } #[test] @@ -248,38 +317,51 @@ mod tests { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); let project_dir = create_project_dir(&temp); - fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap(); - + fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap(); + write_instance( + &project_dir, + "019bf290-cccc-7c23-ba1d-dce7e6d40693", + "2026-02-07T05:00:00Z", + ); let _ = mgr.update_global_sessions(&project_dir).unwrap(); - fs::remove_file(project_dir.join(VIBEBOX_CONFIG_FILENAME)).unwrap(); - let dirs = mgr.update_global_sessions(&project_dir).unwrap(); - assert!(dirs.is_empty()); + fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap(); + let sessions = mgr.list_sessions().unwrap(); + assert!(sessions.is_empty()); + + let session_path = mgr + .index_path() + .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml"); + assert!(!session_path.exists()); } #[test] - fn list_sessions_reads_instance_metadata() { + fn invalid_toml_returns_error() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + fs::create_dir_all(mgr.index_path()).unwrap(); + fs::write(mgr.index_path().join("bad.toml"), "not toml").unwrap(); + + let err = mgr.list_sessions().unwrap_err(); + assert!(matches!(err, SessionError::TomlDe(_))); + } + + #[test] + fn list_sessions_reads_session_files() { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); let project_dir = create_project_dir(&temp); - fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap(); - - let instance_dir = project_dir.join(INSTANCE_DIR_NAME); - fs::create_dir_all(&instance_dir).unwrap(); - fs::write( - instance_dir.join(INSTANCE_TOML_FILENAME), - "id = \"019bf290-cccc-7c23-ba1d-dce7e6d40693\"\nlast_active = \"2026-02-07T05:00:00Z\"\n", - ) - .unwrap(); - - let _ = mgr.update_global_sessions(&project_dir).unwrap(); - let sessions = mgr.list_sessions().unwrap(); - - assert_eq!(sessions.len(), 1); - assert_eq!( - sessions[0].id.as_deref(), - Some("019bf290-cccc-7c23-ba1d-dce7e6d40693") + fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap(); + write_instance( + &project_dir, + "019bf290-cccc-7c23-ba1d-dce7e6d40693", + "2026-02-07T05:00:00Z", ); + let _ = mgr.update_global_sessions(&project_dir).unwrap(); + + let sessions = mgr.list_sessions().unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].id, "019bf290-cccc-7c23-ba1d-dce7e6d40693"); assert_eq!( sessions[0].last_active.as_deref(), Some("2026-02-07T05:00:00Z") diff --git a/src/vm_manager.rs b/src/vm_manager.rs index 6a16a39..809394c 100644 --- a/src/vm_manager.rs +++ b/src/vm_manager.rs @@ -135,7 +135,8 @@ fn spawn_manager_process( let log_path = instance_dir.join("vm_manager.log"); let log_file = fs::OpenOptions::new() .create(true) - .append(true) + .write(true) + .truncate(true) .open(&log_path) .ok(); if let Some(file) = log_file { @@ -282,7 +283,8 @@ fn spawn_manager_io( let log_path = instance_dir.join("serial.log"); let log_file = fs::OpenOptions::new() .create(true) - .append(true) + .write(true) + .truncate(true) .open(&log_path) .ok() .map(|file| Arc::new(Mutex::new(file)));