@@ -5,35 +5,43 @@ use std::{
7use serde::{Deserialize, Serialize};
7use serde::{Deserialize, Serialize};
8−use time::OffsetDateTime;
11pub const INSTANCE_DIR_NAME: &str = ".vibebox";
9pub const INSTANCE_DIR_NAME: &str = ".vibebox";
10+pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
12pub const GLOBAL_DIR_NAME: &str = ".vibebox";
11pub const GLOBAL_DIR_NAME: &str = ".vibebox";
13−pub const SESSION_INDEX_FILENAME: &str = "sessions.toml";
12+pub const GLOBAL_SESSION_FILENAME: &str = "session.toml";
14pub const SESSION_TEMP_PREFIX: &str = "sessions";
13pub const SESSION_TEMP_PREFIX: &str = "sessions";
15pub const SESSION_TOML_SUFFIX: &str = ".toml";
14pub const SESSION_TOML_SUFFIX: &str = ".toml";
15+use crate::config::CONFIG_FILENAME;
16+pub const INSTANCE_TOML_FILENAME: &str = "instance.toml";
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct SessionRecord {
19pub struct SessionRecord {
20 pub directory: PathBuf,
20 pub directory: PathBuf,
21− #[serde(with = "time::serde::rfc3339")]
22− pub last_active: OffsetDateTime,
23 #[serde(default)]
21 #[serde(default)]
24− pub ref_count: u64,
22+ pub id: Option<String>,
24+ pub last_active: Option<String>,
27#[derive(Debug, Default, Serialize, Deserialize)]
27#[derive(Debug, Default, Serialize, Deserialize)]
28−struct SessionIndex {
28+struct GlobalSessionIndex {
30+ directories: Vec<PathBuf>,
33+#[derive(Debug, Default, Deserialize)]
34+struct InstanceMetadata {
29 #[serde(default)]
35 #[serde(default)]
30− sessions: Vec<SessionRecord>,
36+ id: Option<String>,
38+ last_active: Option<String>,
33#[derive(Debug)]
41#[derive(Debug)]
34pub struct SessionManager {
42pub struct SessionManager {
35 global_dir: PathBuf,
43 global_dir: PathBuf,
36− index_path: PathBuf,
44+ session_path: PathBuf,
39#[derive(Debug, thiserror::Error)]
47#[derive(Debug, thiserror::Error)]
@@ -44,12 +52,6 @@ pub enum SessionError {
44 NonAbsoluteDirectory(PathBuf),
52 NonAbsoluteDirectory(PathBuf),
45 #[error("Session directory does not exist: {0}")]
53 #[error("Session directory does not exist: {0}")]
46 MissingDirectory(PathBuf),
54 MissingDirectory(PathBuf),
47− #[error("Session already exists for directory: {0}")]
48− DirectoryAlreadyHasSession(PathBuf),
49− #[error("Session not found: {0}")]
50− SessionNotFound(Uuid),
51− #[error("Ref count underflow for session: {0}")]
52− RefCountUnderflow(Uuid),
53 #[error(transparent)]
55 #[error(transparent)]
54 Io(#[from] std::io::Error),
56 Io(#[from] std::io::Error),
55 #[error(transparent)]
57 #[error(transparent)]
@@ -67,163 +69,55 @@ impl SessionManager {
69 pub fn with_global_dir(global_dir: PathBuf) -> Self {
71 pub fn with_global_dir(global_dir: PathBuf) -> Self {
70− let index_path = global_dir.join(SESSION_INDEX_FILENAME);
72+ let session_path = global_dir.join(GLOBAL_SESSION_FILENAME);
72 global_dir,
74 global_dir,
73− index_path,
75+ session_path,
77 pub fn index_path(&self) -> &Path {
79 pub fn index_path(&self) -> &Path {
78− &self.index_path
80+ &self.session_path
81− pub fn create_session(&self, directory: &Path) -> Result<SessionRecord, SessionError> {
82− let directory = self.normalize_directory(directory)?;
83− let mut index = self.read_index()?;
85− if index.sessions.iter().any(|s| s.directory == directory) {
86− return Err(SessionError::DirectoryAlreadyHasSession(directory));
89− let now = OffsetDateTime::now_utc();
90− let session = SessionRecord {
92− directory: directory.clone(),
97− fs::create_dir_all(self.instance_dir_for(&directory))?;
98− index.sessions.push(session.clone());
99− self.write_index(&index)?;
104− pub fn get_or_create_session(&self, directory: &Path) -> Result<SessionRecord, SessionError> {
83+ pub fn update_global_sessions(&self, directory: &Path) -> Result<Vec<PathBuf>, SessionError> {
105 let directory = self.normalize_directory(directory)?;
84 let directory = self.normalize_directory(directory)?;
106− let mut index = self.read_index()?;
85+ let mut index = self.read_global_index()?;
107−
86+ let mut changed = false;
108− if let Some(pos) = index.sessions.iter().position(|s| s.directory == directory) {
110− .instance_dir_for(&index.sessions[pos].directory)
113− index.sessions.remove(pos);
115− let now = OffsetDateTime::now_utc();
116− let session = &mut index.sessions[pos];
117− session.ref_count = session.ref_count.saturating_add(1);
118− session.last_active = now;
119− let updated = session.clone();
120− self.write_index(&index)?;
125− let now = OffsetDateTime::now_utc();
126− let session = SessionRecord {
128− directory: directory.clone(),
133− fs::create_dir_all(self.instance_dir_for(&directory))?;
134− index.sessions.push(session.clone());
135− self.write_index(&index)?;
140− pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
141− let mut index = self.read_index()?;
142− let removed = self.remove_orphans(&mut index);
88+ let removed = prune_invalid_dirs(&mut index);
143 if removed > 0 {
89 if removed > 0 {
144− self.write_index(&index)?;
90+ changed = true;
149− pub fn delete_session(&self, id: Uuid) -> Result<bool, SessionError> {
93+ if is_vibebox_dir(&directory) && !index.directories.iter().any(|dir| dir == &directory) {
150− let mut index = self.read_index()?;
94+ index.directories.push(directory);
151− let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
95+ changed = true;
155− let session = index.sessions.remove(pos);
156− let instance_dir = self.instance_dir_for(&session.directory);
157− match fs::remove_dir_all(&instance_dir) {
159− Err(err) if err.kind() == io::ErrorKind::NotFound => {}
160− Err(err) => return Err(err.into()),
163− self.write_index(&index)?;
98+ if changed {
164− Ok(true)
99+ self.write_global_index(&index)?;
167− pub fn bump_last_active(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
168− let now = OffsetDateTime::now_utc();
169− self.update_session(id, |session| {
170− session.last_active = now;
175− pub fn increment_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
176− let now = OffsetDateTime::now_utc();
177− self.update_session(id, |session| {
178− session.ref_count = session.ref_count.saturating_add(1);
179− session.last_active = now;
184− pub fn decrement_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
102+ Ok(index.directories)
185− let now = OffsetDateTime::now_utc();
186− self.update_session(id, |session| {
187− if session.ref_count == 0 {
188− return Err(SessionError::RefCountUnderflow(id));
190− session.ref_count -= 1;
191− session.last_active = now;
196− pub fn cleanup_orphans(&self) -> Result<usize, SessionError> {
105+ pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
197− let mut index = self.read_index()?;
106+ let mut index = self.read_global_index()?;
198− let removed = self.remove_orphans(&mut index);
107+ let removed = prune_invalid_dirs(&mut index);
199 if removed > 0 {
108 if removed > 0 {
200− self.write_index(&index)?;
109+ self.write_global_index(&index)?;
202− Ok(removed)
111+ let mut sessions = Vec::with_capacity(index.directories.len());
203− }
112+ for directory in index.directories {
204−
113+ let meta = read_instance_metadata(&directory)?;
205− fn update_session<F>(&self, id: Uuid, mut update: F) -> Result<SessionRecord, SessionError>
114+ sessions.push(SessionRecord {
206− where
115+ directory,
207− F: FnMut(&mut SessionRecord) -> Result<(), SessionError>,
116+ id: meta.id,
208− {
117+ last_active: meta.last_active,
209− let mut index = self.read_index()?;
118+ });
210− let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
211− return Err(SessionError::SessionNotFound(id));
215− .instance_dir_for(&index.sessions[pos].directory)
218− index.sessions.remove(pos);
219− self.write_index(&index)?;
220− return Err(SessionError::SessionNotFound(id));
223− update(&mut index.sessions[pos])?;
224− let updated = index.sessions[pos].clone();
225− self.write_index(&index)?;
229 fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
123 fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
@@ -236,34 +130,57 @@ impl SessionManager {
236 Ok(directory.canonicalize()?)
130 Ok(directory.canonicalize()?)
239− fn instance_dir_for(&self, directory: &Path) -> PathBuf {
240− directory.join(INSTANCE_DIR_NAME)
243− fn remove_orphans(&self, index: &mut SessionIndex) -> usize {
244− let before = index.sessions.len();
247− .retain(|s| self.instance_dir_for(&s.directory).is_dir());
248− before - index.sessions.len()
251− fn read_index(&self) -> Result<SessionIndex, SessionError> {
133+ fn read_global_index(&self) -> Result<GlobalSessionIndex, SessionError> {
252− if !self.index_path.exists() {
134+ if !self.session_path.exists() {
253− return Ok(SessionIndex::default());
135+ return Ok(GlobalSessionIndex::default());
255− let content = fs::read_to_string(&self.index_path)?;
137+ let content = fs::read_to_string(&self.session_path)?;
256 Ok(toml::from_str(&content)?)
138 Ok(toml::from_str(&content)?)
259− fn write_index(&self, index: &SessionIndex) -> Result<(), SessionError> {
141+ fn write_global_index(&self, index: &GlobalSessionIndex) -> Result<(), SessionError> {
260 fs::create_dir_all(&self.global_dir)?;
142 fs::create_dir_all(&self.global_dir)?;
261 let content = toml::to_string_pretty(index)?;
143 let content = toml::to_string_pretty(index)?;
262− atomic_write(&self.index_path, content.as_bytes())?;
144+ atomic_write(&self.session_path, content.as_bytes())?;
149+fn is_vibebox_dir(directory: &Path) -> bool {
150+ if !directory.is_absolute() {
153+ directory.join(CONFIG_FILENAME).is_file()
156+fn prune_invalid_dirs(index: &mut GlobalSessionIndex) -> usize {
157+ let before = index.directories.len();
158+ index.directories.retain(|dir| is_vibebox_dir(dir));
159+ before - index.directories.len()
162+fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
163+ let instance_path = directory
164+ .join(INSTANCE_DIR_NAME)
165+ .join(INSTANCE_TOML_FILENAME);
166+ if !instance_path.exists() {
167+ return Ok(InstanceMetadata::default());
169+ let raw = fs::read_to_string(&instance_path)?;
170+ let mut meta: InstanceMetadata = toml::from_str(&raw)?;
171+ if let Some(id) = &meta.id {
172+ if id.trim().is_empty() {
176+ if let Some(last_active) = &meta.last_active {
177+ if last_active.trim().is_empty() {
178+ meta.last_active = None;
267fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
184fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
268 let Some(parent) = path.parent() else {
185 let Some(parent) = path.parent() else {
269 return Err(io::Error::new(
186 return Err(io::Error::new(
@@ -288,7 +205,6 @@ mod tests {
288 use super::*;
205 use super::*;
289 use std::fs;
206 use std::fs;
290 use tempfile::TempDir;
207 use tempfile::TempDir;
291− use time::OffsetDateTime;
293 fn manager(temp: &TempDir) -> SessionManager {
209 fn manager(temp: &TempDir) -> SessionManager {
294 SessionManager::with_global_dir(temp.path().join("global"))
210 SessionManager::with_global_dir(temp.path().join("global"))
@@ -301,104 +217,17 @@ mod tests {
304− fn create_session_writes_index_and_instance_dir() {
220+ fn update_global_sessions_adds_directory() {
305 let temp = TempDir::new().unwrap();
221 let temp = TempDir::new().unwrap();
306 let mgr = manager(&temp);
222 let mgr = manager(&temp);
307 let project_dir = create_project_dir(&temp);
223 let project_dir = create_project_dir(&temp);
224+ fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
309− let session = mgr.create_session(&project_dir).unwrap();
226+ let dirs = mgr.update_global_sessions(&project_dir).unwrap();
228+ assert_eq!(dirs.len(), 1);
229+ assert_eq!(dirs[0], project_dir.canonicalize().unwrap());
311 assert!(mgr.index_path().exists());
230 assert!(mgr.index_path().exists());
312− assert_eq!(session.directory, project_dir.canonicalize().unwrap());
313− assert_eq!(session.ref_count, 1);
314− assert!(project_dir.join(INSTANCE_DIR_NAME).is_dir());
318− fn create_session_rejects_non_absolute_directory() {
319− let temp = TempDir::new().unwrap();
320− let mgr = manager(&temp);
322− let err = mgr.create_session(Path::new("relative/path")).unwrap_err();
324− assert!(matches!(err, SessionError::NonAbsoluteDirectory(_)));
328− fn create_session_rejects_missing_directory() {
329− let temp = TempDir::new().unwrap();
330− let mgr = manager(&temp);
331− let missing = temp.path().join("missing");
333− let err = mgr.create_session(&missing).unwrap_err();
335− assert!(matches!(err, SessionError::MissingDirectory(_)));
339− fn create_session_rejects_duplicate_directory() {
340− let temp = TempDir::new().unwrap();
341− let mgr = manager(&temp);
342− let project_dir = create_project_dir(&temp);
344− let _session = mgr.create_session(&project_dir).unwrap();
345− let err = mgr.create_session(&project_dir).unwrap_err();
347− assert!(matches!(err, SessionError::DirectoryAlreadyHasSession(_)));
351− fn get_or_create_increments_ref_count_for_existing_session() {
352− let temp = TempDir::new().unwrap();
353− let mgr = manager(&temp);
354− let project_dir = create_project_dir(&temp);
356− let first = mgr.create_session(&project_dir).unwrap();
357− let second = mgr.get_or_create_session(&project_dir).unwrap();
359− assert_eq!(first.id, second.id);
360− assert_eq!(second.ref_count, 2);
364− fn decrement_ref_count_errors_on_underflow() {
365− let temp = TempDir::new().unwrap();
366− let mgr = manager(&temp);
367− let project_dir = create_project_dir(&temp);
369− let session = mgr.create_session(&project_dir).unwrap();
370− let session = mgr.decrement_ref_count(session.id).unwrap();
371− assert_eq!(session.ref_count, 0);
373− let err = mgr.decrement_ref_count(session.id).unwrap_err();
374− assert!(matches!(err, SessionError::RefCountUnderflow(_)));
378− fn list_sessions_cleans_orphans() {
379− let temp = TempDir::new().unwrap();
380− let mgr = manager(&temp);
381− let project_dir = create_project_dir(&temp);
383− let _session = mgr.create_session(&project_dir).unwrap();
384− fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
386− let sessions = mgr.list_sessions().unwrap();
387− assert!(sessions.is_empty());
391− fn delete_session_removes_instance_dir_and_index() {
392− let temp = TempDir::new().unwrap();
393− let mgr = manager(&temp);
394− let project_dir = create_project_dir(&temp);
396− let session = mgr.create_session(&project_dir).unwrap();
397− let removed = mgr.delete_session(session.id).unwrap();
400− assert!(!project_dir.join(INSTANCE_DIR_NAME).exists());
401− assert!(mgr.list_sessions().unwrap().is_empty());
@@ -415,73 +244,45 @@ mod tests {
418− fn bump_last_active_updates_timestamp() {
247+ fn update_global_sessions_removes_missing_vibebox_toml() {
419− let temp = TempDir::new().unwrap();
420− let mgr = manager(&temp);
421− let project_dir = create_project_dir(&temp);
423− let session = mgr.create_session(&project_dir).unwrap();
424− let before = session.last_active;
425− std::thread::sleep(std::time::Duration::from_millis(5));
426− let updated = mgr.bump_last_active(session.id).unwrap();
427− let now = OffsetDateTime::now_utc();
429− assert!(updated.last_active >= before);
430− assert!(updated.last_active <= now);
434− fn cleanup_orphans_returns_removed_count() {
435 let temp = TempDir::new().unwrap();
248 let temp = TempDir::new().unwrap();
436 let mgr = manager(&temp);
249 let mgr = manager(&temp);
437 let project_dir = create_project_dir(&temp);
250 let project_dir = create_project_dir(&temp);
251+ fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
439− let _session = mgr.create_session(&project_dir).unwrap();
253+ let _ = mgr.update_global_sessions(&project_dir).unwrap();
440− fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
254+ fs::remove_file(project_dir.join(VIBEBOX_CONFIG_FILENAME)).unwrap();
442− let removed = mgr.cleanup_orphans().unwrap();
256+ let dirs = mgr.update_global_sessions(&project_dir).unwrap();
443− assert_eq!(removed, 1);
444− assert!(mgr.list_sessions().unwrap().is_empty());
257+ assert!(dirs.is_empty());
448− fn increment_ref_count_updates_last_active() {
261+ fn list_sessions_reads_instance_metadata() {
449 let temp = TempDir::new().unwrap();
262 let temp = TempDir::new().unwrap();
450 let mgr = manager(&temp);
263 let mgr = manager(&temp);
451 let project_dir = create_project_dir(&temp);
264 let project_dir = create_project_dir(&temp);
265+ fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
453− let session = mgr.create_session(&project_dir).unwrap();
267+ let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
454− std::thread::sleep(std::time::Duration::from_millis(5));
268+ fs::create_dir_all(&instance_dir).unwrap();
455− let updated = mgr.increment_ref_count(session.id).unwrap();
269+ fs::write(
456−
270+ instance_dir.join(INSTANCE_TOML_FILENAME),
457− assert_eq!(updated.ref_count, session.ref_count + 1);
271+ "id = \"019bf290-cccc-7c23-ba1d-dce7e6d40693\"\nlast_active = \"2026-02-07T05:00:00Z\"\n",
458− assert!(updated.last_active >= session.last_active);
272+ )
462− fn decrement_ref_count_updates_last_active() {
463− let temp = TempDir::new().unwrap();
464− let mgr = manager(&temp);
465− let project_dir = create_project_dir(&temp);
467− let session = mgr.create_session(&project_dir).unwrap();
468− std::thread::sleep(std::time::Duration::from_millis(5));
469− let updated = mgr.decrement_ref_count(session.id).unwrap();
471− assert_eq!(updated.ref_count, session.ref_count - 1);
472− assert!(updated.last_active >= session.last_active);
476− fn list_sessions_returns_active_sessions() {
477− let temp = TempDir::new().unwrap();
273+ .unwrap();
478− let mgr = manager(&temp);
479− let project_dir = create_project_dir(&temp);
481− let session = mgr.create_session(&project_dir).unwrap();
275+ let _ = mgr.update_global_sessions(&project_dir).unwrap();
482 let sessions = mgr.list_sessions().unwrap();
276 let sessions = mgr.list_sessions().unwrap();
484 assert_eq!(sessions.len(), 1);
278 assert_eq!(sessions.len(), 1);
485− assert_eq!(sessions[0].id, session.id);
279+ assert_eq!(
280+ sessions[0].id.as_deref(),
281+ Some("019bf290-cccc-7c23-ba1d-dce7e6d40693")
284+ sessions[0].last_active.as_deref(),
285+ Some("2026-02-07T05:00:00Z")