diff --git a/Cargo.lock b/Cargo.lock index 159e942d..82a98149 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,15 +719,15 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" dependencies = [ - "async-trait", - "axum-core 0.4.5", + "axum-core", "axum-macros", "base64 0.22.1", "bytes", + "form_urlencoded", "futures-util", "http 1.2.0", "http-body 1.0.1", @@ -735,7 +735,7 @@ dependencies = [ "hyper 1.6.0", "hyper-util", "itoa", - "matchit 0.7.3", + "matchit", "memchr", "mime", "percent-encoding", @@ -755,54 +755,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" -dependencies = [ - "axum-core 0.5.0", - "bytes", - "futures-util", - "http 1.2.0", - "http-body 1.0.1", - "http-body-util", - "itoa", - "matchit 0.8.4", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper 1.0.2", - "tower 0.5.2", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.2.0", - "http-body 1.0.1", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper 1.0.2", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "axum-core" version = "0.5.0" @@ -829,8 +781,8 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" dependencies = [ - "axum 0.8.1", - "axum-core 0.5.0", + "axum", + "axum-core", "bytes", "futures-util", "http 1.2.0", @@ -846,9 +798,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", @@ -1669,6 +1621,15 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "croner" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38fd53511eaf0b00a185613875fee58b208dfce016577d0ad4bb548e1c4fb3ee" +dependencies = [ + "chrono", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1792,7 +1753,7 @@ dependencies = [ "num", "once_cell", "openssl", - "rand", + "rand 0.8.5", ] [[package]] @@ -2073,7 +2034,7 @@ dependencies = [ "hyper-timeout", "log", "pin-project", - "rand", + "rand 0.8.5", "tokio", ] @@ -2529,7 +2490,7 @@ dependencies = [ "aws-config", "aws-sdk-bedrockruntime", "aws-smithy-types", - "axum 0.7.9", + "axum", "base64 0.21.7", "blake3", "chrono", @@ -2552,7 +2513,7 @@ dependencies = [ "nanoid", "once_cell", "paste", - "rand", + "rand 0.8.5", "regex", "reqwest 0.12.12", "serde", @@ -2566,6 +2527,7 @@ dependencies = [ "thiserror 1.0.69", "tokenizers", "tokio", + "tokio-cron-scheduler", "tracing", "tracing-subscriber", "url", @@ -2623,7 +2585,7 @@ dependencies = [ "minijinja", "nix 0.30.1", "once_cell", - "rand", + "rand 0.8.5", "regex", "reqwest 0.12.12", "rustyline", @@ -2741,8 +2703,9 @@ version = "1.0.24" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum", "axum-extra", + "base64 0.21.7", "bytes", "chrono", "clap 4.5.31", @@ -2762,6 +2725,7 @@ dependencies = [ "serde_yaml", "thiserror 1.0.69", "tokio", + "tokio-cron-scheduler", "tokio-stream", "tower 0.5.2", "tower-http", @@ -3819,12 +3783,6 @@ dependencies = [ "regex-automata 0.1.10", ] -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "matchit" version = "0.8.4" @@ -3851,7 +3809,7 @@ dependencies = [ "futures", "mcp-core", "nix 0.30.1", - "rand", + "rand 0.8.5", "reqwest 0.11.27", "serde", "serde_json", @@ -4030,7 +3988,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" dependencies = [ - "rand", + "rand 0.8.5", ] [[package]] @@ -4269,7 +4227,7 @@ dependencies = [ "chrono", "getrandom 0.2.15", "http 1.2.0", - "rand", + "rand 0.8.5", "reqwest 0.12.12", "serde", "serde_json", @@ -4816,7 +4774,7 @@ checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" dependencies = [ "bytes", "getrandom 0.2.15", - "rand", + "rand 0.8.5", "ring", "rustc-hash 2.1.1", "rustls 0.23.23", @@ -4868,8 +4826,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -4879,7 +4847,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -4891,6 +4869,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.1", +] + [[package]] name = "rangemap" version = "1.5.1" @@ -4923,8 +4910,8 @@ dependencies = [ "once_cell", "paste", "profiling", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "simd_helpers", "system-deps", "thiserror 1.0.69", @@ -6268,7 +6255,7 @@ dependencies = [ "monostate", "onig", "paste", - "rand", + "rand 0.8.5", "rayon", "rayon-cond", "regex", @@ -6300,6 +6287,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "tokio-cron-scheduler" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c71ce8f810abc9fabebccc30302a952f9e89c6cf246fafaf170fef164063141" +dependencies = [ + "chrono", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "tokio-io-timeout" version = "1.2.0" @@ -6354,9 +6356,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", @@ -6576,19 +6578,18 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ - "byteorder", "bytes", "data-encoding", "http 1.2.0", "httparse", "log", - "rand", + "rand 0.9.1", "sha1", - "thiserror 1.0.69", + "thiserror 2.0.12", "utf-8", ] diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 5430cac6..0e2651f6 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -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, + }, + /// 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, diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index fdc04a2a..bda22fbd 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -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; diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs new file mode 100644 index 00000000..04498099 --- /dev/null +++ b/crates/goose-cli/src/commands/schedule.rs @@ -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 { + 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) -> 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(()) +} diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 00a7bd10..3b515cc3 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -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?; diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 30ef8925..a343ce5a 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -12,9 +12,10 @@ goose = { path = "../goose" } mcp-core = { path = "../mcp-core" } goose-mcp = { path = "../goose-mcp" } mcp-server = { path = "../mcp-server" } -axum = { version = "0.7.2", features = ["ws", "macros"] } +axum = { version = "0.8.1", features = ["ws", "macros"] } tokio = { version = "1.43", features = ["full"] } chrono = "0.4" +tokio-cron-scheduler = "0.14.0" tower-http = { version = "0.5", features = ["cors"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -26,6 +27,7 @@ tokio-stream = "0.1" anyhow = "1.0" bytes = "1.5" http = "1.0" +base64 = "0.21" config = { version = "0.14.1", features = ["toml"] } thiserror = "1.0" clap = { version = "4.4", features = ["derive"] } @@ -33,7 +35,7 @@ once_cell = "1.20.2" etcetera = "0.8.0" serde_yaml = "0.9.34" axum-extra = "0.10.0" -utoipa = { version = "4.1", features = ["axum_extras"] } +utoipa = { version = "4.1", features = ["axum_extras", "chrono"] } dirs = "6.0.0" reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking"], default-features = false } diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index bf0a2897..f59919bf 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use crate::configuration; use crate::state; use anyhow::Result; +use etcetera::{choose_app_strategy, AppStrategy}; use goose::agents::Agent; +use goose::config::APP_STRATEGY; +use goose::scheduler::Scheduler as GooseScheduler; use tower_http::cors::{Any, CorsLayer}; use tracing::info; @@ -11,27 +14,30 @@ pub async fn run() -> Result<()> { // Initialize logging crate::logging::setup_logging(Some("goosed"))?; - // Load configuration let settings = configuration::Settings::new()?; - // load secret key from GOOSE_SERVER__SECRET_KEY environment variable let secret_key = std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string()); let new_agent = Agent::new(); + let agent_ref = Arc::new(new_agent); - // Create app state with agent - let state = state::AppState::new(Arc::new(new_agent), secret_key.clone()).await; + let app_state = state::AppState::new(agent_ref.clone(), secret_key.clone()).await; + + let schedule_file_path = choose_app_strategy(APP_STRATEGY.clone())? + .data_dir() + .join("schedules.json"); + + let scheduler_instance = GooseScheduler::new(schedule_file_path).await?; + app_state.set_scheduler(scheduler_instance).await; - // Create router with CORS support let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); - let app = crate::routes::configure(state).layer(cors); + let app = crate::routes::configure(app_state).layer(cors); - // Run server let listener = tokio::net::TcpListener::bind(settings.socket_addr()).await?; info!("listening on {}", listener.local_addr()?); axum::serve(listener, app).await?; diff --git a/crates/goose-server/src/logging.rs b/crates/goose-server/src/logging.rs index c0802964..90db8f8a 100644 --- a/crates/goose-server/src/logging.rs +++ b/crates/goose-server/src/logging.rs @@ -8,6 +8,7 @@ use tracing_subscriber::{ Registry, }; +use goose::config::APP_STRATEGY; use goose::tracing::langfuse_layer; /// Returns the directory where log files should be stored. @@ -17,8 +18,8 @@ fn get_log_directory() -> Result { // - macOS/Linux: ~/.local/state/goose/logs/server // - Windows: ~\AppData\Roaming\Block\goose\data\logs\server // - Windows has no convention for state_dir, use data_dir instead - let home_dir = choose_app_strategy(crate::APP_STRATEGY.clone()) - .context("HOME environment variable not set")?; + let home_dir = + choose_app_strategy(APP_STRATEGY.clone()).context("HOME environment variable not set")?; let base_log_dir = home_dir .in_state_dir("logs/server") diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs index 25fb6f42..ccd28568 100644 --- a/crates/goose-server/src/main.rs +++ b/crates/goose-server/src/main.rs @@ -1,12 +1,3 @@ -use etcetera::AppStrategyArgs; -use once_cell::sync::Lazy; - -pub static APP_STRATEGY: Lazy = Lazy::new(|| AppStrategyArgs { - top_level_domain: "Block".to_string(), - author: "Block".to_string(), - app_name: "goose".to_string(), -}); - mod commands; mod configuration; mod error; diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index f74ccec1..0711e3a3 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -37,7 +37,12 @@ use utoipa::OpenApi; super::routes::reply::confirm_permission, super::routes::context::manage_context, super::routes::session::list_sessions, - super::routes::session::get_session_history + super::routes::session::get_session_history, + super::routes::schedule::create_schedule, + super::routes::schedule::list_schedules, + super::routes::schedule::delete_schedule, + super::routes::schedule::run_now_handler, + super::routes::schedule::sessions_handler ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -85,6 +90,12 @@ use utoipa::OpenApi; ModelInfo, SessionInfo, SessionMetadata, + super::routes::schedule::CreateScheduleRequest, + goose::scheduler::ScheduledJob, + super::routes::schedule::RunNowResponse, + super::routes::schedule::ListSchedulesResponse, + super::routes::schedule::SessionsQuery, + super::routes::schedule::SessionDisplayInfo, )) )] pub struct ApiDoc; diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 7774cc4d..02ba313b 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -6,8 +6,9 @@ use axum::{ routing::{delete, get, post}, Json, Router, }; -use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; +use etcetera::{choose_app_strategy, AppStrategy}; use goose::config::Config; +use goose::config::APP_STRATEGY; use goose::config::{extensions::name_to_key, PermissionManager}; use goose::config::{ExtensionConfigManager, ExtensionEntry}; use goose::model::ModelConfig; @@ -15,7 +16,6 @@ use goose::providers::base::ProviderMetadata; use goose::providers::providers as get_providers; use goose::{agents::ExtensionConfig, config::permission::PermissionLevel}; use http::{HeaderMap, StatusCode}; -use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use serde_json::Value; use serde_yaml; @@ -52,14 +52,12 @@ pub struct ConfigResponse { pub config: HashMap, } -// Define a new structure to encapsulate the provider details along with configuration status #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ProviderDetails { - /// Unique identifier and name of the provider pub name: String, - /// Metadata about the provider + pub metadata: ProviderMetadata, - /// Indicates whether the provider is fully configured + pub is_configured: bool, } @@ -70,7 +68,6 @@ pub struct ProvidersResponse { #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ToolPermission { - /// Unique identifier and name of the tool, format __ pub tool_name: String, pub permission: PermissionLevel, } @@ -94,7 +91,6 @@ pub async fn upsert_config( headers: HeaderMap, Json(query): Json, ) -> Result, StatusCode> { - // Use the helper function to verify the secret key verify_secret_key(&headers, &state)?; let config = Config::global(); @@ -121,12 +117,10 @@ pub async fn remove_config( headers: HeaderMap, Json(query): Json, ) -> Result, StatusCode> { - // Use the helper function to verify the secret key verify_secret_key(&headers, &state)?; let config = Config::global(); - // Check if the secret flag is true and call the appropriate method let result = if query.is_secret { config.delete_secret(&query.key) } else { @@ -142,7 +136,7 @@ pub async fn remove_config( #[utoipa::path( post, path = "/config/read", - request_body = ConfigKeyQuery, // Switch back to request_body + request_body = ConfigKeyQuery, responses( (status = 200, description = "Configuration value retrieved successfully", body = Value), (status = 404, description = "Configuration key not found") @@ -155,7 +149,6 @@ pub async fn read_config( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - // Special handling for model-limits if query.key == "model-limits" { let limits = ModelConfig::get_all_model_limits(); return Ok(Json( @@ -166,13 +159,10 @@ pub async fn read_config( let config = Config::global(); match config.get(&query.key, query.is_secret) { - // Always get the actual value Ok(value) => { if query.is_secret { - // If it's marked as secret, return a boolean indicating presence Ok(Json(Value::Bool(true))) } else { - // Return the actual value if not secret Ok(Json(value)) } } @@ -197,7 +187,6 @@ pub async fn get_extensions( match ExtensionConfigManager::get_all() { Ok(extensions) => Ok(Json(ExtensionResponse { extensions })), Err(err) => { - // Return UNPROCESSABLE_ENTITY only for DeserializeError, INTERNAL_SERVER_ERROR for everything else if err .downcast_ref::() .is_some_and(|e| matches!(e, goose::config::base::ConfigError::DeserializeError(_))) @@ -228,7 +217,6 @@ pub async fn add_extension( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - // Get existing extensions to check if this is an update let extensions = ExtensionConfigManager::get_all().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let key = name_to_key(&extension_query.name); @@ -284,12 +272,10 @@ pub async fn read_all_config( State(state): State>, headers: HeaderMap, ) -> Result, StatusCode> { - // Use the helper function to verify the secret key verify_secret_key(&headers, &state)?; let config = Config::global(); - // Load values from config file let values = config .load_values() .map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)?; @@ -297,7 +283,6 @@ pub async fn read_all_config( Ok(Json(ConfigResponse { config: values })) } -// Modified providers function using the new response type #[utoipa::path( get, path = "/config/providers", @@ -311,14 +296,11 @@ pub async fn providers( ) -> Result>, StatusCode> { verify_secret_key(&headers, &state)?; - // Fetch the list of providers, which are likely stored in the AppState or can be retrieved via a function call let providers_metadata = get_providers(); - // Construct the response by checking configuration status for each provider let providers_response: Vec = providers_metadata .into_iter() .map(|metadata| { - // Check if the provider is configured (this will depend on how you track configuration status) let is_configured = check_provider_configured(&metadata); ProviderDetails { @@ -348,21 +330,16 @@ pub async fn init_config( let config = Config::global(); - // 200 if config already exists if config.exists() { return Ok(Json("Config already exists".to_string())); } - // Find the workspace root (where the top-level Cargo.toml with [workspace] is) let workspace_root = match std::env::current_exe() { Ok(mut exe_path) => { - // Start from the executable's directory and traverse up while let Some(parent) = exe_path.parent() { let cargo_toml = parent.join("Cargo.toml"); if cargo_toml.exists() { - // Read the Cargo.toml file if let Ok(content) = std::fs::read_to_string(&cargo_toml) { - // Check if it contains [workspace] if content.contains("[workspace]") { exe_path = parent.to_path_buf(); break; @@ -376,7 +353,6 @@ pub async fn init_config( Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - // Check if init-config.yaml exists at workspace root let init_config_path = workspace_root.join("init-config.yaml"); if !init_config_path.exists() { return Ok(Json( @@ -384,7 +360,6 @@ pub async fn init_config( )); } - // Read init-config.yaml and validate let init_content = match std::fs::read_to_string(&init_config_path) { Ok(content) => content, Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -394,7 +369,6 @@ pub async fn init_config( Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - // Save init-config.yaml to ~/.config/goose/config.yaml match config.save_values(init_values) { Ok(_) => Ok(Json("Config initialized successfully".to_string())), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -418,7 +392,7 @@ pub async fn upsert_permissions( verify_secret_key(&headers, &state)?; let mut permission_manager = PermissionManager::default(); - // Iterate over each tool permission and update permissions + for tool_permission in &query.tool_permissions { permission_manager.update_user_permission( &tool_permission.tool_name, @@ -429,12 +403,6 @@ pub async fn upsert_permissions( Ok(Json("Permissions updated successfully".to_string())) } -pub static APP_STRATEGY: Lazy = Lazy::new(|| AppStrategyArgs { - top_level_domain: "Block".to_string(), - author: "Block".to_string(), - app_name: "goose".to_string(), -}); - #[utoipa::path( post, path = "/config/backup", @@ -460,11 +428,9 @@ pub async fn backup_config( .file_name() .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - // Append ".bak" to the file name let mut backup_name = file_name.to_os_string(); backup_name.push(".bak"); - // Construct the new path with the same parent directory let backup = config_path.with_file_name(backup_name); match std::fs::rename(&config_path, &backup) { Ok(_) => Ok(Json(format!("Moved {:?} to {:?}", config_path, backup))), @@ -483,7 +449,7 @@ pub fn routes(state: Arc) -> Router { .route("/config/read", post(read_config)) .route("/config/extensions", get(get_extensions)) .route("/config/extensions", post(add_extension)) - .route("/config/extensions/:name", delete(remove_extension)) + .route("/config/extensions/{name}", delete(remove_extension)) .route("/config/providers", get(providers)) .route("/config/init", post(init_config)) .route("/config/backup", post(backup_config)) @@ -497,16 +463,22 @@ mod tests { #[tokio::test] async fn test_read_model_limits() { - // Create test state and headers let test_state = AppState::new( Arc::new(goose::agents::Agent::default()), "test".to_string(), ) .await; + let sched_storage_path = choose_app_strategy(APP_STRATEGY.clone()) + .unwrap() + .data_dir() + .join("schedules.json"); + let sched = goose::scheduler::Scheduler::new(sched_storage_path) + .await + .unwrap(); + test_state.set_scheduler(sched).await; let mut headers = HeaderMap::new(); headers.insert("X-Secret-Key", "test".parse().unwrap()); - // Execute let result = read_config( State(test_state), headers, @@ -517,16 +489,13 @@ mod tests { ) .await; - // Assert assert!(result.is_ok()); let response = result.unwrap(); - // Parse the response and check the contents let limits: Vec = serde_json::from_value(response.0).unwrap(); assert!(!limits.is_empty()); - // Check for some expected patterns let gpt4_limit = limits.iter().find(|l| l.pattern == "gpt-4o"); assert!(gpt4_limit.is_some()); assert_eq!(gpt4_limit.unwrap().context_limit, 128_000); diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index dcdb577e..89e46f23 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -6,6 +6,7 @@ pub mod extension; pub mod health; pub mod recipe; pub mod reply; +pub mod schedule; pub mod session; pub mod utils; use std::sync::Arc; @@ -23,4 +24,5 @@ pub fn configure(state: Arc) -> Router { .merge(config_management::routes(state.clone())) .merge(recipe::routes(state.clone())) .merge(session::routes(state.clone())) + .merge(schedule::routes(state.clone())) } diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 764b9065..ef84dc58 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -35,7 +35,6 @@ use tokio::time::timeout; use tokio_stream::wrappers::ReceiverStream; use utoipa::ToSchema; -// Direct message serialization for the chat request #[derive(Debug, Deserialize)] struct ChatRequest { messages: Vec, @@ -43,7 +42,6 @@ struct ChatRequest { session_working_dir: String, } -// Custom SSE response type for streaming messages pub struct SseResponse { rx: ReceiverStream, } @@ -78,7 +76,6 @@ impl IntoResponse for SseResponse { } } -// Message event types for SSE streaming #[derive(Debug, Serialize)] #[serde(tag = "type")] enum MessageEvent { @@ -87,7 +84,6 @@ enum MessageEvent { Finish { reason: String }, } -// Stream a message as an SSE event async fn stream_event( event: MessageEvent, tx: &mpsc::Sender, @@ -108,19 +104,16 @@ async fn handler( ) -> Result { verify_secret_key(&headers, &state)?; - // Create channel for streaming let (tx, rx) = mpsc::channel(100); let stream = ReceiverStream::new(rx); let messages = request.messages; let session_working_dir = request.session_working_dir; - // Generate a new session ID if not provided in the request let session_id = request .session_id .unwrap_or_else(session::generate_session_id); - // Spawn task to handle streaming tokio::spawn(async move { let agent = state.get_agent().await; let agent = match agent { @@ -166,7 +159,6 @@ async fn handler( } }; - // Get the provider first, before starting the reply stream let provider = agent.provider().await; let mut stream = match agent @@ -175,6 +167,7 @@ async fn handler( Some(SessionConfig { id: session::Identifier::Name(session_id.clone()), working_dir: PathBuf::from(session_working_dir), + schedule_id: None, }), ) .await @@ -200,7 +193,6 @@ async fn handler( } }; - // Collect all messages for storage let mut all_messages = messages.clone(); let session_path = session::get_path(session::Identifier::Name(session_id.clone())); @@ -221,7 +213,7 @@ async fn handler( break; } - // Store messages and generate description in background + let session_path = session_path.clone(); let messages = all_messages.clone(); let provider = Arc::clone(provider.as_ref().unwrap()); @@ -255,7 +247,6 @@ async fn handler( } } - // Send finish event let _ = stream_event( MessageEvent::Finish { reason: "stop".to_string(), @@ -280,7 +271,6 @@ struct AskResponse { response: String, } -// Simple ask an AI for a response, non streaming async fn ask_handler( State(state): State>, headers: HeaderMap, @@ -290,7 +280,6 @@ async fn ask_handler( let session_working_dir = request.session_working_dir; - // Generate a new session ID if not provided in the request let session_id = request .session_id .unwrap_or_else(session::generate_session_id); @@ -300,13 +289,10 @@ async fn ask_handler( .await .map_err(|_| StatusCode::PRECONDITION_FAILED)?; - // Get the provider first, before starting the reply stream let provider = agent.provider().await; - // Create a single message for the prompt let messages = vec![Message::user().with_text(request.prompt)]; - // Get response from agent let mut response_text = String::new(); let mut stream = match agent .reply( @@ -314,6 +300,7 @@ async fn ask_handler( Some(SessionConfig { id: session::Identifier::Name(session_id.clone()), working_dir: PathBuf::from(session_working_dir), + schedule_id: None, }), ) .await @@ -325,7 +312,6 @@ async fn ask_handler( } }; - // Collect all messages for storage let mut all_messages = messages.clone(); let mut response_message = Message::assistant(); @@ -349,15 +335,12 @@ async fn ask_handler( } } - // Add the complete response message to the conversation history if !response_message.content.is_empty() { all_messages.push(response_message); } - // Get the session path - file will be created when needed let session_path = session::get_path(session::Identifier::Name(session_id.clone())); - // Store messages and generate description in background let session_path = session_path.clone(); let messages = all_messages.clone(); let provider = Arc::clone(provider.as_ref().unwrap()); @@ -438,13 +421,11 @@ async fn submit_tool_result( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - // Log the raw request for debugging tracing::info!( "Received tool result request: {}", serde_json::to_string_pretty(&raw.0).unwrap() ); - // Try to parse into our struct let payload: ToolResultRequest = match serde_json::from_value(raw.0.clone()) { Ok(req) => req, Err(e) => { @@ -465,7 +446,6 @@ async fn submit_tool_result( Ok(Json(json!({"status": "ok"}))) } -// Configure routes for this module pub fn routes(state: Arc) -> Router { Router::new() .route("/reply", post(handler)) @@ -488,7 +468,6 @@ mod tests { }; use mcp_core::tool::Tool; - // Mock Provider implementation for testing #[derive(Clone)] struct MockProvider { model_config: ModelConfig, @@ -523,10 +502,8 @@ mod tests { use std::sync::Arc; use tower::ServiceExt; - // This test requires tokio runtime #[tokio::test] async fn test_ask_endpoint() { - // Create a mock app state with mock provider let mock_model_config = ModelConfig::new("test-model".to_string()); let mock_provider = Arc::new(MockProvider { model_config: mock_model_config, @@ -534,11 +511,15 @@ mod tests { let agent = Agent::new(); let _ = agent.update_provider(mock_provider).await; let state = AppState::new(Arc::new(agent), "test-secret".to_string()).await; + let scheduler_path = goose::scheduler::get_default_scheduler_storage_path() + .expect("Failed to get default scheduler storage path"); + let scheduler = goose::scheduler::Scheduler::new(scheduler_path) + .await + .unwrap(); + state.set_scheduler(scheduler).await; - // Build router let app = routes(state); - // Create request let request = Request::builder() .uri("/ask") .method("POST") @@ -554,10 +535,8 @@ mod tests { )) .unwrap(); - // Send request let response = app.oneshot(request).await.unwrap(); - // Assert response status assert_eq!(response.status(), StatusCode::OK); } } diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs new file mode 100644 index 00000000..b32df60d --- /dev/null +++ b/crates/goose-server/src/routes/schedule.rs @@ -0,0 +1,270 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + routing::{delete, get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use chrono::NaiveDateTime; + +use crate::routes::utils::verify_secret_key; +use crate::state::AppState; +use goose::scheduler::ScheduledJob; + +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct CreateScheduleRequest { + id: String, + recipe_source: String, + cron: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ListSchedulesResponse { + jobs: Vec, +} + +// Response for the run_now endpoint +#[derive(Serialize, utoipa::ToSchema)] +pub struct RunNowResponse { + session_id: String, +} + +// Query parameters for the sessions endpoint +#[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] +pub struct SessionsQuery { + #[serde(default = "default_limit")] + limit: u32, +} + +fn default_limit() -> u32 { + 50 // Default limit for sessions listed +} + +// Struct for the frontend session list +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionDisplayInfo { + id: String, // Derived from session_name (filename) + name: String, // From metadata.description + created_at: String, // Derived from session_name, in ISO 8601 format + working_dir: String, // from metadata.working_dir (as String) + schedule_id: Option, + message_count: usize, + total_tokens: Option, + input_tokens: Option, + output_tokens: Option, + accumulated_total_tokens: Option, + accumulated_input_tokens: Option, + accumulated_output_tokens: Option, +} + +fn parse_session_name_to_iso(session_name: &str) -> String { + NaiveDateTime::parse_from_str(session_name, "%Y%m%d_%H%M%S") + .map(|dt| dt.and_utc().to_rfc3339()) + .unwrap_or_else(|_| String::new()) // Fallback to empty string if parsing fails +} + +#[utoipa::path( + post, + path = "/schedule/create", + request_body = CreateScheduleRequest, + responses( + (status = 200, description = "Scheduled job created successfully", body = ScheduledJob), + (status = 500, description = "Internal server error") + ), + tag = "schedule" +)] +#[axum::debug_handler] +async fn create_schedule( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let job = ScheduledJob { + id: req.id, + source: req.recipe_source, + cron: req.cron, + last_run: None, + }; + scheduler + .add_scheduled_job(job.clone()) + .await + .map_err(|e| { + eprintln!("Error creating schedule: {:?}", e); // Log error + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Json(job)) +} + +#[utoipa::path( + get, + path = "/schedule/list", + responses( + (status = 200, description = "A list of scheduled jobs", body = ListSchedulesResponse), + (status = 500, description = "Internal server error") + ), + tag = "schedule" +)] +#[axum::debug_handler] +async fn list_schedules( + State(state): State>, + headers: HeaderMap, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let jobs = scheduler.list_scheduled_jobs().await; + Ok(Json(ListSchedulesResponse { jobs })) +} + +#[utoipa::path( + delete, + path = "/schedule/delete/{id}", + params( + ("id" = String, Path, description = "ID of the schedule to delete") + ), + responses( + (status = 204, description = "Scheduled job deleted successfully"), + (status = 404, description = "Scheduled job not found"), + (status = 500, description = "Internal server error") + ), + tag = "schedule" +)] +#[axum::debug_handler] +async fn delete_schedule( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + verify_secret_key(&headers, &state)?; + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + scheduler.remove_scheduled_job(&id).await.map_err(|e| { + eprintln!("Error deleting schedule '{}': {:?}", id, e); + match e { + goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/schedule/{id}/run_now", + params( + ("id" = String, Path, description = "ID of the schedule to run") + ), + responses( + (status = 200, description = "Scheduled job triggered successfully, returns new session ID", body = RunNowResponse), + (status = 404, description = "Scheduled job not found"), + (status = 500, description = "Internal server error when trying to run the job") + ), + tag = "schedule" +)] +#[axum::debug_handler] +async fn run_now_handler( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match scheduler.run_now(&id).await { + Ok(session_id) => Ok(Json(RunNowResponse { session_id })), + Err(e) => { + eprintln!("Error running schedule '{}' now: {:?}", id, e); + match e { + goose::scheduler::SchedulerError::JobNotFound(_) => Err(StatusCode::NOT_FOUND), + _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + } + } + } +} + +#[utoipa::path( + get, + path = "/schedule/{id}/sessions", + params( + ("id" = String, Path, description = "ID of the schedule"), + SessionsQuery // This will automatically pick up 'limit' as a query parameter + ), + responses( + (status = 200, description = "A list of session display info", body = Vec), + (status = 500, description = "Internal server error") + ), + tag = "schedule" +)] +#[axum::debug_handler] +async fn sessions_handler( + State(state): State>, + headers: HeaderMap, // Added this line + Path(schedule_id_param): Path, // Renamed to avoid confusion with session_id + Query(query_params): Query, +) -> Result>, StatusCode> { + verify_secret_key(&headers, &state)?; // Added this line + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match scheduler + .sessions(&schedule_id_param, query_params.limit as usize) + .await + { + Ok(session_tuples) => { + // Expecting Vec<(String, goose::session::storage::SessionMetadata)> + let display_infos: Vec = session_tuples + .into_iter() + .map(|(session_name, metadata)| SessionDisplayInfo { + id: session_name.clone(), + name: metadata.description, // Use description as name + created_at: parse_session_name_to_iso(&session_name), + working_dir: metadata.working_dir.to_string_lossy().into_owned(), + schedule_id: metadata.schedule_id, // This is the ID of the schedule itself + message_count: metadata.message_count, + total_tokens: metadata.total_tokens, + input_tokens: metadata.input_tokens, + output_tokens: metadata.output_tokens, + accumulated_total_tokens: metadata.accumulated_total_tokens, + accumulated_input_tokens: metadata.accumulated_input_tokens, + accumulated_output_tokens: metadata.accumulated_output_tokens, + }) + .collect(); + Ok(Json(display_infos)) + } + Err(e) => { + eprintln!( + "Error fetching sessions for schedule '{}': {:?}", + schedule_id_param, e + ); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/schedule/create", post(create_schedule)) + .route("/schedule/list", get(list_schedules)) + .route("/schedule/delete/{id}", delete(delete_schedule)) // Corrected + .route("/schedule/{id}/run_now", post(run_now_handler)) // Corrected + .route("/schedule/{id}/sessions", get(sessions_handler)) // Corrected + .with_state(state) +} diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index a5b22ebb..edbf128f 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -108,6 +108,6 @@ async fn get_session_history( pub fn routes(state: Arc) -> Router { Router::new() .route("/sessions", get(list_sessions)) - .route("/sessions/:session_id", get(get_session_history)) + .route("/sessions/{session_id}", get(get_session_history)) .with_state(state) } diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index b7f45f19..d8fc7a6c 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -1,21 +1,15 @@ use goose::agents::Agent; +use goose::scheduler::Scheduler; use std::sync::Arc; +use tokio::sync::Mutex; -/// Shared reference to an Agent that can be cloned cheaply -/// without cloning the underlying Agent object pub type AgentRef = Arc; -/// Thread-safe container for an optional Agent reference -/// Outer Arc: Allows multiple route handlers to access the same Mutex -/// - Mutex provides exclusive access for updates -/// - Option allows for the case where no agent exists yet -/// -/// Shared application state #[derive(Clone)] pub struct AppState { - // agent: SharedAgentStore, agent: Option, pub secret_key: String, + pub scheduler: Arc>>>, } impl AppState { @@ -23,6 +17,7 @@ impl AppState { Arc::new(Self { agent: Some(agent.clone()), secret_key, + scheduler: Arc::new(Mutex::new(None)), }) } @@ -31,4 +26,17 @@ impl AppState { .clone() .ok_or_else(|| anyhow::anyhow!("Agent needs to be created first.")) } + + pub async fn set_scheduler(&self, sched: Arc) { + let mut guard = self.scheduler.lock().await; + *guard = Some(sched); + } + + pub async fn scheduler(&self) -> Result, anyhow::Error> { + self.scheduler + .lock() + .await + .clone() + .ok_or_else(|| anyhow::anyhow!("Scheduler not initialized")) + } } diff --git a/crates/goose-server/ui/desktop/openapi.json b/crates/goose-server/ui/desktop/openapi.json index 71924f3a..5e78c8f6 100644 --- a/crates/goose-server/ui/desktop/openapi.json +++ b/crates/goose-server/ui/desktop/openapi.json @@ -37,10 +37,10 @@ "/config/extension": { "post": { "tags": [ - "super::routes::config_management" + "config" ], "summary": "Add an extension configuration", - "operationId": "add_extension", + "operationId": "add_extension_config", "requestBody": { "content": { "application/json": { @@ -208,6 +208,180 @@ } } } + }, + "/schedule/create": { + "post": { + "tags": ["schedule"], + "summary": "Create a new scheduled job", + "operationId": "create_schedule", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled job created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/list": { + "get": { + "tags": ["schedule"], + "summary": "List all scheduled jobs", + "operationId": "list_schedules", + "responses": { + "200": { + "description": "A list of scheduled jobs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/delete/{id}": { + "delete": { + "tags": ["schedule"], + "summary": "Delete a scheduled job by ID", + "operationId": "delete_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID of the schedule to delete", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Scheduled job deleted successfully" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/run_now": { + "post": { + "tags": ["schedule"], + "summary": "Run a scheduled job immediately", + "operationId": "run_schedule_now", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID of the schedule to run", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled job triggered successfully, returns new session ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunNowResponse" + } + } + } + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error when trying to run the job" + } + } + } + }, + "/schedule/{id}/sessions": { + "get": { + "tags": ["schedule"], + "summary": "List sessions created by a specific schedule", + "operationId": "list_schedule_sessions", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID of the schedule", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of sessions to return", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "A list of session metadata", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMetadata" + } + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } } }, "components": { @@ -273,7 +447,127 @@ "description": "The value to set for the configuration" } } + }, + "CreateScheduleRequest": { + "type": "object", + "required": [ + "id", + "recipe_source", + "cron" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique ID for the new schedule." + }, + "recipe_source": { + "type": "string", + "description": "Path to the recipe file to be executed by this schedule." + }, + "cron": { + "type": "string", + "description": "Cron string defining when the job should run." + } + } + }, + "ScheduledJob": { + "type": "object", + "required": [ + "id", + "source", + "cron" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the scheduled job." + }, + "source": { + "type": "string", + "description": "Path to the recipe file for this job." + }, + "cron": { + "type": "string", + "description": "Cron string defining the schedule." + }, + "last_run": { + "type": "string", + "format": "date-time", + "description": "Timestamp of the last time the job was run.", + "nullable": true + } + } + }, + "SessionMetadata": { + "type": "object", + "required": [ + "working_dir", + "description", + "message_count" + ], + "properties": { + "working_dir": { + "type": "string", + "description": "Working directory for the session." + }, + "description": { + "type": "string", + "description": "A short description of the session." + }, + "schedule_id": { + "type": "string", + "description": "ID of the schedule that triggered this session, if any.", + "nullable": true + }, + "message_count": { + "type": "integer", + "format": "int64", + "description": "Number of messages in the session." + }, + "total_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "input_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "output_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulated_total_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulated_input_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulated_output_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + }, + "RunNowResponse": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string", + "description": "The ID of the newly created session." + } + } } } } -} \ No newline at end of file +} diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 0c51113b..9bfa9e85 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -46,7 +46,7 @@ nanoid = "0.4" sha2 = "0.10" base64 = "0.21" url = "2.5" -axum = "0.7" +axum = "0.8.1" webbrowser = "0.8" dotenv = "0.15" lazy_static = "1.5" @@ -60,7 +60,8 @@ serde_yaml = "0.9.34" once_cell = "1.20.2" etcetera = "0.8.0" rand = "0.8.5" -utoipa = "4.1" +utoipa = { version = "4.1", features = ["chrono"] } +tokio-cron-scheduler = "0.14.0" # For Bedrock provider aws-config = { version = "1.5.16", features = ["behavior-version-latest"] } diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 72515c4a..c777a488 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -205,18 +205,17 @@ impl Agent { usage: &crate::providers::base::ProviderUsage, messages_length: usize, ) -> Result<()> { - let session_file = session::get_path(session_config.id); - let mut metadata = session::read_metadata(&session_file)?; + let session_file_path = session::storage::get_path(session_config.id.clone()); + let mut metadata = session::storage::read_metadata(&session_file_path)?; + + metadata.schedule_id = session_config.schedule_id.clone(); - metadata.working_dir = session_config.working_dir.clone(); metadata.total_tokens = usage.usage.total_tokens; metadata.input_tokens = usage.usage.input_tokens; metadata.output_tokens = usage.usage.output_tokens; - // The message count is the number of messages in the session + 1 for the response - // The message count does not include the tool response till next iteration + metadata.message_count = messages_length + 1; - // Keep running sum of tokens to track cost over the entire session let accumulate = |a: Option, b: Option| -> Option { match (a, b) { (Some(x), Some(y)) => Some(x + y), @@ -231,7 +230,8 @@ impl Agent { metadata.accumulated_output_tokens, usage.usage.output_tokens, ); - session::update_metadata(&session_file, &metadata).await?; + + session::storage::update_metadata(&session_file_path, &metadata).await?; Ok(()) } diff --git a/crates/goose/src/agents/types.rs b/crates/goose/src/agents/types.rs index 6a2ba381..9d23150a 100644 --- a/crates/goose/src/agents/types.rs +++ b/crates/goose/src/agents/types.rs @@ -22,4 +22,6 @@ pub struct SessionConfig { pub id: session::Identifier, /// Working directory for the session pub working_dir: PathBuf, + /// ID of the schedule that triggered this session, if any + pub schedule_id: Option, // NEW } diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index d1389adb..e809863c 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -7,6 +7,7 @@ pub mod permission; pub mod prompt_template; pub mod providers; pub mod recipe; +pub mod scheduler; pub mod session; pub mod token_counter; pub mod tool_monitor; diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs new file mode 100644 index 00000000..f0763f8d --- /dev/null +++ b/crates/goose/src/scheduler.rs @@ -0,0 +1,850 @@ +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use chrono::{DateTime, Utc}; +use etcetera::{choose_app_strategy, AppStrategy}; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; +use tokio_cron_scheduler::{job::JobId, Job, JobScheduler as TokioJobScheduler}; + +use crate::agents::{Agent, SessionConfig}; +use crate::config::{self, Config}; +use crate::message::Message; +use crate::providers::base::Provider as GooseProvider; // Alias to avoid conflict in test section +use crate::providers::create; +use crate::recipe::Recipe; +use crate::session; +use crate::session::storage::SessionMetadata; + +pub fn get_default_scheduler_storage_path() -> Result { + let strategy = choose_app_strategy(config::APP_STRATEGY.clone()) + .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e.to_string()))?; + let data_dir = strategy.data_dir(); + fs::create_dir_all(&data_dir)?; + Ok(data_dir.join("schedules.json")) +} + +pub fn get_default_scheduled_recipes_dir() -> Result { + let strategy = choose_app_strategy(config::APP_STRATEGY.clone()).map_err(|e| { + SchedulerError::StorageError(io::Error::new(io::ErrorKind::NotFound, e.to_string())) + })?; + let data_dir = strategy.data_dir(); + let recipes_dir = data_dir.join("scheduled_recipes"); + fs::create_dir_all(&recipes_dir).map_err(SchedulerError::StorageError)?; + tracing::debug!( + "Created scheduled recipes directory at: {}", + recipes_dir.display() + ); + Ok(recipes_dir) +} + +#[derive(Debug)] +pub enum SchedulerError { + JobIdExists(String), + JobNotFound(String), + StorageError(io::Error), + RecipeLoadError(String), + AgentSetupError(String), + PersistError(String), + CronParseError(String), + SchedulerInternalError(String), + AnyhowError(anyhow::Error), +} + +impl std::fmt::Display for SchedulerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SchedulerError::JobIdExists(id) => write!(f, "Job ID '{}' already exists.", id), + SchedulerError::JobNotFound(id) => write!(f, "Job ID '{}' not found.", id), + SchedulerError::StorageError(e) => write!(f, "Storage error: {}", e), + SchedulerError::RecipeLoadError(e) => write!(f, "Recipe load error: {}", e), + SchedulerError::AgentSetupError(e) => write!(f, "Agent setup error: {}", e), + SchedulerError::PersistError(e) => write!(f, "Failed to persist schedules: {}", e), + SchedulerError::CronParseError(e) => write!(f, "Invalid cron string: {}", e), + SchedulerError::SchedulerInternalError(e) => { + write!(f, "Scheduler internal error: {}", e) + } + SchedulerError::AnyhowError(e) => write!(f, "Scheduler operation failed: {}", e), + } + } +} + +impl std::error::Error for SchedulerError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + SchedulerError::StorageError(e) => Some(e), + SchedulerError::AnyhowError(e) => Some(e.as_ref()), + _ => None, + } + } +} + +impl From for SchedulerError { + fn from(err: io::Error) -> Self { + SchedulerError::StorageError(err) + } +} + +impl From for SchedulerError { + fn from(err: serde_json::Error) -> Self { + SchedulerError::PersistError(err.to_string()) + } +} + +impl From for SchedulerError { + fn from(err: anyhow::Error) -> Self { + SchedulerError::AnyhowError(err) + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct ScheduledJob { + pub id: String, + pub source: String, + pub cron: String, + pub last_run: Option>, +} + +async fn persist_jobs_from_arc( + storage_path: &Path, + jobs_arc: &Arc>>, +) -> Result<(), SchedulerError> { + let jobs_guard = jobs_arc.lock().await; + let list: Vec = jobs_guard.values().map(|(_, j)| j.clone()).collect(); + if let Some(parent) = storage_path.parent() { + fs::create_dir_all(parent).map_err(SchedulerError::StorageError)?; + } + let data = serde_json::to_string_pretty(&list).map_err(SchedulerError::from)?; + fs::write(storage_path, data).map_err(SchedulerError::StorageError)?; + Ok(()) +} + +pub struct Scheduler { + internal_scheduler: TokioJobScheduler, + jobs: Arc>>, + storage_path: PathBuf, +} + +impl Scheduler { + pub async fn new(storage_path: PathBuf) -> Result, SchedulerError> { + let internal_scheduler = TokioJobScheduler::new() + .await + .map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?; + + let jobs = Arc::new(Mutex::new(HashMap::new())); + + let arc_self = Arc::new(Self { + internal_scheduler, + jobs, + storage_path, + }); + + arc_self.load_jobs_from_storage().await?; + arc_self + .internal_scheduler + .start() + .await + .map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?; + + Ok(arc_self) + } + + pub async fn add_scheduled_job( + &self, + original_job_spec: ScheduledJob, + ) -> Result<(), SchedulerError> { + let mut jobs_guard = self.jobs.lock().await; + if jobs_guard.contains_key(&original_job_spec.id) { + return Err(SchedulerError::JobIdExists(original_job_spec.id.clone())); + } + + let original_recipe_path = Path::new(&original_job_spec.source); + if !original_recipe_path.exists() { + return Err(SchedulerError::RecipeLoadError(format!( + "Original recipe file not found: {}", + original_job_spec.source + ))); + } + if !original_recipe_path.is_file() { + return Err(SchedulerError::RecipeLoadError(format!( + "Original recipe source is not a file: {}", + original_job_spec.source + ))); + } + + let scheduled_recipes_dir = get_default_scheduled_recipes_dir()?; + let original_extension = original_recipe_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("yaml"); + + let destination_filename = format!("{}.{}", original_job_spec.id, original_extension); + let destination_recipe_path = scheduled_recipes_dir.join(destination_filename); + + tracing::info!( + "Copying recipe from {} to {}", + original_recipe_path.display(), + destination_recipe_path.display() + ); + fs::copy(original_recipe_path, &destination_recipe_path).map_err(|e| { + SchedulerError::StorageError(io::Error::new( + e.kind(), + format!( + "Failed to copy recipe from {} to {}: {}", + original_job_spec.source, + destination_recipe_path.display(), + e + ), + )) + })?; + + let mut stored_job = original_job_spec.clone(); + stored_job.source = destination_recipe_path.to_string_lossy().into_owned(); + tracing::info!("Updated job source path to: {}", stored_job.source); + + let job_for_task = stored_job.clone(); + let jobs_arc_for_task = self.jobs.clone(); + let storage_path_for_task = self.storage_path.clone(); + + let cron_task = Job::new_async(&stored_job.cron, move |_uuid, _l| { + let task_job_id = job_for_task.id.clone(); + let current_jobs_arc = jobs_arc_for_task.clone(); + let local_storage_path = storage_path_for_task.clone(); + let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal + + Box::pin(async move { + let current_time = Utc::now(); + let mut needs_persist = false; + { + let mut jobs_map_guard = current_jobs_arc.lock().await; + if let Some((_, current_job_in_map)) = jobs_map_guard.get_mut(&task_job_id) { + current_job_in_map.last_run = Some(current_time); + needs_persist = true; + } + } + + if needs_persist { + if let Err(e) = + persist_jobs_from_arc(&local_storage_path, ¤t_jobs_arc).await + { + tracing::error!( + "Failed to persist last_run update for job {}: {}", + &task_job_id, + e + ); + } + } + // Pass None for provider_override in normal execution + if let Err(e) = run_scheduled_job_internal(job_to_execute, None).await { + tracing::error!( + "Scheduled job '{}' execution failed: {}", + &e.job_id, + e.error + ); + } + }) + }) + .map_err(|e| SchedulerError::CronParseError(e.to_string()))?; + + let job_uuid = self + .internal_scheduler + .add(cron_task) + .await + .map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?; + + jobs_guard.insert(stored_job.id.clone(), (job_uuid, stored_job)); + // Pass the jobs_guard by reference for the initial persist after adding a job + self.persist_jobs_to_storage_with_guard(&jobs_guard).await?; + Ok(()) + } + + async fn load_jobs_from_storage(self: &Arc) -> Result<(), SchedulerError> { + if !self.storage_path.exists() { + return Ok(()); + } + let data = fs::read_to_string(&self.storage_path)?; + if data.trim().is_empty() { + return Ok(()); + } + + let list: Vec = serde_json::from_str(&data).map_err(|e| { + SchedulerError::PersistError(format!("Failed to deserialize schedules.json: {}", e)) + })?; + + let mut jobs_guard = self.jobs.lock().await; + for job_to_load in list { + if !Path::new(&job_to_load.source).exists() { + tracing::warn!("Recipe file {} for scheduled job {} not found in shared store. Skipping job load.", job_to_load.source, job_to_load.id); + continue; + } + + let job_for_task = job_to_load.clone(); + let jobs_arc_for_task = self.jobs.clone(); + let storage_path_for_task = self.storage_path.clone(); + + let cron_task = Job::new_async(&job_to_load.cron, move |_uuid, _l| { + let task_job_id = job_for_task.id.clone(); + let current_jobs_arc = jobs_arc_for_task.clone(); + let local_storage_path = storage_path_for_task.clone(); + let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal + + Box::pin(async move { + let current_time = Utc::now(); + let mut needs_persist = false; + { + let mut jobs_map_guard = current_jobs_arc.lock().await; + if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) { + stored_job.last_run = Some(current_time); + needs_persist = true; + } + } + + if needs_persist { + if let Err(e) = + persist_jobs_from_arc(&local_storage_path, ¤t_jobs_arc).await + { + tracing::error!( + "Failed to persist last_run update for loaded job {}: {}", + &task_job_id, + e + ); + } + } + // Pass None for provider_override in normal execution + if let Err(e) = run_scheduled_job_internal(job_to_execute, None).await { + tracing::error!( + "Scheduled job '{}' execution failed: {}", + &e.job_id, + e.error + ); + } + }) + }) + .map_err(|e| SchedulerError::CronParseError(e.to_string()))?; + + let job_uuid = self + .internal_scheduler + .add(cron_task) + .await + .map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?; + jobs_guard.insert(job_to_load.id.clone(), (job_uuid, job_to_load)); + } + Ok(()) + } + + // Renamed and kept for direct use when a guard is already held (e.g. add/remove) + async fn persist_jobs_to_storage_with_guard( + &self, + jobs_guard: &tokio::sync::MutexGuard<'_, HashMap>, + ) -> Result<(), SchedulerError> { + let list: Vec = jobs_guard.values().map(|(_, j)| j.clone()).collect(); + if let Some(parent) = self.storage_path.parent() { + fs::create_dir_all(parent)?; + } + let data = serde_json::to_string_pretty(&list)?; + fs::write(&self.storage_path, data)?; + Ok(()) + } + + // New function that locks and calls the helper, for run_now and potentially other places + async fn persist_jobs(&self) -> Result<(), SchedulerError> { + persist_jobs_from_arc(&self.storage_path, &self.jobs).await + } + + pub async fn list_scheduled_jobs(&self) -> Vec { + self.jobs + .lock() + .await + .values() + .map(|(_, j)| j.clone()) + .collect() + } + + pub async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> { + let mut jobs_guard = self.jobs.lock().await; + if let Some((job_uuid, scheduled_job)) = jobs_guard.remove(id) { + self.internal_scheduler + .remove(&job_uuid) + .await + .map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?; + + let recipe_path = Path::new(&scheduled_job.source); + if recipe_path.exists() { + fs::remove_file(recipe_path).map_err(SchedulerError::StorageError)?; + } + + self.persist_jobs_to_storage_with_guard(&jobs_guard).await?; + Ok(()) + } else { + Err(SchedulerError::JobNotFound(id.to_string())) + } + } + + pub async fn sessions( + &self, + sched_id: &str, + limit: usize, + ) -> Result, SchedulerError> { + // Changed return type + let all_session_files = session::storage::list_sessions() + .map_err(|e| SchedulerError::StorageError(io::Error::other(e)))?; + + let mut schedule_sessions: Vec<(String, SessionMetadata)> = Vec::new(); + + for (session_name, session_path) in all_session_files { + match session::storage::read_metadata(&session_path) { + Ok(metadata) => { + // metadata is not mutable here, and SessionMetadata is original + if metadata.schedule_id.as_deref() == Some(sched_id) { + schedule_sessions.push((session_name, metadata)); // Keep the tuple + } + } + Err(e) => { + tracing::warn!( + "Failed to read metadata for session file {}: {}. Skipping.", + session_path.display(), + e + ); + } + } + } + + schedule_sessions.sort_by(|a, b| b.0.cmp(&a.0)); // Sort by session_name (timestamp string) + + // Keep the tuple, just take the limit + let result_sessions: Vec<(String, SessionMetadata)> = + schedule_sessions.into_iter().take(limit).collect(); + + Ok(result_sessions) // Return the Vec of tuples + } + + pub async fn run_now(&self, sched_id: &str) -> Result { + let job_to_run: ScheduledJob = { + let jobs_guard = self.jobs.lock().await; + match jobs_guard.get(sched_id) { + Some((_, job_def)) => job_def.clone(), + None => return Err(SchedulerError::JobNotFound(sched_id.to_string())), + } + }; + // Pass None for provider_override in normal execution + let session_id = run_scheduled_job_internal(job_to_run.clone(), None) + .await + .map_err(|e| { + SchedulerError::AnyhowError(anyhow!( + "Failed to execute job '{}' immediately: {}", + sched_id, + e.error + )) + })?; + + { + let mut jobs_guard = self.jobs.lock().await; + if let Some((_tokio_job_id, job_in_map)) = jobs_guard.get_mut(sched_id) { + job_in_map.last_run = Some(Utc::now()); + } // MutexGuard is dropped here + } + // Persist after the lock is released and update is made. + self.persist_jobs().await?; + + Ok(session_id) + } +} + +#[derive(Debug)] +struct JobExecutionError { + job_id: String, + error: String, +} + +async fn run_scheduled_job_internal( + job: ScheduledJob, + provider_override: Option>, // New optional parameter +) -> std::result::Result { + tracing::info!("Executing job: {} (Source: {})", job.id, job.source); + + let recipe_path = Path::new(&job.source); + + let recipe_content = match fs::read_to_string(recipe_path) { + Ok(content) => content, + Err(e) => { + return Err(JobExecutionError { + job_id: job.id.clone(), + error: format!("Failed to load recipe file '{}': {}", job.source, e), + }); + } + }; + + let recipe: Recipe = { + let extension = recipe_path + .extension() + .and_then(|os_str| os_str.to_str()) + .unwrap_or("yaml") + .to_lowercase(); + + match extension.as_str() { + "json" | "jsonl" => { + serde_json::from_str::(&recipe_content).map_err(|e| JobExecutionError { + job_id: job.id.clone(), + error: format!("Failed to parse JSON recipe '{}': {}", job.source, e), + }) + } + "yaml" | "yml" => { + serde_yaml::from_str::(&recipe_content).map_err(|e| JobExecutionError { + job_id: job.id.clone(), + error: format!("Failed to parse YAML recipe '{}': {}", job.source, e), + }) + } + _ => Err(JobExecutionError { + job_id: job.id.clone(), + error: format!( + "Unsupported recipe file extension '{}' for: {}", + extension, job.source + ), + }), + } + }?; + + let agent: Agent = Agent::new(); + + let agent_provider: Arc; // Use the aliased GooseProvider + + if let Some(provider) = provider_override { + agent_provider = provider; + } else { + let global_config = Config::global(); + let provider_name: String = match global_config.get_param("GOOSE_PROVIDER") { + Ok(name) => name, + Err(_) => return Err(JobExecutionError { + job_id: job.id.clone(), + error: + "GOOSE_PROVIDER not configured globally. Run 'goose configure' or set env var." + .to_string(), + }), + }; + let model_name: String = + match global_config.get_param("GOOSE_MODEL") { + Ok(name) => name, + Err(_) => return Err(JobExecutionError { + job_id: job.id.clone(), + error: + "GOOSE_MODEL not configured globally. Run 'goose configure' or set env var." + .to_string(), + }), + }; + let model_config = crate::model::ModelConfig::new(model_name.clone()); + agent_provider = create(&provider_name, model_config).map_err(|e| JobExecutionError { + job_id: job.id.clone(), + error: format!( + "Failed to create provider instance '{}': {}", + provider_name, e + ), + })?; + } + + if let Err(e) = agent.update_provider(agent_provider).await { + return Err(JobExecutionError { + job_id: job.id.clone(), + error: format!("Failed to set provider on agent: {}", e), + }); + } + tracing::info!("Agent configured with provider for job '{}'", job.id); + + let session_id_for_return = session::generate_session_id(); + let session_file_path = crate::session::storage::get_path( + crate::session::storage::Identifier::Name(session_id_for_return.clone()), + ); + + if let Some(prompt_text) = recipe.prompt { + let mut all_session_messages: Vec = + vec![Message::user().with_text(prompt_text.clone())]; + + let current_dir = match std::env::current_dir() { + Ok(cd) => cd, + Err(e) => { + return Err(JobExecutionError { + job_id: job.id.clone(), + error: format!("Failed to get current directory for job execution: {}", e), + }); + } + }; + + let session_config = SessionConfig { + id: crate::session::storage::Identifier::Name(session_id_for_return.clone()), + working_dir: current_dir.clone(), + schedule_id: Some(job.id.clone()), + }; + + match agent + .reply(&all_session_messages, Some(session_config.clone())) + .await + { + Ok(mut stream) => { + use futures::StreamExt; + + while let Some(message_result) = stream.next().await { + match message_result { + Ok(msg) => { + if msg.role == mcp_core::role::Role::Assistant { + tracing::info!("[Job {}] Assistant: {:?}", job.id, msg.content); + } + all_session_messages.push(msg); + } + Err(e) => { + tracing::error!( + "[Job {}] Error receiving message from agent: {}", + job.id, + e + ); + break; + } + } + } + + match crate::session::storage::read_metadata(&session_file_path) { + Ok(mut updated_metadata) => { + updated_metadata.message_count = all_session_messages.len(); + if let Err(e) = crate::session::storage::save_messages_with_metadata( + &session_file_path, + &updated_metadata, + &all_session_messages, + ) { + tracing::error!( + "[Job {}] Failed to persist final messages: {}", + job.id, + e + ); + } + } + Err(e) => { + tracing::error!( + "[Job {}] Failed to read updated metadata before final save: {}", + job.id, + e + ); + let fallback_metadata = crate::session::storage::SessionMetadata { + working_dir: current_dir.clone(), + description: String::new(), + schedule_id: Some(job.id.clone()), + message_count: all_session_messages.len(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + }; + if let Err(e_fb) = crate::session::storage::save_messages_with_metadata( + &session_file_path, + &fallback_metadata, + &all_session_messages, + ) { + tracing::error!("[Job {}] Failed to persist final messages with fallback metadata: {}", job.id, e_fb); + } + } + } + } + Err(e) => { + return Err(JobExecutionError { + job_id: job.id.clone(), + error: format!("Agent failed to reply for recipe '{}': {}", job.source, e), + }); + } + } + } else { + tracing::warn!( + "[Job {}] Recipe '{}' has no prompt to execute.", + job.id, + job.source + ); + let metadata = crate::session::storage::SessionMetadata { + working_dir: std::env::current_dir().unwrap_or_default(), + description: "Empty job - no prompt".to_string(), + schedule_id: Some(job.id.clone()), + message_count: 0, + ..Default::default() + }; + if let Err(e) = + crate::session::storage::save_messages_with_metadata(&session_file_path, &metadata, &[]) + { + tracing::error!( + "[Job {}] Failed to persist metadata for empty job: {}", + job.id, + e + ); + } + } + + tracing::info!("Finished job: {}", job.id); + Ok(session_id_for_return) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::recipe::Recipe; + use crate::{ + message::MessageContent, + model::ModelConfig, // Use the actual ModelConfig for the mock's field + providers::base::{ProviderMetadata, ProviderUsage, Usage}, + providers::errors::ProviderError, + }; + use mcp_core::{content::TextContent, tool::Tool, Role}; + // Removed: use crate::session::storage::{get_most_recent_session, read_metadata}; + // `read_metadata` is still used by the test itself, so keep it or its module. + use crate::session::storage::read_metadata; + + use std::env; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::tempdir; + + #[derive(Clone)] + struct MockSchedulerTestProvider { + model_config: ModelConfig, + } + + #[async_trait::async_trait] + impl GooseProvider for MockSchedulerTestProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "mock-scheduler-test", + "Mock for Scheduler Test", + "A mock provider for scheduler tests", // description + "test-model", // default_model + vec!["test-model"], // model_names + "", // model_doc_link (empty string if not applicable) + vec![], // config_keys (empty vec if none) + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: vec![MessageContent::Text(TextContent { + text: "Mocked scheduled response".to_string(), + annotations: None, + })], + }, + ProviderUsage::new("mock-scheduler-test".to_string(), Usage::default()), + )) + } + } + + // This function is pub(super) making it visible to run_scheduled_job_internal (parent module) + // when cfg(test) is active for the whole compilation unit. + pub(super) fn create_scheduler_test_mock_provider( + model_config: ModelConfig, + ) -> Arc { + Arc::new(MockSchedulerTestProvider { model_config }) + } + + #[tokio::test] + async fn test_scheduled_session_has_schedule_id() -> Result<(), Box> { + // Set environment variables for the test + env::set_var("GOOSE_PROVIDER", "test_provider"); + env::set_var("GOOSE_MODEL", "test_model"); + + let temp_dir = tempdir()?; + let recipe_dir = temp_dir.path().join("recipes_for_test_scheduler"); + fs::create_dir_all(&recipe_dir)?; + + let _ = session::storage::ensure_session_dir().expect("Failed to ensure app session dir"); + + let schedule_id_str = "test_schedule_001_scheduler_check".to_string(); + let recipe_filename = recipe_dir.join(format!("{}.json", schedule_id_str)); + + let dummy_recipe = Recipe { + version: "1.0.0".to_string(), + title: "Test Schedule ID Recipe".to_string(), + description: "A recipe for testing schedule_id propagation.".to_string(), + instructions: None, + prompt: Some("This is a test prompt for a scheduled job.".to_string()), + extensions: None, + context: None, + activities: None, + author: None, + parameters: None, + }; + let mut recipe_file = File::create(&recipe_filename)?; + writeln!( + recipe_file, + "{}", + serde_json::to_string_pretty(&dummy_recipe)? + )?; + recipe_file.flush()?; + drop(recipe_file); + + let dummy_job = ScheduledJob { + id: schedule_id_str.clone(), + source: recipe_filename.to_string_lossy().into_owned(), + cron: "* * * * * * ".to_string(), // Runs every second for quick testing + last_run: None, + }; + + // Create the mock provider instance for the test + let mock_model_config = ModelConfig::new("test_model".to_string()); + let mock_provider_instance = create_scheduler_test_mock_provider(mock_model_config); + + // Call run_scheduled_job_internal, passing the mock provider + let created_session_id = + run_scheduled_job_internal(dummy_job.clone(), Some(mock_provider_instance)) + .await + .expect("run_scheduled_job_internal failed"); + + let session_dir = session::storage::ensure_session_dir()?; + let expected_session_path = session_dir.join(format!("{}.jsonl", created_session_id)); + + assert!( + expected_session_path.exists(), + "Expected session file {} was not created", + expected_session_path.display() + ); + + let metadata = read_metadata(&expected_session_path)?; + + assert_eq!( + metadata.schedule_id, + Some(schedule_id_str.clone()), + "Session metadata schedule_id ({:?}) does not match the job ID ({}). File: {}", + metadata.schedule_id, + schedule_id_str, + expected_session_path.display() + ); + + // Check if messages were written + let messages_in_file = crate::session::storage::read_messages(&expected_session_path)?; + assert!( + !messages_in_file.is_empty(), + "No messages were written to the session file: {}", + expected_session_path.display() + ); + // We expect at least a user prompt and an assistant response + assert!( + messages_in_file.len() >= 2, + "Expected at least 2 messages (prompt + response), found {} in file: {}", + messages_in_file.len(), + expected_session_path.display() + ); + + // Clean up environment variables + env::remove_var("GOOSE_PROVIDER"); + env::remove_var("GOOSE_MODEL"); + + Ok(()) + } +} diff --git a/crates/goose/src/session/storage.rs b/crates/goose/src/session/storage.rs index 38309667..c89f8e1a 100644 --- a/crates/goose/src/session/storage.rs +++ b/crates/goose/src/session/storage.rs @@ -25,6 +25,8 @@ pub struct SessionMetadata { pub working_dir: PathBuf, /// A short description of the session, typically 3 words or less pub description: String, + /// ID of the schedule that triggered this session, if any + pub schedule_id: Option, /// Number of messages in the session pub message_count: usize, /// The total number of tokens used in the session. Retrieved from the provider's last usage. @@ -51,6 +53,7 @@ impl<'de> Deserialize<'de> for SessionMetadata { struct Helper { description: String, message_count: usize, + schedule_id: Option, // For backward compatibility total_tokens: Option, input_tokens: Option, output_tokens: Option, @@ -71,6 +74,7 @@ impl<'de> Deserialize<'de> for SessionMetadata { Ok(SessionMetadata { description: helper.description, message_count: helper.message_count, + schedule_id: helper.schedule_id, total_tokens: helper.total_tokens, input_tokens: helper.input_tokens, output_tokens: helper.output_tokens, @@ -94,6 +98,7 @@ impl SessionMetadata { Self { working_dir, description: String::new(), + schedule_id: None, message_count: 0, total_tokens: None, input_tokens: None, diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index c4365fd2..bb851ab4 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -152,9 +152,26 @@ async fn run_truncate_test( assert_eq!(responses[0].content.len(), 1); - let response_text = responses[0].content[0].as_text().unwrap(); - assert!(response_text.to_lowercase().contains("no")); - assert!(!response_text.to_lowercase().contains("yes")); + match responses[0].content[0] { + goose::message::MessageContent::Text(ref text_content) => { + assert!(text_content.text.to_lowercase().contains("no")); + assert!(!text_content.text.to_lowercase().contains("yes")); + } + goose::message::MessageContent::ContextLengthExceeded(_) => { + // This is an acceptable outcome for providers that don't truncate themselves + // and correctly report that the context length was exceeded. + println!( + "Received ContextLengthExceeded as expected for {:?}", + provider_type + ); + } + _ => { + panic!( + "Unexpected message content type: {:?}", + responses[0].content[0] + ); + } + } Ok(()) } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 2716c125..ee1cc170 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "1.0.23" + "version": "1.0.24" }, "paths": { "/agent/tools": { @@ -453,6 +453,176 @@ ] } }, + "/schedule/create": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "create_schedule", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Scheduled job created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/delete/{id}": { + "delete": { + "tags": [ + "schedule" + ], + "operationId": "delete_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to delete", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Scheduled job deleted successfully" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/list": { + "get": { + "tags": [ + "schedule" + ], + "operationId": "list_schedules", + "responses": { + "200": { + "description": "A list of scheduled jobs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSchedulesResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/run_now": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "run_now_handler", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to run", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled job triggered successfully, returns new session ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunNowResponse" + } + } + } + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error when trying to run the job" + } + } + } + }, + "/schedule/{id}/sessions": { + "get": { + "tags": [ + "schedule" + ], + "operationId": "sessions_handler", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "A list of session display info", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionDisplayInfo" + } + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/sessions": { "get": { "tags": [ @@ -731,6 +901,25 @@ } } }, + "CreateScheduleRequest": { + "type": "object", + "required": [ + "id", + "recipe_source", + "cron" + ], + "properties": { + "cron": { + "type": "string" + }, + "id": { + "type": "string" + }, + "recipe_source": { + "type": "string" + } + } + }, "EmbeddedResource": { "type": "object", "required": [ @@ -1030,6 +1219,20 @@ } } }, + "ListSchedulesResponse": { + "type": "object", + "required": [ + "jobs" + ], + "properties": { + "jobs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + }, "Message": { "type": "object", "description": "A message to or from an LLM", @@ -1334,15 +1537,13 @@ ], "properties": { "is_configured": { - "type": "boolean", - "description": "Indicates whether the provider is fully configured" + "type": "boolean" }, "metadata": { "$ref": "#/components/schemas/ProviderMetadata" }, "name": { - "type": "string", - "description": "Unique identifier and name of the provider" + "type": "string" } } }, @@ -1469,6 +1670,103 @@ "assistant" ] }, + "RunNowResponse": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, + "ScheduledJob": { + "type": "object", + "required": [ + "id", + "source", + "cron" + ], + "properties": { + "cron": { + "type": "string" + }, + "id": { + "type": "string" + }, + "last_run": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "source": { + "type": "string" + } + } + }, + "SessionDisplayInfo": { + "type": "object", + "required": [ + "id", + "name", + "createdAt", + "workingDir", + "messageCount" + ], + "properties": { + "accumulatedInputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulatedOutputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulatedTotalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "inputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "messageCount": { + "type": "integer", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "outputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "scheduleId": { + "type": "string", + "nullable": true + }, + "totalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "workingDir": { + "type": "string" + } + } + }, "SessionHistoryResponse": { "type": "object", "required": [ @@ -1579,6 +1877,11 @@ "description": "The number of output tokens used in the session. Retrieved from the provider's last usage.", "nullable": true }, + "schedule_id": { + "type": "string", + "description": "ID of the schedule that triggered this session, if any", + "nullable": true + }, "total_tokens": { "type": "integer", "format": "int32", @@ -1592,6 +1895,16 @@ } } }, + "SessionsQuery": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "SummarizationRequested": { "type": "object", "required": [ @@ -1757,8 +2070,7 @@ "$ref": "#/components/schemas/PermissionLevel" }, "tool_name": { - "type": "string", - "description": "Unique identifier and name of the tool, format __" + "type": "string" } } }, diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index bd70c8b5..6cc2c894 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -31,6 +31,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cors": "^2.8.5", + "cronstrue": "^2.48.0", "dotenv": "^16.4.5", "electron-log": "^5.2.2", "electron-squirrel-startup": "^1.0.1", @@ -6796,6 +6797,15 @@ "node": ">= 6" } }, + "node_modules/cronstrue": { + "version": "2.61.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.61.0.tgz", + "integrity": "sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA==", + "license": "MIT", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 48370ff3..c74a1f93 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -102,6 +102,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cors": "^2.8.5", + "cronstrue": "^2.48.0", "dotenv": "^16.4.5", "electron-log": "^5.2.2", "electron-squirrel-startup": "^1.0.1", diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index f032675e..91392ce0 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -8,7 +8,6 @@ import { ToastContainer } from 'react-toastify'; import { toastService } from './toasts'; import { extractExtensionName } from './components/settings/extensions/utils'; import { GoosehintsModal } from './components/GoosehintsModal'; -import { SessionDetails } from './sessions'; import ChatView from './components/ChatView'; import SuspenseLoader from './suspense-loader'; @@ -18,6 +17,7 @@ import MoreModelsView from './components/settings/models/MoreModelsView'; import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; +import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage'; import RecipeEditor from './components/RecipeEditor'; import { useChat } from './hooks/useChat'; @@ -28,7 +28,8 @@ import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './compon import { backupConfig, initConfig, readAllConfig } from './api/sdk.gen'; import PermissionSettingsView from './components/settings_v2/permission/PermissionSetting'; -// Views and their options +import { type SessionDetails } from './sessions'; + export type View = | 'welcome' | 'chat' @@ -39,6 +40,7 @@ export type View = | 'ConfigureProviders' | 'settingsV2' | 'sessions' + | 'schedules' | 'sharedSession' | 'loading' | 'recipeEditor' @@ -47,8 +49,7 @@ export type View = export type ViewOptions = | SettingsViewOptions | { resumedSession?: SessionDetails } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - | Record; + | Record; export type ViewConfig = { view: View; @@ -69,7 +70,6 @@ const getInitialView = (): ViewConfig => { }; } - // Any other URL-specified view if (viewFromUrl) { return { view: viewFromUrl as View, @@ -77,7 +77,6 @@ const getInitialView = (): ViewConfig => { }; } - // Default case return { view: 'loading', viewOptions: {}, @@ -93,10 +92,10 @@ export default function App() { const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); const [{ view, viewOptions }, setInternalView] = useState(getInitialView()); + const { getExtensions, addExtension, read } = useConfig(); const initAttemptedRef = useRef(false); - // Utility function to extract the command from the link function extractCommand(link: string): string { const url = new URL(link); const cmd = url.searchParams.get('cmd') || 'Unknown Command'; @@ -104,7 +103,6 @@ export default function App() { return `${cmd} ${args.join(' ')}`.trim(); } - // Utility function to extract the remote url from the link function extractRemoteUrl(link: string): string { const url = new URL(link); return url.searchParams.get('url'); @@ -116,7 +114,6 @@ export default function App() { }; useEffect(() => { - // Guard against multiple initialization attempts if (initAttemptedRef.current) { console.log('Initialization already attempted, skipping...'); return; @@ -129,7 +126,6 @@ export default function App() { const viewType = urlParams.get('view'); const recipeConfig = window.appConfig.get('recipeConfig'); - // If we have a specific view type in the URL, use that and skip provider detection if (viewType) { if (viewType === 'recipeEditor' && recipeConfig) { console.log('Setting view to recipeEditor with config:', recipeConfig); @@ -142,39 +138,31 @@ export default function App() { const initializeApp = async () => { try { - // checks if there is a config, and if not creates it await initConfig(); - - // now try to read config, if we fail and are migrating backup, then re-init config try { await readAllConfig({ throwOnError: true }); } catch (error) { - // NOTE: we do this check here and in providerUtils.ts, be sure to clean up both in the future const configVersion = localStorage.getItem('configVersion'); const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3; if (shouldMigrateExtensions) { await backupConfig({ throwOnError: true }); await initConfig(); } else { - // if we've migrated throw this back up throw new Error('Unable to read config file, it may be malformed'); } } - // note: if in a non recipe session, recipeConfig is undefined, otherwise null if error if (recipeConfig === null) { setFatalError('Cannot read recipe config. Please check the deeplink and try again.'); return; } const config = window.electron.getConfig(); - const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; if (provider && model) { setView('chat'); - try { await initializeSystem(provider, model, { getExtensions, @@ -182,13 +170,9 @@ export default function App() { }); } catch (error) { console.error('Error in initialization:', error); - - // propagate the error upward so the global ErrorUI shows in cases - // where going through welcome/onboarding wouldn't address the issue if (error instanceof MalformedConfigError) { throw error; } - setView('welcome'); } } else { @@ -201,8 +185,6 @@ export default function App() { ); setView('welcome'); } - - // Reset toast service after initialization toastService.configure({ silent: false }); }; @@ -215,8 +197,7 @@ export default function App() { setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`); } })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Empty dependency array since we only want this to run once + }, [read, getExtensions, addExtension]); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); const [isLoadingSession, setIsLoadingSession] = useState(false); @@ -236,32 +217,26 @@ export default function App() { } }, []); - // Handle shared session deep links useEffect(() => { const handleOpenSharedSession = async (_event: IpcRendererEvent, link: string) => { window.electron.logInfo(`Opening shared session from deep link ${link}`); setIsLoadingSharedSession(true); setSharedSessionError(null); - try { await openSharedSessionFromDeepLink(link, setView); - // No need to handle errors here as openSharedSessionFromDeepLink now handles them internally } catch (error) { - // This should not happen, but just in case console.error('Unexpected error opening shared session:', error); - setView('sessions'); // Fallback to sessions view + setView('sessions'); } finally { setIsLoadingSharedSession(false); } }; - window.electron.on('open-shared-session', handleOpenSharedSession); return () => { window.electron.off('open-shared-session', handleOpenSharedSession); }; }, []); - // Keyboard shortcut handler useEffect(() => { console.log('Setting up keyboard shortcuts'); const handleKeyDown = (event: KeyboardEvent) => { @@ -277,7 +252,6 @@ export default function App() { } } }; - window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); @@ -288,17 +262,15 @@ export default function App() { console.log('Setting up fatal error handler'); const handleFatalError = (_event: IpcRendererEvent, errorMessage: string) => { console.error('Encountered a fatal error: ', errorMessage); - // Log additional context that might help diagnose the issue console.error('Current view:', view); console.error('Is loading session:', isLoadingSession); setFatalError(errorMessage); }; - window.electron.on('fatal-error', handleFatalError); return () => { window.electron.off('fatal-error', handleFatalError); }; - }, [view, isLoadingSession]); // Add dependencies to provide context in error logs + }, [view, isLoadingSession]); useEffect(() => { console.log('Setting up view change handler'); @@ -306,14 +278,10 @@ export default function App() { console.log(`Received view change request to: ${newView}`); setView(newView); }; - - // Get initial view and config const urlParams = new URLSearchParams(window.location.search); const viewFromUrl = urlParams.get('view'); if (viewFromUrl) { - // Get the config from the electron window config const windowConfig = window.electron.getConfig(); - if (viewFromUrl === 'recipeEditor') { const initialViewOptions = { recipeConfig: windowConfig?.recipeConfig, @@ -324,12 +292,10 @@ export default function App() { setView(viewFromUrl); } } - window.electron.on('set-view', handleSetView); return () => window.electron.off('set-view', handleSetView); }, []); - // Add cleanup for session states when view changes useEffect(() => { console.log(`View changed to: ${view}`); if (view !== 'chat' && view !== 'recipeEditor') { @@ -338,10 +304,7 @@ export default function App() { } }, [view]); - // Configuration for extension security const config = window.electron.getConfig(); - // If GOOSE_ALLOWLIST_WARNING is true, use warning-only mode (STRICT_ALLOWLIST=false) - // If GOOSE_ALLOWLIST_WARNING is not set or false, use strict blocking mode (STRICT_ALLOWLIST=true) const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true; useEffect(() => { @@ -354,35 +317,24 @@ export default function App() { const extName = extractExtensionName(link); window.electron.logInfo(`Adding extension from deep link ${link}`); setPendingLink(link); - - // Default values for confirmation dialog let warningMessage = ''; let label = 'OK'; let title = 'Confirm Extension Installation'; let isBlocked = false; let useDetailedMessage = false; - - // For SSE extensions (with remoteUrl), always use detailed message if (remoteUrl) { useDetailedMessage = true; } else { - // For command-based extensions, check against allowlist try { const allowedCommands = await window.electron.getAllowedExtensions(); - - // Only check and show warning if we have a non-empty allowlist if (allowedCommands && allowedCommands.length > 0) { const isCommandAllowed = allowedCommands.some((allowedCmd) => command.startsWith(allowedCmd) ); - if (!isCommandAllowed) { - // Not in allowlist - use detailed message and show warning/block useDetailedMessage = true; title = '⛔️ Untrusted Extension ⛔️'; - if (STRICT_ALLOWLIST) { - // Block installation completely unless override is active isBlocked = true; label = 'Extension Blocked'; warningMessage = @@ -390,7 +342,6 @@ export default function App() { 'Installation is blocked by your administrator. ' + 'Please contact your administrator if you need this extension.'; } else { - // Allow override (either because STRICT_ALLOWLIST is false or secret key combo was used) label = 'Override and install'; warningMessage = '\n\n⚠️ WARNING: This extension command is not in the allowed list. ' + @@ -398,51 +349,38 @@ export default function App() { 'Please contact an admin if you are unsure or want to allow this extension.'; } } - // If in allowlist, use simple message (useDetailedMessage remains false) } - // If no allowlist, use simple message (useDetailedMessage remains false) } catch (error) { console.error('Error checking allowlist:', error); } } - - // Set the appropriate message based on the extension type and allowlist status if (useDetailedMessage) { - // Detailed message for SSE extensions or non-allowlisted command extensions const detailedMessage = remoteUrl ? `You are about to install the ${extName} extension which connects to:\n\n${remoteUrl}\n\nThis extension will be able to access your conversations and provide additional functionality.` : `You are about to install the ${extName} extension which runs the command:\n\n${command}\n\nThis extension will be able to access your conversations and provide additional functionality.`; - setModalMessage(`${detailedMessage}${warningMessage}`); } else { - // Simple message for allowlisted command extensions or when no allowlist exists const messageDetails = `Command: ${command}`; setModalMessage( `Are you sure you want to install the ${extName} extension?\n\n${messageDetails}` ); } - setExtensionConfirmLabel(label); setExtensionConfirmTitle(title); - - // If blocked, disable the confirmation button functionality by setting a special flag if (isBlocked) { - setPendingLink(null); // Clear the pending link so confirmation does nothing + setPendingLink(null); } - setModalVisible(true); } catch (error) { console.error('Error handling add-extension event:', error); } }; - window.electron.on('add-extension', handleAddExtension); return () => { window.electron.off('add-extension', handleAddExtension); }; }, [STRICT_ALLOWLIST]); - // Focus the first found input field useEffect(() => { const handleFocusInput = (_event: IpcRendererEvent) => { const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; @@ -456,28 +394,24 @@ export default function App() { }; }, []); - // TODO: modify const handleConfirm = async () => { if (pendingLink) { console.log(`Confirming installation of extension from: ${pendingLink}`); - setModalVisible(false); // Dismiss modal immediately + setModalVisible(false); try { await addExtensionFromDeepLinkV2(pendingLink, addExtension, setView); console.log('Extension installation successful'); } catch (error) { console.error('Failed to add extension:', error); - // Consider showing a user-visible error notification here } finally { setPendingLink(null); } } else { - // This case happens when pendingLink was cleared due to blocking console.log('Extension installation blocked by allowlist restrictions'); setModalVisible(false); } }; - // TODO: modify const handleCancel = () => { console.log('Cancelled extension installation.'); setModalVisible(false); @@ -566,6 +500,7 @@ export default function App() { /> )} {view === 'sessions' && } + {view === 'schedules' && setView('chat')} />} {view === 'sharedSession' && ( = ClientOptions & { @@ -144,6 +144,45 @@ export const manageContext = (options: Opt }); }; +export const createSchedule = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/schedule/create', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +export const deleteSchedule = (options: Options) => { + return (options.client ?? _heyApiClient).delete({ + url: '/schedule/delete/{id}', + ...options + }); +}; + +export const listSchedules = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/schedule/list', + ...options + }); +}; + +export const runNowHandler = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/schedule/{id}/run_now', + ...options + }); +}; + +export const sessionsHandler = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/schedule/{id}/sessions', + ...options + }); +}; + export const listSessions = (options?: Options) => { return (options?.client ?? _heyApiClient).get({ url: '/sessions', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 6f0b909b..5ffb382d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -62,6 +62,12 @@ export type ContextManageResponse = { tokenCounts: Array; }; +export type CreateScheduleRequest = { + cron: string; + id: string; + recipe_source: string; +}; + export type EmbeddedResource = { annotations?: Annotations | null; resource: ResourceContents; @@ -166,6 +172,10 @@ export type ImageContent = { mimeType: string; }; +export type ListSchedulesResponse = { + jobs: Array; +}; + /** * A message to or from an LLM */ @@ -228,14 +238,8 @@ export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow'; export type PrincipalType = 'Extension' | 'Tool'; export type ProviderDetails = { - /** - * Indicates whether the provider is fully configured - */ is_configured: boolean; metadata: ProviderMetadata; - /** - * Unique identifier and name of the provider - */ name: string; }; @@ -294,6 +298,32 @@ export type ResourceContents = { export type Role = 'user' | 'assistant'; +export type RunNowResponse = { + session_id: string; +}; + +export type ScheduledJob = { + cron: string; + id: string; + last_run?: string | null; + source: string; +}; + +export type SessionDisplayInfo = { + accumulatedInputTokens?: number | null; + accumulatedOutputTokens?: number | null; + accumulatedTotalTokens?: number | null; + createdAt: string; + id: string; + inputTokens?: number | null; + messageCount: number; + name: string; + outputTokens?: number | null; + scheduleId?: string | null; + totalTokens?: number | null; + workingDir: string; +}; + export type SessionHistoryResponse = { /** * List of messages in the session conversation @@ -352,6 +382,10 @@ export type SessionMetadata = { * The number of output tokens used in the session. Retrieved from the provider's last usage. */ output_tokens?: number | null; + /** + * ID of the schedule that triggered this session, if any + */ + schedule_id?: string | null; /** * The total number of tokens used in the session. Retrieved from the provider's last usage. */ @@ -362,6 +396,10 @@ export type SessionMetadata = { working_dir: string; }; +export type SessionsQuery = { + limit?: number; +}; + export type SummarizationRequested = { msg: string; }; @@ -464,9 +502,6 @@ export type ToolInfo = { export type ToolPermission = { permission: PermissionLevel; - /** - * Unique identifier and name of the tool, format __ - */ tool_name: string; }; @@ -849,6 +884,146 @@ export type ManageContextResponses = { export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses]; +export type CreateScheduleData = { + body: CreateScheduleRequest; + path?: never; + query?: never; + url: '/schedule/create'; +}; + +export type CreateScheduleErrors = { + /** + * Internal server error + */ + 500: unknown; +}; + +export type CreateScheduleResponses = { + /** + * Scheduled job created successfully + */ + 200: ScheduledJob; +}; + +export type CreateScheduleResponse = CreateScheduleResponses[keyof CreateScheduleResponses]; + +export type DeleteScheduleData = { + body?: never; + path: { + /** + * ID of the schedule to delete + */ + id: string; + }; + query?: never; + url: '/schedule/delete/{id}'; +}; + +export type DeleteScheduleErrors = { + /** + * Scheduled job not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type DeleteScheduleResponses = { + /** + * Scheduled job deleted successfully + */ + 204: void; +}; + +export type DeleteScheduleResponse = DeleteScheduleResponses[keyof DeleteScheduleResponses]; + +export type ListSchedulesData = { + body?: never; + path?: never; + query?: never; + url: '/schedule/list'; +}; + +export type ListSchedulesErrors = { + /** + * Internal server error + */ + 500: unknown; +}; + +export type ListSchedulesResponses = { + /** + * A list of scheduled jobs + */ + 200: ListSchedulesResponse; +}; + +export type ListSchedulesResponse2 = ListSchedulesResponses[keyof ListSchedulesResponses]; + +export type RunNowHandlerData = { + body?: never; + path: { + /** + * ID of the schedule to run + */ + id: string; + }; + query?: never; + url: '/schedule/{id}/run_now'; +}; + +export type RunNowHandlerErrors = { + /** + * Scheduled job not found + */ + 404: unknown; + /** + * Internal server error when trying to run the job + */ + 500: unknown; +}; + +export type RunNowHandlerResponses = { + /** + * Scheduled job triggered successfully, returns new session ID + */ + 200: RunNowResponse; +}; + +export type RunNowHandlerResponse = RunNowHandlerResponses[keyof RunNowHandlerResponses]; + +export type SessionsHandlerData = { + body?: never; + path: { + /** + * ID of the schedule + */ + id: string; + }; + query?: { + limit?: number; + }; + url: '/schedule/{id}/sessions'; +}; + +export type SessionsHandlerErrors = { + /** + * Internal server error + */ + 500: unknown; +}; + +export type SessionsHandlerResponses = { + /** + * A list of session display info + */ + 200: Array; +}; + +export type SessionsHandlerResponse = SessionsHandlerResponses[keyof SessionsHandlerResponses]; + export type ListSessionsData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/icons/TrashIcon.tsx b/ui/desktop/src/components/icons/TrashIcon.tsx new file mode 100644 index 00000000..63db6fc4 --- /dev/null +++ b/ui/desktop/src/components/icons/TrashIcon.tsx @@ -0,0 +1,19 @@ +// /Users/mnovich/Development/goose-1.0/ui/desktop/src/components/icons/TrashIcon.tsx +import React from 'react'; + +interface IconProps extends React.SVGProps {} + +export const TrashIcon: React.FC = (props) => ( + + + +); diff --git a/ui/desktop/src/components/more_menu/MoreMenu.tsx b/ui/desktop/src/components/more_menu/MoreMenu.tsx index 5820093d..b3323dd0 100644 --- a/ui/desktop/src/components/more_menu/MoreMenu.tsx +++ b/ui/desktop/src/components/more_menu/MoreMenu.tsx @@ -125,17 +125,14 @@ export default function MoreMenu({ useEffect(() => { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - // Handler for system theme changes const handleThemeChange = (e: { matches: boolean }) => { if (themeMode === 'system') { setDarkMode(e.matches); } }; - // Add listener for system theme changes mediaQuery.addEventListener('change', handleThemeChange); - // Initial setup if (themeMode === 'system') { setDarkMode(mediaQuery.matches); localStorage.setItem('use_system_theme', 'true'); @@ -145,7 +142,6 @@ export default function MoreMenu({ localStorage.setItem('theme', themeMode); } - // Cleanup return () => mediaQuery.removeEventListener('change', handleThemeChange); }, [themeMode]); @@ -221,6 +217,16 @@ export default function MoreMenu({ Session history + {process.env.ALPHA && ( + setView('schedules')} + subtitle="Manage scheduled runs" + icon={ + )} + setIsGoosehintsModalOpen(true)} subtitle="Customize instructions" diff --git a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx new file mode 100644 index 00000000..8ea6a06a --- /dev/null +++ b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx @@ -0,0 +1,439 @@ +import React, { useState, useEffect, FormEvent } from 'react'; +import { Card } from '../ui/card'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Select } from '../ui/select'; +import cronstrue from 'cronstrue'; + +type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly'; + +interface FrequencyOption { + value: FrequencyValue; + label: string; +} + +export interface NewSchedulePayload { + id: string; + recipe_source: string; + cron: string; +} + +interface CreateScheduleModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (payload: NewSchedulePayload) => Promise; + isLoadingExternally: boolean; + apiErrorExternally: string | null; +} + +const frequencies: FrequencyOption[] = [ + { value: 'once', label: 'Once' }, + { value: 'hourly', label: 'Hourly' }, + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, +]; + +const daysOfWeekOptions: { value: string; label: string }[] = [ + { value: '1', label: 'Mon' }, + { value: '2', label: 'Tue' }, + { value: '3', label: 'Wed' }, + { value: '4', label: 'Thu' }, + { value: '5', label: 'Fri' }, + { value: '6', label: 'Sat' }, + { value: '0', label: 'Sun' }, +]; + +const modalLabelClassName = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'; +const cronPreviewTextColor = 'text-xs text-gray-500 dark:text-gray-400 mt-1'; +const cronPreviewSpecialNoteColor = 'text-xs text-yellow-600 dark:text-yellow-500 mt-1'; +const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark:text-gray-300'; +const checkboxInputClassName = + 'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2'; + +export const CreateScheduleModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + isLoadingExternally, + apiErrorExternally, +}) => { + const [scheduleId, setScheduleId] = useState(''); + const [recipeSourcePath, setRecipeSourcePath] = useState(''); + const [frequency, setFrequency] = useState('daily'); + const [selectedDate, setSelectedDate] = useState( + () => new Date().toISOString().split('T')[0] + ); + const [selectedTime, setSelectedTime] = useState('09:00'); + const [selectedMinute, setSelectedMinute] = useState('0'); + const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState>(new Set(['1'])); + const [selectedDayOfMonth, setSelectedDayOfMonth] = useState('1'); + const [derivedCronExpression, setDerivedCronExpression] = useState(''); + const [readableCronExpression, setReadableCronExpression] = useState(''); + const [internalValidationError, setInternalValidationError] = useState(null); + + const resetForm = () => { + setScheduleId(''); + setRecipeSourcePath(''); + setFrequency('daily'); + setSelectedDate(new Date().toISOString().split('T')[0]); + setSelectedTime('09:00'); + setSelectedMinute('0'); + setSelectedDaysOfWeek(new Set(['1'])); + setSelectedDayOfMonth('1'); + setInternalValidationError(null); + setReadableCronExpression(''); + }; + + const handleBrowseFile = async () => { + const filePath = await window.electron.selectFileOrDirectory(); + if (filePath) { + if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) { + setRecipeSourcePath(filePath); + setInternalValidationError(null); + } else { + setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)'); + console.warn('Invalid file type: Please select a YAML file (.yaml or .yml)'); + } + } + }; + + useEffect(() => { + const generateCronExpression = (): string => { + const timeParts = selectedTime.split(':'); + const minutePart = timeParts.length > 1 ? String(parseInt(timeParts[1], 10)) : '0'; + const hourPart = timeParts.length > 0 ? String(parseInt(timeParts[0], 10)) : '0'; + if (isNaN(parseInt(minutePart)) || isNaN(parseInt(hourPart))) { + return 'Invalid time format.'; + } + const secondsPart = '0'; + switch (frequency) { + case 'once': + if (selectedDate && selectedTime) { + try { + const dateObj = new Date(`${selectedDate}T${selectedTime}`); + if (isNaN(dateObj.getTime())) return "Invalid date/time for 'once'."; + return `${secondsPart} ${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${ + dateObj.getMonth() + 1 + } *`; + } catch (e) { + return "Error parsing date/time for 'once'."; + } + } + return 'Date and Time are required for "Once" frequency.'; + case 'hourly': { + const sMinute = parseInt(selectedMinute, 10); + if (isNaN(sMinute) || sMinute < 0 || sMinute > 59) { + return 'Invalid minute (0-59) for hourly frequency.'; + } + return `${secondsPart} ${sMinute} * * * *`; + } + case 'daily': + return `${secondsPart} ${minutePart} ${hourPart} * * *`; + case 'weekly': { + if (selectedDaysOfWeek.size === 0) { + return 'Select at least one day for weekly frequency.'; + } + const days = Array.from(selectedDaysOfWeek) + .sort((a, b) => parseInt(a) - parseInt(b)) + .join(','); + return `${secondsPart} ${minutePart} ${hourPart} * * ${days}`; + } + case 'monthly': { + const sDayOfMonth = parseInt(selectedDayOfMonth, 10); + if (isNaN(sDayOfMonth) || sDayOfMonth < 1 || sDayOfMonth > 31) { + return 'Invalid day of month (1-31) for monthly frequency.'; + } + return `${secondsPart} ${minutePart} ${hourPart} ${sDayOfMonth} * *`; + } + default: + return 'Invalid frequency selected.'; + } + }; + const cron = generateCronExpression(); + setDerivedCronExpression(cron); + try { + if ( + cron.includes('Invalid') || + cron.includes('required') || + cron.includes('Error') || + cron.includes('Select at least one') + ) { + setReadableCronExpression('Invalid cron details provided.'); + } else { + setReadableCronExpression(cronstrue.toString(cron)); + } + } catch (e) { + setReadableCronExpression('Could not parse cron string.'); + } + }, [ + frequency, + selectedDate, + selectedTime, + selectedMinute, + selectedDaysOfWeek, + selectedDayOfMonth, + ]); + + const handleDayOfWeekChange = (dayValue: string) => { + setSelectedDaysOfWeek((prev) => { + const newSet = new Set(prev); + if (newSet.has(dayValue)) { + newSet.delete(dayValue); + } else { + newSet.add(dayValue); + } + return newSet; + }); + }; + + const handleLocalSubmit = async (event: FormEvent) => { + event.preventDefault(); + setInternalValidationError(null); + + if (!scheduleId.trim()) { + setInternalValidationError('Schedule ID is required.'); + return; + } + if (!recipeSourcePath) { + setInternalValidationError('Recipe source file is required.'); + return; + } + if ( + !derivedCronExpression || + derivedCronExpression.includes('Invalid') || + derivedCronExpression.includes('required') || + derivedCronExpression.includes('Error') || + derivedCronExpression.includes('Select at least one') + ) { + setInternalValidationError(`Invalid cron expression: ${derivedCronExpression}`); + return; + } + if (frequency === 'weekly' && selectedDaysOfWeek.size === 0) { + setInternalValidationError('For weekly frequency, select at least one day.'); + return; + } + + const newSchedulePayload: NewSchedulePayload = { + id: scheduleId.trim(), + recipe_source: recipeSourcePath, + cron: derivedCronExpression, + }; + + await onSubmit(newSchedulePayload); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+ +
+

+ Create New Schedule +

+
+ +
+ {apiErrorExternally && ( +

+ {apiErrorExternally} +

+ )} + {internalValidationError && ( +

+ {internalValidationError} +

+ )} + +
+ + setScheduleId(e.target.value)} + placeholder="e.g., daily-summary-job" + required + /> +
+
+ + + {recipeSourcePath && ( +

+ Selected: {recipeSourcePath} +

+ )} +
+
+ + setSelectedDate(e.target.value)} + required + /> +
+
+ + setSelectedTime(e.target.value)} + required + /> +
+ + )} + {frequency === 'hourly' && ( +
+ + setSelectedMinute(e.target.value)} + required + /> +
+ )} + {(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && ( +
+ + setSelectedTime(e.target.value)} + required + /> +
+ )} + {frequency === 'weekly' && ( +
+ +
+ {daysOfWeekOptions.map((day) => ( + + ))} +
+
+ )} + {frequency === 'monthly' && ( +
+ + setSelectedDayOfMonth(e.target.value)} + required + /> +
+ )} +
+

