@@ -6,28 +6,27 @@ use std::{
66
77use serde::{Deserialize, Serialize};
88
9+use crate::config::CONFIG_FILENAME;
10+
911pub const INSTANCE_DIR_NAME: &str = ".vibebox";
1012pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
1113pub const GLOBAL_DIR_NAME: &str = ".vibebox";
12−pub const GLOBAL_SESSION_FILENAME: &str = "session.toml";
14+pub const INSTANCE_TOML_FILENAME: &str = "instance.toml";
1315pub const SESSION_TEMP_PREFIX: &str = "sessions";
1416pub const SESSION_TOML_SUFFIX: &str = ".toml";
15−use crate::config::CONFIG_FILENAME;
16−pub const INSTANCE_TOML_FILENAME: &str = "instance.toml";
17+const SESSIONS_DIR_NAME: &str = "sessions";
1718
18−#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19+#[derive(Debug, Clone, PartialEq, Eq)]
1920pub struct SessionRecord {
2021 pub directory: PathBuf,
21− #[serde(default)]
22− pub id: Option<String>,
23− #[serde(default)]
22+ pub id: String,
2423 pub last_active: Option<String>,
2524}
2625
27−#[derive(Debug, Default, Serialize, Deserialize)]
28−struct GlobalSessionIndex {
29− #[serde(default)]
30− directories: Vec<PathBuf>,
26+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27+struct SessionEntry {
28+ pub directory: PathBuf,
29+ pub id: String,
3130}
3231
3332#[derive(Debug, Default, Deserialize)]
@@ -40,8 +39,7 @@ struct InstanceMetadata {
4039
4140#[derive(Debug)]
4241pub struct SessionManager {
43− global_dir: PathBuf,
44− session_path: PathBuf,
42+ sessions_dir: PathBuf,
4543}
4644
4745#[derive(Debug, thiserror::Error)]
@@ -69,55 +67,84 @@ impl SessionManager {
6967 }
7068
7169 pub fn with_global_dir(global_dir: PathBuf) -> Self {
72− let session_path = global_dir.join(GLOBAL_SESSION_FILENAME);
73− Self {
74− global_dir,
75− session_path,
76− }
70+ let sessions_dir = global_dir.join(SESSIONS_DIR_NAME);
71+ Self { sessions_dir }
7772 }
7873
7974 pub fn index_path(&self) -> &Path {
80− &self.session_path
75+ &self.sessions_dir
8176 }
8277
8378 pub fn update_global_sessions(&self, directory: &Path) -> Result<Vec<PathBuf>, SessionError> {
8479 let directory = self.normalize_directory(directory)?;
85− let mut index = self.read_global_index()?;
86− let mut changed = false;
80+ fs::create_dir_all(&self.sessions_dir)?;
8781
88− let removed = prune_invalid_dirs(&mut index);
89− if removed > 0 {
90− changed = true;
91− }
82+ let (mut sessions, removed) = self.prune_stale_sessions()?;
83+ let has_config = is_vibebox_dir(&directory);
84+ let mut added = false;
9285
93− if is_vibebox_dir(&directory) && !index.directories.iter().any(|dir| dir == &directory) {
94− index.directories.push(directory);
95− changed = true;
86+ if has_config {
87+ let meta = read_instance_metadata(&directory)?;
88+ if let Some(id) = meta.id {
89+ let record = SessionEntry {
90+ directory: directory.clone(),
91+ id: id.clone(),
92+ };
93+ self.write_session_record(&record)?;
94+ if let Some(existing) = sessions.iter_mut().find(|s| s.id == id) {
95+ *existing = record;
96+ } else {
97+ sessions.push(record);
98+ }
99+ added = true;
100+ } else {
101+ tracing::warn!(
102+ directory = %directory.display(),
103+ "missing session id in instance.toml"
104+ );
105+ }
96106 }
97107
98− if changed {
99− self.write_global_index(&index)?;
108+ if removed > 0 || added {
109+ tracing::info!(
110+ path = %self.sessions_dir.display(),
111+ removed,
112+ added,
113+ entries = sessions.len(),
114+ "updated global sessions"
115+ );
116+ } else {
117+ tracing::debug!(
118+ path = %self.sessions_dir.display(),
119+ entries = sessions.len(),
120+ has_config,
121+ "global sessions unchanged"
122+ );
100123 }
101124
102− Ok(index.directories)
125+ Ok(sessions.into_iter().map(|s| s.directory).collect())
103126 }
104127
105128 pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
106− let mut index = self.read_global_index()?;
107− let removed = prune_invalid_dirs(&mut index);
129+ let (sessions, removed) = self.prune_stale_sessions()?;
108130 if removed > 0 {
109− self.write_global_index(&index)?;
131+ tracing::info!(
132+ path = %self.sessions_dir.display(),
133+ removed,
134+ entries = sessions.len(),
135+ "pruned stale sessions"
136+ );
110137 }
111− let mut sessions = Vec::with_capacity(index.directories.len());
112− for directory in index.directories {
113− let meta = read_instance_metadata(&directory)?;
114− sessions.push(SessionRecord {
115− directory,
116− id: meta.id,
138+ let mut records = Vec::with_capacity(sessions.len());
139+ for session in sessions {
140+ let meta = read_instance_metadata(&session.directory)?;
141+ records.push(SessionRecord {
142+ directory: session.directory,
143+ id: session.id,
117144 last_active: meta.last_active,
118145 });
119146 }
120− Ok(sessions)
147+ Ok(records)
121148 }
122149
123150 fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
@@ -130,20 +157,55 @@ impl SessionManager {
130157 Ok(directory.canonicalize()?)
131158 }
132159
133− fn read_global_index(&self) -> Result<GlobalSessionIndex, SessionError> {
134− if !self.session_path.exists() {
135− return Ok(GlobalSessionIndex::default());
136− }
137− let content = fs::read_to_string(&self.session_path)?;
138− Ok(toml::from_str(&content)?)
160+ fn session_path_for(&self, id: &str) -> PathBuf {
161+ let filename = format!("{id}.toml");
162+ self.sessions_dir.join(filename)
139163 }
140164
141− fn write_global_index(&self, index: &GlobalSessionIndex) -> Result<(), SessionError> {
142− fs::create_dir_all(&self.global_dir)?;
143− let content = toml::to_string_pretty(index)?;
144− atomic_write(&self.session_path, content.as_bytes())?;
165+ fn write_session_record(&self, record: &SessionEntry) -> Result<(), SessionError> {
166+ fs::create_dir_all(&self.sessions_dir)?;
167+ let path = self.session_path_for(&record.id);
168+ let content = toml::to_string_pretty(record)?;
169+ atomic_write(&path, content.as_bytes())?;
170+ tracing::info!(
171+ path = %path.display(),
172+ "wrote session record"
173+ );
145174 Ok(())
146175 }
176+
177+ fn prune_stale_sessions(&self) -> Result<(Vec<SessionEntry>, usize), SessionError> {
178+ if !self.sessions_dir.exists() {
179+ return Ok((Vec::new(), 0));
180+ }
181+
182+ let mut sessions = Vec::new();
183+ let mut removed = 0usize;
184+
185+ for entry in fs::read_dir(&self.sessions_dir)? {
186+ let entry = entry?;
187+ let path = entry.path();
188+ if !path.is_file() {
189+ continue;
190+ }
191+ let record = read_session_file(&path)?;
192+ if !is_vibebox_dir(&record.directory) {
193+ let _ = fs::remove_file(&path);
194+ removed += 1;
195+ continue;
196+ }
197+ if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
198+ if stem != record.id {
199+ let _ = fs::remove_file(&path);
200+ removed += 1;
201+ continue;
202+ }
203+ }
204+ sessions.push(record);
205+ }
206+
207+ Ok((sessions, removed))
208+ }
147209}
148210
149211fn is_vibebox_dir(directory: &Path) -> bool {
@@ -153,10 +215,13 @@ fn is_vibebox_dir(directory: &Path) -> bool {
153215 directory.join(CONFIG_FILENAME).is_file()
154216}
155217
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()
218+fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
219+ let raw = fs::read_to_string(path)?;
220+ let record: SessionEntry = toml::from_str(&raw)?;
221+ if record.id.trim().is_empty() {
222+ return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
223+ }
224+ Ok(record)
160225}
161226
162227fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
@@ -216,70 +281,87 @@ mod tests {
216281 dir
217282 }
218283
284+ fn write_instance(project_dir: &Path, id: &str, last_active: &str) {
285+ let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
286+ fs::create_dir_all(&instance_dir).unwrap();
287+ let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n");
288+ fs::write(instance_dir.join(INSTANCE_TOML_FILENAME), content).unwrap();
289+ }
290+
219291 #[test]
220292 fn update_global_sessions_adds_directory() {
221293 let temp = TempDir::new().unwrap();
222294 let mgr = manager(&temp);
223295 let project_dir = create_project_dir(&temp);
224− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
296+ fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
297+ write_instance(
298+ &project_dir,
299+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
300+ "2026-02-07T05:00:00Z",
301+ );
225302
226303 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
227304
228305 assert_eq!(dirs.len(), 1);
229306 assert_eq!(dirs[0], project_dir.canonicalize().unwrap());
230307 assert!(mgr.index_path().exists());
308+
309+ let session_path = mgr
310+ .index_path()
311+ .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml");
312+ assert!(session_path.exists());
231313 }
232314
233315 #[test]
234− fn invalid_toml_returns_error() {
316+ fn update_global_sessions_removes_missing_vibebox_toml() {
235317 let temp = TempDir::new().unwrap();
236318 let mgr = manager(&temp);
237− let index_path = mgr.index_path();
238− fs::create_dir_all(index_path.parent().unwrap()).unwrap();
239− fs::write(index_path, "this is not toml").unwrap();
319+ let project_dir = create_project_dir(&temp);
320+ fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
321+ write_instance(
322+ &project_dir,
323+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
324+ "2026-02-07T05:00:00Z",
325+ );
326+ let _ = mgr.update_global_sessions(&project_dir).unwrap();
240327
241− let err = mgr.list_sessions().unwrap_err();
328+ fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap();
329+ let sessions = mgr.list_sessions().unwrap();
330+ assert!(sessions.is_empty());
242331
243− assert!(matches!(err, SessionError::TomlDe(_)));
332+ let session_path = mgr
333+ .index_path()
334+ .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml");
335+ assert!(!session_path.exists());
244336 }
245337
246338 #[test]
247− fn update_global_sessions_removes_missing_vibebox_toml() {
339+ fn invalid_toml_returns_error() {
248340 let temp = TempDir::new().unwrap();
249341 let mgr = manager(&temp);
250− let project_dir = create_project_dir(&temp);
251− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
342+ fs::create_dir_all(mgr.index_path()).unwrap();
343+ fs::write(mgr.index_path().join("bad.toml"), "not toml").unwrap();
252344
253− let _ = mgr.update_global_sessions(&project_dir).unwrap();
254− fs::remove_file(project_dir.join(VIBEBOX_CONFIG_FILENAME)).unwrap();
255−
256− let dirs = mgr.update_global_sessions(&project_dir).unwrap();
257− assert!(dirs.is_empty());
345+ let err = mgr.list_sessions().unwrap_err();
346+ assert!(matches!(err, SessionError::TomlDe(_)));
258347 }
259348
260349 #[test]
261− fn list_sessions_reads_instance_metadata() {
350+ fn list_sessions_reads_session_files() {
262351 let temp = TempDir::new().unwrap();
263352 let mgr = manager(&temp);
264353 let project_dir = create_project_dir(&temp);
265− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
266−
267− let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
268− fs::create_dir_all(&instance_dir).unwrap();
269− fs::write(
270− instance_dir.join(INSTANCE_TOML_FILENAME),
271− "id = \"019bf290-cccc-7c23-ba1d-dce7e6d40693\"\nlast_active = \"2026-02-07T05:00:00Z\"\n",
272− )
273− .unwrap();
274−
354+ fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
355+ write_instance(
356+ &project_dir,
357+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
358+ "2026-02-07T05:00:00Z",
359+ );
275360 let _ = mgr.update_global_sessions(&project_dir).unwrap();
276− let sessions = mgr.list_sessions().unwrap();
277361
362+ let sessions = mgr.list_sessions().unwrap();
278363 assert_eq!(sessions.len(), 1);
279− assert_eq!(
280− sessions[0].id.as_deref(),
281− Some("019bf290-cccc-7c23-ba1d-dce7e6d40693")
282− );
364+ assert_eq!(sessions[0].id, "019bf290-cccc-7c23-ba1d-dce7e6d40693");
283365 assert_eq!(
284366 sessions[0].last_active.as_deref(),
285367 Some("2026-02-07T05:00:00Z")