From c21e040fb92a6edf2d3beca1f0b4a28d4d651cca Mon Sep 17 00:00:00 2001 From: proboscis Date: Sun, 28 Dec 2025 15:41:21 +0900 Subject: [PATCH] Implement run execution management (ISSUE-005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add execution management features to runbox, enabling observation and control of running jobs while maintaining the core reproducibility focus. Key changes: - Extended Run struct with status, runtime, log_ref, timeline, exit_code fields - Added RunStatus enum: Pending, Running, Exited, Failed, Killed - Added Runtime enum: Background, Tmux, Zellij - Implemented new CLI commands: - `runbox ps` - list running and recent runs with status filtering - `runbox logs ` - view logs with -f for tail mode - `runbox stop ` - stop a running job - `runbox attach ` - attach to tmux/zellij session - `runbox _on-exit` - internal command for exit detection - Extended `runbox run` with --runtime option (bg, tmux, zellij) - Added atomic run updates and logs directory to storage layer - Updated JSON schema for Run with new fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crates/runbox-cli/src/main.rs | 586 +++++++++++++++++++++++++++++- crates/runbox-core/src/lib.rs | 2 +- crates/runbox-core/src/run.rs | 227 ++++++++++++ crates/runbox-core/src/storage.rs | 61 +++- specs/run.schema.json | 57 +++ 5 files changed, 910 insertions(+), 23 deletions(-) diff --git a/crates/runbox-cli/src/main.rs b/crates/runbox-cli/src/main.rs index 05bfc3b..61f7f59 100644 --- a/crates/runbox-cli/src/main.rs +++ b/crates/runbox-cli/src/main.rs @@ -1,12 +1,17 @@ -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, RunStatus, + RunTemplate, Runtime, Storage, Validator, VerboseLogger, }; +use std::fs::File; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; #[derive(Parser)] #[command(name = "runbox")] @@ -16,6 +21,44 @@ struct Cli { command: Commands, } +#[derive(Clone, Copy, ValueEnum)] +enum RuntimeArg { + Bg, + Tmux, + Zellij, +} + +impl From for Runtime { + fn from(arg: RuntimeArg) -> Self { + match arg { + RuntimeArg::Bg => Runtime::Background, + RuntimeArg::Tmux => Runtime::Tmux, + RuntimeArg::Zellij => Runtime::Zellij, + } + } +} + +#[derive(Clone, Copy, ValueEnum)] +enum StatusFilter { + Running, + Pending, + Exited, + Failed, + Killed, +} + +impl From for RunStatus { + fn from(filter: StatusFilter) -> Self { + match filter { + StatusFilter::Running => RunStatus::Running, + StatusFilter::Pending => RunStatus::Pending, + StatusFilter::Exited => RunStatus::Exited, + StatusFilter::Failed => RunStatus::Failed, + StatusFilter::Killed => RunStatus::Killed, + } + } +} + #[derive(Subcommand)] enum Commands { /// Run from a template @@ -31,6 +74,47 @@ enum Commands { /// Skip execution (dry run) #[arg(long)] dry_run: bool, + + /// Runtime environment (bg, tmux, zellij) + #[arg(long, value_enum, default_value = "bg")] + runtime: RuntimeArg, + }, + + /// List running and recent runs + Ps { + /// Filter by status + #[arg(long, value_enum)] + 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 end + #[arg(short = 'n', long, default_value = "100")] + lines: usize, + }, + + /// Stop a running run + Stop { + /// Run ID + run_id: String, + }, + + /// Attach to a run's session (tmux/zellij) + Attach { + /// Run ID + run_id: String, }, /// Manage templates @@ -93,6 +177,16 @@ enum Commands { /// Path to JSON file path: String, }, + + /// Internal: Handle run exit (called by runtime) + #[command(name = "_on-exit", hide = true)] + OnExit { + /// Run ID + run_id: String, + + /// Exit code + exit_code: i32, + }, } #[derive(Subcommand)] @@ -139,7 +233,16 @@ 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::Ps { status, limit } => cmd_ps(&storage, status.map(|s| s.into()), 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::Template { command } => match command { TemplateCommands::List => cmd_template_list(&storage), TemplateCommands::Show { template_id } => cmd_template_show(&storage, &template_id), @@ -181,12 +284,19 @@ fn main() -> Result<()> { verbose, ), Commands::Validate { path } => cmd_validate(&path), + 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 @@ -220,8 +330,15 @@ fn cmd_run(storage: &Storage, template_id: &str, bindings: Vec, dry_run: let temp_run_id = format!("run_{}", uuid::Uuid::new_v4()); let code_state = git.build_code_state(&temp_run_id)?; - // Build run - let run = resolver.build_run(&template, code_state)?; + // Build run with runtime + let mut run = resolver.build_run(&template, code_state)?; + run.runtime = runtime.clone(); + + // Set log ref + let log_path = storage.log_path(&run.run_id); + run.log_ref = Some(LogRef { + path: log_path.clone(), + }); // Validate run.validate()?; @@ -232,28 +349,461 @@ fn cmd_run(storage: &Storage, template_id: &str, bindings: Vec, dry_run: return Ok(()); } - // Save run + // Save run with Pending status let path = storage.save_run(&run)?; println!("Run saved: {}", path.display()); - // Execute - println!("\nExecuting: {:?}", run.exec.argv); - let status = Command::new(&run.exec.argv[0]) - .args(&run.exec.argv[1..]) + // Execute based on runtime + match runtime { + 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 in background (nohup style) +fn execute_background(storage: &Storage, run: &mut runbox_core::Run, log_path: &Path) -> Result<()> { + println!("\nExecuting in background: {:?}", run.exec.argv); + + // Create log file + let log_file = File::create(log_path)?; + + // Build the command + let mut cmd = Command::new(&run.exec.argv[0]); + cmd.args(&run.exec.argv[1..]) .current_dir(&run.exec.cwd) .envs(&run.exec.env) + .stdout(Stdio::from(log_file.try_clone()?)) + .stderr(Stdio::from(log_file)); + + // Spawn the process + let child = cmd.spawn().context("Failed to spawn command")?; + let pid = child.id(); + + // Update run with PID and Running status + run.pid = Some(pid); + run.mark_started(); + storage.save_run(run)?; + + println!("Started with PID: {}", pid); + println!("Log file: {}", log_path.display()); + println!("Run ID: {}", run.run_id); + + // Spawn a thread to wait for completion and update status + let run_id = run.run_id.clone(); + let storage_base = storage.base_dir().clone(); + thread::spawn(move || { + let mut child = child; + if let Ok(status) = child.wait() { + let exit_code = status.code().unwrap_or(-1); + // Update run status + if let Ok(storage) = Storage::with_base_dir(storage_base) { + let _ = storage.update_run(&run_id, |r| { + r.mark_completed(exit_code); + }); + } + } + }); + + Ok(()) +} + +/// Execute in tmux session +fn execute_tmux(storage: &Storage, run: &mut runbox_core::Run, log_path: &Path) -> Result<()> { + println!("\nExecuting in tmux: {:?}", run.exec.argv); + + // Check if tmux is available + Command::new("tmux") + .arg("-V") + .output() + .context("tmux is not installed")?; + + // Ensure runbox session exists + let session_exists = Command::new("tmux") + .args(["has-session", "-t", "runbox"]) .status() - .context("Failed to execute command")?; + .map(|s| s.success()) + .unwrap_or(false); + + if !session_exists { + Command::new("tmux") + .args(["new-session", "-d", "-s", "runbox"]) + .status() + .context("Failed to create tmux session")?; + } - if status.success() { - println!("\nRun completed successfully: {}", run.run_id); + // Get runbox executable path + let runbox_exe = std::env::current_exe()?; + + // Build the command string to run in tmux + let cmd_str = run.exec.argv.join(" "); + let window_name = run.run_id.clone(); + + // Build the full command with logging and exit callback + let full_cmd = format!( + "cd {} && {} 2>&1 | tee {}; {} _on-exit {} $?", + shell_escape(&run.exec.cwd), + cmd_str, + log_path.display(), + runbox_exe.display(), + run.run_id + ); + + // Create new window in runbox session + Command::new("tmux") + .args([ + "new-window", + "-t", + "runbox", + "-n", + &window_name, + &full_cmd, + ]) + .status() + .context("Failed to create tmux window")?; + + // Set session_ref + run.session_ref = Some(format!("tmux:session=runbox;window={}", window_name)); + run.mark_started(); + storage.save_run(run)?; + + println!("Started in tmux session 'runbox', window '{}'", window_name); + println!("Log file: {}", log_path.display()); + println!("Run ID: {}", run.run_id); + println!("\nTo attach: runbox attach {}", run.run_id); + + Ok(()) +} + +/// Execute in zellij session +fn execute_zellij(storage: &Storage, run: &mut runbox_core::Run, log_path: &Path) -> Result<()> { + println!("\nExecuting in zellij: {:?}", run.exec.argv); + + // Check if zellij is available + Command::new("zellij") + .arg("--version") + .output() + .context("zellij is not installed")?; + + // Get runbox executable path + let runbox_exe = std::env::current_exe()?; + + // Build the command string + let cmd_str = run.exec.argv.join(" "); + let tab_name = run.run_id.clone(); + + // Build the full command with logging and exit callback + let full_cmd = format!( + "cd {} && {} 2>&1 | tee {}; {} _on-exit {} $?", + shell_escape(&run.exec.cwd), + cmd_str, + log_path.display(), + runbox_exe.display(), + run.run_id + ); + + // Check if runbox session exists + let sessions_output = Command::new("zellij") + .args(["list-sessions"]) + .output() + .context("Failed to list zellij sessions")?; + + let sessions = String::from_utf8_lossy(&sessions_output.stdout); + let session_exists = sessions.lines().any(|line| line.starts_with("runbox")); + + if !session_exists { + // Create new session with the command + Command::new("zellij") + .args(["-s", "runbox", "--", "bash", "-c", &full_cmd]) + .spawn() + .context("Failed to create zellij session")?; + } else { + // Create new tab in existing session + Command::new("zellij") + .args([ + "-s", + "runbox", + "action", + "new-tab", + "-n", + &tab_name, + "--", + "bash", + "-c", + &full_cmd, + ]) + .status() + .context("Failed to create zellij tab")?; + } + + // Set session_ref + run.session_ref = Some(format!("zellij:session=runbox;tab={}", tab_name)); + run.mark_started(); + storage.save_run(run)?; + + println!("Started in zellij session 'runbox', tab '{}'", tab_name); + println!("Log file: {}", log_path.display()); + println!("Run ID: {}", run.run_id); + println!("\nTo attach: runbox attach {}", run.run_id); + + Ok(()) +} + +/// Escape a string for shell +fn shell_escape(s: &str) -> String { + if s.contains(' ') || s.contains('\'') || s.contains('"') { + format!("'{}'", s.replace('\'', "'\\''")) } else { - println!("\nRun failed with status: {:?}", status.code()); + s.to_string() + } +} + +// === Ps Command === + +fn cmd_ps(storage: &Storage, status: Option, limit: usize) -> Result<()> { + let runs = storage.list_runs_by_status(status, limit)?; + + if runs.is_empty() { + println!("No runs found."); + return Ok(()); + } + + println!( + "{:<45} {:<10} {:<12} {:<30}", + "RUN ID", "STATUS", "RUNTIME", "COMMAND" + ); + println!("{}", "-".repeat(100)); + + for run in runs { + let cmd = run.exec.argv.join(" "); + let cmd_truncated = if cmd.len() > 28 { + format!("{}...", &cmd[..25]) + } else { + cmd + }; + println!( + "{:<45} {:<10} {:<12} {:<30}", + run.run_id, run.status, run.runtime, cmd_truncated + ); + } + + 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(|lr| lr.path.clone()) + .unwrap_or_else(|| storage.log_path(run_id)); + + if !log_path.exists() { + bail!("Log file not found: {}", log_path.display()); + } + + if follow { + // Tail -f style following + cmd_logs_follow(&log_path)?; + } else { + // Show last N lines + cmd_logs_tail(&log_path, lines)?; + } + + Ok(()) +} + +fn cmd_logs_tail(log_path: &Path, lines: usize) -> Result<()> { + let file = File::open(log_path)?; + let reader = BufReader::new(file); + let all_lines: Vec = reader.lines().filter_map(|l| l.ok()).collect(); + + let start = if all_lines.len() > lines { + all_lines.len() - lines + } else { + 0 + }; + + for line in &all_lines[start..] { + println!("{}", line); } Ok(()) } +fn cmd_logs_follow(log_path: &Path) -> Result<()> { + let mut file = File::open(log_path)?; + file.seek(SeekFrom::End(0))?; + + let mut reader = BufReader::new(file); + let mut line = String::new(); + + println!("Following {}... (Ctrl+C to stop)", log_path.display()); + + loop { + match reader.read_line(&mut line) { + Ok(0) => { + // No new data, wait a bit + thread::sleep(Duration::from_millis(100)); + } + Ok(_) => { + print!("{}", line); + line.clear(); + } + Err(e) => { + bail!("Error reading log file: {}", e); + } + } + } +} + +// === 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") + .context("Missing session in session_ref")?; + let window = params + .get("window") + .context("Missing window in session_ref")?; + Command::new("tmux") + .args(["kill-window", "-t", &format!("{}:{}", session, window)]) + .status() + .context("Failed to kill tmux window")?; + } + "zellij" => { + // Zellij tab killing is more complex, try to close-tab + Command::new("zellij") + .args(["-s", "runbox", "action", "close-tab"]) + .status() + .context("Failed to close zellij tab")?; + } + _ => bail!("Unknown runtime in session_ref: {}", runtime), + } + } + None => { + // Background runtime: 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 or session_ref found 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() + .context("Run has no session_ref (was it started with --runtime bg?)")?; + + let (runtime, params) = parse_session_ref(session_ref)?; + + match runtime { + "tmux" => { + let session = params + .get("session") + .context("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()?; + } + + // Check if already in tmux + if std::env::var("TMUX").is_ok() { + // Switch client + let err = Command::new("tmux") + .args(["switch-client", "-t", session]) + .exec(); + bail!("Failed to switch tmux client: {}", err); + } else { + // Attach + let err = Command::new("tmux") + .args(["attach", "-t", session]) + .exec(); + bail!("Failed to attach to tmux: {}", err); + } + } + "zellij" => { + let session = params + .get("session") + .context("Missing session in session_ref")?; + let err = Command::new("zellij") + .args(["attach", session]) + .exec(); + bail!("Failed to attach to zellij: {}", err); + } + _ => bail!("Unknown runtime: {}", runtime), + } +} + +/// 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 params: std::collections::HashMap<&str, &str> = parts[1] + .split(';') + .filter_map(|kv| { + let mut parts = kv.splitn(2, '='); + match (parts.next(), parts.next()) { + (Some(k), Some(v)) => Some((k, v)), + _ => None, + } + }) + .collect(); + + Ok((runtime, params)) +} + +// === OnExit Command (internal) === + +fn cmd_on_exit(storage: &Storage, run_id: &str, exit_code: i32) -> Result<()> { + storage.update_run(run_id, |run| { + run.mark_completed(exit_code); + })?; + Ok(()) +} + // === 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..cafcac1 100644 --- a/crates/runbox-core/src/run.rs +++ b/crates/runbox-core/src/run.rs @@ -1,13 +1,115 @@ +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)] pub struct Run { pub run_version: u32, pub run_id: String, + + // Existing (required) pub exec: Exec, pub code_state: CodeState, + + // New fields for execution management + #[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, PartialEq, Eq, Serialize, Deserialize, Default)] +#[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, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Runtime { + #[default] + Background, + Tmux, + Zellij, +} + +impl std::fmt::Display for Runtime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Runtime::Background => write!(f, "background"), + Runtime::Tmux => write!(f, "tmux"), + Runtime::Zellij => write!(f, "zellij"), + } + } +} + +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), + _ => Err(format!("Unknown runtime: {}. Valid values: bg, tmux, zellij", s)), + } + } +} + +/// Log file reference +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogRef { + pub path: PathBuf, +} + +/// Timeline tracking for run execution +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Timeline { + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub ended_at: Option>, } /// Execution specification @@ -56,9 +158,63 @@ impl Run { run_id, exec, code_state, + status: RunStatus::Pending, + runtime: Runtime::Background, + 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 specific 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 completed with exit code + pub fn mark_completed(&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 currently running + pub fn is_running(&self) -> bool { + self.status == RunStatus::Running + } + + /// Check if the run has finished (exited, failed, or killed) + pub fn is_finished(&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 +277,81 @@ 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); + } + + #[test] + fn test_run_status_transitions() { + let mut run = Run::new( + Exec { + argv: vec!["test".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.timeline.created_at.is_some()); + assert!(run.timeline.started_at.is_none()); + + run.mark_started(); + assert_eq!(run.status, RunStatus::Running); + assert!(run.is_running()); + assert!(run.timeline.started_at.is_some()); + + run.mark_completed(0); + assert_eq!(run.status, RunStatus::Exited); + assert!(run.is_finished()); + assert_eq!(run.exit_code, Some(0)); + assert!(run.timeline.ended_at.is_some()); + } + + #[test] + fn test_run_failed_status() { + let mut run = Run::new( + Exec { + argv: vec!["test".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_completed(1); + assert_eq!(run.status, RunStatus::Failed); + assert_eq!(run.exit_code, Some(1)); + } + + #[test] + fn test_runtime_from_str() { + assert_eq!("bg".parse::().unwrap(), Runtime::Background); + assert_eq!("background".parse::().unwrap(), Runtime::Background); + assert_eq!("tmux".parse::().unwrap(), Runtime::Tmux); + assert_eq!("zellij".parse::().unwrap(), Runtime::Zellij); + assert!("invalid".parse::().is_err()); } } diff --git a/crates/runbox-core/src/storage.rs b/crates/runbox-core/src/storage.rs index ed93bb5..610a2de 100644 --- a/crates/runbox-core/src/storage.rs +++ b/crates/runbox-core/src/storage.rs @@ -1,6 +1,7 @@ -use crate::{Playlist, Run, RunTemplate}; +use crate::{Playlist, Run, RunStatus, RunTemplate}; use anyhow::{Context, Result}; -use std::fs; +use std::fs::{self, File}; +use std::io::Write; use std::path::PathBuf; /// Storage for runs, templates, and playlists @@ -23,6 +24,7 @@ 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 }) } @@ -32,16 +34,47 @@ impl Storage { &self.base_dir } + /// Get the logs directory + pub fn logs_dir(&self) -> PathBuf { + self.base_dir.join("logs") + } + + /// Get log file path for a run + pub fn log_path(&self, run_id: &str) -> PathBuf { + self.logs_dir().join(format!("{}.log", run_id)) + } + // === Run operations === - /// Save a run + /// Save a run atomically (write to .tmp then rename) pub fn save_run(&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(&path, json)?; + + // Write to temp file first + let mut file = File::create(&tmp_path)?; + file.write_all(json.as_bytes())?; + file.sync_all()?; + + // Atomic rename + fs::rename(&tmp_path, &path)?; + Ok(path) } + /// Update a run (load, modify, save) + pub fn update_run(&self, run_id: &str, f: F) -> Result + where + F: FnOnce(&mut Run), + { + let mut run = self.load_run(run_id)?; + f(&mut run); + self.save_run(&run)?; + Ok(run) + } + /// Load a run by ID pub fn load_run(&self, run_id: &str) -> Result { let path = self.base_dir.join("runs").join(format!("{}.json", run_id)); @@ -81,6 +114,17 @@ impl Storage { Ok(runs) } + /// List runs filtered by status + pub fn list_runs_by_status(&self, status: Option, limit: usize) -> Result> { + let runs = self.list_runs(limit * 10)?; // Fetch more to account for filtering + let filtered: Vec = runs + .into_iter() + .filter(|r| status.as_ref().map(|s| &r.status == s).unwrap_or(true)) + .take(limit) + .collect(); + Ok(filtered) + } + /// Delete a run by ID pub fn delete_run(&self, run_id: &str) -> Result<()> { let path = self.base_dir.join("runs").join(format!("{}.json", run_id)); @@ -88,6 +132,15 @@ impl Storage { Ok(()) } + /// Delete a run's log file + pub fn delete_log(&self, run_id: &str) -> Result<()> { + let path = self.log_path(run_id); + if path.exists() { + fs::remove_file(&path)?; + } + Ok(()) + } + // === Template operations === /// Save a template diff --git a/specs/run.schema.json b/specs/run.schema.json index 68f32c8..51c8982 100644 --- a/specs/run.schema.json +++ b/specs/run.schema.json @@ -76,6 +76,36 @@ "ref", "sha256" ] + }, + "#LogRef": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "#Timeline": { + "type": "object", + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "ended_at": { + "type": "string", + "format": "date-time" + } + } } }, "type": "object", @@ -93,6 +123,33 @@ }, "run_version": { "const": 0 + }, + "status": { + "type": "string", + "enum": ["pending", "running", "exited", "failed", "killed"], + "default": "pending" + }, + "runtime": { + "type": "string", + "enum": ["background", "tmux", "zellij"], + "default": "background" + }, + "session_ref": { + "type": "string", + "description": "Runtime-specific session reference (e.g., 'tmux:session=runbox;window=run_xxx')" + }, + "log_ref": { + "$ref": "#/$defs/#LogRef" + }, + "timeline": { + "$ref": "#/$defs/#Timeline" + }, + "exit_code": { + "type": "integer" + }, + "pid": { + "type": "integer", + "description": "Process ID for background runtime" } }, "required": [