on main help sign in
feat: wire up SessionManager.
5fc9149robcholz
docs/tasks.md+4−3
@@ -39,16 +39,17 @@
392. [x] Use ssh to connect to vm.
392. [x] Use ssh to connect to vm.
403. [x] allow multi vibebox to connect to the same vm.
403. [x] allow multi vibebox to connect to the same vm.
414. [x] use vm.lock to ensure process concurrency safety.
414. [x] use vm.lock to ensure process concurrency safety.
42−5. [ ] wire up SessionManager.
42+5. [x] wire up SessionManager.
436. [x] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
436. [x] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
447. [ ] setup vibebox commands
447. [ ] setup vibebox commands
458. [ ] setup cli commands.
458. [ ] setup cli commands.
469. [ ] fix ui overlap.
469. [ ] fix ui overlap.
47+10. [ ] intensive integration test.
47
48
48## Publish
49## Publish
49
50
50−1. [ ] write the docs
51+1. [ ] write the docs.
51−2. [ ] setup quick link.
52+2. [ ] setup quick install link.
523. [ ] setup website.
533. [ ] setup website.
53
54
54## Stage 2
55## Stage 2
src/bin/vibebox-cli.rs+4−4
@@ -54,6 +54,10 @@ fn main() -> Result<()> {
54 };
54 };
55 let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
55 let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
56 tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
56 tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
57+ let auto_shutdown_ms = config::load_config(&cwd)
58+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?
59+ .auto_shutdown_ms
60+ .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
57 if let Ok(manager) = SessionManager::new() {
61 if let Ok(manager) = SessionManager::new() {
58 if let Err(err) = manager.update_global_sessions(&cwd) {
62 if let Err(err) = manager.update_global_sessions(&cwd) {
59 tracing::warn!(error = %err, "failed to update global session list");
63 tracing::warn!(error = %err, "failed to update global session list");
@@ -73,10 +77,6 @@ fn main() -> Result<()> {
73 stdout.flush()?;
77 stdout.flush()?;
74 }
78 }
75
79
76− let auto_shutdown_ms = config::load_config(&cwd)
77− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?
78− .auto_shutdown_ms
79− .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
80 tracing::info!(auto_shutdown_ms, "auto shutdown config");
80 tracing::info!(auto_shutdown_ms, "auto shutdown config");
81 let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms)
81 let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms)
82 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
82 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
src/instance.rs+3−3
@@ -1,4 +1,3 @@
1−use crate::session_manager::INSTANCE_TOML_FILENAME;
2use std::{
1use std::{
3 env, fs,
2 env, fs,
4 io::{self, Write},
3 io::{self, Write},
@@ -20,7 +19,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
20use uuid::Uuid;
19use uuid::Uuid;
21
20
22use crate::{
21use crate::{
23− session_manager::INSTANCE_DIR_NAME,
22+ session_manager::{INSTANCE_DIR_NAME, INSTANCE_TOML_FILENAME},
24 tui::{self, AppState},
23 tui::{self, AppState},
25 vm::{self, LoginAction, VmInput},
24 vm::{self, LoginAction, VmInput},
26};
25};
@@ -367,7 +366,8 @@ fn spawn_ssh_io(
367 let log_path = instance_dir.join(SERIAL_LOG_NAME);
366 let log_path = instance_dir.join(SERIAL_LOG_NAME);
368 let log_file = fs::OpenOptions::new()
367 let log_file = fs::OpenOptions::new()
369 .create(true)
368 .create(true)
370− .append(true)
369+ .write(true)
370+ .truncate(true)
371 .open(&log_path)
371 .open(&log_path)
372 .ok()
372 .ok()
373 .map(|file| Arc::new(Mutex::new(file)));
373 .map(|file| Arc::new(Mutex::new(file)));
src/session_manager.rs+168−86
@@ -6,28 +6,27 @@ use std::{
6
6
7use serde::{Deserialize, Serialize};
7use serde::{Deserialize, Serialize};
8
8
9+use crate::config::CONFIG_FILENAME;
10+
9pub const INSTANCE_DIR_NAME: &str = ".vibebox";
11pub const INSTANCE_DIR_NAME: &str = ".vibebox";
10pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
12pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
11pub const GLOBAL_DIR_NAME: &str = ".vibebox";
13pub const GLOBAL_DIR_NAME: &str = ".vibebox";
12−pub const GLOBAL_SESSION_FILENAME: &str = "session.toml";
14+pub const INSTANCE_TOML_FILENAME: &str = "instance.toml";
13pub const SESSION_TEMP_PREFIX: &str = "sessions";
15pub const SESSION_TEMP_PREFIX: &str = "sessions";
14pub const SESSION_TOML_SUFFIX: &str = ".toml";
16pub const SESSION_TOML_SUFFIX: &str = ".toml";
15−use crate::config::CONFIG_FILENAME;
17+const SESSIONS_DIR_NAME: &str = "sessions";
16−pub const INSTANCE_TOML_FILENAME: &str = "instance.toml";
17
18
18−#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19+#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SessionRecord {
20pub struct SessionRecord {
20 pub directory: PathBuf,
21 pub directory: PathBuf,
21− #[serde(default)]
22− pub id: Option<String>,
22+ pub id: String,
23− #[serde(default)]
24 pub last_active: Option<String>,
23 pub last_active: Option<String>,
25}
24}
26
25
27−#[derive(Debug, Default, Serialize, Deserialize)]
26+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28−struct GlobalSessionIndex {
27+struct SessionEntry {
29− #[serde(default)]
28+ pub directory: PathBuf,
30− directories: Vec<PathBuf>,
29+ pub id: String,
31}
30}
32
31
33#[derive(Debug, Default, Deserialize)]
32#[derive(Debug, Default, Deserialize)]
@@ -40,8 +39,7 @@ struct InstanceMetadata {
40
39
41#[derive(Debug)]
40#[derive(Debug)]
42pub struct SessionManager {
41pub struct SessionManager {
43− global_dir: PathBuf,
42+ sessions_dir: PathBuf,
44− session_path: PathBuf,
45}
43}
46
44
47#[derive(Debug, thiserror::Error)]
45#[derive(Debug, thiserror::Error)]
@@ -69,55 +67,84 @@ impl SessionManager {
69 }
67 }
70
68
71 pub fn with_global_dir(global_dir: PathBuf) -> Self {
69 pub fn with_global_dir(global_dir: PathBuf) -> Self {
72− let session_path = global_dir.join(GLOBAL_SESSION_FILENAME);
70+ let sessions_dir = global_dir.join(SESSIONS_DIR_NAME);
73− Self {
71+ Self { sessions_dir }
74− global_dir,
75− session_path,
76− }
77 }
72 }
78
73
79 pub fn index_path(&self) -> &Path {
74 pub fn index_path(&self) -> &Path {
80− &self.session_path
75+ &self.sessions_dir
81 }
76 }
82
77
83 pub fn update_global_sessions(&self, directory: &Path) -> Result<Vec<PathBuf>, SessionError> {
78 pub fn update_global_sessions(&self, directory: &Path) -> Result<Vec<PathBuf>, SessionError> {
84 let directory = self.normalize_directory(directory)?;
79 let directory = self.normalize_directory(directory)?;
85− let mut index = self.read_global_index()?;
80+ fs::create_dir_all(&self.sessions_dir)?;
86− let mut changed = false;
87
81
88− let removed = prune_invalid_dirs(&mut index);
82+ let (mut sessions, removed) = self.prune_stale_sessions()?;
89− if removed > 0 {
83+ let has_config = is_vibebox_dir(&directory);
90− changed = true;
84+ let mut added = false;
91− }
92
85
93− if is_vibebox_dir(&directory) && !index.directories.iter().any(|dir| dir == &directory) {
86+ if has_config {
94− index.directories.push(directory);
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+ }
95− changed = true;
99+ added = true;
100+ } else {
101+ tracing::warn!(
102+ directory = %directory.display(),
103+ "missing session id in instance.toml"
104+ );
105+ }
96 }
106 }
97
107
98− if changed {
108+ if removed > 0 || added {
99− self.write_global_index(&index)?;
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+ );
100 }
123 }
101
124
102− Ok(index.directories)
125+ Ok(sessions.into_iter().map(|s| s.directory).collect())
103 }
126 }
104
127
105 pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
128 pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
106− let mut index = self.read_global_index()?;
129+ let (sessions, removed) = self.prune_stale_sessions()?;
107− let removed = prune_invalid_dirs(&mut index);
108 if removed > 0 {
130 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+ );
110 }
137 }
111− let mut sessions = Vec::with_capacity(index.directories.len());
138+ let mut records = Vec::with_capacity(sessions.len());
112− for directory in index.directories {
139+ for session in sessions {
113− let meta = read_instance_metadata(&directory)?;
140+ let meta = read_instance_metadata(&session.directory)?;
114− sessions.push(SessionRecord {
141+ records.push(SessionRecord {
115− directory,
142+ directory: session.directory,
116− id: meta.id,
143+ id: session.id,
117 last_active: meta.last_active,
144 last_active: meta.last_active,
118 });
145 });
119 }
146 }
120− Ok(sessions)
147+ Ok(records)
121 }
148 }
122
149
123 fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
150 fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
@@ -130,20 +157,55 @@ impl SessionManager {
130 Ok(directory.canonicalize()?)
157 Ok(directory.canonicalize()?)
131 }
158 }
132
159
133− fn read_global_index(&self) -> Result<GlobalSessionIndex, SessionError> {
160+ fn session_path_for(&self, id: &str) -> PathBuf {
134− if !self.session_path.exists() {
161+ let filename = format!("{id}.toml");
135− return Ok(GlobalSessionIndex::default());
162+ self.sessions_dir.join(filename)
136− }
137− let content = fs::read_to_string(&self.session_path)?;
138− Ok(toml::from_str(&content)?)
139 }
163 }
140
164
141− fn write_global_index(&self, index: &GlobalSessionIndex) -> Result<(), SessionError> {
165+ fn write_session_record(&self, record: &SessionEntry) -> Result<(), SessionError> {
142− fs::create_dir_all(&self.global_dir)?;
166+ fs::create_dir_all(&self.sessions_dir)?;
167+ let path = self.session_path_for(&record.id);
143− let content = toml::to_string_pretty(index)?;
168+ let content = toml::to_string_pretty(record)?;
144− atomic_write(&self.session_path, content.as_bytes())?;
169+ atomic_write(&path, content.as_bytes())?;
170+ tracing::info!(
171+ path = %path.display(),
172+ "wrote session record"
173+ );
145 Ok(())
174 Ok(())
146 }
175 }
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+ }
147}
209}
148
210
149fn is_vibebox_dir(directory: &Path) -> bool {
211fn is_vibebox_dir(directory: &Path) -> bool {
@@ -153,10 +215,13 @@ fn is_vibebox_dir(directory: &Path) -> bool {
153 directory.join(CONFIG_FILENAME).is_file()
215 directory.join(CONFIG_FILENAME).is_file()
154}
216}
155
217
156−fn prune_invalid_dirs(index: &mut GlobalSessionIndex) -> usize {
218+fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
157− let before = index.directories.len();
219+ let raw = fs::read_to_string(path)?;
158− index.directories.retain(|dir| is_vibebox_dir(dir));
220+ let record: SessionEntry = toml::from_str(&raw)?;
159− before - index.directories.len()
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)
160}
225}
161
226
162fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
227fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
@@ -216,70 +281,87 @@ mod tests {
216 dir
281 dir
217 }
282 }
218
283
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+
219 #[test]
291 #[test]
220 fn update_global_sessions_adds_directory() {
292 fn update_global_sessions_adds_directory() {
221 let temp = TempDir::new().unwrap();
293 let temp = TempDir::new().unwrap();
222 let mgr = manager(&temp);
294 let mgr = manager(&temp);
223 let project_dir = create_project_dir(&temp);
295 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+ );
225
302
226 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
303 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
227
304
228 assert_eq!(dirs.len(), 1);
305 assert_eq!(dirs.len(), 1);
229 assert_eq!(dirs[0], project_dir.canonicalize().unwrap());
306 assert_eq!(dirs[0], project_dir.canonicalize().unwrap());
230 assert!(mgr.index_path().exists());
307 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());
231 }
313 }
232
314
233 #[test]
315 #[test]
234− fn invalid_toml_returns_error() {
316+ fn update_global_sessions_removes_missing_vibebox_toml() {
235 let temp = TempDir::new().unwrap();
317 let temp = TempDir::new().unwrap();
236 let mgr = manager(&temp);
318 let mgr = manager(&temp);
237− let index_path = mgr.index_path();
319+ let project_dir = create_project_dir(&temp);
238− fs::create_dir_all(index_path.parent().unwrap()).unwrap();
320+ fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
239− fs::write(index_path, "this is not toml").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();
240
327
328+ fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap();
241− let err = mgr.list_sessions().unwrap_err();
329+ let sessions = mgr.list_sessions().unwrap();
330+ assert!(sessions.is_empty());
242
331
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());
244 }
336 }
245
337
246 #[test]
338 #[test]
247− fn update_global_sessions_removes_missing_vibebox_toml() {
339+ fn invalid_toml_returns_error() {
248 let temp = TempDir::new().unwrap();
340 let temp = TempDir::new().unwrap();
249 let mgr = manager(&temp);
341 let mgr = manager(&temp);
250− let project_dir = create_project_dir(&temp);
342+ fs::create_dir_all(mgr.index_path()).unwrap();
251− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
343+ fs::write(mgr.index_path().join("bad.toml"), "not toml").unwrap();
252
344
253− let _ = mgr.update_global_sessions(&project_dir).unwrap();
345+ let err = mgr.list_sessions().unwrap_err();
254− fs::remove_file(project_dir.join(VIBEBOX_CONFIG_FILENAME)).unwrap();
346+ assert!(matches!(err, SessionError::TomlDe(_)));
255−
256− let dirs = mgr.update_global_sessions(&project_dir).unwrap();
257− assert!(dirs.is_empty());
258 }
347 }
259
348
260 #[test]
349 #[test]
261− fn list_sessions_reads_instance_metadata() {
350+ fn list_sessions_reads_session_files() {
262 let temp = TempDir::new().unwrap();
351 let temp = TempDir::new().unwrap();
263 let mgr = manager(&temp);
352 let mgr = manager(&temp);
264 let project_dir = create_project_dir(&temp);
353 let project_dir = create_project_dir(&temp);
265− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
354+ fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
266−
355+ write_instance(
267− let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
356+ &project_dir,
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",
357+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
358+ "2026-02-07T05:00:00Z",
272− )
359+ );
273− .unwrap();
274−
275 let _ = mgr.update_global_sessions(&project_dir).unwrap();
360 let _ = mgr.update_global_sessions(&project_dir).unwrap();
276− let sessions = mgr.list_sessions().unwrap();
277
361
362+ let sessions = mgr.list_sessions().unwrap();
278 assert_eq!(sessions.len(), 1);
363 assert_eq!(sessions.len(), 1);
279− assert_eq!(
280− sessions[0].id.as_deref(),
281− Some("019bf290-cccc-7c23-ba1d-dce7e6d40693")
364+ assert_eq!(sessions[0].id, "019bf290-cccc-7c23-ba1d-dce7e6d40693");
282− );
283 assert_eq!(
365 assert_eq!(
284 sessions[0].last_active.as_deref(),
366 sessions[0].last_active.as_deref(),
285 Some("2026-02-07T05:00:00Z")
367 Some("2026-02-07T05:00:00Z")
src/vm_manager.rs+4−2
@@ -135,7 +135,8 @@ fn spawn_manager_process(
135 let log_path = instance_dir.join("vm_manager.log");
135 let log_path = instance_dir.join("vm_manager.log");
136 let log_file = fs::OpenOptions::new()
136 let log_file = fs::OpenOptions::new()
137 .create(true)
137 .create(true)
138− .append(true)
138+ .write(true)
139+ .truncate(true)
139 .open(&log_path)
140 .open(&log_path)
140 .ok();
141 .ok();
141 if let Some(file) = log_file {
142 if let Some(file) = log_file {
@@ -282,7 +283,8 @@ fn spawn_manager_io(
282 let log_path = instance_dir.join("serial.log");
283 let log_path = instance_dir.join("serial.log");
283 let log_file = fs::OpenOptions::new()
284 let log_file = fs::OpenOptions::new()
284 .create(true)
285 .create(true)
285− .append(true)
286+ .write(true)
287+ .truncate(true)
286 .open(&log_path)
288 .open(&log_path)
287 .ok()
289 .ok()
288 .map(|file| Arc::new(Mutex::new(file)));
290 .map(|file| Arc::new(Mutex::new(file)));