diff --git a/crates/runbox-cli/src/main.rs b/crates/runbox-cli/src/main.rs index 05bfc3b..d31e38c 100644 --- a/crates/runbox-cli/src/main.rs +++ b/crates/runbox-cli/src/main.rs @@ -1,12 +1,34 @@ -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand, ValueEnum}; use dialoguer::{theme::ColorfulTheme, Input}; use runbox_core::{ - BindingResolver, ConfigResolver, GitContext, Playlist, PlaylistItem, RunTemplate, Storage, - Validator, VerboseLogger, + BindingResolver, ConfigResolver, GitContext, LogRef, Playlist, PlaylistItem, Run, RunStatus, + RunTemplate, Runtime, Storage, Validator, VerboseLogger, }; +use std::fs::File; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; + +/// Runtime argument for CLI +#[derive(Debug, Clone, Copy, ValueEnum)] +enum RuntimeArg { + Bg, + Tmux, + Zellij, + Foreground, +} + +impl From for Runtime { + fn from(arg: RuntimeArg) -> Self { + match arg { + RuntimeArg::Bg => Runtime::Background, + RuntimeArg::Tmux => Runtime::Tmux, + RuntimeArg::Zellij => Runtime::Zellij, + RuntimeArg::Foreground => Runtime::Foreground, + } + } +} #[derive(Parser)] #[command(name = "runbox")] @@ -31,6 +53,10 @@ enum Commands { /// Skip execution (dry run) #[arg(long)] dry_run: bool, + + /// Runtime environment (bg, tmux, zellij, foreground) + #[arg(long, value_enum, default_value = "foreground")] + runtime: RuntimeArg, }, /// Manage templates @@ -93,6 +119,53 @@ enum Commands { /// Path to JSON file path: String, }, + + /// List running and recent runs + Ps { + /// Filter by status (running, pending, exited, failed, killed) + #[arg(long)] + status: Option, + + /// Limit number of results + #[arg(short, long, default_value = "10")] + limit: usize, + }, + + /// Show logs for a run + Logs { + /// Run ID + run_id: String, + + /// Follow log output (tail -f style) + #[arg(short, long)] + follow: bool, + + /// Number of lines to show from the end + #[arg(short = 'n', long, default_value = "100")] + lines: usize, + }, + + /// Stop a running run + Stop { + /// Run ID + run_id: String, + }, + + /// Attach to a running run's session (tmux/zellij) + Attach { + /// Run ID + run_id: String, + }, + + /// Internal: Called when a run exits (do not use directly) + #[command(name = "_on-exit", hide = true)] + OnExit { + /// Run ID + run_id: String, + + /// Exit code + exit_code: i32, + }, } #[derive(Subcommand)] @@ -139,7 +212,8 @@ fn main() -> Result<()> { template, binding, dry_run, - } => cmd_run(&storage, &template, binding, dry_run), + runtime, + } => cmd_run(&storage, &template, binding, dry_run, runtime.into()), Commands::Template { command } => match command { TemplateCommands::List => cmd_template_list(&storage), TemplateCommands::Show { template_id } => cmd_template_show(&storage, &template_id), @@ -181,12 +255,27 @@ fn main() -> Result<()> { verbose, ), Commands::Validate { path } => cmd_validate(&path), + Commands::Ps { status, limit } => cmd_ps(&storage, status, limit), + Commands::Logs { + run_id, + follow, + lines, + } => cmd_logs(&storage, &run_id, follow, lines), + Commands::Stop { run_id } => cmd_stop(&storage, &run_id), + Commands::Attach { run_id } => cmd_attach(&storage, &run_id), + Commands::OnExit { run_id, exit_code } => cmd_on_exit(&storage, &run_id, exit_code), } } // === Run Command === -fn cmd_run(storage: &Storage, template_id: &str, bindings: Vec, dry_run: bool) -> Result<()> { +fn cmd_run( + storage: &Storage, + template_id: &str, + bindings: Vec, + dry_run: bool, + runtime: Runtime, +) -> Result<()> { let template = storage.load_template(template_id)?; // Create interactive callback @@ -221,7 +310,14 @@ fn cmd_run(storage: &Storage, template_id: &str, bindings: Vec, dry_run: let code_state = git.build_code_state(&temp_run_id)?; // Build run - let run = resolver.build_run(&template, code_state)?; + let mut run = resolver.build_run(&template, code_state)?; + + // Set runtime and log path + run.runtime = runtime.clone(); + let log_path = storage.log_path(&run.run_id); + run.log_ref = Some(LogRef { + path: log_path.clone(), + }); // Validate run.validate()?; @@ -232,28 +328,558 @@ fn cmd_run(storage: &Storage, template_id: &str, bindings: Vec, dry_run: return Ok(()); } - // Save run - let path = storage.save_run(&run)?; - println!("Run saved: {}", path.display()); + // Save initial run state + storage.save_run(&run)?; + println!("Run created: {}", run.run_id); + + match runtime { + Runtime::Foreground => execute_foreground(storage, &mut run)?, + Runtime::Background => execute_background(storage, &mut run, &log_path)?, + Runtime::Tmux => execute_tmux(storage, &mut run, &log_path)?, + Runtime::Zellij => execute_zellij(storage, &mut run, &log_path)?, + } + + Ok(()) +} + +/// Execute command in foreground (blocking) +fn execute_foreground(storage: &Storage, run: &mut Run) -> Result<()> { + // Mark as started + run.mark_started(); + storage.save_run_atomic(run)?; - // Execute println!("\nExecuting: {:?}", run.exec.argv); - let status = Command::new(&run.exec.argv[0]) + + // Create log file and execute with tee-like behavior + let log_path = run.log_ref.as_ref().map(|l| &l.path); + + let mut child = Command::new(&run.exec.argv[0]) .args(&run.exec.argv[1..]) .current_dir(&run.exec.cwd) .envs(&run.exec.env) - .status() + .stdout(if log_path.is_some() { + Stdio::piped() + } else { + Stdio::inherit() + }) + .stderr(if log_path.is_some() { + Stdio::piped() + } else { + Stdio::inherit() + }) + .spawn() .context("Failed to execute command")?; - if status.success() { - println!("\nRun completed successfully: {}", run.run_id); + // If we have a log path, tee output to both console and file + if let Some(path) = log_path { + let mut log_file = File::create(path)?; + + // Simple approach: wait for completion and capture output + let output = child.wait_with_output()?; + + use std::io::Write; + // Write to log file + log_file.write_all(&output.stdout)?; + log_file.write_all(&output.stderr)?; + + // Also print to console + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + + let exit_code = output.status.code().unwrap_or(-1); + run.mark_exited(exit_code); + storage.save_run_atomic(run)?; + + if output.status.success() { + println!("\nRun completed successfully: {}", run.run_id); + } else { + println!("\nRun failed with exit code: {}", exit_code); + } } else { - println!("\nRun failed with status: {:?}", status.code()); + let status = child.wait()?; + let exit_code = status.code().unwrap_or(-1); + run.mark_exited(exit_code); + storage.save_run_atomic(run)?; + + if status.success() { + println!("\nRun completed successfully: {}", run.run_id); + } else { + println!("\nRun failed with exit code: {}", exit_code); + } + } + + Ok(()) +} + +/// Execute command in background +fn execute_background(storage: &Storage, run: &mut Run, log_path: &PathBuf) -> Result<()> { + // Create log file + let log_file = File::create(log_path)?; + + // Build the shell command that will run the actual command and then call _on-exit + let cmd_str = run.exec.argv.join(" "); + + // Get the path to the runbox executable + let runbox_exe = std::env::current_exe()?; + + // Create a shell script that runs the command and then calls _on-exit + let script = format!( + "cd {} && {} 2>&1; {} _on-exit {} $?", + shell_escape(&run.exec.cwd), + cmd_str, + runbox_exe.display(), + run.run_id + ); + + let child = Command::new("sh") + .args(["-c", &script]) + .stdout(log_file.try_clone()?) + .stderr(log_file) + .envs(&run.exec.env) + .spawn() + .context("Failed to spawn background process")?; + + run.pid = Some(child.id()); + run.mark_started(); + storage.save_run_atomic(run)?; + + println!("Started in background (PID: {})", child.id()); + println!("Log: {}", log_path.display()); + println!("Use 'runbox logs -f {}' to follow output", run.run_id); + + Ok(()) +} + +/// Execute command in tmux session +fn execute_tmux(storage: &Storage, run: &mut Run, log_path: &PathBuf) -> Result<()> { + // Check if tmux is available + let tmux_check = Command::new("tmux").arg("-V").output(); + if tmux_check.is_err() { + bail!("tmux is not installed or not in PATH"); + } + + // Ensure session exists + let session_name = "runbox"; + let has_session = Command::new("tmux") + .args(["has-session", "-t", session_name]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if !has_session { + Command::new("tmux") + .args(["new-session", "-d", "-s", session_name]) + .status() + .context("Failed to create tmux session")?; } + // Build the command with logging and exit callback + let cmd_str = run.exec.argv.join(" "); + let runbox_exe = std::env::current_exe()?; + + let script = format!( + "cd {} && {} 2>&1 | tee {}; {} _on-exit {} ${{PIPESTATUS[0]}}", + shell_escape(&run.exec.cwd), + cmd_str, + log_path.display(), + runbox_exe.display(), + run.run_id + ); + + // Create a new window in the session + let window_name = run.run_id.clone(); + Command::new("tmux") + .args([ + "new-window", + "-t", + session_name, + "-n", + &window_name, + "sh", + "-c", + &script, + ]) + .envs(&run.exec.env) + .status() + .context("Failed to create tmux window")?; + + // Record session reference + run.session_ref = Some(format!("tmux:session={};window={}", session_name, window_name)); + run.mark_started(); + storage.save_run_atomic(run)?; + + println!("Started in tmux session '{}'", session_name); + println!("Window: {}", window_name); + println!("Log: {}", log_path.display()); + println!("Use 'runbox attach {}' to attach", run.run_id); + println!("Use 'runbox logs -f {}' to follow output", run.run_id); + Ok(()) } +/// Execute command in zellij session +fn execute_zellij(storage: &Storage, run: &mut Run, log_path: &PathBuf) -> Result<()> { + // Check if zellij is available + let zellij_check = Command::new("zellij").arg("--version").output(); + if zellij_check.is_err() { + bail!("zellij is not installed or not in PATH"); + } + + // Build the command with logging and exit callback + let cmd_str = run.exec.argv.join(" "); + let runbox_exe = std::env::current_exe()?; + + let script = format!( + "cd {} && {} 2>&1 | tee {}; {} _on-exit {} ${{PIPESTATUS[0]}}", + shell_escape(&run.exec.cwd), + cmd_str, + log_path.display(), + runbox_exe.display(), + run.run_id + ); + + // Try to attach to existing session or create new one + let session_name = "runbox"; + + // Check if session exists + let list_output = Command::new("zellij") + .args(["list-sessions"]) + .output() + .context("Failed to list zellij sessions")?; + + let sessions = String::from_utf8_lossy(&list_output.stdout); + let has_session = sessions.lines().any(|line| line.trim().starts_with(session_name)); + + if has_session { + // Run in existing session + Command::new("zellij") + .args(["-s", session_name, "action", "new-tab", "-n", &run.run_id, "--", "sh", "-c", &script]) + .envs(&run.exec.env) + .status() + .context("Failed to create zellij tab")?; + } else { + // Create new detached session with the command + Command::new("zellij") + .args(["-s", session_name, "--", "sh", "-c", &script]) + .envs(&run.exec.env) + .spawn() + .context("Failed to create zellij session")?; + } + + // Record session reference + run.session_ref = Some(format!("zellij:session={};tab={}", session_name, run.run_id)); + run.mark_started(); + storage.save_run_atomic(run)?; + + println!("Started in zellij session '{}'", session_name); + println!("Tab: {}", run.run_id); + println!("Log: {}", log_path.display()); + println!("Use 'runbox attach {}' to attach", run.run_id); + println!("Use 'runbox logs -f {}' to follow output", run.run_id); + + Ok(()) +} + +/// Escape a string for shell use +fn shell_escape(s: &str) -> String { + if s.contains(char::is_whitespace) || s.contains('\'') || s.contains('"') { + format!("'{}'", s.replace('\'', "'\\''")) + } else { + s.to_string() + } +} + +// === Ps Command === + +fn cmd_ps(storage: &Storage, status_filter: Option, limit: usize) -> Result<()> { + let status = if let Some(s) = status_filter { + Some(match s.to_lowercase().as_str() { + "pending" => RunStatus::Pending, + "running" => RunStatus::Running, + "exited" => RunStatus::Exited, + "failed" => RunStatus::Failed, + "killed" => RunStatus::Killed, + _ => bail!("Invalid status: {}. Valid values: pending, running, exited, failed, killed", s), + }) + } else { + None + }; + + let runs = storage.list_runs_by_status(status.as_ref(), limit)?; + + if runs.is_empty() { + println!("No runs found."); + return Ok(()); + } + + println!( + "{:<45} {:<10} {:<10} {:<10}", + "RUN ID", "STATUS", "RUNTIME", "EXIT" + ); + println!("{}", "-".repeat(80)); + + for run in runs { + let exit_str = run + .exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "-".to_string()); + println!( + "{:<45} {:<10} {:<10} {:<10}", + run.run_id, + run.status.to_string(), + run.runtime.to_string(), + exit_str + ); + } + + Ok(()) +} + +// === Logs Command === + +fn cmd_logs(storage: &Storage, run_id: &str, follow: bool, lines: usize) -> Result<()> { + let run = storage.load_run(run_id)?; + + let log_path = run + .log_ref + .as_ref() + .map(|l| &l.path) + .ok_or_else(|| anyhow::anyhow!("No log file for run {}", run_id))?; + + if !log_path.exists() { + bail!("Log file not found: {}", log_path.display()); + } + + if follow { + // Tail -f style following + let mut file = File::open(log_path)?; + let mut pos = file.seek(SeekFrom::End(0))?; + + // First, show the last N lines + let content = std::fs::read_to_string(log_path)?; + let all_lines: Vec<&str> = content.lines().collect(); + let start = if all_lines.len() > lines { + all_lines.len() - lines + } else { + 0 + }; + for line in &all_lines[start..] { + println!("{}", line); + } + + // Check if the run is still running + if run.is_completed() { + println!("\n--- Run has completed (exit code: {:?}) ---", run.exit_code); + return Ok(()); + } + + // Now follow new content + println!("\n--- Following log output (Ctrl+C to stop) ---"); + loop { + file.seek(SeekFrom::Start(pos))?; + let mut reader = BufReader::new(&file); + let mut line = String::new(); + + loop { + match reader.read_line(&mut line) { + Ok(0) => break, // No more data + Ok(_) => { + print!("{}", line); + line.clear(); + } + Err(e) => { + eprintln!("Error reading log: {}", e); + break; + } + } + } + + pos = file.seek(SeekFrom::Current(0))?; + + // Check if run is still active + let current_run = storage.load_run(run_id)?; + if current_run.is_completed() { + println!("\n--- Run completed (exit code: {:?}) ---", current_run.exit_code); + break; + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + } + } else { + // Just show the last N lines + let content = std::fs::read_to_string(log_path)?; + let all_lines: Vec<&str> = content.lines().collect(); + let start = if all_lines.len() > lines { + all_lines.len() - lines + } else { + 0 + }; + for line in &all_lines[start..] { + println!("{}", line); + } + } + + Ok(()) +} + +// === Stop Command === + +fn cmd_stop(storage: &Storage, run_id: &str) -> Result<()> { + let run = storage.load_run(run_id)?; + + if !run.is_running() { + bail!("Run {} is not running (status: {})", run_id, run.status); + } + + match &run.session_ref { + Some(session_ref) => { + let (runtime, params) = parse_session_ref(session_ref)?; + match runtime { + "tmux" => { + let session = params + .get("session") + .ok_or_else(|| anyhow::anyhow!("Missing session in session_ref"))?; + let window = params + .get("window") + .ok_or_else(|| anyhow::anyhow!("Missing window in session_ref"))?; + + Command::new("tmux") + .args(["kill-window", "-t", &format!("{}:{}", session, window)]) + .status() + .context("Failed to kill tmux window")?; + } + "zellij" => { + // Zellij doesn't have a simple way to kill a specific tab + // We'll try to send a kill signal to the process + if let Some(pid) = run.pid { + Command::new("kill") + .arg(pid.to_string()) + .status() + .context("Failed to kill process")?; + } else { + bail!("Cannot stop zellij run without PID"); + } + } + _ => bail!("Unknown runtime in session_ref: {}", runtime), + } + } + None => { + // Background process - kill by PID + if let Some(pid) = run.pid { + Command::new("kill") + .arg(pid.to_string()) + .status() + .context("Failed to kill process")?; + } else { + bail!("No PID recorded for run {}", run_id); + } + } + } + + // Update status + storage.update_run(run_id, |r| r.mark_killed())?; + println!("Stopped run: {}", run_id); + + Ok(()) +} + +// === Attach Command === + +fn cmd_attach(storage: &Storage, run_id: &str) -> Result<()> { + let run = storage.load_run(run_id)?; + + let session_ref = run + .session_ref + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Run {} has no session to attach to", run_id))?; + + let (runtime, params) = parse_session_ref(session_ref)?; + + match runtime { + "tmux" => { + let session = params + .get("session") + .ok_or_else(|| anyhow::anyhow!("Missing session in session_ref"))?; + let window = params.get("window"); + + // Select window if specified + if let Some(w) = window { + Command::new("tmux") + .args(["select-window", "-t", &format!("{}:{}", session, w)]) + .status() + .context("Failed to select tmux window")?; + } + + // Attach or switch client + if std::env::var("TMUX").is_ok() { + // Already in tmux - switch client + let status = Command::new("tmux") + .args(["switch-client", "-t", session]) + .status() + .context("Failed to switch tmux client")?; + if !status.success() { + bail!("Failed to switch to tmux session"); + } + } else { + // Not in tmux - attach + let status = Command::new("tmux") + .args(["attach", "-t", session]) + .status() + .context("Failed to attach to tmux session")?; + if !status.success() { + bail!("Failed to attach to tmux session"); + } + } + } + "zellij" => { + let session = params + .get("session") + .ok_or_else(|| anyhow::anyhow!("Missing session in session_ref"))?; + + let status = Command::new("zellij") + .args(["attach", session]) + .status() + .context("Failed to attach to zellij session")?; + if !status.success() { + bail!("Failed to attach to zellij session"); + } + } + _ => bail!("Unknown runtime: {}", runtime), + } + + Ok(()) +} + +// === OnExit Command (Internal) === + +fn cmd_on_exit(storage: &Storage, run_id: &str, exit_code: i32) -> Result<()> { + storage.update_run(run_id, |run| { + run.mark_exited(exit_code); + })?; + + Ok(()) +} + +/// Parse session_ref format: "runtime:key1=value1;key2=value2" +fn parse_session_ref(session_ref: &str) -> Result<(&str, std::collections::HashMap<&str, &str>)> { + let parts: Vec<&str> = session_ref.splitn(2, ':').collect(); + if parts.len() != 2 { + bail!("Invalid session_ref format: {}", session_ref); + } + + let runtime = parts[0]; + let mut params = std::collections::HashMap::new(); + + for pair in parts[1].split(';') { + let kv: Vec<&str> = pair.splitn(2, '=').collect(); + if kv.len() == 2 { + params.insert(kv[0], kv[1]); + } + } + + Ok((runtime, params)) +} + // === Template Commands === fn cmd_template_list(storage: &Storage) -> Result<()> { diff --git a/crates/runbox-core/src/lib.rs b/crates/runbox-core/src/lib.rs index 0a9bb20..2a8cd89 100644 --- a/crates/runbox-core/src/lib.rs +++ b/crates/runbox-core/src/lib.rs @@ -11,7 +11,7 @@ pub use binding::BindingResolver; pub use config::{ConfigResolver, ConfigSource, ResolvedValue, RunboxConfig, VerboseLogger}; pub use git::{GitContext, WorktreeInfo, WorktreeReplayResult}; pub use playlist::{Playlist, PlaylistItem}; -pub use run::{CodeState, Exec, Patch, Run}; +pub use run::{CodeState, Exec, LogRef, Patch, Run, RunStatus, Runtime, Timeline}; pub use storage::Storage; pub use template::{Bindings, RunTemplate, TemplateCodeState, TemplateExec}; pub use validation::{ValidationType, Validator}; diff --git a/crates/runbox-core/src/run.rs b/crates/runbox-core/src/run.rs index 557407a..692918b 100644 --- a/crates/runbox-core/src/run.rs +++ b/crates/runbox-core/src/run.rs @@ -1,5 +1,7 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::PathBuf; /// A fully-resolved, reproducible execution record #[derive(Debug, Clone, Serialize, Deserialize)] @@ -8,6 +10,98 @@ pub struct Run { pub run_id: String, pub exec: Exec, pub code_state: CodeState, + + // Execution management fields + #[serde(default)] + pub status: RunStatus, + #[serde(default)] + pub runtime: Runtime, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub log_ref: Option, + #[serde(default)] + pub timeline: Timeline, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pid: Option, +} + +/// Run execution status +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum RunStatus { + #[default] + Pending, + Running, + Exited, + Failed, + Killed, +} + +impl std::fmt::Display for RunStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RunStatus::Pending => write!(f, "pending"), + RunStatus::Running => write!(f, "running"), + RunStatus::Exited => write!(f, "exited"), + RunStatus::Failed => write!(f, "failed"), + RunStatus::Killed => write!(f, "killed"), + } + } +} + +/// Runtime environment for execution +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Runtime { + #[default] + #[serde(rename = "bg")] + Background, + Tmux, + Zellij, + /// Direct foreground execution (no background process) + Foreground, +} + +impl std::fmt::Display for Runtime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Runtime::Background => write!(f, "bg"), + Runtime::Tmux => write!(f, "tmux"), + Runtime::Zellij => write!(f, "zellij"), + Runtime::Foreground => write!(f, "foreground"), + } + } +} + +impl std::str::FromStr for Runtime { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "bg" | "background" => Ok(Runtime::Background), + "tmux" => Ok(Runtime::Tmux), + "zellij" => Ok(Runtime::Zellij), + "foreground" | "fg" => Ok(Runtime::Foreground), + _ => Err(format!("Unknown runtime: {}", s)), + } + } +} + +/// Reference to log file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogRef { + pub path: PathBuf, +} + +/// Timeline of run events +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Timeline { + pub created_at: Option>, + pub started_at: Option>, + pub ended_at: Option>, } /// Execution specification @@ -56,9 +150,63 @@ impl Run { run_id, exec, code_state, + status: RunStatus::Pending, + runtime: Runtime::default(), + session_ref: None, + log_ref: None, + timeline: Timeline { + created_at: Some(Utc::now()), + started_at: None, + ended_at: None, + }, + exit_code: None, + pid: None, } } + /// Create a new Run with specified runtime + pub fn new_with_runtime(exec: Exec, code_state: CodeState, runtime: Runtime) -> Self { + let mut run = Self::new(exec, code_state); + run.runtime = runtime; + run + } + + /// Mark the run as started + pub fn mark_started(&mut self) { + self.status = RunStatus::Running; + self.timeline.started_at = Some(Utc::now()); + } + + /// Mark the run as exited with an exit code + pub fn mark_exited(&mut self, exit_code: i32) { + self.status = if exit_code == 0 { + RunStatus::Exited + } else { + RunStatus::Failed + }; + self.exit_code = Some(exit_code); + self.timeline.ended_at = Some(Utc::now()); + } + + /// Mark the run as killed + pub fn mark_killed(&mut self) { + self.status = RunStatus::Killed; + self.timeline.ended_at = Some(Utc::now()); + } + + /// Check if the run is still running + pub fn is_running(&self) -> bool { + self.status == RunStatus::Running + } + + /// Check if the run has completed (successfully or not) + pub fn is_completed(&self) -> bool { + matches!( + self.status, + RunStatus::Exited | RunStatus::Failed | RunStatus::Killed + ) + } + /// Validate the Run pub fn validate(&self) -> Result<(), ValidationError> { // run_id format @@ -121,10 +269,130 @@ mod tests { base_commit: "a1b2c3d4e5f6789012345678901234567890abcd".to_string(), patch: None, }, + status: RunStatus::Pending, + runtime: Runtime::Background, + session_ref: None, + log_ref: None, + timeline: Timeline::default(), + exit_code: None, + pid: None, }; let json = serde_json::to_string_pretty(&run).unwrap(); let parsed: Run = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.run_id, run.run_id); + assert_eq!(parsed.status, RunStatus::Pending); + assert_eq!(parsed.runtime, Runtime::Background); + } + + #[test] + fn test_run_new() { + let run = Run::new( + Exec { + argv: vec!["echo".to_string(), "hello".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }, + CodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + base_commit: "a1b2c3d4e5f6789012345678901234567890abcd".to_string(), + patch: None, + }, + ); + + assert!(run.run_id.starts_with("run_")); + assert_eq!(run.status, RunStatus::Pending); + assert!(run.timeline.created_at.is_some()); + assert!(run.timeline.started_at.is_none()); + } + + #[test] + fn test_run_lifecycle() { + let mut run = Run::new( + Exec { + argv: vec!["echo".to_string(), "hello".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }, + CodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + base_commit: "a1b2c3d4e5f6789012345678901234567890abcd".to_string(), + patch: None, + }, + ); + + assert_eq!(run.status, RunStatus::Pending); + assert!(!run.is_running()); + assert!(!run.is_completed()); + + run.mark_started(); + assert_eq!(run.status, RunStatus::Running); + assert!(run.is_running()); + assert!(!run.is_completed()); + assert!(run.timeline.started_at.is_some()); + + run.mark_exited(0); + assert_eq!(run.status, RunStatus::Exited); + assert!(!run.is_running()); + assert!(run.is_completed()); + assert_eq!(run.exit_code, Some(0)); + assert!(run.timeline.ended_at.is_some()); + } + + #[test] + fn test_run_failed() { + let mut run = Run::new( + Exec { + argv: vec!["false".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }, + CodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + base_commit: "a1b2c3d4e5f6789012345678901234567890abcd".to_string(), + patch: None, + }, + ); + + run.mark_started(); + run.mark_exited(1); + assert_eq!(run.status, RunStatus::Failed); + assert_eq!(run.exit_code, Some(1)); + } + + #[test] + fn test_runtime_parse() { + assert_eq!("bg".parse::().unwrap(), Runtime::Background); + assert_eq!("tmux".parse::().unwrap(), Runtime::Tmux); + assert_eq!("zellij".parse::().unwrap(), Runtime::Zellij); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_backward_compatibility() { + // Test that we can deserialize old JSON without new fields + let old_json = r#"{ + "run_version": 0, + "run_id": "run_test", + "exec": { + "argv": ["echo", "hello"], + "cwd": "." + }, + "code_state": { + "repo_url": "git@github.com:org/repo.git", + "base_commit": "a1b2c3d4e5f6789012345678901234567890abcd" + } + }"#; + + let run: Run = serde_json::from_str(old_json).unwrap(); + assert_eq!(run.run_id, "run_test"); + assert_eq!(run.status, RunStatus::Pending); + assert_eq!(run.runtime, Runtime::Background); + assert!(run.session_ref.is_none()); + assert!(run.log_ref.is_none()); + assert!(run.exit_code.is_none()); } } diff --git a/crates/runbox-core/src/storage.rs b/crates/runbox-core/src/storage.rs index ed93bb5..0a880f3 100644 --- a/crates/runbox-core/src/storage.rs +++ b/crates/runbox-core/src/storage.rs @@ -23,10 +23,21 @@ impl Storage { fs::create_dir_all(base_dir.join("runs"))?; fs::create_dir_all(base_dir.join("templates"))?; fs::create_dir_all(base_dir.join("playlists"))?; + fs::create_dir_all(base_dir.join("logs"))?; Ok(Self { base_dir }) } + /// Get the logs directory + pub fn logs_dir(&self) -> PathBuf { + self.base_dir.join("logs") + } + + /// Get the log file path for a run + pub fn log_path(&self, run_id: &str) -> PathBuf { + self.logs_dir().join(format!("{}.log", run_id)) + } + /// Get the base directory pub fn base_dir(&self) -> &PathBuf { &self.base_dir @@ -88,6 +99,58 @@ impl Storage { Ok(()) } + /// Save a run atomically (write to tmp file, then rename) + pub fn save_run_atomic(&self, run: &Run) -> Result { + let path = self + .base_dir + .join("runs") + .join(format!("{}.json", run.run_id)); + let tmp_path = self + .base_dir + .join("runs") + .join(format!("{}.json.tmp", run.run_id)); + + let json = serde_json::to_string_pretty(run)?; + fs::write(&tmp_path, json)?; + fs::rename(&tmp_path, &path)?; + Ok(path) + } + + /// Update a run atomically with a closure + pub fn update_run(&self, run_id: &str, update_fn: F) -> Result + where + F: FnOnce(&mut Run), + { + let mut run = self.load_run(run_id)?; + update_fn(&mut run); + self.save_run_atomic(&run)?; + Ok(run) + } + + /// List runs filtered by status + pub fn list_runs_by_status( + &self, + status: Option<&crate::RunStatus>, + limit: usize, + ) -> Result> { + let runs = self.list_runs(if status.is_some() { + usize::MAX + } else { + limit + })?; + + let filtered: Vec = if let Some(s) = status { + runs.into_iter() + .filter(|r| &r.status == s) + .take(limit) + .collect() + } else { + runs + }; + + Ok(filtered) + } + // === Template operations === /// Save a template