Files
g3/crates/studio/src/session.rs
Dhanji R. Prasanna 65e0217c68 Add unit tests for studio session management
New tests:
- test_new_session_has_short_id
- test_new_interactive_session
- test_branch_name_format
- test_session_save_and_load
- test_session_mark_complete
- test_session_mark_paused
- test_list_empty_sessions
- test_backwards_compatibility_no_session_type

Added tempfile as dev dependency for temp directory tests.
2026-01-16 06:52:23 +05:30

312 lines
9.7 KiB
Rust

//! Session management for studio
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
/// Session status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionStatus {
Running,
Complete,
Paused,
Failed,
}
/// Session type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionType {
OneShot,
Interactive,
}
/// A studio session representing a g3 agent run in a worktree
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
/// Short unique identifier
pub id: String,
/// Agent name
pub agent: String,
/// When the session was created
pub created_at: DateTime<Utc>,
/// Current status
pub status: SessionStatus,
/// Process ID if running
pub pid: Option<u32>,
/// Path to the worktree
pub worktree_path: Option<PathBuf>,
/// Type of session
#[serde(default = "default_session_type")]
pub session_type: SessionType,
}
impl Session {
/// Create a new session with a short UUID
pub fn new(agent: &str) -> Self {
// Generate a short UUID (first 8 chars of a UUID v4)
let full_uuid = Uuid::new_v4();
let short_id = full_uuid.to_string()[..8].to_string();
Self {
id: short_id,
agent: agent.to_string(),
created_at: Utc::now(),
status: SessionStatus::Running,
pid: None,
worktree_path: None,
session_type: SessionType::OneShot,
}
}
/// Create a new interactive session
pub fn new_interactive() -> Self {
let full_uuid = Uuid::new_v4();
let short_id = full_uuid.to_string()[..8].to_string();
Self {
id: short_id,
agent: "interactive".to_string(),
created_at: Utc::now(),
status: SessionStatus::Running,
pid: None,
worktree_path: None,
session_type: SessionType::Interactive,
}
}
/// Get the git branch name for this session
pub fn branch_name(&self) -> String {
format!("sessions/{}/{}", self.agent, self.id)
}
/// Get the sessions metadata directory
fn sessions_dir(repo_root: &Path) -> PathBuf {
repo_root.join(".worktrees").join(".sessions")
}
/// Get the path to this session's metadata file
fn metadata_path(&self, repo_root: &Path) -> PathBuf {
Self::sessions_dir(repo_root).join(format!("{}.json", self.id))
}
/// Save session metadata
pub fn save(&self, repo_root: &Path, worktree_path: &Path) -> Result<()> {
let mut session = self.clone();
session.worktree_path = Some(worktree_path.to_path_buf());
let sessions_dir = Self::sessions_dir(repo_root);
fs::create_dir_all(&sessions_dir).context("Failed to create sessions directory")?;
let path = session.metadata_path(repo_root);
let json = serde_json::to_string_pretty(&session)?;
fs::write(&path, json).context("Failed to write session metadata")?;
Ok(())
}
/// Update session with PID
pub fn update_pid(&self, repo_root: &Path, pid: u32) -> Result<()> {
let path = self.metadata_path(repo_root);
let content = fs::read_to_string(&path).context("Failed to read session metadata")?;
let mut session: Session = serde_json::from_str(&content)?;
session.pid = Some(pid);
let json = serde_json::to_string_pretty(&session)?;
fs::write(&path, json).context("Failed to write session metadata")?;
Ok(())
}
/// Mark session as complete
pub fn mark_complete(&self, repo_root: &Path, success: bool) -> Result<()> {
let path = self.metadata_path(repo_root);
let content = fs::read_to_string(&path).context("Failed to read session metadata")?;
let mut session: Session = serde_json::from_str(&content)?;
session.status = if success {
SessionStatus::Complete
} else {
SessionStatus::Failed
};
session.pid = None;
let json = serde_json::to_string_pretty(&session)?;
fs::write(&path, json).context("Failed to write session metadata")?;
Ok(())
}
/// Mark session as paused (for interactive sessions)
pub fn mark_paused(&self, repo_root: &Path) -> Result<()> {
let path = self.metadata_path(repo_root);
let content = fs::read_to_string(&path).context("Failed to read session metadata")?;
let mut session: Session = serde_json::from_str(&content)?;
session.status = SessionStatus::Paused;
session.pid = None;
let json = serde_json::to_string_pretty(&session)?;
fs::write(&path, json).context("Failed to write session metadata")?;
Ok(())
}
/// Load a session by ID
pub fn load(repo_root: &Path, session_id: &str) -> Result<Session> {
let path = Self::sessions_dir(repo_root).join(format!("{}.json", session_id));
if !path.exists() {
bail!("Session '{}' not found", session_id);
}
let content = fs::read_to_string(&path).context("Failed to read session metadata")?;
let session: Session = serde_json::from_str(&content)?;
Ok(session)
}
/// List all sessions
pub fn list_all(repo_root: &Path) -> Result<Vec<Session>> {
let sessions_dir = Self::sessions_dir(repo_root);
if !sessions_dir.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in fs::read_dir(&sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(session) = serde_json::from_str::<Session>(&content) {
sessions.push(session);
}
}
}
}
// Sort by creation time, newest first
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(sessions)
}
/// Delete session metadata
pub fn delete(&self, repo_root: &Path) -> Result<()> {
let path = self.metadata_path(repo_root);
if path.exists() {
fs::remove_file(&path).context("Failed to delete session metadata")?;
}
Ok(())
}
}
/// Default session type for backwards compatibility with existing sessions
fn default_session_type() -> SessionType {
SessionType::OneShot
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_new_session_has_short_id() {
let session = Session::new("carmack");
assert_eq!(session.id.len(), 8);
assert_eq!(session.agent, "carmack");
assert_eq!(session.status, SessionStatus::Running);
assert_eq!(session.session_type, SessionType::OneShot);
}
#[test]
fn test_new_interactive_session() {
let session = Session::new_interactive();
assert_eq!(session.id.len(), 8);
assert_eq!(session.agent, "interactive");
assert_eq!(session.status, SessionStatus::Running);
assert_eq!(session.session_type, SessionType::Interactive);
}
#[test]
fn test_branch_name_format() {
let session = Session::new("fowler");
let branch = session.branch_name();
assert!(branch.starts_with("sessions/fowler/"));
assert_eq!(branch, format!("sessions/fowler/{}", session.id));
}
#[test]
fn test_session_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let worktree_path = repo_root.join("worktree");
let session = Session::new("hopper");
session.save(repo_root, &worktree_path).unwrap();
let loaded = Session::load(repo_root, &session.id).unwrap();
assert_eq!(loaded.id, session.id);
assert_eq!(loaded.agent, "hopper");
assert_eq!(loaded.worktree_path, Some(worktree_path));
}
#[test]
fn test_session_mark_complete() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let worktree_path = repo_root.join("worktree");
let session = Session::new("lamport");
session.save(repo_root, &worktree_path).unwrap();
session.mark_complete(repo_root, true).unwrap();
let loaded = Session::load(repo_root, &session.id).unwrap();
assert_eq!(loaded.status, SessionStatus::Complete);
}
#[test]
fn test_session_mark_paused() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let worktree_path = repo_root.join("worktree");
let session = Session::new_interactive();
session.save(repo_root, &worktree_path).unwrap();
session.mark_paused(repo_root).unwrap();
let loaded = Session::load(repo_root, &session.id).unwrap();
assert_eq!(loaded.status, SessionStatus::Paused);
}
#[test]
fn test_list_empty_sessions() {
let temp_dir = TempDir::new().unwrap();
let sessions = Session::list_all(temp_dir.path()).unwrap();
assert!(sessions.is_empty());
}
#[test]
fn test_backwards_compatibility_no_session_type() {
// Old sessions don't have session_type field - should default to OneShot
let json = r#"{
"id": "abc12345",
"agent": "carmack",
"created_at": "2025-01-15T10:00:00Z",
"status": "Complete",
"pid": null,
"worktree_path": null
}"#;
let session: Session = serde_json::from_str(json).unwrap();
assert_eq!(session.session_type, SessionType::OneShot);
}
}