Add basic cron scheduler to goose-server (#2621)

This commit is contained in:
Max Novich
2025-05-27 10:36:27 -07:00
committed by GitHub
parent c8e3f6ac69
commit c272b5df95
39 changed files with 3554 additions and 352 deletions
+78
View File
@@ -9,6 +9,11 @@ use crate::commands::info::handle_info;
use crate::commands::mcp::run_server;
use crate::commands::project::{handle_project_default, handle_projects_interactive};
use crate::commands::recipe::{handle_deeplink, handle_validate};
// Import the new handlers from commands::schedule
use crate::commands::schedule::{
handle_schedule_add, handle_schedule_list, handle_schedule_remove, handle_schedule_run_now,
handle_schedule_sessions,
};
use crate::commands::session::{handle_session_list, handle_session_remove};
use crate::logging::setup_logging;
use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_as_template};
@@ -99,6 +104,46 @@ enum SessionCommand {
},
}
#[derive(Subcommand, Debug)]
enum SchedulerCommand {
#[command(about = "Add a new scheduled job")]
Add {
#[arg(long, help = "Unique ID for the job")]
id: String,
#[arg(long, help = "Cron string for the schedule (e.g., '0 0 * * * *')")]
cron: String,
#[arg(
long,
help = "Recipe source (path to file, or base64 encoded recipe string)"
)]
recipe_source: String,
},
#[command(about = "List all scheduled jobs")]
List {},
#[command(about = "Remove a scheduled job by ID")]
Remove {
#[arg(long, help = "ID of the job to remove")] // Changed from positional to named --id
id: String,
},
/// List sessions created by a specific schedule
#[command(about = "List sessions created by a specific schedule")]
Sessions {
/// ID of the schedule
#[arg(long, help = "ID of the schedule")] // Explicitly make it --id
id: String,
/// Maximum number of sessions to return
#[arg(long, help = "Maximum number of sessions to return")]
limit: Option<u32>,
},
/// Run a scheduled job immediately
#[command(about = "Run a scheduled job immediately")]
RunNow {
/// ID of the schedule to run
#[arg(long, help = "ID of the schedule to run")] // Explicitly make it --id
id: String,
},
}
#[derive(Subcommand)]
pub enum BenchCommand {
#[command(name = "init-config", about = "Create a new starter-config")]
@@ -418,6 +463,13 @@ enum Command {
command: RecipeCommand,
},
/// Manage scheduled jobs
#[command(about = "Manage scheduled jobs", visible_alias = "sched")]
Schedule {
#[command(subcommand)]
command: SchedulerCommand,
},
/// Update the Goose CLI version
#[command(about = "Update the goose CLI version")]
Update {
@@ -638,6 +690,32 @@ pub async fn cli() -> Result<()> {
return Ok(());
}
Some(Command::Schedule { command }) => {
match command {
SchedulerCommand::Add {
id,
cron,
recipe_source,
} => {
handle_schedule_add(id, cron, recipe_source).await?;
}
SchedulerCommand::List {} => {
handle_schedule_list().await?;
}
SchedulerCommand::Remove { id } => {
handle_schedule_remove(id).await?;
}
SchedulerCommand::Sessions { id, limit } => {
// New arm
handle_schedule_sessions(id, limit).await?;
}
SchedulerCommand::RunNow { id } => {
// New arm
handle_schedule_run_now(id).await?;
}
}
return Ok(());
}
Some(Command::Update {
canary,
reconfigure,
+1
View File
@@ -4,5 +4,6 @@ pub mod info;
pub mod mcp;
pub mod project;
pub mod recipe;
pub mod schedule;
pub mod session;
pub mod update;
+184
View File
@@ -0,0 +1,184 @@
use anyhow::{bail, Context, Result};
use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine};
use goose::scheduler::{
get_default_scheduled_recipes_dir, get_default_scheduler_storage_path, ScheduledJob, Scheduler,
SchedulerError,
};
use std::path::Path;
// Base64 decoding function - might be needed if recipe_source_arg can be base64
// For now, handle_schedule_add will assume it's a path.
async fn _decode_base64_recipe(source: &str) -> Result<String> {
let bytes = BASE64_STANDARD
.decode(source.as_bytes())
.with_context(|| "Recipe source is not a valid path and not valid Base64.")?;
String::from_utf8(bytes).with_context(|| "Decoded Base64 recipe source is not valid UTF-8.")
}
pub async fn handle_schedule_add(
id: String,
cron: String,
recipe_source_arg: String, // This is expected to be a file path by the Scheduler
) -> Result<()> {
println!(
"[CLI Debug] Scheduling job ID: {}, Cron: {}, Recipe Source Path: {}",
id, cron, recipe_source_arg
);
// The Scheduler's add_scheduled_job will handle copying the recipe from recipe_source_arg
// to its internal storage and validating the path.
let job = ScheduledJob {
id: id.clone(),
source: recipe_source_arg.clone(), // Pass the original user-provided path
cron,
last_run: None,
};
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
match scheduler.add_scheduled_job(job).await {
Ok(_) => {
// The scheduler has copied the recipe to its internal directory.
// We can reconstruct the likely path for display if needed, or adjust success message.
let scheduled_recipes_dir = get_default_scheduled_recipes_dir()
.unwrap_or_else(|_| Path::new("./.goose_scheduled_recipes").to_path_buf()); // Fallback for display
let extension = Path::new(&recipe_source_arg)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("yaml");
let final_recipe_path = scheduled_recipes_dir.join(format!("{}.{}", id, extension));
println!(
"Scheduled job '{}' added. Recipe expected at {:?}",
id, final_recipe_path
);
Ok(())
}
Err(e) => {
// No local file to clean up by the CLI in this revised flow.
match e {
SchedulerError::JobIdExists(job_id) => {
bail!("Error: Job with ID '{}' already exists.", job_id);
}
SchedulerError::RecipeLoadError(msg) => {
bail!(
"Error with recipe source: {}. Path: {}",
msg,
recipe_source_arg
);
}
_ => Err(anyhow::Error::new(e))
.context(format!("Failed to add job '{}' to scheduler", id)),
}
}
}
}
pub async fn handle_schedule_list() -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
let jobs = scheduler.list_scheduled_jobs().await;
if jobs.is_empty() {
println!("No scheduled jobs found.");
} else {
println!("Scheduled Jobs:");
for job in jobs {
println!(
"- ID: {}\n Cron: {}\n Recipe Source (in store): {}\n Last Run: {}",
job.id,
job.cron,
job.source, // This source is now the path within scheduled_recipes_dir
job.last_run
.map_or_else(|| "Never".to_string(), |dt| dt.to_rfc3339())
);
}
}
Ok(())
}
pub async fn handle_schedule_remove(id: String) -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
match scheduler.remove_scheduled_job(&id).await {
Ok(_) => {
println!("Scheduled job '{}' and its associated recipe removed.", id);
Ok(())
}
Err(e) => match e {
SchedulerError::JobNotFound(job_id) => {
bail!("Error: Job with ID '{}' not found.", job_id);
}
_ => Err(anyhow::Error::new(e))
.context(format!("Failed to remove job '{}' from scheduler", id)),
},
}
}
pub async fn handle_schedule_sessions(id: String, limit: Option<u32>) -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
match scheduler.sessions(&id, limit.unwrap_or(50) as usize).await {
Ok(sessions) => {
if sessions.is_empty() {
println!("No sessions found for schedule ID '{}'.", id);
} else {
println!("Sessions for schedule ID '{}':", id);
// sessions is now Vec<(String, SessionMetadata)>
for (session_name, metadata) in sessions {
println!(
" - Session ID: {}, Working Dir: {}, Description: \"{}\", Messages: {}, Schedule ID: {:?}",
session_name, // Display the session_name as Session ID
metadata.working_dir.display(),
metadata.description,
metadata.message_count,
metadata.schedule_id.as_deref().unwrap_or("N/A")
);
}
}
}
Err(e) => {
bail!("Failed to get sessions for schedule '{}': {:?}", id, e);
}
}
Ok(())
}
pub async fn handle_schedule_run_now(id: String) -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
match scheduler.run_now(&id).await {
Ok(session_id) => {
println!(
"Successfully triggered schedule '{}'. New session ID: {}",
id, session_id
);
}
Err(e) => match e {
SchedulerError::JobNotFound(job_id) => {
bail!("Error: Job with ID '{}' not found.", job_id);
}
_ => bail!("Failed to run schedule '{}' now: {:?}", id, e),
},
}
Ok(())
}
+2
View File
@@ -688,6 +688,7 @@ impl Session {
id: session_id.clone(),
working_dir: std::env::current_dir()
.expect("failed to get current session working directory"),
schedule_id: None,
}),
)
.await?;
@@ -793,6 +794,7 @@ impl Session {
id: session_id.clone(),
working_dir: std::env::current_dir()
.expect("failed to get current session working directory"),
schedule_id: None,
}),
)
.await?;