diff --git a/crates/runbox-cli/src/main.rs b/crates/runbox-cli/src/main.rs index 3a1c5ae..d5c4dc7 100644 --- a/crates/runbox-cli/src/main.rs +++ b/crates/runbox-cli/src/main.rs @@ -3,9 +3,9 @@ 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, GitContext, LogRef, Playlist, PlaylistItem, RunStatus, RunTemplate, - RuntimeRegistry, Storage, Timeline, Validator, VerboseLogger, + default_pid_path, default_socket_path, short_id, BindingResolver, CodeState, ConfigResolver, + DaemonClient, Exec, GitContext, LogRef, Playlist, PlaylistItem, Run, RunSource, RunStatus, + RunTemplate, RuntimeRegistry, Storage, Timeline, Validator, VerboseLogger, }; use std::fs::File; use std::io::{BufRead, BufReader, Seek, SeekFrom}; @@ -45,13 +45,16 @@ impl std::fmt::Display for RuntimeType { #[derive(Subcommand)] enum Commands { - /// Run from a template + /// Run from a template or execute a command directly + /// + /// To run from a template: runbox run --template [--binding key=value] + /// To run directly: runbox run -- Run { - /// Template ID + /// Template ID (for template-based runs) #[arg(short, long)] - template: String, + template: Option, - /// Variable bindings (key=value) + /// Variable bindings (key=value) for template runs #[arg(short, long)] binding: Vec, @@ -59,9 +62,60 @@ enum Commands { #[arg(long, default_value = "bg")] runtime: RuntimeType, + /// Command timeout in seconds (0 = no timeout) + #[arg(long, default_value = "0")] + timeout: u64, + + /// Additional environment variables (KEY=VALUE) + #[arg(long = "env", short = 'e')] + env_vars: Vec, + + /// Working directory (default: current) + #[arg(long)] + cwd: Option, + + /// Skip git context capture (for direct runs) + #[arg(long)] + no_git: bool, + /// Skip execution (dry run) #[arg(long)] dry_run: bool, + + /// Command to execute directly (everything after --) + #[arg(last = true)] + command: Vec, + }, + + /// Log a command execution (alias for `runbox run --`) + Log { + /// Runtime environment (bg, background, tmux) + #[arg(long, default_value = "bg")] + runtime: RuntimeType, + + /// Command timeout in seconds (0 = no timeout) + #[arg(long, default_value = "0")] + timeout: u64, + + /// Additional environment variables (KEY=VALUE) + #[arg(long = "env", short = 'e')] + env_vars: Vec, + + /// Working directory (default: current) + #[arg(long)] + cwd: Option, + + /// Skip git context capture + #[arg(long)] + no_git: bool, + + /// Skip execution (dry run) + #[arg(long)] + dry_run: bool, + + /// Command to execute (everything after --) + #[arg(last = true, required = true)] + command: Vec, }, /// List running and recent runs @@ -237,8 +291,32 @@ fn main() -> Result<()> { template, binding, runtime, + timeout, + env_vars, + cwd, + no_git, + dry_run, + command, + } => { + if let Some(template_id) = template { + // Template-based run + cmd_run_template(&storage, &template_id, binding, runtime, dry_run) + } else if !command.is_empty() { + // Direct command execution + cmd_run_direct(&storage, command, runtime, timeout, env_vars, cwd, no_git, dry_run) + } else { + bail!("Either --template or a command after -- is required.\n\nUsage:\n runbox run --template # Run from template\n runbox run -- # Run command directly") + } + } + Commands::Log { + runtime, + timeout, + env_vars, + cwd, + no_git, dry_run, - } => cmd_run(&storage, &template, binding, runtime, dry_run), + command, + } => cmd_run_direct(&storage, command, runtime, timeout, env_vars, cwd, no_git, dry_run), Commands::Ps { status, all, limit } => cmd_ps(&storage, status, all, limit), Commands::Stop { run_id, force } => cmd_stop(&storage, &run_id, force), Commands::Logs { @@ -421,9 +499,10 @@ fn which_daemon() -> Result { Ok(PathBuf::from("runbox-daemon")) } -// === Run Command === +// === Run Commands === -fn cmd_run( +/// Run a command from a template +fn cmd_run_template( storage: &Storage, template_id: &str, bindings: Vec, @@ -497,6 +576,7 @@ fn cmd_run( ended_at: None, }; run.status = RunStatus::Pending; + run.source = RunSource::Template; // Save run (before spawning) storage.save_run(&run)?; @@ -549,6 +629,147 @@ fn cmd_run( Ok(()) } +/// Run a command directly without a template +fn cmd_run_direct( + storage: &Storage, + command: Vec, + runtime: RuntimeType, + timeout: u64, + env_vars: Vec, + cwd: Option, + no_git: bool, + dry_run: bool, +) -> Result<()> { + if command.is_empty() { + bail!("No command specified. Usage: runbox run -- "); + } + + // Generate run_id + let run_id = format!("run_{}", uuid::Uuid::new_v4()); + + // Parse environment variables + let mut env = std::collections::HashMap::new(); + for env_var in env_vars { + if let Some((key, value)) = env_var.split_once('=') { + env.insert(key.to_string(), value.to_string()); + } else { + bail!("Invalid environment variable format: '{}'. Use KEY=VALUE", env_var); + } + } + + // Determine working directory + let cwd_str = if let Some(ref dir) = cwd { + dir.to_string_lossy().to_string() + } else { + std::env::current_dir()?.to_string_lossy().to_string() + }; + + // Build exec + let exec = Exec { + argv: command.clone(), + cwd: cwd_str, + env, + timeout_sec: timeout, + }; + + // Build code state (optionally skip git) + let code_state = if no_git { + // Create a placeholder code state when git is skipped + CodeState { + repo_url: "none".to_string(), + base_commit: "0".repeat(40), // Placeholder 40-char SHA + patch: None, + } + } else { + let git = GitContext::from_current_dir()?; + git.build_code_state(&run_id)? + }; + + // Create run + let mut run = Run::new_direct(exec, code_state); + run.run_id = run_id; + + // Validate + run.validate()?; + + if dry_run { + println!("Dry run - would execute:"); + println!("{}", serde_json::to_string_pretty(&run)?); + return Ok(()); + } + + // Get runtime adapter + let registry = RuntimeRegistry::new(); + let runtime_name = runtime.to_string(); + let adapter = registry + .get(&runtime_name) + .context(format!("Unknown runtime: {}", runtime_name))?; + + // Set up log path + let log_path = storage.log_path(&run.run_id); + + // Update run with runtime info + run.runtime = runtime_name.clone(); + run.log_ref = Some(LogRef { + path: log_path.clone(), + }); + run.timeline = Timeline { + created_at: Some(Utc::now()), + started_at: None, + ended_at: None, + }; + run.status = RunStatus::Pending; + + // Save run (before spawning) + storage.save_run(&run)?; + + // Spawn process + println!("Starting run: {}", run.run_id); + println!("Source: direct"); + println!("Runtime: {}", runtime_name); + println!("Command: {:?}", run.exec.argv); + + let handle = adapter.spawn(&run.exec, &run.run_id, &log_path)?; + + // CAS-style update with lock: only update if still Pending + let saved = storage.save_run_if_status_with( + &run.run_id, + &[RunStatus::Pending], + |current| { + current.handle = Some(handle.clone()); + current.status = RunStatus::Running; + current.timeline.started_at = Some(Utc::now()); + } + )?; + + if !saved { + // Process already exited - daemon captured the status + let _ = storage.save_run_if_status_with( + &run.run_id, + &[RunStatus::Exited, RunStatus::Failed, RunStatus::Unknown], + |current| { + if current.handle.is_none() { + current.handle = Some(handle.clone()); + } + } + ); + log::debug!( + "Run {} already exited - daemon captured status", + run.run_id + ); + } + + println!("Run started: {}", run.run_id); + println!("Short ID: {}", run.short_id()); + println!("Logs: {}", log_path.display()); + + if matches!(runtime, RuntimeType::Tmux) { + println!("Attach with: runbox attach {}", run.short_id()); + } + + Ok(()) +} + // === Ps Command === fn cmd_ps(storage: &Storage, status_filter: Option, _all: bool, limit: usize) -> Result<()> { @@ -1004,6 +1225,7 @@ fn cmd_show(storage: &Storage, run_id: &str) -> Result<()> { println!("Run ID: {}", run.run_id); println!("Short ID: {}", run.short_id()); println!("Status: {}", run.status); + println!("Source: {}", run.source); println!("Runtime: {}", if run.runtime.is_empty() { "-" } else { &run.runtime }); println!(); println!("Command: {:?}", run.exec.argv); diff --git a/crates/runbox-cli/tests/run_direct.rs b/crates/runbox-cli/tests/run_direct.rs new file mode 100644 index 0000000..964c0c9 --- /dev/null +++ b/crates/runbox-cli/tests/run_direct.rs @@ -0,0 +1,414 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use std::process::Command as StdCommand; +use tempfile::TempDir; + +/// Helper to create a runbox command with RUNBOX_HOME set to temp directory +fn runbox_cmd(temp_dir: &TempDir) -> Command { + let mut cmd = Command::cargo_bin("runbox").unwrap(); + cmd.env("RUNBOX_HOME", temp_dir.path()); + cmd +} + +/// Create a minimal git repository with origin remote +fn init_git_repo(path: &std::path::Path) -> std::io::Result<()> { + // Initialize git repo + StdCommand::new("git") + .current_dir(path) + .args(["init"]) + .output()?; + + // Configure git user for commits + StdCommand::new("git") + .current_dir(path) + .args(["config", "user.email", "test@example.com"]) + .output()?; + + StdCommand::new("git") + .current_dir(path) + .args(["config", "user.name", "Test User"]) + .output()?; + + // Create a file and commit + fs::write(path.join("README.md"), "# Test")?; + + StdCommand::new("git") + .current_dir(path) + .args(["add", "."]) + .output()?; + + StdCommand::new("git") + .current_dir(path) + .args(["commit", "-m", "Initial commit"]) + .output()?; + + // Add origin remote (doesn't need to exist for our tests) + StdCommand::new("git") + .current_dir(path) + .args(["remote", "add", "origin", "git@github.com:test/repo.git"]) + .output()?; + + Ok(()) +} + +// ============================================================================= +// Direct Command Execution - Happy Path Tests +// ============================================================================= + +#[test] +fn test_run_direct_simple_command_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--dry-run", "--", "echo", "hello"]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("echo")) + .stdout(predicate::str::contains("hello")) + .stdout(predicate::str::contains("\"source\": \"direct\"")); +} + +#[test] +fn test_run_direct_with_timeout_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--timeout", "300", "--dry-run", "--", "sleep", "10"]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("\"timeout_sec\": 300")); +} + +#[test] +fn test_run_direct_with_env_vars_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args([ + "run", + "--env", + "MY_VAR=my_value", + "--env", + "ANOTHER=another_value", + "--dry-run", + "--", + "echo", + "test", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("MY_VAR")) + .stdout(predicate::str::contains("my_value")) + .stdout(predicate::str::contains("ANOTHER")) + .stdout(predicate::str::contains("another_value")); +} + +#[test] +fn test_run_direct_with_cwd_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + // Create a subdirectory + let subdir = temp.path().join("subdir"); + fs::create_dir(&subdir).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args([ + "run", + "--cwd", + subdir.to_str().unwrap(), + "--dry-run", + "--", + "pwd", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("subdir")); +} + +#[test] +fn test_run_direct_with_no_git_dry_run() { + let temp = TempDir::new().unwrap(); + // Note: NOT initializing git repo + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--no-git", "--dry-run", "--", "echo", "hello"]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("echo")) + // Should have placeholder code_state when --no-git is used + .stdout(predicate::str::contains("\"repo_url\": \"none\"")); +} + +#[test] +fn test_run_direct_with_bg_runtime_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--runtime", "bg", "--dry-run", "--", "echo", "test"]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")); +} + +#[test] +fn test_run_direct_with_tmux_runtime_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--runtime", "tmux", "--dry-run", "--", "echo", "test"]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")); +} + +#[test] +fn test_run_direct_complex_command_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args([ + "run", + "--dry-run", + "--", + "python", + "train.py", + "--epochs", + "10", + "--lr", + "0.001", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("python")) + .stdout(predicate::str::contains("train.py")) + .stdout(predicate::str::contains("--epochs")) + .stdout(predicate::str::contains("10")); +} + +// ============================================================================= +// Log Command (Alias for Direct Execution) - Happy Path Tests +// ============================================================================= + +#[test] +fn test_log_simple_command_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["log", "--dry-run", "--", "echo", "hello"]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("echo")) + .stdout(predicate::str::contains("hello")) + .stdout(predicate::str::contains("\"source\": \"direct\"")); +} + +#[test] +fn test_log_with_all_options_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args([ + "log", + "--runtime", + "bg", + "--timeout", + "60", + "--env", + "TEST=1", + "--dry-run", + "--", + "make", + "test", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("make")) + .stdout(predicate::str::contains("test")) + .stdout(predicate::str::contains("TEST")); +} + +#[test] +fn test_log_with_no_git_dry_run() { + let temp = TempDir::new().unwrap(); + // Note: NOT initializing git repo + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["log", "--no-git", "--dry-run", "--", "echo", "no-git-test"]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("no-git-test")); +} + +// ============================================================================= +// Error Path Tests +// ============================================================================= + +#[test] +fn test_run_no_template_no_command_error() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run"]) + .assert() + .failure() + .stderr(predicate::str::contains("--template").or(predicate::str::contains("command"))); +} + +#[test] +fn test_run_direct_not_in_git_repo_without_no_git_flag() { + let temp = TempDir::new().unwrap(); + // Note: NOT initializing git repo + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--dry-run", "--", "echo", "hello"]) + .assert() + .failure() + .stderr(predicate::str::contains("git").or(predicate::str::contains("repository"))); +} + +#[test] +fn test_run_direct_invalid_env_var_format() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--env", "INVALID_FORMAT", "--dry-run", "--", "echo", "test"]) + .assert() + .failure() + .stderr(predicate::str::contains("KEY=VALUE").or(predicate::str::contains("Invalid"))); +} + +#[test] +fn test_run_direct_invalid_runtime() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--runtime", "invalid", "--dry-run", "--", "echo", "test"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid").or(predicate::str::contains("possible values"))); +} + +#[test] +fn test_log_no_command_error() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["log", "--"]) + .assert() + .failure(); +} + +// ============================================================================= +// Edge Cases +// ============================================================================= + +#[test] +fn test_run_direct_with_special_characters_in_command_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--dry-run", "--", "echo", "hello world", "foo=bar"]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("hello world")) + .stdout(predicate::str::contains("foo=bar")); +} + +#[test] +fn test_run_direct_combined_with_template_flag() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + // When both --template and -- command are provided, template takes precedence + // This should fail because template doesn't exist + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--template", "tpl_nonexistent", "--", "echo", "test"]) + .assert() + .failure() + .stderr( + predicate::str::contains("not found") + .or(predicate::str::contains("No template")) + .or(predicate::str::contains("No item found")), + ); +} + +#[test] +fn test_run_direct_env_var_with_special_chars_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args([ + "run", + "--env", + "PATH_VAR=/usr/bin:/usr/local/bin", + "--env", + "QUOTED=hello world", + "--dry-run", + "--", + "echo", + "test", + ]) + .assert() + .success() + .stdout(predicate::str::contains("/usr/bin:/usr/local/bin")) + .stdout(predicate::str::contains("hello world")); +} + +#[test] +fn test_run_direct_single_word_command_dry_run() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()).unwrap(); + + runbox_cmd(&temp) + .current_dir(temp.path()) + .args(["run", "--dry-run", "--", "ls"]) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")) + .stdout(predicate::str::contains("ls")); +} diff --git a/crates/runbox-core/src/lib.rs b/crates/runbox-core/src/lib.rs index cc07dc9..c118142 100644 --- a/crates/runbox-core/src/lib.rs +++ b/crates/runbox-core/src/lib.rs @@ -14,7 +14,7 @@ pub use config::{ConfigResolver, ConfigSource, ResolvedValue, RunboxConfig, Verb pub use daemon::{default_pid_path, default_socket_path, DaemonClient, Request, Response}; pub use git::{GitContext, WorktreeInfo, WorktreeReplayResult}; pub use playlist::{Playlist, PlaylistItem}; -pub use run::{CodeState, Exec, LogRef, Patch, Run, RunStatus, RuntimeHandle, Timeline}; +pub use run::{CodeState, Exec, LogRef, Patch, Run, RunSource, RunStatus, RuntimeHandle, Timeline}; pub use runtime::{BackgroundAdapter, RuntimeAdapter, RuntimeRegistry, TmuxAdapter}; pub use storage::{short_id, Storage}; pub use template::{Bindings, RunTemplate, TemplateCodeState, TemplateExec}; diff --git a/crates/runbox-core/src/run.rs b/crates/runbox-core/src/run.rs index 8344962..6ca2d68 100644 --- a/crates/runbox-core/src/run.rs +++ b/crates/runbox-core/src/run.rs @@ -3,6 +3,26 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +/// Source of how a run was created +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RunSource { + /// Run created from a template + #[default] + Template, + /// Run created directly from command line + Direct, +} + +impl std::fmt::Display for RunSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RunSource::Template => write!(f, "template"), + RunSource::Direct => write!(f, "direct"), + } + } +} + /// A fully-resolved, reproducible execution record #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Run { @@ -27,6 +47,9 @@ pub struct Run { /// Reason for Unknown status (set by reconcile) #[serde(skip_serializing_if = "Option::is_none")] pub reconcile_reason: Option, + /// Source of how this run was created (template or direct) + #[serde(default)] + pub source: RunSource, } /// Run status @@ -138,9 +161,17 @@ impl Run { }, exit_code: None, reconcile_reason: None, + source: RunSource::Template, } } + /// Create a new Run for direct command execution + pub fn new_direct(exec: Exec, code_state: CodeState) -> Self { + let mut run = Self::new(exec, code_state); + run.source = RunSource::Direct; + run + } + /// Get short ID (first 8 chars of UUID portion) pub fn short_id(&self) -> &str { // run_id format: "run_{uuid}" @@ -220,6 +251,7 @@ mod tests { timeline: Timeline::default(), exit_code: None, reconcile_reason: None, + source: RunSource::Template, }; let json = serde_json::to_string_pretty(&run).unwrap(); @@ -250,6 +282,7 @@ mod tests { timeline: Timeline::default(), exit_code: None, reconcile_reason: None, + source: RunSource::Template, }; assert_eq!(run.short_id(), "550e8400"); @@ -265,6 +298,12 @@ mod tests { assert_eq!(RunStatus::Unknown.to_string(), "unknown"); } + #[test] + fn test_run_source_display() { + assert_eq!(RunSource::Template.to_string(), "template"); + assert_eq!(RunSource::Direct.to_string(), "direct"); + } + #[test] fn test_backwards_compatibility() { // Test that old JSON without new fields can still be deserialized @@ -285,5 +324,25 @@ mod tests { assert_eq!(run.run_id, "run_test123"); assert_eq!(run.status, RunStatus::Pending); assert!(run.handle.is_none()); + // Source should default to Template + assert_eq!(run.source, RunSource::Template); + } + + #[test] + fn test_new_direct() { + let exec = Exec { + argv: vec!["echo".to_string(), "hello".to_string()], + cwd: ".".to_string(), + env: HashMap::new(), + timeout_sec: 0, + }; + let code_state = CodeState { + repo_url: "git@github.com:org/repo.git".to_string(), + base_commit: "a1b2c3d4e5f6789012345678901234567890abcd".to_string(), + patch: None, + }; + + let run = Run::new_direct(exec, code_state); + assert_eq!(run.source, RunSource::Direct); } } diff --git a/specs/run.schema.json b/specs/run.schema.json index 68f32c8..0816fbf 100644 --- a/specs/run.schema.json +++ b/specs/run.schema.json @@ -76,6 +76,11 @@ "ref", "sha256" ] + }, + "#Source": { + "type": "string", + "enum": ["template", "direct"], + "description": "Source of how the run was created (template-based or direct command)" } }, "type": "object", @@ -93,6 +98,9 @@ }, "run_version": { "const": 0 + }, + "source": { + "$ref": "#/$defs/#Source" } }, "required": [