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:
@@ -576,6 +576,14 @@ enum SchedulerCommand {
|
|||||||
help = "Recipe source (path to file, or base64 encoded recipe string)"
|
help = "Recipe source (path to file, or base64 encoded recipe string)"
|
||||||
)]
|
)]
|
||||||
recipe_source: 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")]
|
#[command(about = "List all scheduled jobs")]
|
||||||
List {},
|
List {},
|
||||||
@@ -1562,7 +1570,8 @@ async fn handle_schedule_command(command: SchedulerCommand) -> Result<()> {
|
|||||||
schedule_id,
|
schedule_id,
|
||||||
cron,
|
cron,
|
||||||
recipe_source,
|
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::List {} => handle_schedule_list().await,
|
||||||
SchedulerCommand::Remove { schedule_id } => handle_schedule_remove(schedule_id).await,
|
SchedulerCommand::Remove { schedule_id } => handle_schedule_remove(schedule_id).await,
|
||||||
SchedulerCommand::Sessions { schedule_id, limit } => {
|
SchedulerCommand::Sessions { schedule_id, limit } => {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ pub async fn handle_schedule_add(
|
|||||||
schedule_id: String,
|
schedule_id: String,
|
||||||
cron: String,
|
cron: String,
|
||||||
recipe_source_arg: String, // This is expected to be a file path by the Scheduler
|
recipe_source_arg: String, // This is expected to be a file path by the Scheduler
|
||||||
|
params: Vec<(String, String)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
println!(
|
println!(
|
||||||
"[CLI Debug] Scheduling job ID: {}, Cron: {}, Recipe Source Path: {}",
|
"[CLI Debug] Scheduling job ID: {}, Cron: {}, Recipe Source Path: {}",
|
||||||
@@ -88,6 +89,8 @@ pub async fn handle_schedule_add(
|
|||||||
paused: false,
|
paused: false,
|
||||||
current_session_id: None,
|
current_session_id: None,
|
||||||
process_start_time: None,
|
process_start_time: None,
|
||||||
|
parameters: params,
|
||||||
|
recipe_base_dir: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let scheduler_storage_path =
|
let scheduler_storage_path =
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ async fn create_schedule(
|
|||||||
paused: false,
|
paused: false,
|
||||||
current_session_id: None,
|
current_session_id: None,
|
||||||
process_start_time: None,
|
process_start_time: None,
|
||||||
|
parameters: vec![],
|
||||||
|
recipe_base_dir: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let scheduler = state.scheduler();
|
let scheduler = state.scheduler();
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ impl Agent {
|
|||||||
paused: false,
|
paused: false,
|
||||||
current_session_id: None,
|
current_session_id: None,
|
||||||
process_start_time: None,
|
process_start_time: None,
|
||||||
|
parameters: vec![],
|
||||||
|
recipe_base_dir: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match scheduler.add_scheduled_job(job, true).await {
|
match scheduler.add_scheduled_job(job, true).await {
|
||||||
|
|||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -446,7 +446,10 @@ instructions: Child instructions"#;
|
|||||||
let result = resolve_sub_recipe_path("./sub-recipes/child.yaml", parent_dir);
|
let result = resolve_sub_recipe_path("./sub-recipes/child.yaml", parent_dir);
|
||||||
assert!(result.is_ok());
|
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());
|
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);
|
let result = resolve_sub_recipe_path(absolute_path_str, parent_dir);
|
||||||
assert!(result.is_ok());
|
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]
|
#[test]
|
||||||
@@ -534,7 +538,10 @@ instructions: Child instructions
|
|||||||
assert_eq!(sub_recipes.len(), 1);
|
assert_eq!(sub_recipes.len(), 1);
|
||||||
assert_eq!(sub_recipes[0].name, "child");
|
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!(
|
assert_eq!(
|
||||||
sub_recipes[0].path,
|
sub_recipes[0].path,
|
||||||
expected_absolute_path.to_str().unwrap()
|
expected_absolute_path.to_str().unwrap()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use crate::conversation::Conversation;
|
|||||||
#[cfg(feature = "telemetry")]
|
#[cfg(feature = "telemetry")]
|
||||||
use crate::posthog;
|
use crate::posthog;
|
||||||
use crate::providers::create;
|
use crate::providers::create;
|
||||||
|
use crate::recipe::build_recipe::build_recipe_from_template;
|
||||||
use crate::recipe::Recipe;
|
use crate::recipe::Recipe;
|
||||||
use crate::scheduler_trait::SchedulerTrait;
|
use crate::scheduler_trait::SchedulerTrait;
|
||||||
use crate::session::session_manager::SessionType;
|
use crate::session::session_manager::SessionType;
|
||||||
@@ -115,6 +116,13 @@ pub struct ScheduledJob {
|
|||||||
pub current_session_id: Option<String>,
|
pub current_session_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub process_start_time: Option<DateTime<Utc>>,
|
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(
|
async fn persist_jobs(
|
||||||
@@ -291,7 +299,13 @@ impl Scheduler {
|
|||||||
|
|
||||||
let mut stored_job = original_job_spec;
|
let mut stored_job = original_job_spec;
|
||||||
if make_copy {
|
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() {
|
if !original_recipe_path.is_file() {
|
||||||
return Err(SchedulerError::RecipeLoadError(format!(
|
return Err(SchedulerError::RecipeLoadError(format!(
|
||||||
"Recipe file not found: {}",
|
"Recipe file not found: {}",
|
||||||
@@ -308,7 +322,10 @@ impl Scheduler {
|
|||||||
let destination_filename = format!("{}.{}", stored_job.id, original_extension);
|
let destination_filename = format!("{}.{}", stored_job.id, original_extension);
|
||||||
let destination_recipe_path = scheduled_recipes_dir.join(destination_filename);
|
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.source = destination_recipe_path.to_string_lossy().into_owned();
|
||||||
stored_job.current_session_id = None;
|
stored_job.current_session_id = None;
|
||||||
stored_job.process_start_time = None;
|
stored_job.process_start_time = None;
|
||||||
@@ -361,6 +378,8 @@ impl Scheduler {
|
|||||||
paused: false,
|
paused: false,
|
||||||
current_session_id: None,
|
current_session_id: None,
|
||||||
process_start_time: None,
|
process_start_time: None,
|
||||||
|
parameters: vec![],
|
||||||
|
recipe_base_dir: None,
|
||||||
};
|
};
|
||||||
self.add_scheduled_job(job, false).await
|
self.add_scheduled_job(job, false).await
|
||||||
}
|
}
|
||||||
@@ -792,20 +811,24 @@ async fn execute_job(
|
|||||||
|
|
||||||
let recipe_path = Path::new(&job.source);
|
let recipe_path = Path::new(&job.source);
|
||||||
let recipe_content = fs::read_to_string(recipe_path)?;
|
let recipe_content = fs::read_to_string(recipe_path)?;
|
||||||
|
// Use the original recipe directory for path resolution so that relative
|
||||||
let recipe: Recipe = {
|
// references (sub-recipes, template includes) survive the copy into scheduled_recipes/.
|
||||||
let extension = recipe_path
|
let recipe_dir_owned;
|
||||||
.extension()
|
let recipe_dir = if let Some(ref base) = job.recipe_base_dir {
|
||||||
.and_then(|s| s.to_str())
|
recipe_dir_owned = PathBuf::from(base);
|
||||||
.unwrap_or("yaml")
|
recipe_dir_owned.as_path()
|
||||||
.to_lowercase();
|
} else {
|
||||||
|
recipe_path.parent().unwrap_or(Path::new("."))
|
||||||
match extension.as_str() {
|
|
||||||
"json" | "jsonl" => serde_json::from_str(&recipe_content)?,
|
|
||||||
_ => serde_yaml::from_str(&recipe_content)?,
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 agent = Agent::new();
|
||||||
|
|
||||||
let config = Config::global();
|
let config = Config::global();
|
||||||
@@ -1106,6 +1129,8 @@ mod tests {
|
|||||||
paused: false,
|
paused: false,
|
||||||
current_session_id: None,
|
current_session_id: None,
|
||||||
process_start_time: None,
|
process_start_time: None,
|
||||||
|
parameters: vec![],
|
||||||
|
recipe_base_dir: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
scheduler.add_scheduled_job(job, true).await.unwrap();
|
scheduler.add_scheduled_job(job, true).await.unwrap();
|
||||||
@@ -1138,6 +1163,8 @@ mod tests {
|
|||||||
paused: false,
|
paused: false,
|
||||||
current_session_id: None,
|
current_session_id: None,
|
||||||
process_start_time: None,
|
process_start_time: None,
|
||||||
|
parameters: vec![],
|
||||||
|
recipe_base_dir: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
scheduler.add_scheduled_job(job, true).await.unwrap();
|
scheduler.add_scheduled_job(job, true).await.unwrap();
|
||||||
@@ -1165,6 +1192,8 @@ mod tests {
|
|||||||
paused: false,
|
paused: false,
|
||||||
current_session_id: None,
|
current_session_id: None,
|
||||||
process_start_time: None,
|
process_start_time: None,
|
||||||
|
parameters: vec![],
|
||||||
|
recipe_base_dir: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
scheduler
|
scheduler
|
||||||
@@ -1220,6 +1249,8 @@ mod tests {
|
|||||||
paused: false,
|
paused: false,
|
||||||
current_session_id: None,
|
current_session_id: None,
|
||||||
process_start_time: None,
|
process_start_time: None,
|
||||||
|
parameters: vec![],
|
||||||
|
recipe_base_dir: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Schedule the job and let it run — should not panic
|
// Schedule the job and let it run — should not panic
|
||||||
|
|||||||
@@ -7712,6 +7712,22 @@
|
|||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"parameters": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"paused": {
|
"paused": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -7720,6 +7736,11 @@
|
|||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"nullable": true
|
"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": {
|
"source": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1255,8 +1255,15 @@ export type ScheduledJob = {
|
|||||||
currently_running?: boolean;
|
currently_running?: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
last_run?: string | null;
|
last_run?: string | null;
|
||||||
|
parameters?: Array<Array<string>>;
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
process_start_time?: string | null;
|
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;
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user