+ Generated Cron:{' '} + + {derivedCronExpression} + +

+

+ Human Readable: {readableCronExpression} +

+

Syntax: S M H D M DoW. (S=0, DoW: 0/7=Sun)

+ {frequency === 'once' && ( +

+ Note: "Once" schedules recur annually. True one-time tasks may need backend deletion + after execution. +

+ )} +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +}; diff --git a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx new file mode 100644 index 00000000..4ae3a86d --- /dev/null +++ b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Button } from '../ui/button'; +import { ScrollArea } from '../ui/scroll-area'; +import BackButton from '../ui/BackButton'; +import { Card } from '../ui/card'; +import MoreMenuLayout from '../more_menu/MoreMenuLayout'; +import { fetchSessionDetails, SessionDetails } from '../../sessions'; +import { getScheduleSessions, runScheduleNow } from '../../schedule'; +import SessionHistoryView from '../sessions/SessionHistoryView'; +import { toastError, toastSuccess } from '../../toasts'; + +interface ScheduleSessionMeta { + id: string; + name: string; + createdAt: string; + workingDir?: string; + scheduleId?: string | null; + messageCount?: number; + totalTokens?: number | null; + inputTokens?: number | null; + outputTokens?: number | null; + accumulatedTotalTokens?: number | null; + accumulatedInputTokens?: number | null; + accumulatedOutputTokens?: number | null; +} + +interface ScheduleDetailViewProps { + scheduleId: string | null; + onNavigateBack: () => void; +} + +const ScheduleDetailView: React.FC = ({ scheduleId, onNavigateBack }) => { + const [sessions, setSessions] = useState([]); + const [isLoadingSessions, setIsLoadingSessions] = useState(false); + const [sessionsError, setSessionsError] = useState(null); + const [runNowLoading, setRunNowLoading] = useState(false); + + const [selectedSessionDetails, setSelectedSessionDetails] = useState(null); + const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false); + const [sessionDetailsError, setSessionDetailsError] = useState(null); + + const fetchScheduleSessions = useCallback(async (sId: string) => { + if (!sId) return; + setIsLoadingSessions(true); + setSessionsError(null); + try { + const fetchedSessions = await getScheduleSessions(sId, 20); // MODIFIED + // Assuming ScheduleSession from ../../schedule can be cast or mapped to ScheduleSessionMeta + // You may need to transform/map fields if they differ significantly + setSessions(fetchedSessions as ScheduleSessionMeta[]); + } catch (err) { + console.error('Failed to fetch schedule sessions:', err); + setSessionsError(err instanceof Error ? err.message : 'Failed to fetch schedule sessions'); + } finally { + setIsLoadingSessions(false); + } + }, []); + + useEffect(() => { + if (scheduleId && !selectedSessionDetails) { + fetchScheduleSessions(scheduleId); + } else if (!scheduleId) { + setSessions([]); + setSessionsError(null); + setRunNowLoading(false); + setSelectedSessionDetails(null); + } + }, [scheduleId, fetchScheduleSessions, selectedSessionDetails]); + + const handleRunNow = async () => { + if (!scheduleId) return; + setRunNowLoading(true); + try { + const newSessionId = await runScheduleNow(scheduleId); // MODIFIED + toastSuccess({ + title: 'Schedule Triggered', + msg: `Successfully triggered schedule. New session ID: ${newSessionId}`, + }); + setTimeout(() => { + if (scheduleId) fetchScheduleSessions(scheduleId); + }, 1000); + } catch (err) { + console.error('Failed to run schedule now:', err); + const errorMsg = err instanceof Error ? err.message : 'Failed to trigger schedule'; + toastError({ title: 'Run Schedule Error', msg: errorMsg }); + } finally { + setRunNowLoading(false); + } + }; + + const loadAndShowSessionDetails = async (sessionId: string) => { + setIsLoadingSessionDetails(true); + setSessionDetailsError(null); + setSelectedSessionDetails(null); + try { + const details = await fetchSessionDetails(sessionId); + setSelectedSessionDetails(details); + } catch (err) { + console.error(`Failed to load session details for ${sessionId}:`, err); + const errorMsg = err instanceof Error ? err.message : 'Failed to load session details.'; + setSessionDetailsError(errorMsg); + toastError({ + title: 'Failed to load session details', + msg: errorMsg, + }); + } finally { + setIsLoadingSessionDetails(false); + } + }; + + const handleSessionCardClick = (sessionIdFromCard: string) => { + loadAndShowSessionDetails(sessionIdFromCard); + }; + + const handleResumeViewedSession = () => { + if (selectedSessionDetails) { + const { session_id, metadata } = selectedSessionDetails; + if (metadata.working_dir) { + console.log( + `Resuming session ID ${session_id} in new chat window. Dir: ${metadata.working_dir}` + ); + window.electron.createChatWindow(undefined, metadata.working_dir, undefined, session_id); + } else { + console.error('Cannot resume session: working directory is missing.'); + toastError({ title: 'Cannot Resume Session', msg: 'Working directory is missing.' }); + } + } + }; + + if (selectedSessionDetails) { + return ( + { + setSelectedSessionDetails(null); + setSessionDetailsError(null); + }} + onResume={handleResumeViewedSession} + onRetry={() => loadAndShowSessionDetails(selectedSessionDetails.session_id)} + showActionButtons={true} + /> + ); + } + + if (!scheduleId) { + return ( +
+ + +

+ Schedule Not Found +

+

+ No schedule ID was provided. Please return to the schedules list and select a schedule. +

+
+ ); + } + + return ( +
+ +
+ +

+ Schedule Details +

+

+ Viewing Schedule ID: {scheduleId} +

+
+ + +
+
+

Actions

+ +
+ +
+

+ Recent Sessions for this Schedule +

+ {isLoadingSessions && ( +

Loading sessions...

+ )} + {sessionsError && ( +

+ Error: {sessionsError} +

+ )} + {!isLoadingSessions && !sessionsError && sessions.length === 0 && ( +

+ No sessions found for this schedule. +

+ )} + + {!isLoadingSessions && sessions.length > 0 && ( +
+ {sessions.map((session) => ( + handleSessionCardClick(session.id)} + role="button" + tabIndex={0} + onKeyPress={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + handleSessionCardClick(session.id); + } + }} + > +

+ {session.name || `Session ID: ${session.id}`}{' '} +

+

+ Created:{' '} + {session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'} +

+ {session.messageCount !== undefined && ( +

+ Messages: {session.messageCount} +

+ )} + {session.workingDir && ( +

+ Dir: {session.workingDir} +

+ )} + {session.accumulatedTotalTokens !== undefined && + session.accumulatedTotalTokens !== null && ( +

+ Tokens: {session.accumulatedTotalTokens} +

+ )} +

+ ID: {session.id} +

+
+ ))} +
+ )} +
+
+
+
+ ); +}; + +export default ScheduleDetailView; diff --git a/ui/desktop/src/components/schedule/SchedulesView.tsx b/ui/desktop/src/components/schedule/SchedulesView.tsx new file mode 100644 index 00000000..b89e2138 --- /dev/null +++ b/ui/desktop/src/components/schedule/SchedulesView.tsx @@ -0,0 +1,230 @@ +import React, { useState, useEffect } from 'react'; +import { listSchedules, createSchedule, deleteSchedule, ScheduledJob } from '../../schedule'; +import BackButton from '../ui/BackButton'; +import { ScrollArea } from '../ui/scroll-area'; +import MoreMenuLayout from '../more_menu/MoreMenuLayout'; +import { Card } from '../ui/card'; +import { Button } from '../ui/button'; +import { TrashIcon } from '../icons/TrashIcon'; +import Plus from '../ui/Plus'; +import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal'; +import ScheduleDetailView from './ScheduleDetailView'; +import cronstrue from 'cronstrue'; + +interface SchedulesViewProps { + onClose: () => void; +} + +const SchedulesView: React.FC = ({ onClose }) => { + const [schedules, setSchedules] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [apiError, setApiError] = useState(null); + const [submitApiError, setSubmitApiError] = useState(null); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const [viewingScheduleId, setViewingScheduleId] = useState(null); + + const fetchSchedules = async () => { + setIsLoading(true); + setApiError(null); + try { + const fetchedSchedules = await listSchedules(); + setSchedules(fetchedSchedules); + } catch (error) { + console.error('Failed to fetch schedules:', error); + setApiError( + error instanceof Error + ? error.message + : 'An unknown error occurred while fetching schedules.' + ); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (viewingScheduleId === null) { + fetchSchedules(); + } + }, [viewingScheduleId]); + + const handleOpenCreateModal = () => { + setSubmitApiError(null); + setIsCreateModalOpen(true); + }; + + const handleCloseCreateModal = () => { + setIsCreateModalOpen(false); + setSubmitApiError(null); + }; + + const handleCreateScheduleSubmit = async (payload: NewSchedulePayload) => { + setIsSubmitting(true); + setSubmitApiError(null); + try { + await createSchedule(payload); + await fetchSchedules(); + setIsCreateModalOpen(false); + } catch (error) { + console.error('Failed to create schedule:', error); + const errorMessage = + error instanceof Error ? error.message : 'Unknown error creating schedule.'; + setSubmitApiError(errorMessage); + } finally { + setIsSubmitting(false); + } + }; + + const handleDeleteSchedule = async (idToDelete: string) => { + if (!window.confirm(`Are you sure you want to delete schedule "${idToDelete}"?`)) return; + if (viewingScheduleId === idToDelete) { + setViewingScheduleId(null); + } + setIsLoading(true); + setApiError(null); + try { + await deleteSchedule(idToDelete); + await fetchSchedules(); + } catch (error) { + console.error(`Failed to delete schedule "${idToDelete}":`, error); + setApiError( + error instanceof Error ? error.message : `Unknown error deleting "${idToDelete}".` + ); + } finally { + setIsLoading(false); + } + }; + + const handleNavigateToScheduleDetail = (scheduleId: string) => { + setViewingScheduleId(scheduleId); + }; + + const handleNavigateBackFromDetail = () => { + setViewingScheduleId(null); + }; + + const getReadableCron = (cronString: string) => { + try { + return cronstrue.toString(cronString); + } catch (e) { + console.warn(`Could not parse cron string "${cronString}":`, e); + return cronString; + } + }; + + if (viewingScheduleId) { + return ( + + ); + } + + return ( +
+ +
+ +

