From 6c17f269d7aa82da4238511874fe1475d42703b0 Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Mon, 12 Jan 2026 07:26:17 +0530 Subject: [PATCH] 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 [args...]: Create worktree, spawn g3, tail output - studio list: Show all active sessions - studio status : Show session details and summary - studio accept : Merge session branch to main and cleanup - studio discard : Delete session without merging Each session gets: - Isolated worktree at .worktrees/sessions// - Dedicated branch: sessions// - Short UUID (8 chars) for easy reference - Automatic --workspace and --agent flags passed to g3 --- .gitignore | 3 + Cargo.lock | 13 ++ Cargo.toml | 3 +- crates/studio/Cargo.toml | 18 ++ crates/studio/src/git.rs | 180 +++++++++++++++ crates/studio/src/main.rs | 410 +++++++++++++++++++++++++++++++++++ crates/studio/src/session.rs | 166 ++++++++++++++ 7 files changed, 792 insertions(+), 1 deletion(-) create mode 100644 crates/studio/Cargo.toml create mode 100644 crates/studio/src/git.rs create mode 100644 crates/studio/src/main.rs create mode 100644 crates/studio/src/session.rs diff --git a/.gitignore b/.gitignore index 073037e..aebcdb6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ logs/ requirements.md todo.g3.md tmp/ + +# Studio worktrees +.worktrees/ diff --git a/Cargo.lock b/Cargo.lock index 610cb1d..4832e09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3437,6 +3437,19 @@ dependencies = [ "syn", ] +[[package]] +name = "studio" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "serde", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "syn" version = "2.0.108" diff --git a/Cargo.toml b/Cargo.toml index 56258b0..77793b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,8 @@ members = [ "crates/g3-config", "crates/g3-execution", "crates/g3-computer-control", - "crates/g3-ensembles" + "crates/g3-ensembles", + "crates/studio" ] resolver = "2" diff --git a/crates/studio/Cargo.toml b/crates/studio/Cargo.toml new file mode 100644 index 0000000..396031b --- /dev/null +++ b/crates/studio/Cargo.toml @@ -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"] } diff --git a/crates/studio/src/git.rs b/crates/studio/src/git.rs new file mode 100644 index 0000000..b3841d7 --- /dev/null +++ b/crates/studio/src/git.rs @@ -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 { + 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 + 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> { + 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) + } +} diff --git a/crates/studio/src/main.rs b/crates/studio/src/main.rs new file mode 100644 index 0000000..b2ed237 --- /dev/null +++ b/crates/studio/src/main.rs @@ -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, + }, + + /// 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, + }, + + /// 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 { + 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 { + 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 { + // 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 +} diff --git a/crates/studio/src/session.rs b/crates/studio/src/session.rs new file mode 100644 index 0000000..35ddc50 --- /dev/null +++ b/crates/studio/src/session.rs @@ -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, + /// Current status + pub status: SessionStatus, + /// Process ID if running + pub pid: Option, + /// Path to the worktree + pub worktree_path: Option, +} + +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 { + 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> { + 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::(&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(()) + } +}