feat(CLI): Add optional --parameters to scheduled recipe (#8741)

Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Spike Wang 2026-05-12 16:30:38 -07:00 committed by GitHub
parent bc6e0b25dc
commit ba60b597fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 108 additions and 19 deletions

View file

@ -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 } => {

View file

@ -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 =

View file

@ -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();

View file

@ -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 {

View file

@ -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)]

View file

@ -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()

View file

@ -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<String>,
#[serde(default)]
pub process_start_time: Option<DateTime<Utc>>,
#[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<String>,
}
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::<fn(&str, &str) -> anyhow::Result<String>>,
)
.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

View file

@ -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"
}

View file

@ -1255,8 +1255,15 @@ export type ScheduledJob = {
currently_running?: boolean;
id: string;
last_run?: string | null;
parameters?: Array<Array<string>>;
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;
};