Add studio tool for multi-agent workspace management
Studio enables running multiple g3 agents concurrently without conflicts by using git worktrees for isolation. Features: - studio run --agent <name> [args...]: Create worktree, spawn g3, tail output - studio list: Show all active sessions - studio status <id>: Show session details and summary - studio accept <id>: Merge session branch to main and cleanup - studio discard <id>: Delete session without merging Each session gets: - Isolated worktree at .worktrees/sessions/<agent>/<session-id> - Dedicated branch: sessions/<agent>/<session-id> - Short UUID (8 chars) for easy reference - Automatic --workspace and --agent flags passed to g3
This commit is contained in:
18
crates/studio/Cargo.toml
Normal file
18
crates/studio/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "studio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Multi-agent workspace manager for G3"
|
||||
|
||||
[[bin]]
|
||||
name = "studio"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
180
crates/studio/src/git.rs
Normal file
180
crates/studio/src/git.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
//! Git worktree management for studio sessions
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::session::Session;
|
||||
|
||||
/// Manages git worktrees for studio sessions
|
||||
pub struct GitWorktree {
|
||||
repo_root: PathBuf,
|
||||
}
|
||||
|
||||
impl GitWorktree {
|
||||
pub fn new(repo_root: &Path) -> Self {
|
||||
Self {
|
||||
repo_root: repo_root.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the base directory for all worktrees
|
||||
fn worktrees_base(&self) -> PathBuf {
|
||||
self.repo_root.join(".worktrees").join("sessions")
|
||||
}
|
||||
|
||||
/// Get the worktree path for a session
|
||||
pub fn worktree_path(&self, session: &Session) -> PathBuf {
|
||||
self.worktrees_base()
|
||||
.join(&session.agent)
|
||||
.join(&session.id)
|
||||
}
|
||||
|
||||
/// Create a new worktree for a session
|
||||
pub fn create(&self, session: &Session) -> Result<PathBuf> {
|
||||
let worktree_path = self.worktree_path(session);
|
||||
let branch_name = session.branch_name();
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = worktree_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.context("Failed to create worktree parent directory")?;
|
||||
}
|
||||
|
||||
// Create the worktree with a new branch
|
||||
// git worktree add -b <branch> <path>
|
||||
let output = Command::new("git")
|
||||
.current_dir(&self.repo_root)
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
&branch_name,
|
||||
worktree_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.context("Failed to run git worktree add")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to create worktree: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(worktree_path)
|
||||
}
|
||||
|
||||
/// Remove a worktree and its branch
|
||||
pub fn remove(&self, session: &Session) -> Result<()> {
|
||||
let worktree_path = self.worktree_path(session);
|
||||
let branch_name = session.branch_name();
|
||||
|
||||
// Remove the worktree (force to handle uncommitted changes)
|
||||
if worktree_path.exists() {
|
||||
let output = Command::new("git")
|
||||
.current_dir(&self.repo_root)
|
||||
.args([
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
worktree_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.context("Failed to run git worktree remove")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Don't fail if worktree is already gone
|
||||
if !stderr.contains("is not a working tree") {
|
||||
bail!("Failed to remove worktree: {}", stderr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prune worktrees to clean up any stale entries
|
||||
let _ = Command::new("git")
|
||||
.current_dir(&self.repo_root)
|
||||
.args(["worktree", "prune"])
|
||||
.output();
|
||||
|
||||
// Delete the branch
|
||||
let output = Command::new("git")
|
||||
.current_dir(&self.repo_root)
|
||||
.args(["branch", "-D", &branch_name])
|
||||
.output()
|
||||
.context("Failed to run git branch -D")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Don't fail if branch doesn't exist
|
||||
if !stderr.contains("not found") {
|
||||
bail!("Failed to delete branch: {}", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty directories
|
||||
let agent_dir = self.worktrees_base().join(&session.agent);
|
||||
if agent_dir.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&agent_dir) {
|
||||
if entries.count() == 0 {
|
||||
let _ = std::fs::remove_dir(&agent_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge a branch to main
|
||||
pub fn merge_to_main(&self, branch_name: &str) -> Result<()> {
|
||||
// First, checkout main
|
||||
let output = Command::new("git")
|
||||
.current_dir(&self.repo_root)
|
||||
.args(["checkout", "main"])
|
||||
.output()
|
||||
.context("Failed to checkout main")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to checkout main: {}", stderr);
|
||||
}
|
||||
|
||||
// Merge the branch (allow merge commits)
|
||||
let output = Command::new("git")
|
||||
.current_dir(&self.repo_root)
|
||||
.args(["merge", branch_name, "-m", &format!("Merge {}", branch_name)])
|
||||
.output()
|
||||
.context("Failed to merge branch")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to merge branch: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all worktrees
|
||||
pub fn list(&self) -> Result<Vec<String>> {
|
||||
let output = Command::new("git")
|
||||
.current_dir(&self.repo_root)
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
.context("Failed to list worktrees")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to list worktrees: {}", stderr);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut worktrees = Vec::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(path) = line.strip_prefix("worktree ") {
|
||||
worktrees.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(worktrees)
|
||||
}
|
||||
}
|
||||
410
crates/studio/src/main.rs
Normal file
410
crates/studio/src/main.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
mod git;
|
||||
mod session;
|
||||
|
||||
use git::GitWorktree;
|
||||
use session::{Session, SessionStatus};
|
||||
|
||||
/// Studio - Multi-agent workspace manager for G3
|
||||
#[derive(Parser)]
|
||||
#[command(name = "studio")]
|
||||
#[command(about = "Manage multiple G3 agent sessions using git worktrees")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Run a new G3 agent session (tails output until complete)
|
||||
Run {
|
||||
/// Agent name (e.g., carmack, torvalds)
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
|
||||
/// Additional arguments to pass to g3
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
g3_args: Vec<String>,
|
||||
},
|
||||
|
||||
/// Execute a G3 agent session in detached mode (for future use)
|
||||
Exec {
|
||||
/// Agent name (e.g., carmack, torvalds)
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
|
||||
/// Additional arguments to pass to g3
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
g3_args: Vec<String>,
|
||||
},
|
||||
|
||||
/// List all active sessions
|
||||
List,
|
||||
|
||||
/// Show status of a session
|
||||
Status {
|
||||
/// Session ID
|
||||
session_id: String,
|
||||
},
|
||||
|
||||
/// Accept a session: merge to main and cleanup
|
||||
Accept {
|
||||
/// Session ID
|
||||
session_id: String,
|
||||
},
|
||||
|
||||
/// Discard a session: delete without merging
|
||||
Discard {
|
||||
/// Session ID
|
||||
session_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Run { agent, g3_args } => cmd_run(&agent, &g3_args),
|
||||
Commands::Exec { agent, g3_args } => cmd_exec(&agent, &g3_args),
|
||||
Commands::List => cmd_list(),
|
||||
Commands::Status { session_id } => cmd_status(&session_id),
|
||||
Commands::Accept { session_id } => cmd_accept(&session_id),
|
||||
Commands::Discard { session_id } => cmd_discard(&session_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the path to the g3 binary (same directory as studio)
|
||||
fn get_g3_binary_path() -> Result<PathBuf> {
|
||||
let current_exe = env::current_exe().context("Failed to get current executable path")?;
|
||||
let exe_dir = current_exe
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("Failed to get executable directory"))?;
|
||||
let g3_path = exe_dir.join("g3");
|
||||
|
||||
if !g3_path.exists() {
|
||||
bail!(
|
||||
"g3 binary not found at {:?}. Ensure g3 is built and in the same directory as studio.",
|
||||
g3_path
|
||||
);
|
||||
}
|
||||
|
||||
Ok(g3_path)
|
||||
}
|
||||
|
||||
/// Get the repository root (where .git is)
|
||||
fn get_repo_root() -> Result<PathBuf> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.output()
|
||||
.context("Failed to run git rev-parse")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("Not in a git repository");
|
||||
}
|
||||
|
||||
let path = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Run a new G3 session (foreground, tails output)
|
||||
fn cmd_run(agent: &str, g3_args: &[String]) -> Result<()> {
|
||||
let g3_binary = get_g3_binary_path()?;
|
||||
let repo_root = get_repo_root()?;
|
||||
let session = Session::new(agent);
|
||||
|
||||
// Create worktree
|
||||
let worktree = GitWorktree::new(&repo_root);
|
||||
let worktree_path = worktree.create(&session)?;
|
||||
|
||||
println!("📁 Created worktree: {}", worktree_path.display());
|
||||
println!("🌿 Branch: {}", session.branch_name());
|
||||
println!("🆔 Session: {}", session.id);
|
||||
println!();
|
||||
|
||||
// Build g3 command with --workspace prepended
|
||||
let mut cmd = Command::new(&g3_binary);
|
||||
cmd.arg("--workspace").arg(&worktree_path);
|
||||
cmd.arg("--agent").arg(agent);
|
||||
cmd.args(g3_args);
|
||||
cmd.current_dir(&worktree_path);
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
// Save session metadata
|
||||
session.save(&repo_root, &worktree_path)?;
|
||||
|
||||
println!("🚀 Starting g3 agent '{}'...", agent);
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
// Spawn and tail output
|
||||
let mut child = cmd.spawn().context("Failed to spawn g3 process")?;
|
||||
|
||||
// Update session with PID
|
||||
session.update_pid(&repo_root, child.id())?;
|
||||
|
||||
// Tail stdout in a separate thread
|
||||
let stdout = child.stdout.take().expect("Failed to capture stdout");
|
||||
let stderr = child.stderr.take().expect("Failed to capture stderr");
|
||||
|
||||
let stdout_handle = std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
println!("{}", line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stderr_handle = std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stderr);
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
eprintln!("{}", line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for process to complete
|
||||
let status = child.wait().context("Failed to wait for g3 process")?;
|
||||
|
||||
stdout_handle.join().ok();
|
||||
stderr_handle.join().ok();
|
||||
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
// Update session status
|
||||
session.mark_complete(&repo_root, status.success())?;
|
||||
|
||||
if status.success() {
|
||||
println!("✅ Session {} completed successfully", session.id);
|
||||
println!();
|
||||
println!("Next steps:");
|
||||
println!(" studio accept {} - Merge changes to main", session.id);
|
||||
println!(" studio discard {} - Discard changes", session.id);
|
||||
} else {
|
||||
println!("❌ Session {} failed (exit code: {:?})", session.id, status.code());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a G3 session in detached mode (placeholder for future)
|
||||
fn cmd_exec(agent: &str, g3_args: &[String]) -> Result<()> {
|
||||
// For now, just print what would happen
|
||||
println!("exec command not yet implemented");
|
||||
println!("Would run agent '{}' with args: {:?}", agent, g3_args);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all sessions
|
||||
fn cmd_list() -> Result<()> {
|
||||
let repo_root = get_repo_root()?;
|
||||
let sessions = Session::list_all(&repo_root)?;
|
||||
|
||||
if sessions.is_empty() {
|
||||
println!("No active sessions.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<12} {:<12} {:<10} {:<20}", "SESSION", "AGENT", "STATUS", "CREATED");
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
for session in sessions {
|
||||
let status_str = match session.status {
|
||||
SessionStatus::Running => "🔄 running",
|
||||
SessionStatus::Complete => "✅ complete",
|
||||
SessionStatus::Failed => "❌ failed",
|
||||
};
|
||||
println!(
|
||||
"{:<12} {:<12} {:<10} {:<20}",
|
||||
session.id,
|
||||
session.agent,
|
||||
status_str,
|
||||
session.created_at.format("%Y-%m-%d %H:%M")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show status of a specific session
|
||||
fn cmd_status(session_id: &str) -> Result<()> {
|
||||
let repo_root = get_repo_root()?;
|
||||
let session = Session::load(&repo_root, session_id)?;
|
||||
|
||||
println!("Session: {}", session.id);
|
||||
println!("Agent: {}", session.agent);
|
||||
println!("Branch: {}", session.branch_name());
|
||||
println!("Created: {}", session.created_at.format("%Y-%m-%d %H:%M:%S"));
|
||||
println!("Status: {:?}", session.status);
|
||||
|
||||
if let Some(path) = &session.worktree_path {
|
||||
println!("Worktree: {}", path.display());
|
||||
}
|
||||
|
||||
// Check if process is still running
|
||||
if session.status == SessionStatus::Running {
|
||||
if let Some(pid) = session.pid {
|
||||
let is_running = is_process_running(pid);
|
||||
if is_running {
|
||||
println!("Process: Running (PID {})", pid);
|
||||
} else {
|
||||
println!("Process: Not running (stale session)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract summary from session logs if complete
|
||||
if session.status != SessionStatus::Running {
|
||||
if let Some(summary) = extract_session_summary(&session) {
|
||||
println!();
|
||||
println!("Summary:");
|
||||
println!("{}", summary);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept a session: merge to main and cleanup
|
||||
fn cmd_accept(session_id: &str) -> Result<()> {
|
||||
let repo_root = get_repo_root()?;
|
||||
let session = Session::load(&repo_root, session_id)?;
|
||||
|
||||
// Check session is not still running
|
||||
if session.status == SessionStatus::Running {
|
||||
if let Some(pid) = session.pid {
|
||||
if is_process_running(pid) {
|
||||
bail!("Session {} is still running (PID {}). Wait for it to complete or kill it first.", session_id, pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let worktree = GitWorktree::new(&repo_root);
|
||||
let branch_name = session.branch_name();
|
||||
|
||||
println!("🔀 Merging {} to main...", branch_name);
|
||||
|
||||
// Merge the branch to main
|
||||
worktree.merge_to_main(&branch_name)?;
|
||||
|
||||
println!("🧹 Cleaning up worktree and branch...");
|
||||
|
||||
// Remove worktree and branch
|
||||
worktree.remove(&session)?;
|
||||
|
||||
// Remove session metadata
|
||||
session.delete(&repo_root)?;
|
||||
|
||||
println!("✅ Session {} accepted and merged to main", session_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discard a session: delete without merging
|
||||
fn cmd_discard(session_id: &str) -> Result<()> {
|
||||
let repo_root = get_repo_root()?;
|
||||
let session = Session::load(&repo_root, session_id)?;
|
||||
|
||||
// Check session is not still running
|
||||
if session.status == SessionStatus::Running {
|
||||
if let Some(pid) = session.pid {
|
||||
if is_process_running(pid) {
|
||||
bail!("Session {} is still running (PID {}). Wait for it to complete or kill it first.", session_id, pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let worktree = GitWorktree::new(&repo_root);
|
||||
|
||||
println!("🗑️ Discarding session {}...", session_id);
|
||||
|
||||
// Remove worktree and branch
|
||||
worktree.remove(&session)?;
|
||||
|
||||
// Remove session metadata
|
||||
session.delete(&repo_root)?;
|
||||
|
||||
println!("✅ Session {} discarded", session_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a process is running by PID
|
||||
fn is_process_running(pid: u32) -> bool {
|
||||
// Use kill -0 to check if process exists
|
||||
Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Extract summary from session logs
|
||||
fn extract_session_summary(session: &Session) -> Option<String> {
|
||||
// Look for session logs in the worktree's .g3 directory
|
||||
let worktree_path = session.worktree_path.as_ref()?;
|
||||
let session_dir = worktree_path.join(".g3").join("sessions");
|
||||
|
||||
if !session_dir.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the most recent session log
|
||||
let mut latest_log: Option<(PathBuf, std::time::SystemTime)> = None;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&session_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let log_file = path.join("session.json");
|
||||
if log_file.exists() {
|
||||
if let Ok(metadata) = log_file.metadata() {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if latest_log.is_none() || modified > latest_log.as_ref().unwrap().1 {
|
||||
latest_log = Some((log_file, modified));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let log_file = latest_log?.0;
|
||||
let content = fs::read_to_string(&log_file).ok()?;
|
||||
|
||||
// Parse JSON and extract the last assistant message as summary
|
||||
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
let messages = json.get("messages")?.as_array()?;
|
||||
|
||||
// Find the last assistant message
|
||||
for msg in messages.iter().rev() {
|
||||
if msg.get("role")?.as_str()? == "assistant" {
|
||||
if let Some(content) = msg.get("content") {
|
||||
if let Some(text) = content.as_str() {
|
||||
// Truncate if too long
|
||||
let summary = if text.len() > 500 {
|
||||
format!("{}...", &text[..500])
|
||||
} else {
|
||||
text.to_string()
|
||||
};
|
||||
return Some(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
166
crates/studio/src/session.rs
Normal file
166
crates/studio/src/session.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! Session management for studio
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
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,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user