+ Schedules Management +

+
+ + +
+ + + {apiError && ( +

+ Error: {apiError} +

+ )} + +
+

+ Existing Schedules +

+ {isLoading && schedules.length === 0 && ( +

Loading schedules...

+ )} + {!isLoading && !apiError && schedules.length === 0 && ( +

+ No schedules found. Create one to get started! +

+ )} + + {!isLoading && schedules.length > 0 && ( +
+ {schedules.map((job) => ( + handleNavigateToScheduleDetail(job.id)} + > +
+
+

+ {job.id} +

+

+ Source: {job.source} +

+

+ Schedule: {getReadableCron(job.cron)} +

+

+ Last Run:{' '} + {job.last_run ? new Date(job.last_run).toLocaleString() : 'Never'} +

+
+
+ +
+
+
+ ))} +
+ )} +
+
+
+ +
+ ); +}; + +export default SchedulesView; diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 8cadecf0..da6c2cb8 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -26,6 +26,7 @@ interface SessionHistoryViewProps { onBack: () => void; onResume: () => void; onRetry: () => void; + showActionButtons?: boolean; } const SessionHistoryView: React.FC = ({ @@ -35,6 +36,7 @@ const SessionHistoryView: React.FC = ({ onBack, onResume, onRetry, + showActionButtons = true, }) => { const [isShareModalOpen, setIsShareModalOpen] = useState(false); const [shareLink, setShareLink] = useState(''); @@ -47,7 +49,6 @@ const SessionHistoryView: React.FC = ({ if (savedSessionConfig) { try { const config = JSON.parse(savedSessionConfig); - // If config.enabled is true and config.baseUrl is non-empty, we can share if (config.enabled && config.baseUrl) { setCanShare(true); } @@ -61,7 +62,6 @@ const SessionHistoryView: React.FC = ({ setIsSharing(true); try { - // Get the session sharing configuration from localStorage const savedSessionConfig = localStorage.getItem('session_sharing_config'); if (!savedSessionConfig) { throw new Error('Session sharing is not configured. Please configure it in settings.'); @@ -72,7 +72,6 @@ const SessionHistoryView: React.FC = ({ throw new Error('Session sharing is not enabled or base URL is not configured.'); } - // Create a shared session const shareToken = await createSharedSession( config.baseUrl, session.metadata.working_dir, @@ -81,7 +80,6 @@ const SessionHistoryView: React.FC = ({ session.metadata.total_tokens ); - // Create the shareable link const shareableLink = `goose://sessions/${shareToken}`; setShareLink(shareableLink); setIsShareModalOpen(true); @@ -112,9 +110,7 @@ const SessionHistoryView: React.FC = ({
- {/* Top Row - back, info, reopen thread (fixed) */} - {/* Session info row */}

{session.metadata.description || session.session_id} @@ -143,37 +139,39 @@ const SessionHistoryView: React.FC = ({

-
- + {showActionButtons && ( +
+ - -
+ +
+ )} = ({ onRetry={onRetry} /> - {/* Share Link Modal */} - {/* Share Icon */}
- {/* Centered Title */}

Share Session (beta)

- {/* Description & Link */}

Share this session link to give others a read only view of your goose chat. @@ -219,7 +213,6 @@ const SessionHistoryView: React.FC = ({

- {/* Footer */}