diff --git a/crates/runbox-cli/Cargo.toml b/crates/runbox-cli/Cargo.toml index c58eebb..93eb87b 100644 --- a/crates/runbox-cli/Cargo.toml +++ b/crates/runbox-cli/Cargo.toml @@ -18,3 +18,4 @@ anyhow = { workspace = true } uuid = { workspace = true } dirs = "5" dialoguer = "0.11" +chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/runbox-cli/src/main.rs b/crates/runbox-cli/src/main.rs index 05bfc3b..e9c76b7 100644 --- a/crates/runbox-cli/src/main.rs +++ b/crates/runbox-cli/src/main.rs @@ -1,10 +1,13 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use chrono::Utc; use clap::{Parser, Subcommand}; use dialoguer::{theme::ColorfulTheme, Input}; use runbox_core::{ - BindingResolver, ConfigResolver, GitContext, Playlist, PlaylistItem, RunTemplate, Storage, + available_runtimes, get_adapter, BindingResolver, ConfigResolver, GitContext, LogRef, + Playlist, PlaylistItem, Run, RunStatus, RunTemplate, RuntimeHandle, Storage, Timeline, Validator, VerboseLogger, }; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -31,6 +34,51 @@ enum Commands { /// Skip execution (dry run) #[arg(long)] dry_run: bool, + + /// Runtime: background (bg) or tmux (default: background) + #[arg(long, default_value = "background")] + runtime: String, + }, + + /// List running and recent runs + Ps { + /// Filter by status (running, exited, failed, etc.) + #[arg(long)] + status: Option, + + /// Number of runs to show (default: 20) + #[arg(short, long, default_value = "20")] + limit: usize, + }, + + /// Stop a running process + Stop { + /// Run ID (full or short) + run_id: String, + + /// Force kill with SIGKILL (default: SIGTERM) + #[arg(long)] + force: bool, + }, + + /// View logs for a run + Logs { + /// Run ID (full or short) + run_id: String, + + /// Follow log output (like tail -f) + #[arg(short, long)] + follow: bool, + + /// Number of lines to show (default: all) + #[arg(short = 'n', long)] + lines: Option, + }, + + /// Attach to a running process (tmux/zellij only) + Attach { + /// Run ID (full or short) + run_id: String, }, /// Manage templates @@ -139,7 +187,16 @@ fn main() -> Result<()> { template, binding, dry_run, - } => cmd_run(&storage, &template, binding, dry_run), + runtime, + } => cmd_run(&storage, &template, binding, dry_run, &runtime), + Commands::Ps { status, limit } => cmd_ps(&storage, status, limit), + Commands::Stop { run_id, force } => cmd_stop(&storage, &run_id, force), + Commands::Logs { + run_id, + follow, + lines, + } => cmd_logs(&storage, &run_id, follow, lines), + 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), @@ -186,7 +243,17 @@ fn main() -> Result<()> { // === 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_name: &str, +) -> Result<()> { + // Validate runtime + let adapter = get_adapter(runtime_name) + .ok_or_else(|| anyhow::anyhow!("Unknown runtime: {}. Available: {:?}", runtime_name, available_runtimes()))?; + let template = storage.load_template(template_id)?; // Create interactive callback @@ -220,8 +287,8 @@ 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 (this creates a Run with Pending status) + let mut run = resolver.build_run(&template, code_state)?; // Validate run.validate()?; @@ -232,23 +299,44 @@ fn cmd_run(storage: &Storage, template_id: &str, bindings: Vec, dry_run: return Ok(()); } - // Save run + // Set runtime and log path + let log_path = storage.log_path(&run.run_id); + run.runtime = adapter.name().to_string(); + run.log_ref = Some(LogRef { + path: log_path.clone(), + }); + run.timeline = Timeline { + created_at: Some(Utc::now()), + started_at: None, + ended_at: None, + }; + + // Save run before spawning + storage.save_run(&run)?; + + // Spawn the process + println!("Starting run: {}", run.run_id); + println!("Runtime: {}", adapter.name()); + println!("Command: {:?}", run.exec.argv); + + let handle = adapter.spawn(&run.exec, &run.run_id, &log_path)?; + + // Update run with handle and status + run.handle = Some(handle); + run.status = RunStatus::Running; + run.timeline.started_at = Some(Utc::now()); + + // Save updated run 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..]) - .current_dir(&run.exec.cwd) - .envs(&run.exec.env) - .status() - .context("Failed to execute command")?; + println!("\nRun started: {}", run.run_id); + println!("Log file: {}", log_path.display()); + println!("Run saved: {}", path.display()); + println!("\nUse 'runbox logs {}' to view output", run.short_id()); + println!("Use 'runbox stop {}' to stop the run", run.short_id()); - if status.success() { - println!("\nRun completed successfully: {}", run.run_id); - } else { - println!("\nRun failed with status: {:?}", status.code()); + if adapter.name() == "tmux" { + println!("Use 'runbox attach {}' to attach to tmux", run.short_id()); } Ok(()) @@ -398,8 +486,385 @@ fn cmd_history(storage: &Storage, limit: usize) -> Result<()> { } fn cmd_show(storage: &Storage, run_id: &str) -> Result<()> { - let run = storage.load_run(run_id)?; - println!("{}", serde_json::to_string_pretty(&run)?); + let run = find_run(storage, run_id)?; + + println!("Run ID: {}", run.run_id); + println!("Short ID: {}", run.short_id()); + println!("Status: {}", run.status); + println!("Runtime: {}", if run.runtime.is_empty() { "none" } else { &run.runtime }); + println!("Command: {}", run.exec.argv.join(" ")); + println!("Working Dir: {}", run.exec.cwd); + + if !run.exec.env.is_empty() { + println!("Environment:"); + for (k, v) in &run.exec.env { + println!(" {}={}", k, v); + } + } + + println!("\nCode State:"); + println!(" Repo: {}", run.code_state.repo_url); + println!(" Commit: {}", run.code_state.base_commit); + if let Some(patch) = &run.code_state.patch { + println!(" Patch: {}", patch.ref_); + } + + println!("\nTimeline:"); + if let Some(created) = &run.timeline.created_at { + println!(" Created: {}", created); + } + if let Some(started) = &run.timeline.started_at { + println!(" Started: {}", started); + } + if let Some(ended) = &run.timeline.ended_at { + println!(" Ended: {}", ended); + } + + if let Some(exit_code) = run.exit_code { + println!("\nExit Code: {}", exit_code); + } + + if let Some(reason) = &run.reconcile_reason { + println!("\nReconcile Reason: {}", reason); + } + + if let Some(log_ref) = &run.log_ref { + println!("\nLog File: {}", log_ref.path.display()); + } + + if let Some(handle) = &run.handle { + println!("\nRuntime Handle:"); + match handle { + RuntimeHandle::Background { pid, pgid } => { + println!(" Type: Background"); + println!(" PID: {}", pid); + println!(" PGID: {}", pgid); + } + RuntimeHandle::Tmux { session, window } => { + println!(" Type: Tmux"); + println!(" Session: {}", session); + println!(" Window: {}", window); + } + RuntimeHandle::Zellij { session, tab } => { + println!(" Type: Zellij"); + println!(" Session: {}", session); + println!(" Tab: {}", tab); + } + } + } + + Ok(()) +} + +/// Find a run by full ID or short ID +fn find_run(storage: &Storage, run_id: &str) -> Result { + // Try loading by full ID first + if let Ok(run) = storage.load_run(run_id) { + return Ok(run); + } + + // Try prefixing with "run_" if not already + if !run_id.starts_with("run_") { + let full_id = format!("run_{}", run_id); + if let Ok(run) = storage.load_run(&full_id) { + return Ok(run); + } + } + + // Search by short ID prefix + let runs = storage.list_runs(1000)?; + let matches: Vec<_> = runs + .into_iter() + .filter(|r| r.short_id().starts_with(run_id) || r.run_id.contains(run_id)) + .collect(); + + match matches.len() { + 0 => bail!("Run not found: {}", run_id), + 1 => Ok(matches.into_iter().next().unwrap()), + _ => { + println!("Multiple matches found:"); + for r in &matches { + println!(" {} ({})", r.short_id(), r.run_id); + } + bail!("Ambiguous run ID: {}", run_id) + } + } +} + +/// Reconcile run statuses by checking if processes are still alive +/// Uses CAS-style updates: only updates if status is Running +fn reconcile_runs(storage: &Storage) -> Result<()> { + let runs = storage.list_runs(1000)?; + + for run in runs { + if run.status != RunStatus::Running { + continue; + } + + let reason = match &run.handle { + None => Some("no runtime handle".to_string()), + Some(handle) => { + let Some(adapter) = get_adapter(&run.runtime) else { + continue; + }; + + if !adapter.is_alive(handle) { + // Process is dead but status is Running + let reason = match handle { + RuntimeHandle::Background { pid, .. } => { + format!("process {} not found", pid) + } + RuntimeHandle::Tmux { session, window } => { + format!("tmux window '{}:{}' not found", session, window) + } + RuntimeHandle::Zellij { session, tab } => { + format!("zellij tab '{}:{}' not found", session, tab) + } + }; + Some(reason) + } else { + None + } + } + }; + + if let Some(reason) = reason { + mark_unknown(storage, &run.run_id, &reason)?; + } + } + + Ok(()) +} + +/// Mark a run as Unknown with CAS-style update +/// Only updates if the run is currently Running +/// Does not overwrite ended_at if already set +fn mark_unknown(storage: &Storage, run_id: &str, reason: &str) -> Result<()> { + let mut run = storage.load_run(run_id)?; + + // CAS: Only update if Running + if run.status != RunStatus::Running { + return Ok(()); + } + + run.status = RunStatus::Unknown; + run.reconcile_reason = Some(reason.to_string()); + + // Don't overwrite ended_at (preserve first end time) + if run.timeline.ended_at.is_none() { + run.timeline.ended_at = Some(Utc::now()); + } + + storage.save_run(&run)?; + Ok(()) +} + +// === Process Status Command === + +fn cmd_ps(storage: &Storage, status_filter: Option, limit: usize) -> Result<()> { + // Run reconcile to update stale statuses + reconcile_runs(storage)?; + + let runs = storage.list_runs(limit)?; + + let filtered: Vec<_> = if let Some(ref filter) = status_filter { + let filter_status = match filter.to_lowercase().as_str() { + "pending" => RunStatus::Pending, + "running" => RunStatus::Running, + "exited" => RunStatus::Exited, + "failed" => RunStatus::Failed, + "killed" => RunStatus::Killed, + "unknown" => RunStatus::Unknown, + _ => bail!("Invalid status: {}. Valid: pending, running, exited, failed, killed, unknown", filter), + }; + runs.into_iter().filter(|r| r.status == filter_status).collect() + } else { + runs + }; + + if filtered.is_empty() { + if status_filter.is_some() { + println!("No runs with status '{}'", status_filter.unwrap()); + } else { + println!("No runs found."); + } + return Ok(()); + } + + println!( + "{:<10} {:<10} {:<12} {:<40}", + "SHORT_ID", "STATUS", "RUNTIME", "COMMAND" + ); + println!("{}", "-".repeat(75)); + + for run in filtered { + let cmd = run.exec.argv.join(" "); + let cmd_truncated = if cmd.len() > 38 { + format!("{}...", &cmd[..35]) + } else { + cmd + }; + let runtime = if run.runtime.is_empty() { + "-" + } else { + &run.runtime + }; + + println!( + "{:<10} {:<10} {:<12} {:<40}", + run.short_id(), + run.status, + runtime, + cmd_truncated + ); + } + + Ok(()) +} + +// === Stop Command === + +fn cmd_stop(storage: &Storage, run_id: &str, force: bool) -> Result<()> { + let mut run = find_run(storage, run_id)?; + + if run.status != RunStatus::Running { + bail!( + "Run {} is not running (status: {})", + run.short_id(), + run.status + ); + } + + let Some(ref handle) = run.handle else { + bail!("Run {} has no runtime handle", run.short_id()); + }; + + let adapter = get_adapter(&run.runtime) + .ok_or_else(|| anyhow::anyhow!("Unknown runtime: {}", run.runtime))?; + + if force { + println!("Force stopping run {} (SIGKILL)...", run.short_id()); + } else { + println!("Stopping run {} (SIGTERM)...", run.short_id()); + } + adapter.stop(handle, force)?; + + // CAS-style update: reload and check status + run = storage.load_run(&run.run_id)?; + if run.status != RunStatus::Running { + // Already updated by another process + println!("Run {} already stopped", run.short_id()); + return Ok(()); + } + + // Update run status + run.status = RunStatus::Killed; + // Don't overwrite ended_at if already set + if run.timeline.ended_at.is_none() { + run.timeline.ended_at = Some(Utc::now()); + } + storage.save_run(&run)?; + + println!("Run {} stopped", run.short_id()); + Ok(()) +} + +// === Logs Command === + +fn cmd_logs(storage: &Storage, run_id: &str, follow: bool, lines: Option) -> Result<()> { + let run = find_run(storage, run_id)?; + + let Some(ref log_ref) = run.log_ref else { + bail!("Run {} has no log file", run.short_id()); + }; + + if !log_ref.path.exists() { + bail!("Log file not found: {}", log_ref.path.display()); + } + + if follow { + // Follow mode - similar to tail -f + let mut file = std::fs::File::open(&log_ref.path)?; + let mut reader = BufReader::new(&mut file); + + // Print existing content + let mut line = String::new(); + while reader.read_line(&mut line)? > 0 { + print!("{}", line); + line.clear(); + } + + // Follow new content + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => { + // No new content - check if process is still running + if run.status != RunStatus::Running { + break; + } + // Also check if process is actually alive + if let (Some(ref handle), Some(adapter)) = + (&run.handle, get_adapter(&run.runtime)) + { + if !adapter.is_alive(handle) { + println!("\n[Process exited]"); + break; + } + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Ok(_) => { + print!("{}", line); + } + Err(e) => { + eprintln!("Error reading log: {}", e); + break; + } + } + } + } else { + // Regular mode - print all or last N lines + let content = std::fs::read_to_string(&log_ref.path)?; + + if let Some(n) = lines { + let all_lines: Vec<_> = content.lines().collect(); + let start = if all_lines.len() > n { + all_lines.len() - n + } else { + 0 + }; + for line in &all_lines[start..] { + println!("{}", line); + } + } else { + print!("{}", content); + } + } + + Ok(()) +} + +// === Attach Command === + +fn cmd_attach(storage: &Storage, run_id: &str) -> Result<()> { + let run = find_run(storage, run_id)?; + + if run.runtime == "background" { + bail!("Cannot attach to background runtime. Use 'runbox logs -f {}' instead.", run.short_id()); + } + + let Some(ref handle) = run.handle else { + bail!("Run {} has no runtime handle", run.short_id()); + }; + + let adapter = get_adapter(&run.runtime) + .ok_or_else(|| anyhow::anyhow!("Unknown runtime: {}", run.runtime))?; + + println!("Attaching to run {}...", run.short_id()); + adapter.attach(handle)?; + + // Note: attach() calls exec() and replaces this process, so we never reach here Ok(()) } diff --git a/crates/runbox-core/Cargo.toml b/crates/runbox-core/Cargo.toml index 9e34d6a..77d1558 100644 --- a/crates/runbox-core/Cargo.toml +++ b/crates/runbox-core/Cargo.toml @@ -17,6 +17,7 @@ sha2 = "0.10" chrono = { version = "0.4", features = ["serde"] } jsonschema = "0.18" toml = "0.8" +libc = "0.2" [dev-dependencies] tempfile = "3" diff --git a/crates/runbox-core/src/lib.rs b/crates/runbox-core/src/lib.rs index 0a9bb20..9462a39 100644 --- a/crates/runbox-core/src/lib.rs +++ b/crates/runbox-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; pub mod git; pub mod playlist; pub mod run; +pub mod runtime; pub mod storage; pub mod template; pub mod validation; @@ -11,7 +12,8 @@ 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, RuntimeHandle, Timeline}; +pub use runtime::{available_runtimes, get_adapter, BackgroundAdapter, RuntimeAdapter, TmuxAdapter}; 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..cc8094a 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,74 @@ pub struct Run { pub run_id: String, pub exec: Exec, pub code_state: CodeState, + + // Execution state (optional for backwards compatibility) + #[serde(default)] + pub status: RunStatus, + #[serde(default)] + pub runtime: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub handle: 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 reconcile_reason: Option, +} + +/// Run execution status +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunStatus { + #[default] + Pending, + Running, + Exited, + Failed, + Killed, + Unknown, +} + +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"), + RunStatus::Unknown => write!(f, "unknown"), + } + } +} + +/// Runtime-specific handle data +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum RuntimeHandle { + Background { pid: u32, pgid: u32 }, + Tmux { session: String, window: String }, + Zellij { session: String, tab: String }, +} + +/// Reference to log file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogRef { + pub path: PathBuf, +} + +/// Timeline of run execution +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +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,6 +126,29 @@ impl Run { run_id, exec, code_state, + status: RunStatus::Pending, + runtime: String::new(), + handle: None, + log_ref: None, + timeline: Timeline { + created_at: Some(Utc::now()), + started_at: None, + ended_at: None, + }, + exit_code: None, + reconcile_reason: None, + } + } + + /// Get short ID (first 8 chars of UUID part) + pub fn short_id(&self) -> &str { + // run_id format is "run_", so skip "run_" prefix + let uuid_part = self.run_id.strip_prefix("run_").unwrap_or(&self.run_id); + // Return first 8 chars or full string if shorter + if uuid_part.len() >= 8 { + &uuid_part[..8] + } else { + uuid_part } } @@ -121,10 +214,107 @@ mod tests { base_commit: "a1b2c3d4e5f6789012345678901234567890abcd".to_string(), patch: None, }, + status: RunStatus::Pending, + runtime: String::new(), + handle: None, + log_ref: None, + timeline: Timeline::default(), + exit_code: None, + reconcile_reason: 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); } + + #[test] + fn test_run_with_handle() { + let run = Run { + run_version: 0, + run_id: "run_550e8400-e29b-41d4-a716-446655440000".to_string(), + exec: Exec { + argv: vec!["python".to_string(), "train.py".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }, + code_state: CodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + base_commit: "a1b2c3d4e5f6789012345678901234567890abcd".to_string(), + patch: None, + }, + status: RunStatus::Running, + runtime: "tmux".to_string(), + handle: Some(RuntimeHandle::Tmux { + session: "runbox".to_string(), + window: "550e8400".to_string(), + }), + log_ref: Some(LogRef { + path: PathBuf::from("/Users/me/.local/share/runbox/logs/run_550e8400.log"), + }), + timeline: Timeline { + created_at: Some(Utc::now()), + started_at: Some(Utc::now()), + ended_at: None, + }, + exit_code: None, + reconcile_reason: None, + }; + + let json = serde_json::to_string_pretty(&run).unwrap(); + let parsed: Run = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.status, RunStatus::Running); + assert!(matches!(parsed.handle, Some(RuntimeHandle::Tmux { .. }))); + } + + #[test] + fn test_short_id() { + let run = Run { + run_version: 0, + run_id: "run_550e8400-e29b-41d4-a716-446655440000".to_string(), + exec: Exec { + argv: vec!["echo".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }, + code_state: CodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + base_commit: "a1b2c3d4e5f6789012345678901234567890abcd".to_string(), + patch: None, + }, + status: RunStatus::Pending, + runtime: String::new(), + handle: None, + log_ref: None, + timeline: Timeline::default(), + exit_code: None, + reconcile_reason: None, + }; + + assert_eq!(run.short_id(), "550e8400"); + } + + #[test] + fn test_backwards_compatibility() { + // Test that old JSON without new fields can still be deserialized + let old_json = r#"{ + "run_version": 0, + "run_id": "run_test123", + "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_test123"); + assert_eq!(run.status, RunStatus::Pending); + assert!(run.handle.is_none()); + } } diff --git a/crates/runbox-core/src/runtime/background.rs b/crates/runbox-core/src/runtime/background.rs new file mode 100644 index 0000000..feb66c3 --- /dev/null +++ b/crates/runbox-core/src/runtime/background.rs @@ -0,0 +1,149 @@ +//! Background runtime adapter for running processes in the background. + +use super::RuntimeAdapter; +use crate::{Exec, RuntimeHandle}; +use anyhow::{bail, Result}; +use std::fs::File; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Command, Stdio}; + +/// Adapter for running processes in the background +pub struct BackgroundAdapter; + +impl BackgroundAdapter { + pub fn new() -> Self { + Self + } +} + +impl Default for BackgroundAdapter { + fn default() -> Self { + Self::new() + } +} + +impl RuntimeAdapter for BackgroundAdapter { + fn name(&self) -> &str { + "background" + } + + fn spawn(&self, exec: &Exec, _run_id: &str, log_path: &Path) -> Result { + let log_file = File::create(log_path)?; + + let mut cmd = Command::new(&exec.argv[0]); + cmd.args(&exec.argv[1..]) + .current_dir(&exec.cwd) + .envs(&exec.env) + .stdout(Stdio::from(log_file.try_clone()?)) + .stderr(Stdio::from(log_file)); + + // Create a new process group so we can kill all children + cmd.process_group(0); + + let child = cmd.spawn()?; + + let pid = child.id(); + let pgid = pid; // process_group(0) means pid == pgid + + // Spawn a thread to wait for the process and clean up + // Note: We're not updating the run status here - that's handled by the caller + std::thread::spawn(move || { + let _ = child.wait_with_output(); + }); + + Ok(RuntimeHandle::Background { pid, pgid }) + } + + fn stop(&self, handle: &RuntimeHandle, force: bool) -> Result<()> { + if let RuntimeHandle::Background { pgid, .. } = handle { + // Send SIGTERM (default) or SIGKILL (force) + let signal = if force { libc::SIGKILL } else { libc::SIGTERM }; + unsafe { + let ret = libc::killpg(*pgid as i32, signal); + if ret != 0 { + // Process may already be dead, which is fine + let errno = std::io::Error::last_os_error(); + if errno.raw_os_error() != Some(libc::ESRCH) { + bail!("Failed to kill process group: {}", errno); + } + } + } + } + Ok(()) + } + + fn attach(&self, _handle: &RuntimeHandle) -> Result<()> { + bail!("Background runtime does not support attach") + } + + fn is_alive(&self, handle: &RuntimeHandle) -> bool { + if let RuntimeHandle::Background { pid, .. } = handle { + // Use kill -0 to check if process exists + unsafe { libc::kill(*pid as i32, 0) == 0 } + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tempfile::tempdir; + + #[test] + fn test_background_spawn_and_stop() { + let adapter = BackgroundAdapter::new(); + let dir = tempdir().unwrap(); + let log_path = dir.path().join("test.log"); + + let exec = Exec { + argv: vec!["sleep".to_string(), "10".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }; + + let handle = adapter.spawn(&exec, "test_run", &log_path).unwrap(); + + // Should be alive + assert!(adapter.is_alive(&handle)); + + // Stop it (graceful SIGTERM) + adapter.stop(&handle, false).unwrap(); + + // Give it a moment to die + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Should be dead now + assert!(!adapter.is_alive(&handle)); + } + + #[test] + fn test_background_quick_exit() { + let adapter = BackgroundAdapter::new(); + let dir = tempdir().unwrap(); + let log_path = dir.path().join("test.log"); + + let exec = Exec { + argv: vec!["echo".to_string(), "hello".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }; + + let handle = adapter.spawn(&exec, "test_run", &log_path).unwrap(); + + // Give it time to finish + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Should be dead + assert!(!adapter.is_alive(&handle)); + + // Check log content + let content = std::fs::read_to_string(&log_path).unwrap(); + assert!(content.contains("hello")); + } +} diff --git a/crates/runbox-core/src/runtime/mod.rs b/crates/runbox-core/src/runtime/mod.rs new file mode 100644 index 0000000..370e6e8 --- /dev/null +++ b/crates/runbox-core/src/runtime/mod.rs @@ -0,0 +1,55 @@ +//! Runtime adapter module for managing different execution environments. + +mod background; +mod tmux; + +pub use background::BackgroundAdapter; +pub use tmux::TmuxAdapter; + +use crate::{Exec, RuntimeHandle}; +use anyhow::Result; +use std::path::Path; + +/// Trait for runtime adapters that spawn and manage processes +pub trait RuntimeAdapter: Send + Sync { + /// Runtime name ("background", "tmux", "zellij") + fn name(&self) -> &str; + + /// Spawn a process + /// + /// # Arguments + /// * `exec` - The execution specification + /// * `run_id` - The run identifier (used for naming windows/tabs) + /// * `log_path` - Path for stdout/stderr output + /// + /// # Returns + /// A RuntimeHandle containing runtime-specific data + fn spawn(&self, exec: &Exec, run_id: &str, log_path: &Path) -> Result; + + /// Stop a running process + /// + /// # Arguments + /// * `handle` - The runtime handle + /// * `force` - If false, send SIGTERM; if true, send SIGKILL + fn stop(&self, handle: &RuntimeHandle, force: bool) -> Result<()>; + + /// Attach to a running process (terminal takeover) + fn attach(&self, handle: &RuntimeHandle) -> Result<()>; + + /// Check if the process is still alive (for reconcile) + fn is_alive(&self, handle: &RuntimeHandle) -> bool; +} + +/// Get a runtime adapter by name +pub fn get_adapter(name: &str) -> Option> { + match name { + "background" | "bg" => Some(Box::new(BackgroundAdapter::new())), + "tmux" => Some(Box::new(TmuxAdapter::new("runbox".to_string()))), + _ => None, + } +} + +/// List available runtime names +pub fn available_runtimes() -> Vec<&'static str> { + vec!["background", "tmux"] +} diff --git a/crates/runbox-core/src/runtime/tmux.rs b/crates/runbox-core/src/runtime/tmux.rs new file mode 100644 index 0000000..eeb5c3c --- /dev/null +++ b/crates/runbox-core/src/runtime/tmux.rs @@ -0,0 +1,219 @@ +//! Tmux runtime adapter for running processes in tmux windows. + +use super::RuntimeAdapter; +use crate::{Exec, RuntimeHandle}; +use anyhow::{bail, Context, Result}; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::Command; + +/// Adapter for running processes in tmux windows +pub struct TmuxAdapter { + session_name: String, +} + +impl TmuxAdapter { + pub fn new(session_name: String) -> Self { + Self { session_name } + } + + /// Ensure the tmux session exists + fn ensure_session(&self) -> Result<()> { + let has_session = Command::new("tmux") + .args(["has-session", "-t", &self.session_name]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !has_session { + let status = Command::new("tmux") + .args(["new-session", "-d", "-s", &self.session_name]) + .status() + .context("Failed to create tmux session")?; + + if !status.success() { + bail!("Failed to create tmux session: {}", self.session_name); + } + } + + Ok(()) + } + + /// Get short ID from run_id string + fn short_id(run_id: &str) -> String { + let uuid_part = run_id.strip_prefix("run_").unwrap_or(run_id); + if uuid_part.len() >= 8 { + uuid_part[..8].to_string() + } else { + uuid_part.to_string() + } + } +} + +impl RuntimeAdapter for TmuxAdapter { + fn name(&self) -> &str { + "tmux" + } + + fn spawn(&self, exec: &Exec, run_id: &str, log_path: &Path) -> Result { + // Ensure session exists + self.ensure_session()?; + + // Window name from short run_id + let window_name = Self::short_id(run_id); + + // Build env prefix (VAR=value format) + // Note: .envs() only affects tmux command, not the spawned shell + let env_prefix = exec + .env + .iter() + .map(|(k, v)| format!("{}={}", shell_escape(k), shell_escape(v))) + .collect::>() + .join(" "); + + // Build the command string with output redirection + // Use single quotes for log_path as per spec + let cmd_str = format!( + "{} exec {} > '{}' 2>&1", + env_prefix, + shell_escape_argv(&exec.argv), + log_path.display() + ); + + // Trim leading space if no env vars + let cmd_str = cmd_str.trim_start(); + + // Create new window and run command with bash -lc + // Note: -c option sets the working directory for the new window + let status = Command::new("tmux") + .args([ + "new-window", + "-t", + &self.session_name, + "-n", + &window_name, + "-c", + &exec.cwd, + "bash", + "-lc", + cmd_str, + ]) + .status() + .context("Failed to create tmux window")?; + + if !status.success() { + bail!("Failed to create tmux window for run: {}", run_id); + } + + Ok(RuntimeHandle::Tmux { + session: self.session_name.clone(), + window: window_name, + }) + } + + fn stop(&self, handle: &RuntimeHandle, _force: bool) -> Result<()> { + // For tmux, kill-window sends SIGHUP to processes + // The force parameter doesn't change behavior for tmux + // (kill-window is the same regardless) + if let RuntimeHandle::Tmux { session, window } = handle { + let target = format!("{}:{}", session, window); + let status = Command::new("tmux") + .args(["kill-window", "-t", &target]) + .status() + .context("Failed to kill tmux window")?; + + if !status.success() { + // Window may already be dead + eprintln!("Warning: tmux window may already be closed"); + } + } + Ok(()) + } + + fn attach(&self, handle: &RuntimeHandle) -> Result<()> { + if let RuntimeHandle::Tmux { session, window } = handle { + let target = format!("{}:{}", session, window); + + // First, select the window + let _ = Command::new("tmux") + .args(["select-window", "-t", &target]) + .status(); + + // Check if we're already inside tmux + if std::env::var("TMUX").is_ok() { + // Switch client to the session + let err = Command::new("tmux") + .args(["switch-client", "-t", session]) + .exec(); + bail!("Failed to switch tmux client: {:?}", err); + } else { + // Attach to the session + let err = Command::new("tmux") + .args(["attach", "-t", session]) + .exec(); + bail!("Failed to attach to tmux: {:?}", err); + } + } + Ok(()) + } + + fn is_alive(&self, handle: &RuntimeHandle) -> bool { + if let RuntimeHandle::Tmux { session, window } = handle { + // Check if window exists using list-windows + Command::new("tmux") + .args(["list-windows", "-t", session, "-F", "#{window_name}"]) + .output() + .map(|o| { + let output = String::from_utf8_lossy(&o.stdout); + output.lines().any(|line| line == window) + }) + .unwrap_or(false) + } else { + false + } + } +} + +/// Escape a string for shell use +fn shell_escape(s: &str) -> String { + if s.chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/') + { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +/// Escape an argv array for shell use +fn shell_escape_argv(argv: &[String]) -> String { + argv.iter().map(|s| shell_escape(s)).collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_shell_escape() { + assert_eq!(shell_escape("hello"), "hello"); + assert_eq!(shell_escape("hello world"), "'hello world'"); + assert_eq!(shell_escape("it's"), "'it'\\''s'"); + assert_eq!(shell_escape("/path/to/file"), "/path/to/file"); + } + + #[test] + fn test_shell_escape_argv() { + let argv = vec!["echo".to_string(), "hello world".to_string()]; + assert_eq!(shell_escape_argv(&argv), "echo 'hello world'"); + } + + #[test] + fn test_short_id() { + assert_eq!( + TmuxAdapter::short_id("run_550e8400-e29b-41d4-a716-446655440000"), + "550e8400" + ); + assert_eq!(TmuxAdapter::short_id("run_short"), "short"); + } +} diff --git a/crates/runbox-core/src/storage.rs b/crates/runbox-core/src/storage.rs index ed93bb5..83d6344 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 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