Add basic cron scheduler to goose-server (#2621)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user