diff --git a/crates/runbox-cli/src/main.rs b/crates/runbox-cli/src/main.rs index 83bb2b0..be67510 100644 --- a/crates/runbox-cli/src/main.rs +++ b/crates/runbox-cli/src/main.rs @@ -3,7 +3,7 @@ use chrono::Utc; use clap::{Parser, Subcommand, ValueEnum}; use dialoguer::{theme::ColorfulTheme, Input}; use runbox_core::{ - default_pid_path, default_socket_path, short_id, BindingResolver, ConfigResolver, DaemonClient, + default_pid_path, default_socket_path, short_id, ResolveTargetError, BindingResolver, ConfigResolver, DaemonClient, GitContext, LogRef, Playlist, PlaylistItem, RunStatus, RunTemplate, RuntimeRegistry, Storage, Timeline, Validator, VerboseLogger, }; @@ -70,9 +70,13 @@ impl std::fmt::Display for RuntimeType { } #[derive(Subcommand)] enum Commands { - /// Run from a template or execute a command directly + /// Run from a template, playlist item, or execute a command directly #[command(after_help = "\ EXAMPLES: + # Smart resolution (template or playlist item by short ID) + runbox run echo # Template by name/id prefix + runbox run a1b2c3d4 # Template or playlist item by short ID + runbox run --dry-run f5e6d7c8 # Show what would run # Direct execution (everything after -- is the command) runbox run -- echo 'Hello, World!' runbox run -- python train.py --epochs 10 @@ -84,20 +88,28 @@ EXAMPLES: runbox run --cwd /path/to/project -- npm test runbox run --no-git -- echo 'skip git capture' runbox run --dry-run -- python train.py - # Template-based execution + # Explicit template-based execution runbox run --template tpl_train_model runbox run --template tpl_train_model --binding epochs=100 runbox run --template tpl_hello --binding name=World --runtime tmux +RESOLUTION ORDER: + 1. Templates (exact match on template_id) + 2. Templates (prefix match on normalized ID) + 3. Playlist items (prefix match on hex short ID) RELATED COMMANDS: runbox log Alias for direct execution (runbox log -- ) runbox ps List runs to check status runbox logs View stdout/stderr from a run - runbox template Manage templates")] + runbox template Manage templates + runbox playlist Manage playlists")] Run { - /// Template ID (for template-based execution) + /// Short ID to resolve (template or playlist item) + #[arg(value_name = "TARGET")] + target: Option, + /// Template ID (explicit, skips smart resolution) #[arg(short, long)] template: Option, - /// Variable bindings (key=value) - only for template mode + /// Variable bindings (key=value) - for template mode #[arg(short, long)] binding: Vec, /// Runtime environment (bg, background, tmux) @@ -774,6 +786,7 @@ fn main() -> Result<()> { }; match cli.command { Commands::Run { + target, template, binding, runtime, @@ -785,13 +798,18 @@ fn main() -> Result<()> { command, } => { if let Some(tpl_id) = template { + // Explicit template override (--template flag) cmd_run_template(&storage, &tpl_id, binding, runtime, dry_run) + } else if let Some(target_id) = target { + // Smart resolution (positional TARGET argument) + cmd_run_smart(&storage, &target_id, binding, runtime, dry_run) } else if !command.is_empty() { + // Direct command execution (-- ) cmd_run_direct( &storage, command, runtime, dry_run, timeout, env_vars, cwd, no_git, ) } else { - anyhow::bail!("Either --template or a command (after --) is required.\n\nUsage:\n runbox run --template [--binding key=value]\n runbox run [OPTIONS] -- ") + anyhow::bail!("Specify a target (template/playlist item ID), --template, or a command after --.\n\nUsage:\n runbox run # Smart resolution\n runbox run --template # Explicit template\n runbox run [OPTIONS] -- # Direct execution") } } Commands::Log { @@ -1084,6 +1102,54 @@ fn cmd_run_template( } Ok(()) } + +/// Smart resolution: resolve target to template or playlist item and execute +fn cmd_run_smart( + storage: &Storage, + target: &str, + bindings: Vec, + runtime: RuntimeType, + dry_run: bool, +) -> Result<()> { + // Resolve the target + let resolved = match storage.resolve_target(target) { + Ok(r) => r, + Err(ResolveTargetError::NotFound(t)) => { + bail!("No template or playlist item found matching \"{}\"", t) + } + Err(ResolveTargetError::Ambiguous { input, count, candidates }) => { + bail!( + "Ambiguous short ID \"{}\" matches {} items:\n{}\n\nUse more characters or specify explicitly:\n runbox run --template \n runbox playlist run ", + input, count, candidates + ) + } + Err(ResolveTargetError::Storage(e)) => { + bail!("Storage error: {}", e) + } + }; + + // Display resolution info + println!("Resolved: {}", resolved.description()); + + // Get the template ID to execute + let template_id = resolved.template_id(); + + // Load and display the template info + let template = storage.load_template(template_id)?; + println!(" Template: {} ({})", template.name, template.template_id); + println!(" Command: {:?}", template.exec.argv); + + if dry_run { + println!("\n(dry run - would execute with the above configuration)"); + return Ok(()); + } + + println!(); + + // Delegate to cmd_run_template for actual execution + cmd_run_template(storage, template_id, bindings, runtime, false) +} + fn cmd_run_direct( storage: &Storage, command: Vec, diff --git a/crates/runbox-core/src/lib.rs b/crates/runbox-core/src/lib.rs index 4e8e300..a4c3035 100644 --- a/crates/runbox-core/src/lib.rs +++ b/crates/runbox-core/src/lib.rs @@ -18,6 +18,6 @@ pub use playlist::{Playlist, PlaylistItem}; pub use result::{Artifact, Execution, Output, RunResult}; pub use run::{CodeState, Exec, LogRef, Patch, Run, RunStatus, RuntimeHandle, Timeline}; pub use runtime::{BackgroundAdapter, RuntimeAdapter, RuntimeRegistry, TmuxAdapter}; -pub use storage::{short_id, Storage}; +pub use storage::{short_id, ResolvedTarget, ResolveTargetError, Storage}; pub use template::{Bindings, RunTemplate, TemplateCodeState, TemplateExec}; pub use validation::{ValidationType, Validator}; diff --git a/crates/runbox-core/src/storage.rs b/crates/runbox-core/src/storage.rs index 74d3bc3..d16d1d0 100644 --- a/crates/runbox-core/src/storage.rs +++ b/crates/runbox-core/src/storage.rs @@ -516,6 +516,188 @@ where } } +/// Represents a resolved target for smart run resolution +#[derive(Debug, Clone)] +pub enum ResolvedTarget { + /// A template was resolved + Template { + template_id: String, + template_name: String, + }, + /// A playlist item was resolved + PlaylistItem { + playlist_id: String, + playlist_name: String, + index: usize, + template_id: String, + label: Option, + short_id: String, + }, +} + +impl ResolvedTarget { + /// Get the template ID from the resolved target + pub fn template_id(&self) -> &str { + match self { + ResolvedTarget::Template { template_id, .. } => template_id, + ResolvedTarget::PlaylistItem { template_id, .. } => template_id, + } + } + + /// Get the display short ID for this target + pub fn display_short_id(&self) -> String { + match self { + ResolvedTarget::Template { template_id, .. } => short_id(template_id), + ResolvedTarget::PlaylistItem { short_id, .. } => short_id.clone(), + } + } + + /// Get a human-readable description of what was resolved + pub fn description(&self) -> String { + match self { + ResolvedTarget::Template { template_id, template_name } => { + format!("template \"{}\" ({})", template_name, template_id) + } + ResolvedTarget::PlaylistItem { + playlist_id, + playlist_name, + index, + label, + template_id, + .. + } => { + let item_label = label.as_deref().unwrap_or(template_id); + let playlist_short = short_id(playlist_id); + format!( + "playlist \"{}\" ({}) item {} \"{}\" ({})", + playlist_name, playlist_short, index, item_label, template_id + ) + } + } + } + + /// Get a formatted candidate line for ambiguity display + pub fn candidate_line(&self) -> String { + match self { + ResolvedTarget::Template { template_id, template_name } => { + format!( + " [template] {} \"{}\" ({})", + short_id(template_id), + template_name, + template_id + ) + } + ResolvedTarget::PlaylistItem { + playlist_id, + index: _, + template_id, + label, + short_id, + .. + } => { + let playlist_short = crate::storage::short_id(playlist_id); + let item_label = label.as_deref().unwrap_or("(no label)"); + format!( + " [playlist:{}] {} \"{}\" ({})", + playlist_short, short_id, item_label, template_id + ) + } + } + } +} + +/// Error type for resolution failures +#[derive(Debug, thiserror::Error)] +pub enum ResolveTargetError { + #[error("No template or playlist item found matching \"{0}\"")] + NotFound(String), + #[error("Ambiguous short ID \"{input}\" matches {count} items:\n{candidates}\n\nUse more characters or specify explicitly:\n runbox run --template \n runbox playlist run ")] + Ambiguous { + input: String, + count: usize, + candidates: String, + }, + #[error(transparent)] + Storage(#[from] anyhow::Error), +} + +impl Storage { + /// Resolve a target string to either a template or playlist item. + /// + /// Resolution order: + /// 1. Check templates for exact match on template_id + /// 2. Check templates for prefix match on normalized ID + /// 3. Check playlist items for prefix match on generated hex short ID + /// + /// Returns an error if no match is found or if the match is ambiguous. + pub fn resolve_target(&self, target: &str) -> Result { + let mut matches: Vec = vec![]; + + // 1. Check templates + let templates = self.list_templates().map_err(ResolveTargetError::Storage)?; + + // Exact match on template_id first + for template in &templates { + if template.template_id == target { + return Ok(ResolvedTarget::Template { + template_id: template.template_id.clone(), + template_name: template.name.clone(), + }); + } + } + + // Prefix match on normalized ID + let target_normalized = normalize_for_match(target); + for template in &templates { + let id_normalized = normalize_for_match(&template.template_id); + if id_normalized.starts_with(&target_normalized) { + matches.push(ResolvedTarget::Template { + template_id: template.template_id.clone(), + template_name: template.name.clone(), + }); + } + } + + // 2. Check playlist items + let playlists = self.list_playlists().map_err(ResolveTargetError::Storage)?; + let target_lower = target.to_lowercase(); + + for playlist in &playlists { + for (idx, item) in playlist.items.iter().enumerate() { + let item_short = item.short_id(&playlist.playlist_id, idx); + if item_short.starts_with(&target_lower) { + matches.push(ResolvedTarget::PlaylistItem { + playlist_id: playlist.playlist_id.clone(), + playlist_name: playlist.name.clone(), + index: idx, + template_id: item.template_id.clone(), + label: item.label.clone(), + short_id: item_short, + }); + } + } + } + + // Return based on match count + match matches.len() { + 0 => Err(ResolveTargetError::NotFound(target.to_string())), + 1 => Ok(matches.remove(0)), + n => { + let candidates = matches + .iter() + .map(|m| m.candidate_line()) + .collect::>() + .join("\n"); + Err(ResolveTargetError::Ambiguous { + input: target.to_string(), + count: n, + candidates, + }) + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -895,4 +1077,139 @@ mod tests { let resolved = storage.resolve_result_id(&result.result_id).unwrap(); assert_eq!(resolved, result.result_id); } + + #[test] + fn test_resolve_target_template() { + let dir = tempdir().unwrap(); + let storage = Storage::with_base_dir(dir.path().to_path_buf()).unwrap(); + + // Create a template + let template = crate::RunTemplate { + template_version: 0, + template_id: "tpl_hello_world".to_string(), + name: "Hello World".to_string(), + exec: crate::TemplateExec { + argv: vec!["echo".to_string(), "hello".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }, + bindings: None, + code_state: crate::TemplateCodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + }, + }; + storage.save_template(&template).unwrap(); + + // Exact match + let resolved = storage.resolve_target("tpl_hello_world").unwrap(); + assert!(matches!(resolved, ResolvedTarget::Template { .. })); + assert_eq!(resolved.template_id(), "tpl_hello_world"); + + // Prefix match + let resolved = storage.resolve_target("hello").unwrap(); + assert!(matches!(resolved, ResolvedTarget::Template { .. })); + assert_eq!(resolved.template_id(), "tpl_hello_world"); + } + + #[test] + fn test_resolve_target_playlist_item() { + let dir = tempdir().unwrap(); + let storage = Storage::with_base_dir(dir.path().to_path_buf()).unwrap(); + + // Create a template first + let template = crate::RunTemplate { + template_version: 0, + template_id: "tpl_echo".to_string(), + name: "Echo".to_string(), + exec: crate::TemplateExec { + argv: vec!["echo".to_string(), "test".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }, + bindings: None, + code_state: crate::TemplateCodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + }, + }; + storage.save_template(&template).unwrap(); + + // Create a playlist with an item + let mut playlist = crate::Playlist::new("pl_daily", "Daily Tasks"); + playlist.add("tpl_echo", Some("Echo Task")); + storage.save_playlist(&playlist).unwrap(); + + // Get the short ID of the playlist item + let item_short_id = playlist.items[0].short_id(&playlist.playlist_id, 0); + + // Resolve by short ID + let resolved = storage.resolve_target(&item_short_id).unwrap(); + assert!(matches!(resolved, ResolvedTarget::PlaylistItem { .. })); + assert_eq!(resolved.template_id(), "tpl_echo"); + + // Resolve by prefix + let prefix = &item_short_id[..4]; + let resolved = storage.resolve_target(prefix).unwrap(); + assert!(matches!(resolved, ResolvedTarget::PlaylistItem { .. })); + } + + #[test] + fn test_resolve_target_not_found() { + let dir = tempdir().unwrap(); + let storage = Storage::with_base_dir(dir.path().to_path_buf()).unwrap(); + + let result = storage.resolve_target("nonexistent"); + assert!(matches!(result, Err(ResolveTargetError::NotFound(_)))); + } + + #[test] + fn test_resolve_target_ambiguous() { + let dir = tempdir().unwrap(); + let storage = Storage::with_base_dir(dir.path().to_path_buf()).unwrap(); + + // Create two templates with similar prefixes + let template1 = crate::RunTemplate { + template_version: 0, + template_id: "tpl_abc_one".to_string(), + name: "ABC One".to_string(), + exec: crate::TemplateExec { + argv: vec!["echo".to_string(), "one".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }, + bindings: None, + code_state: crate::TemplateCodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + }, + }; + storage.save_template(&template1).unwrap(); + + let template2 = crate::RunTemplate { + template_version: 0, + template_id: "tpl_abc_two".to_string(), + name: "ABC Two".to_string(), + exec: crate::TemplateExec { + argv: vec!["echo".to_string(), "two".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }, + bindings: None, + code_state: crate::TemplateCodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + }, + }; + storage.save_template(&template2).unwrap(); + + // Short prefix should be ambiguous + let result = storage.resolve_target("abc"); + assert!(matches!(result, Err(ResolveTargetError::Ambiguous { .. }))); + + // More specific prefix should work + let resolved = storage.resolve_target("abc_one").unwrap(); + assert_eq!(resolved.template_id(), "tpl_abc_one"); + } + }