From ba60b597fa9b21fceae988e2d9a21be971676843 Mon Sep 17 00:00:00 2001 From: Spike Wang Date: Tue, 12 May 2026 16:30:38 -0700 Subject: [PATCH] feat(CLI): Add optional --parameters to scheduled recipe (#8741) Signed-off-by: Douwe Osinga Co-authored-by: Douwe Osinga --- crates/goose-cli/src/cli.rs | 11 +++- crates/goose-cli/src/commands/schedule.rs | 3 + crates/goose-server/src/routes/schedule.rs | 2 + crates/goose/src/agents/schedule_tool.rs | 2 + crates/goose/src/recipe/build_recipe/mod.rs | 9 ++- crates/goose/src/recipe/build_recipe/tests.rs | 13 +++- crates/goose/src/scheduler.rs | 59 ++++++++++++++----- ui/desktop/openapi.json | 21 +++++++ ui/desktop/src/api/types.gen.ts | 7 +++ 9 files changed, 108 insertions(+), 19 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index ce301cc691..5d2ad9324c 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -576,6 +576,14 @@ enum SchedulerCommand { help = "Recipe source (path to file, or base64 encoded recipe string)" )] recipe_source: String, + #[arg( + long, + value_name = "KEY=VALUE", + help = "Recipe parameter in KEY=VALUE format (can be specified multiple times)", + action = clap::ArgAction::Append, + value_parser = parse_key_val, + )] + params: Vec<(String, String)>, }, #[command(about = "List all scheduled jobs")] List {}, @@ -1562,7 +1570,8 @@ async fn handle_schedule_command(command: SchedulerCommand) -> Result<()> { schedule_id, cron, recipe_source, - } => handle_schedule_add(schedule_id, cron, recipe_source).await, + params, + } => handle_schedule_add(schedule_id, cron, recipe_source, params).await, SchedulerCommand::List {} => handle_schedule_list().await, SchedulerCommand::Remove { schedule_id } => handle_schedule_remove(schedule_id).await, SchedulerCommand::Sessions { schedule_id, limit } => { diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index 2fdbed6986..1f56481bdd 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -69,6 +69,7 @@ pub async fn handle_schedule_add( schedule_id: String, cron: String, recipe_source_arg: String, // This is expected to be a file path by the Scheduler + params: Vec<(String, String)>, ) -> Result<()> { println!( "[CLI Debug] Scheduling job ID: {}, Cron: {}, Recipe Source Path: {}", @@ -88,6 +89,8 @@ pub async fn handle_schedule_add( paused: false, current_session_id: None, process_start_time: None, + parameters: params, + recipe_base_dir: None, }; let scheduler_storage_path = diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index ca856c2356..afdc806b0c 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -143,6 +143,8 @@ async fn create_schedule( paused: false, current_session_id: None, process_start_time: None, + parameters: vec![], + recipe_base_dir: None, }; let scheduler = state.scheduler(); diff --git a/crates/goose/src/agents/schedule_tool.rs b/crates/goose/src/agents/schedule_tool.rs index f17e926d1e..70d2136ab6 100644 --- a/crates/goose/src/agents/schedule_tool.rs +++ b/crates/goose/src/agents/schedule_tool.rs @@ -159,6 +159,8 @@ impl Agent { paused: false, current_session_id: None, process_start_time: None, + parameters: vec![], + recipe_base_dir: None, }; match scheduler.add_scheduled_job(job, true).await { diff --git a/crates/goose/src/recipe/build_recipe/mod.rs b/crates/goose/src/recipe/build_recipe/mod.rs index b14d322ceb..a69abfb705 100644 --- a/crates/goose/src/recipe/build_recipe/mod.rs +++ b/crates/goose/src/recipe/build_recipe/mod.rs @@ -173,7 +173,14 @@ pub fn resolve_sub_recipe_path( }); } - Ok(path.display().to_string()) + let canonical = path.canonicalize().map_err(|e| RecipeError::Invalid { + source: anyhow::anyhow!( + "Failed to resolve sub-recipe path {}: {}", + path.display(), + e + ), + })?; + Ok(canonical.display().to_string()) } #[cfg(test)] diff --git a/crates/goose/src/recipe/build_recipe/tests.rs b/crates/goose/src/recipe/build_recipe/tests.rs index de602260e1..35e67cd806 100644 --- a/crates/goose/src/recipe/build_recipe/tests.rs +++ b/crates/goose/src/recipe/build_recipe/tests.rs @@ -446,7 +446,10 @@ instructions: Child instructions"#; let result = resolve_sub_recipe_path("./sub-recipes/child.yaml", parent_dir); assert!(result.is_ok()); - let expected_path = parent_dir.join("./sub-recipes/child.yaml"); + let expected_path = parent_dir + .join("sub-recipes/child.yaml") + .canonicalize() + .unwrap(); assert_eq!(result.unwrap(), expected_path.to_str().unwrap()); } @@ -466,7 +469,8 @@ instructions: Absolute instructions"#; let result = resolve_sub_recipe_path(absolute_path_str, parent_dir); assert!(result.is_ok()); - assert_eq!(result.unwrap(), absolute_path_str); + let expected = absolute_path.canonicalize().unwrap(); + assert_eq!(result.unwrap(), expected.to_str().unwrap()); } #[test] @@ -534,7 +538,10 @@ instructions: Child instructions assert_eq!(sub_recipes.len(), 1); assert_eq!(sub_recipes[0].name, "child"); - let expected_absolute_path = temp_path.join("./sub-recipes/child.yaml"); + let expected_absolute_path = temp_path + .join("sub-recipes/child.yaml") + .canonicalize() + .unwrap(); assert_eq!( sub_recipes[0].path, expected_absolute_path.to_str().unwrap() diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index e2dd91dd64..3e646bed32 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -21,6 +21,7 @@ use crate::conversation::Conversation; #[cfg(feature = "telemetry")] use crate::posthog; use crate::providers::create; +use crate::recipe::build_recipe::build_recipe_from_template; use crate::recipe::Recipe; use crate::scheduler_trait::SchedulerTrait; use crate::session::session_manager::SessionType; @@ -115,6 +116,13 @@ pub struct ScheduledJob { pub current_session_id: Option, #[serde(default)] pub process_start_time: Option>, + #[serde(default)] + pub parameters: Vec<(String, String)>, + /// Original directory of the recipe file before it was copied to scheduled_recipes/. + /// Preserved so that relative paths (sub-recipes, template includes) resolve correctly + /// against the source tree rather than the scheduler's internal storage directory. + #[serde(default)] + pub recipe_base_dir: Option, } async fn persist_jobs( @@ -291,7 +299,13 @@ impl Scheduler { let mut stored_job = original_job_spec; if make_copy { - let original_recipe_path = Path::new(&stored_job.source); + let original_recipe_path = + Path::new(&stored_job.source).canonicalize().map_err(|e| { + SchedulerError::RecipeLoadError(format!( + "Recipe file not found: {}: {}", + stored_job.source, e + )) + })?; if !original_recipe_path.is_file() { return Err(SchedulerError::RecipeLoadError(format!( "Recipe file not found: {}", @@ -308,7 +322,10 @@ impl Scheduler { let destination_filename = format!("{}.{}", stored_job.id, original_extension); let destination_recipe_path = scheduled_recipes_dir.join(destination_filename); - fs::copy(original_recipe_path, &destination_recipe_path)?; + fs::copy(&original_recipe_path, &destination_recipe_path)?; + stored_job.recipe_base_dir = original_recipe_path + .parent() + .map(|p| p.to_string_lossy().into_owned()); stored_job.source = destination_recipe_path.to_string_lossy().into_owned(); stored_job.current_session_id = None; stored_job.process_start_time = None; @@ -361,6 +378,8 @@ impl Scheduler { paused: false, current_session_id: None, process_start_time: None, + parameters: vec![], + recipe_base_dir: None, }; self.add_scheduled_job(job, false).await } @@ -792,20 +811,24 @@ async fn execute_job( let recipe_path = Path::new(&job.source); let recipe_content = fs::read_to_string(recipe_path)?; - - let recipe: Recipe = { - let extension = recipe_path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("yaml") - .to_lowercase(); - - match extension.as_str() { - "json" | "jsonl" => serde_json::from_str(&recipe_content)?, - _ => serde_yaml::from_str(&recipe_content)?, - } + // Use the original recipe directory for path resolution so that relative + // references (sub-recipes, template includes) survive the copy into scheduled_recipes/. + let recipe_dir_owned; + let recipe_dir = if let Some(ref base) = job.recipe_base_dir { + recipe_dir_owned = PathBuf::from(base); + recipe_dir_owned.as_path() + } else { + recipe_path.parent().unwrap_or(Path::new(".")) }; + let recipe: Recipe = build_recipe_from_template( + recipe_content, + recipe_dir, + job.parameters.clone(), + None:: anyhow::Result>, + ) + .map_err(|e| anyhow!(e.to_string()))?; + let agent = Agent::new(); let config = Config::global(); @@ -1106,6 +1129,8 @@ mod tests { paused: false, current_session_id: None, process_start_time: None, + parameters: vec![], + recipe_base_dir: None, }; scheduler.add_scheduled_job(job, true).await.unwrap(); @@ -1138,6 +1163,8 @@ mod tests { paused: false, current_session_id: None, process_start_time: None, + parameters: vec![], + recipe_base_dir: None, }; scheduler.add_scheduled_job(job, true).await.unwrap(); @@ -1165,6 +1192,8 @@ mod tests { paused: false, current_session_id: None, process_start_time: None, + parameters: vec![], + recipe_base_dir: None, }; scheduler @@ -1220,6 +1249,8 @@ mod tests { paused: false, current_session_id: None, process_start_time: None, + parameters: vec![], + recipe_base_dir: None, }; // Schedule the job and let it run — should not panic diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 185ac93987..a8efa20204 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -7712,6 +7712,22 @@ "format": "date-time", "nullable": true }, + "parameters": { + "type": "array", + "items": { + "type": "array", + "items": { + "allOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, "paused": { "type": "boolean" }, @@ -7720,6 +7736,11 @@ "format": "date-time", "nullable": true }, + "recipe_base_dir": { + "type": "string", + "description": "Original directory of the recipe file before it was copied to scheduled_recipes/.\nPreserved so that relative paths (sub-recipes, template includes) resolve correctly\nagainst the source tree rather than the scheduler's internal storage directory.", + "nullable": true + }, "source": { "type": "string" } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 019ed5d86a..0f398a0e0e 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1255,8 +1255,15 @@ export type ScheduledJob = { currently_running?: boolean; id: string; last_run?: string | null; + parameters?: Array>; paused?: boolean; process_start_time?: string | null; + /** + * Original directory of the recipe file before it was copied to scheduled_recipes/. + * Preserved so that relative paths (sub-recipes, template includes) resolve correctly + * against the source tree rather than the scheduler's internal storage directory. + */ + recipe_base_dir?: string | null; source: string; };