diff --git a/crates/runbox-cli/Cargo.toml b/crates/runbox-cli/Cargo.toml index cfb90d8..819379a 100644 --- a/crates/runbox-cli/Cargo.toml +++ b/crates/runbox-cli/Cargo.toml @@ -24,6 +24,8 @@ log = "0.4" [features] # Enable tests that require tmux to be installed tmux-tests = [] +# Enable tests that require zellij to be installed +zellij-tests = [] [dev-dependencies] assert_cmd = "2" diff --git a/crates/runbox-cli/src/main.rs b/crates/runbox-cli/src/main.rs index 0a4ec5a..17a9e17 100644 --- a/crates/runbox-cli/src/main.rs +++ b/crates/runbox-cli/src/main.rs @@ -121,7 +121,7 @@ RELATED COMMANDS: /// Variable bindings (key=value) - only for template mode #[arg(short, long)] binding: Vec, - /// Runtime environment (bg, background, tmux) + /// Runtime environment (bg, background, tmux, zellij) #[arg(long, default_value = "bg")] runtime: RuntimeType, /// Skip execution (dry run) @@ -163,7 +163,7 @@ RELATED COMMANDS: runbox ps List runs to check status runbox logs View stdout/stderr from a run")] Log { - /// Runtime environment (bg, background, tmux) + /// Runtime environment (bg, background, tmux, zellij) #[arg(long, default_value = "bg")] runtime: RuntimeType, /// Skip execution (dry run) @@ -364,7 +364,7 @@ NOTES: RELATED COMMANDS: runbox ps List runs to find run IDs runbox show Show run metadata including log file path - runbox attach Attach to tmux session (for tmux runtime)")] + runbox attach Attach to tmux/zellij session (interactive runtime)")] Logs { /// Run ID (or short ID prefix, e.g., '550e8400') run_id: String, @@ -375,21 +375,22 @@ RELATED COMMANDS: #[arg(short, long)] lines: Option, }, - /// Attach to a running tmux session for interactive access + /// Attach to a running tmux/zellij session for interactive access #[command(after_help = "\ EXAMPLES: - # Attach to a tmux-based run + # Attach to a tmux/zellij-based run runbox attach 550e8400 # Using full run ID runbox attach run_550e8400-e29b-41d4-a716-446655440000 NOTES: - Only works for runs started with --runtime tmux or --runtime zellij - - Use Ctrl+B, D to detach from the tmux session + - Detach from tmux with Ctrl+B, D or from zellij with Ctrl+O, D - The process continues running after detaching RELATED COMMANDS: runbox ps List runs to find run IDs runbox logs View logs (for background runs) - runbox run --runtime tmux Start a new run in tmux")] + runbox run --runtime tmux Start a new run in tmux + runbox run --runtime zellij Start a new run in zellij")] Attach { /// Run ID (or short ID prefix, e.g., '550e8400') run_id: String, @@ -926,7 +927,7 @@ NOTES: /// Variable bindings (key=value) for template #[arg(short, long)] binding: Vec, - /// Runtime environment (bg, background, tmux) + /// Runtime environment (bg, background, tmux, zellij) #[arg(short, long, default_value = "bg")] runtime: RuntimeType, /// Show what would be executed without running @@ -1713,7 +1714,7 @@ fn cmd_run_replay( println!("Short ID: {}", run.short_id()); println!("Logs: {}", log_path.display()); - if matches!(runtime, RuntimeType::Tmux) { + if matches!(runtime, RuntimeType::Tmux | RuntimeType::Zellij) { println!("Attach with: runbox attach {}", run.short_id()); } diff --git a/crates/runbox-cli/tests/attach.rs b/crates/runbox-cli/tests/attach.rs index c54b43d..c58f30b 100644 --- a/crates/runbox-cli/tests/attach.rs +++ b/crates/runbox-cli/tests/attach.rs @@ -64,7 +64,7 @@ fn test_attach_background_not_supported() { .failure() .stderr( predicate::str::contains("not support") - .or(predicate::str::contains("only supported for tmux")), + .or(predicate::str::contains("only supported for tmux/zellij")), ); } @@ -107,7 +107,7 @@ fn test_attach_no_runtime_not_supported() { .args(["attach", "b2c3d4e5"]) .assert() .failure() - .stderr(predicate::str::contains("only supported for tmux")); + .stderr(predicate::str::contains("only supported for tmux/zellij")); } /// Feature-gated test for tmux attach functionality. @@ -199,3 +199,97 @@ fn test_attach_tmux() { .args(["kill-session", "-t", &session_name]) .status(); } + +/// Feature-gated test for zellij attach functionality. +/// This test requires zellij to be installed and available. +/// Run with: cargo test --features zellij-tests +#[cfg(feature = "zellij-tests")] +#[test] +fn test_attach_zellij() { + use std::process::Command as StdCommand; + + let zellij_check = StdCommand::new("zellij").arg("--version").output(); + if zellij_check.is_err() || !zellij_check.unwrap().status.success() { + eprintln!("Skipping zellij test: zellij not available"); + return; + } + + let temp = TempDir::new().unwrap(); + let runs_dir = temp.path().join("runs"); + fs::create_dir_all(&runs_dir).unwrap(); + + let session_name = format!("runbox_test_{}", std::process::id()); + let tab_name = "test_tab"; + + let session_result = StdCommand::new("zellij") + .args(["attach", &session_name, "--create-background"]) + .status(); + + if session_result.is_err() || !session_result.unwrap().success() { + eprintln!("Failed to create zellij session"); + return; + } + + let tab_result = StdCommand::new("zellij") + .args([ + "--session", + &session_name, + "action", + "new-tab", + "--name", + tab_name, + ]) + .status(); + + if tab_result.is_err() || !tab_result.unwrap().success() { + eprintln!("Failed to create zellij tab"); + let _ = StdCommand::new("zellij") + .args(["kill-session", &session_name]) + .status(); + return; + } + + let run = format!( + r#"{{ + "run_version": 0, + "run_id": "run_d4e5f607-8901-2345-def0-123456789012", + "exec": {{ + "argv": ["echo", "hello"], + "cwd": ".", + "env": {{}}, + "timeout_sec": 0 + }}, + "code_state": {{ + "repo_url": "git@github.com:org/repo.git", + "base_commit": "a1b2c3d4e5f6789012345678901234567890abcd" + }}, + "status": "running", + "runtime": "zellij", + "handle": {{ + "type": "Zellij", + "session": "{}", + "tab": "{}" + }} + }}"#, + session_name, tab_name + ); + + fs::write( + runs_dir.join("run_d4e5f607-8901-2345-def0-123456789012.json"), + run, + ) + .unwrap(); + + Command::cargo_bin("runbox") + .unwrap() + .env("RUNBOX_HOME", temp.path()) + .env_remove("ZELLIJ") + .args(["attach", "d4e5f607"]) + .assert() + .failure() + .stderr(predicate::str::contains("terminal")); + + let _ = StdCommand::new("zellij") + .args(["kill-session", &session_name]) + .status(); +} diff --git a/crates/runbox-core/src/index.rs b/crates/runbox-core/src/index.rs index 79bfa20..e0a748d 100644 --- a/crates/runbox-core/src/index.rs +++ b/crates/runbox-core/src/index.rs @@ -368,7 +368,7 @@ impl Index { let started_at_str: Option = row.get(6)?; let ended_at_str: Option = row.get(7)?; let log_path_str: Option = row.get(9)?; - + Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, diff --git a/crates/runbox-core/src/lib.rs b/crates/runbox-core/src/lib.rs index be23879..e3e64f5 100644 --- a/crates/runbox-core/src/lib.rs +++ b/crates/runbox-core/src/lib.rs @@ -31,7 +31,7 @@ pub use run::{CodeState, Exec, LogRef, Patch, Run, RunStatus, RuntimeHandle, Tim pub use runnable::{ format_ambiguous_matches, ResolveResult, Runnable, RunnableMatch, RunnableType, }; -pub use runtime::{BackgroundAdapter, RuntimeAdapter, RuntimeRegistry, TmuxAdapter}; +pub use runtime::{BackgroundAdapter, RuntimeAdapter, RuntimeRegistry, TmuxAdapter, ZellijAdapter}; pub use skill::{ find_skill_by_name, find_skills, ExportResult, Platform, Skill, SkillError, SkillMetadata, }; diff --git a/crates/runbox-core/src/runtime/zellij.rs b/crates/runbox-core/src/runtime/zellij.rs index e3da516..94dcf0a 100644 --- a/crates/runbox-core/src/runtime/zellij.rs +++ b/crates/runbox-core/src/runtime/zellij.rs @@ -1,7 +1,6 @@ //! Zellij runtime adapter //! -//! Runs processes in zellij sessions for easy monitoring and attachment. -//! Uses one session per run for reliable stop/attach semantics. +//! Runs processes in zellij tabs for easy monitoring and attachment. use super::RuntimeAdapter; use crate::run::{Exec, RuntimeHandle}; @@ -10,9 +9,8 @@ use std::os::unix::process::CommandExt; use std::path::Path; use std::process::Command; -/// Adapter for running processes in zellij sessions (one session per run) +/// Adapter for running processes in zellij tabs pub struct ZellijAdapter { - /// Prefix for session names (e.g., "runbox" -> "runbox-550e8400-e29b-...") session_prefix: String, } @@ -21,70 +19,68 @@ impl ZellijAdapter { Self { session_prefix } } - /// Generate session name for a run (uses full UUID to avoid collisions) fn session_name(&self, run_id: &str) -> String { - // run_id format: "run_{uuid}" - use full UUID portion for uniqueness - let uuid_part = run_id.get(4..).unwrap_or(run_id); - format!("{}-{}", self.session_prefix, uuid_part) + format!("{}-{}", self.session_prefix, Self::tab_name(run_id)) } - /// Get short ID from run_id for display purposes only - fn short_id(run_id: &str) -> &str { - // run_id format: "run_{uuid}" - first 8 chars of UUID for display - run_id.get(4..12).unwrap_or(run_id) + fn tab_name(run_id: &str) -> String { + run_id.get(4..12).unwrap_or(run_id).to_string() } - /// Check if a session exists and is running (not EXITED) - fn session_is_running(session_name: &str) -> bool { + fn session_is_running(session_name: &str) -> Result { let output = Command::new("zellij") .args(["list-sessions", "--no-formatting"]) - .output(); - - match output { - Ok(out) if out.status.success() => { - let sessions = String::from_utf8_lossy(&out.stdout); - sessions.lines().any(|line| { - let trimmed = line.trim(); - // Session lines: "name" (running) or "name (EXITED)" (dead) - // Only consider running if exact match without EXITED - if trimmed == session_name { - return true; - } - // Check for "name " prefix but NOT "(EXITED)" - if trimmed.starts_with(&format!("{} ", session_name)) { - return !trimmed.contains("(EXITED)"); - } - false - }) - } - _ => false, + .output() + .context("Failed to list zellij sessions")?; + + if !output.status.success() { + return Ok(false); } + + let sessions = String::from_utf8_lossy(&output.stdout); + Ok(sessions.lines().any(|line| { + let trimmed = line.trim(); + trimmed == session_name + || (trimmed.starts_with(&format!("{} ", session_name)) + && !trimmed.contains("(EXITED)")) + })) } - /// Check if a session exists (running or exited) - fn session_exists(session_name: &str) -> bool { + fn session_exists(session_name: &str) -> Result { let output = Command::new("zellij") .args(["list-sessions", "--no-formatting"]) - .output(); - - match output { - Ok(out) if out.status.success() => { - let sessions = String::from_utf8_lossy(&out.stdout); - sessions.lines().any(|line| { - let trimmed = line.trim(); - trimmed == session_name || trimmed.starts_with(&format!("{} ", session_name)) - }) - } - _ => false, + .output() + .context("Failed to list zellij sessions")?; + + if !output.status.success() { + return Ok(false); + } + + let sessions = String::from_utf8_lossy(&output.stdout); + Ok(sessions.lines().any(|line| { + let trimmed = line.trim(); + trimmed == session_name || trimmed.starts_with(&format!("{} ", session_name)) + })) + } + + fn tab_exists(session_name: &str, tab_name: &str) -> Result { + let output = Command::new("zellij") + .args(["--session", session_name, "action", "query-tab-names"]) + .output() + .context("Failed to query zellij tab names")?; + + if !output.status.success() { + return Ok(false); } + + let tabs = String::from_utf8_lossy(&output.stdout); + Ok(tabs.lines().any(|line| line.trim() == tab_name)) } - /// Escape a string for shell execution fn shell_escape(s: &str) -> String { format!("'{}'", s.replace('\'', "'\"'\"'")) } - /// Check if we're currently inside a zellij session fn is_inside_zellij() -> bool { std::env::var("ZELLIJ").is_ok() } @@ -97,14 +93,12 @@ impl RuntimeAdapter for ZellijAdapter { fn spawn(&self, exec: &Exec, run_id: &str, log_path: &Path) -> Result { let session_name = self.session_name(run_id); + let tab_name = Self::tab_name(run_id); - // Check if session already exists (collision or leftover) - if Self::session_exists(&session_name) { - bail!("Zellij session '{}' already exists. This may indicate a collision or leftover session.", session_name); + if Self::session_exists(&session_name)? { + bail!("Zellij session '{}' already exists", session_name); } - // Build environment using `env` command - // Note: env keys should be valid identifiers, values are escaped let env_args: Vec = exec .env .iter() @@ -117,11 +111,8 @@ impl RuntimeAdapter for ZellijAdapter { format!("env {} ", env_args.join(" ")) }; - // Build command with escaping let argv_escaped: Vec = exec.argv.iter().map(|s| Self::shell_escape(s)).collect(); let cmd_str = argv_escaped.join(" "); - - // Full command with log redirection let full_cmd = format!( "{}exec {} > {} 2>&1", env_prefix, @@ -129,39 +120,68 @@ impl RuntimeAdapter for ZellijAdapter { Self::shell_escape(&log_path.display().to_string()) ); - // Create a new zellij session running the command - let output = Command::new("zellij") + let session_output = Command::new("zellij") + .args(["attach", &session_name, "--create-background"]) + .output() + .context("Failed to create zellij session")?; + + if !session_output.status.success() { + let stderr = String::from_utf8_lossy(&session_output.stderr); + bail!( + "Failed to create zellij session '{}': {}", + session_name, + stderr + ); + } + + let tab_output = Command::new("zellij") + .args([ + "--session", + &session_name, + "action", + "new-tab", + "--name", + &tab_name, + "--cwd", + &exec.cwd, + ]) + .output() + .context("Failed to create zellij tab")?; + + if !tab_output.status.success() { + let stderr = String::from_utf8_lossy(&tab_output.stderr); + bail!("Failed to create zellij tab: {}", stderr); + } + + let run_output = Command::new("zellij") .args([ "--session", &session_name, "run", "--cwd", &exec.cwd, + "--close-on-exit", "--", "bash", "-lc", &full_cmd, ]) .output() - .context("Failed to create zellij session. Is zellij installed?")?; + .context("Failed to run command in zellij")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - bail!("Failed to create zellij session: {}", stderr); + if !run_output.status.success() { + let stderr = String::from_utf8_lossy(&run_output.stderr); + bail!("Failed to run command in zellij: {}", stderr); } - // Verify session was created with bounded retry - for _ in 0..5 { - if Self::session_exists(&session_name) { - return Ok(RuntimeHandle::Zellij { - session: session_name, - tab: Self::short_id(run_id).to_string(), - }); - } - std::thread::sleep(std::time::Duration::from_millis(100)); + if !Self::tab_exists(&session_name, &tab_name)? { + bail!("Zellij tab '{}:{}' was not created", session_name, tab_name); } - bail!("Zellij session '{}' was not created", session_name); + Ok(RuntimeHandle::Zellij { + session: session_name, + tab: tab_name, + }) } fn stop(&self, handle: &RuntimeHandle, _force: bool) -> Result<()> { @@ -171,19 +191,15 @@ impl RuntimeAdapter for ZellijAdapter { .output() .context("Failed to kill zellij session")?; - // Accept success or "session not found" errors if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase(); - // Ignore various "not found" error messages - if !stderr.contains("not found") - && !stderr.contains("no session") - && !stderr.contains("doesn't exist") - && !stderr.is_empty() + let stderr = String::from_utf8_lossy(&output.stderr); + let lower = stderr.to_lowercase(); + if !lower.contains("not found") + && !lower.contains("no session") + && !lower.contains("doesn't exist") + && !stderr.trim().is_empty() { - bail!( - "Failed to kill zellij session: {}", - String::from_utf8_lossy(&output.stderr) - ); + bail!("Failed to kill zellij session '{}': {}", session, stderr); } } @@ -194,9 +210,16 @@ impl RuntimeAdapter for ZellijAdapter { } fn attach(&self, handle: &RuntimeHandle) -> Result<()> { - if let RuntimeHandle::Zellij { session, .. } = handle { + if let RuntimeHandle::Zellij { session, tab } = handle { + if !Self::tab_exists(session, tab)? { + bail!("Zellij tab '{}:{}' not found", session, tab); + } + if Self::is_inside_zellij() { - bail!("Cannot attach from inside zellij. Detach first (Ctrl+O, D) then run: zellij attach {}", session); + bail!( + "Cannot attach from inside zellij. Detach first (Ctrl+O, D) then run: zellij attach {}", + session + ); } let err = Command::new("zellij").args(["attach", session]).exec(); @@ -207,9 +230,9 @@ impl RuntimeAdapter for ZellijAdapter { } fn is_alive(&self, handle: &RuntimeHandle) -> bool { - if let RuntimeHandle::Zellij { session, .. } = handle { - // Only consider running sessions as alive, not EXITED ones - Self::session_is_running(session) + if let RuntimeHandle::Zellij { session, tab } = handle { + Self::session_is_running(session).unwrap_or(false) + && Self::tab_exists(session, tab).unwrap_or(false) } else { false } @@ -221,21 +244,20 @@ mod tests { use super::*; #[test] - fn test_short_id() { + fn test_tab_name() { assert_eq!( - ZellijAdapter::short_id("run_550e8400-e29b-41d4-a716-446655440000"), - "550e8400" + ZellijAdapter::tab_name("run_550e8400-e29b-41d4-a716-446655440000"), + "550e8400".to_string() ); - assert_eq!(ZellijAdapter::short_id("short"), "short"); - assert_eq!(ZellijAdapter::short_id("run"), "run"); // Edge case + assert_eq!(ZellijAdapter::tab_name("short"), "short".to_string()); + assert_eq!(ZellijAdapter::tab_name("run"), "run".to_string()); } #[test] - fn test_session_name_uses_full_uuid() { + fn test_session_name_uses_short_id() { let adapter = ZellijAdapter::new("runbox".to_string()); let session = adapter.session_name("run_550e8400-e29b-41d4-a716-446655440000"); - // Should use full UUID, not just 8 chars - assert_eq!(session, "runbox-550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(session, "runbox-550e8400"); } #[test]