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

This commit is contained in:
Max Novich
2025-05-27 10:36:27 -07:00
committed by GitHub
parent c8e3f6ac69
commit c272b5df95
39 changed files with 3554 additions and 352 deletions

173
Cargo.lock generated
View File

@@ -719,15 +719,15 @@ dependencies = [
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.7.9" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
dependencies = [ dependencies = [
"async-trait", "axum-core",
"axum-core 0.4.5",
"axum-macros", "axum-macros",
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"form_urlencoded",
"futures-util", "futures-util",
"http 1.2.0", "http 1.2.0",
"http-body 1.0.1", "http-body 1.0.1",
@@ -735,7 +735,7 @@ dependencies = [
"hyper 1.6.0", "hyper 1.6.0",
"hyper-util", "hyper-util",
"itoa", "itoa",
"matchit 0.7.3", "matchit",
"memchr", "memchr",
"mime", "mime",
"percent-encoding", "percent-encoding",
@@ -755,54 +755,6 @@ dependencies = [
"tracing", "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]] [[package]]
name = "axum-core" name = "axum-core"
version = "0.5.0" version = "0.5.0"
@@ -829,8 +781,8 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b"
dependencies = [ dependencies = [
"axum 0.8.1", "axum",
"axum-core 0.5.0", "axum-core",
"bytes", "bytes",
"futures-util", "futures-util",
"http 1.2.0", "http 1.2.0",
@@ -846,9 +798,9 @@ dependencies = [
[[package]] [[package]]
name = "axum-macros" name = "axum-macros"
version = "0.4.2" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1669,6 +1621,15 @@ dependencies = [
"itertools 0.10.5", "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]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.15" version = "0.5.15"
@@ -1792,7 +1753,7 @@ dependencies = [
"num", "num",
"once_cell", "once_cell",
"openssl", "openssl",
"rand", "rand 0.8.5",
] ]
[[package]] [[package]]
@@ -2073,7 +2034,7 @@ dependencies = [
"hyper-timeout", "hyper-timeout",
"log", "log",
"pin-project", "pin-project",
"rand", "rand 0.8.5",
"tokio", "tokio",
] ]
@@ -2529,7 +2490,7 @@ dependencies = [
"aws-config", "aws-config",
"aws-sdk-bedrockruntime", "aws-sdk-bedrockruntime",
"aws-smithy-types", "aws-smithy-types",
"axum 0.7.9", "axum",
"base64 0.21.7", "base64 0.21.7",
"blake3", "blake3",
"chrono", "chrono",
@@ -2552,7 +2513,7 @@ dependencies = [
"nanoid", "nanoid",
"once_cell", "once_cell",
"paste", "paste",
"rand", "rand 0.8.5",
"regex", "regex",
"reqwest 0.12.12", "reqwest 0.12.12",
"serde", "serde",
@@ -2566,6 +2527,7 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
"tokenizers", "tokenizers",
"tokio", "tokio",
"tokio-cron-scheduler",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
@@ -2623,7 +2585,7 @@ dependencies = [
"minijinja", "minijinja",
"nix 0.30.1", "nix 0.30.1",
"once_cell", "once_cell",
"rand", "rand 0.8.5",
"regex", "regex",
"reqwest 0.12.12", "reqwest 0.12.12",
"rustyline", "rustyline",
@@ -2741,8 +2703,9 @@ version = "1.0.24"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum 0.7.9", "axum",
"axum-extra", "axum-extra",
"base64 0.21.7",
"bytes", "bytes",
"chrono", "chrono",
"clap 4.5.31", "clap 4.5.31",
@@ -2762,6 +2725,7 @@ dependencies = [
"serde_yaml", "serde_yaml",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-cron-scheduler",
"tokio-stream", "tokio-stream",
"tower 0.5.2", "tower 0.5.2",
"tower-http", "tower-http",
@@ -3819,12 +3783,6 @@ dependencies = [
"regex-automata 0.1.10", "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]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -3851,7 +3809,7 @@ dependencies = [
"futures", "futures",
"mcp-core", "mcp-core",
"nix 0.30.1", "nix 0.30.1",
"rand", "rand 0.8.5",
"reqwest 0.11.27", "reqwest 0.11.27",
"serde", "serde",
"serde_json", "serde_json",
@@ -4030,7 +3988,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
dependencies = [ dependencies = [
"rand", "rand 0.8.5",
] ]
[[package]] [[package]]
@@ -4269,7 +4227,7 @@ dependencies = [
"chrono", "chrono",
"getrandom 0.2.15", "getrandom 0.2.15",
"http 1.2.0", "http 1.2.0",
"rand", "rand 0.8.5",
"reqwest 0.12.12", "reqwest 0.12.12",
"serde", "serde",
"serde_json", "serde_json",
@@ -4816,7 +4774,7 @@ checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d"
dependencies = [ dependencies = [
"bytes", "bytes",
"getrandom 0.2.15", "getrandom 0.2.15",
"rand", "rand 0.8.5",
"ring", "ring",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
"rustls 0.23.23", "rustls 0.23.23",
@@ -4868,8 +4826,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "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]] [[package]]
@@ -4879,7 +4847,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
@@ -4891,6 +4869,15 @@ dependencies = [
"getrandom 0.2.15", "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]] [[package]]
name = "rangemap" name = "rangemap"
version = "1.5.1" version = "1.5.1"
@@ -4923,8 +4910,8 @@ dependencies = [
"once_cell", "once_cell",
"paste", "paste",
"profiling", "profiling",
"rand", "rand 0.8.5",
"rand_chacha", "rand_chacha 0.3.1",
"simd_helpers", "simd_helpers",
"system-deps", "system-deps",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -6268,7 +6255,7 @@ dependencies = [
"monostate", "monostate",
"onig", "onig",
"paste", "paste",
"rand", "rand 0.8.5",
"rayon", "rayon",
"rayon-cond", "rayon-cond",
"regex", "regex",
@@ -6300,6 +6287,21 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "tokio-io-timeout" name = "tokio-io-timeout"
version = "1.2.0" version = "1.2.0"
@@ -6354,9 +6356,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-tungstenite" name = "tokio-tungstenite"
version = "0.24.0" version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log",
@@ -6576,19 +6578,18 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "tungstenite" name = "tungstenite"
version = "0.24.0" version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [ dependencies = [
"byteorder",
"bytes", "bytes",
"data-encoding", "data-encoding",
"http 1.2.0", "http 1.2.0",
"httparse", "httparse",
"log", "log",
"rand", "rand 0.9.1",
"sha1", "sha1",
"thiserror 1.0.69", "thiserror 2.0.12",
"utf-8", "utf-8",
] ]

View File

@@ -9,6 +9,11 @@ use crate::commands::info::handle_info;
use crate::commands::mcp::run_server; use crate::commands::mcp::run_server;
use crate::commands::project::{handle_project_default, handle_projects_interactive}; use crate::commands::project::{handle_project_default, handle_projects_interactive};
use crate::commands::recipe::{handle_deeplink, handle_validate}; 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::commands::session::{handle_session_list, handle_session_remove};
use crate::logging::setup_logging; use crate::logging::setup_logging;
use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_as_template}; use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_as_template};
@@ -99,6 +104,46 @@ enum SessionCommand {
}, },
} }
#[derive(Subcommand, Debug)]
enum SchedulerCommand {
#[command(about = "Add a new scheduled job")]
Add {
#[arg(long, help = "Unique ID for the job")]
id: String,
#[arg(long, help = "Cron string for the schedule (e.g., '0 0 * * * *')")]
cron: String,
#[arg(
long,
help = "Recipe source (path to file, or base64 encoded recipe string)"
)]
recipe_source: String,
},
#[command(about = "List all scheduled jobs")]
List {},
#[command(about = "Remove a scheduled job by ID")]
Remove {
#[arg(long, help = "ID of the job to remove")] // Changed from positional to named --id
id: String,
},
/// List sessions created by a specific schedule
#[command(about = "List sessions created by a specific schedule")]
Sessions {
/// ID of the schedule
#[arg(long, help = "ID of the schedule")] // Explicitly make it --id
id: String,
/// Maximum number of sessions to return
#[arg(long, help = "Maximum number of sessions to return")]
limit: Option<u32>,
},
/// Run a scheduled job immediately
#[command(about = "Run a scheduled job immediately")]
RunNow {
/// ID of the schedule to run
#[arg(long, help = "ID of the schedule to run")] // Explicitly make it --id
id: String,
},
}
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum BenchCommand { pub enum BenchCommand {
#[command(name = "init-config", about = "Create a new starter-config")] #[command(name = "init-config", about = "Create a new starter-config")]
@@ -418,6 +463,13 @@ enum Command {
command: RecipeCommand, command: RecipeCommand,
}, },
/// Manage scheduled jobs
#[command(about = "Manage scheduled jobs", visible_alias = "sched")]
Schedule {
#[command(subcommand)]
command: SchedulerCommand,
},
/// Update the Goose CLI version /// Update the Goose CLI version
#[command(about = "Update the goose CLI version")] #[command(about = "Update the goose CLI version")]
Update { Update {
@@ -638,6 +690,32 @@ pub async fn cli() -> Result<()> {
return Ok(()); 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 { Some(Command::Update {
canary, canary,
reconfigure, reconfigure,

View File

@@ -4,5 +4,6 @@ pub mod info;
pub mod mcp; pub mod mcp;
pub mod project; pub mod project;
pub mod recipe; pub mod recipe;
pub mod schedule;
pub mod session; pub mod session;
pub mod update; pub mod update;

View File

@@ -0,0 +1,184 @@
use anyhow::{bail, Context, Result};
use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine};
use goose::scheduler::{
get_default_scheduled_recipes_dir, get_default_scheduler_storage_path, ScheduledJob, Scheduler,
SchedulerError,
};
use std::path::Path;
// Base64 decoding function - might be needed if recipe_source_arg can be base64
// For now, handle_schedule_add will assume it's a path.
async fn _decode_base64_recipe(source: &str) -> Result<String> {
let bytes = BASE64_STANDARD
.decode(source.as_bytes())
.with_context(|| "Recipe source is not a valid path and not valid Base64.")?;
String::from_utf8(bytes).with_context(|| "Decoded Base64 recipe source is not valid UTF-8.")
}
pub async fn handle_schedule_add(
id: String,
cron: String,
recipe_source_arg: String, // This is expected to be a file path by the Scheduler
) -> Result<()> {
println!(
"[CLI Debug] Scheduling job ID: {}, Cron: {}, Recipe Source Path: {}",
id, cron, recipe_source_arg
);
// The Scheduler's add_scheduled_job will handle copying the recipe from recipe_source_arg
// to its internal storage and validating the path.
let job = ScheduledJob {
id: id.clone(),
source: recipe_source_arg.clone(), // Pass the original user-provided path
cron,
last_run: None,
};
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
match scheduler.add_scheduled_job(job).await {
Ok(_) => {
// The scheduler has copied the recipe to its internal directory.
// We can reconstruct the likely path for display if needed, or adjust success message.
let scheduled_recipes_dir = get_default_scheduled_recipes_dir()
.unwrap_or_else(|_| Path::new("./.goose_scheduled_recipes").to_path_buf()); // Fallback for display
let extension = Path::new(&recipe_source_arg)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("yaml");
let final_recipe_path = scheduled_recipes_dir.join(format!("{}.{}", id, extension));
println!(
"Scheduled job '{}' added. Recipe expected at {:?}",
id, final_recipe_path
);
Ok(())
}
Err(e) => {
// No local file to clean up by the CLI in this revised flow.
match e {
SchedulerError::JobIdExists(job_id) => {
bail!("Error: Job with ID '{}' already exists.", job_id);
}
SchedulerError::RecipeLoadError(msg) => {
bail!(
"Error with recipe source: {}. Path: {}",
msg,
recipe_source_arg
);
}
_ => Err(anyhow::Error::new(e))
.context(format!("Failed to add job '{}' to scheduler", id)),
}
}
}
}
pub async fn handle_schedule_list() -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
let jobs = scheduler.list_scheduled_jobs().await;
if jobs.is_empty() {
println!("No scheduled jobs found.");
} else {
println!("Scheduled Jobs:");
for job in jobs {
println!(
"- ID: {}\n Cron: {}\n Recipe Source (in store): {}\n Last Run: {}",
job.id,
job.cron,
job.source, // This source is now the path within scheduled_recipes_dir
job.last_run
.map_or_else(|| "Never".to_string(), |dt| dt.to_rfc3339())
);
}
}
Ok(())
}
pub async fn handle_schedule_remove(id: String) -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
match scheduler.remove_scheduled_job(&id).await {
Ok(_) => {
println!("Scheduled job '{}' and its associated recipe removed.", id);
Ok(())
}
Err(e) => match e {
SchedulerError::JobNotFound(job_id) => {
bail!("Error: Job with ID '{}' not found.", job_id);
}
_ => Err(anyhow::Error::new(e))
.context(format!("Failed to remove job '{}' from scheduler", id)),
},
}
}
pub async fn handle_schedule_sessions(id: String, limit: Option<u32>) -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
match scheduler.sessions(&id, limit.unwrap_or(50) as usize).await {
Ok(sessions) => {
if sessions.is_empty() {
println!("No sessions found for schedule ID '{}'.", id);
} else {
println!("Sessions for schedule ID '{}':", id);
// sessions is now Vec<(String, SessionMetadata)>
for (session_name, metadata) in sessions {
println!(
" - Session ID: {}, Working Dir: {}, Description: \"{}\", Messages: {}, Schedule ID: {:?}",
session_name, // Display the session_name as Session ID
metadata.working_dir.display(),
metadata.description,
metadata.message_count,
metadata.schedule_id.as_deref().unwrap_or("N/A")
);
}
}
}
Err(e) => {
bail!("Failed to get sessions for schedule '{}': {:?}", id, e);
}
}
Ok(())
}
pub async fn handle_schedule_run_now(id: String) -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
match scheduler.run_now(&id).await {
Ok(session_id) => {
println!(
"Successfully triggered schedule '{}'. New session ID: {}",
id, session_id
);
}
Err(e) => match e {
SchedulerError::JobNotFound(job_id) => {
bail!("Error: Job with ID '{}' not found.", job_id);
}
_ => bail!("Failed to run schedule '{}' now: {:?}", id, e),
},
}
Ok(())
}

View File

@@ -688,6 +688,7 @@ impl Session {
id: session_id.clone(), id: session_id.clone(),
working_dir: std::env::current_dir() working_dir: std::env::current_dir()
.expect("failed to get current session working directory"), .expect("failed to get current session working directory"),
schedule_id: None,
}), }),
) )
.await?; .await?;
@@ -793,6 +794,7 @@ impl Session {
id: session_id.clone(), id: session_id.clone(),
working_dir: std::env::current_dir() working_dir: std::env::current_dir()
.expect("failed to get current session working directory"), .expect("failed to get current session working directory"),
schedule_id: None,
}), }),
) )
.await?; .await?;

View File

@@ -12,9 +12,10 @@ goose = { path = "../goose" }
mcp-core = { path = "../mcp-core" } mcp-core = { path = "../mcp-core" }
goose-mcp = { path = "../goose-mcp" } goose-mcp = { path = "../goose-mcp" }
mcp-server = { path = "../mcp-server" } 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"] } tokio = { version = "1.43", features = ["full"] }
chrono = "0.4" chrono = "0.4"
tokio-cron-scheduler = "0.14.0"
tower-http = { version = "0.5", features = ["cors"] } tower-http = { version = "0.5", features = ["cors"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
@@ -26,6 +27,7 @@ tokio-stream = "0.1"
anyhow = "1.0" anyhow = "1.0"
bytes = "1.5" bytes = "1.5"
http = "1.0" http = "1.0"
base64 = "0.21"
config = { version = "0.14.1", features = ["toml"] } config = { version = "0.14.1", features = ["toml"] }
thiserror = "1.0" thiserror = "1.0"
clap = { version = "4.4", features = ["derive"] } clap = { version = "4.4", features = ["derive"] }
@@ -33,7 +35,7 @@ once_cell = "1.20.2"
etcetera = "0.8.0" etcetera = "0.8.0"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
axum-extra = "0.10.0" axum-extra = "0.10.0"
utoipa = { version = "4.1", features = ["axum_extras"] } utoipa = { version = "4.1", features = ["axum_extras", "chrono"] }
dirs = "6.0.0" dirs = "6.0.0"
reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking"], default-features = false } reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking"], default-features = false }

View File

@@ -3,7 +3,10 @@ use std::sync::Arc;
use crate::configuration; use crate::configuration;
use crate::state; use crate::state;
use anyhow::Result; use anyhow::Result;
use etcetera::{choose_app_strategy, AppStrategy};
use goose::agents::Agent; use goose::agents::Agent;
use goose::config::APP_STRATEGY;
use goose::scheduler::Scheduler as GooseScheduler;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use tracing::info; use tracing::info;
@@ -11,27 +14,30 @@ pub async fn run() -> Result<()> {
// Initialize logging // Initialize logging
crate::logging::setup_logging(Some("goosed"))?; crate::logging::setup_logging(Some("goosed"))?;
// Load configuration
let settings = configuration::Settings::new()?; let settings = configuration::Settings::new()?;
// load secret key from GOOSE_SERVER__SECRET_KEY environment variable
let secret_key = let secret_key =
std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string()); std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string());
let new_agent = Agent::new(); let new_agent = Agent::new();
let agent_ref = Arc::new(new_agent);
// Create app state with agent let app_state = state::AppState::new(agent_ref.clone(), secret_key.clone()).await;
let state = state::AppState::new(Arc::new(new_agent), 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() let cors = CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)
.allow_methods(Any) .allow_methods(Any)
.allow_headers(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?; let listener = tokio::net::TcpListener::bind(settings.socket_addr()).await?;
info!("listening on {}", listener.local_addr()?); info!("listening on {}", listener.local_addr()?);
axum::serve(listener, app).await?; axum::serve(listener, app).await?;

View File

@@ -8,6 +8,7 @@ use tracing_subscriber::{
Registry, Registry,
}; };
use goose::config::APP_STRATEGY;
use goose::tracing::langfuse_layer; use goose::tracing::langfuse_layer;
/// Returns the directory where log files should be stored. /// Returns the directory where log files should be stored.
@@ -17,8 +18,8 @@ fn get_log_directory() -> Result<PathBuf> {
// - macOS/Linux: ~/.local/state/goose/logs/server // - macOS/Linux: ~/.local/state/goose/logs/server
// - Windows: ~\AppData\Roaming\Block\goose\data\logs\server // - Windows: ~\AppData\Roaming\Block\goose\data\logs\server
// - Windows has no convention for state_dir, use data_dir instead // - Windows has no convention for state_dir, use data_dir instead
let home_dir = choose_app_strategy(crate::APP_STRATEGY.clone()) let home_dir =
.context("HOME environment variable not set")?; choose_app_strategy(APP_STRATEGY.clone()).context("HOME environment variable not set")?;
let base_log_dir = home_dir let base_log_dir = home_dir
.in_state_dir("logs/server") .in_state_dir("logs/server")

View File

@@ -1,12 +1,3 @@
use etcetera::AppStrategyArgs;
use once_cell::sync::Lazy;
pub static APP_STRATEGY: Lazy<AppStrategyArgs> = Lazy::new(|| AppStrategyArgs {
top_level_domain: "Block".to_string(),
author: "Block".to_string(),
app_name: "goose".to_string(),
});
mod commands; mod commands;
mod configuration; mod configuration;
mod error; mod error;

View File

@@ -37,7 +37,12 @@ use utoipa::OpenApi;
super::routes::reply::confirm_permission, super::routes::reply::confirm_permission,
super::routes::context::manage_context, super::routes::context::manage_context,
super::routes::session::list_sessions, 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( components(schemas(
super::routes::config_management::UpsertConfigQuery, super::routes::config_management::UpsertConfigQuery,
@@ -85,6 +90,12 @@ use utoipa::OpenApi;
ModelInfo, ModelInfo,
SessionInfo, SessionInfo,
SessionMetadata, 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; pub struct ApiDoc;

View File

@@ -6,8 +6,9 @@ use axum::{
routing::{delete, get, post}, routing::{delete, get, post},
Json, Router, Json, Router,
}; };
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; use etcetera::{choose_app_strategy, AppStrategy};
use goose::config::Config; use goose::config::Config;
use goose::config::APP_STRATEGY;
use goose::config::{extensions::name_to_key, PermissionManager}; use goose::config::{extensions::name_to_key, PermissionManager};
use goose::config::{ExtensionConfigManager, ExtensionEntry}; use goose::config::{ExtensionConfigManager, ExtensionEntry};
use goose::model::ModelConfig; use goose::model::ModelConfig;
@@ -15,7 +16,6 @@ use goose::providers::base::ProviderMetadata;
use goose::providers::providers as get_providers; use goose::providers::providers as get_providers;
use goose::{agents::ExtensionConfig, config::permission::PermissionLevel}; use goose::{agents::ExtensionConfig, config::permission::PermissionLevel};
use http::{HeaderMap, StatusCode}; use http::{HeaderMap, StatusCode};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use serde_yaml; use serde_yaml;
@@ -52,14 +52,12 @@ pub struct ConfigResponse {
pub config: HashMap<String, Value>, pub config: HashMap<String, Value>,
} }
// Define a new structure to encapsulate the provider details along with configuration status
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ProviderDetails { pub struct ProviderDetails {
/// Unique identifier and name of the provider
pub name: String, pub name: String,
/// Metadata about the provider
pub metadata: ProviderMetadata, pub metadata: ProviderMetadata,
/// Indicates whether the provider is fully configured
pub is_configured: bool, pub is_configured: bool,
} }
@@ -70,7 +68,6 @@ pub struct ProvidersResponse {
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ToolPermission { pub struct ToolPermission {
/// Unique identifier and name of the tool, format <extension_name>__<tool_name>
pub tool_name: String, pub tool_name: String,
pub permission: PermissionLevel, pub permission: PermissionLevel,
} }
@@ -94,7 +91,6 @@ pub async fn upsert_config(
headers: HeaderMap, headers: HeaderMap,
Json(query): Json<UpsertConfigQuery>, Json(query): Json<UpsertConfigQuery>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
// Use the helper function to verify the secret key
verify_secret_key(&headers, &state)?; verify_secret_key(&headers, &state)?;
let config = Config::global(); let config = Config::global();
@@ -121,12 +117,10 @@ pub async fn remove_config(
headers: HeaderMap, headers: HeaderMap,
Json(query): Json<ConfigKeyQuery>, Json(query): Json<ConfigKeyQuery>,
) -> Result<Json<String>, StatusCode> { ) -> Result<Json<String>, StatusCode> {
// Use the helper function to verify the secret key
verify_secret_key(&headers, &state)?; verify_secret_key(&headers, &state)?;
let config = Config::global(); let config = Config::global();
// Check if the secret flag is true and call the appropriate method
let result = if query.is_secret { let result = if query.is_secret {
config.delete_secret(&query.key) config.delete_secret(&query.key)
} else { } else {
@@ -142,7 +136,7 @@ pub async fn remove_config(
#[utoipa::path( #[utoipa::path(
post, post,
path = "/config/read", path = "/config/read",
request_body = ConfigKeyQuery, // Switch back to request_body request_body = ConfigKeyQuery,
responses( responses(
(status = 200, description = "Configuration value retrieved successfully", body = Value), (status = 200, description = "Configuration value retrieved successfully", body = Value),
(status = 404, description = "Configuration key not found") (status = 404, description = "Configuration key not found")
@@ -155,7 +149,6 @@ pub async fn read_config(
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
verify_secret_key(&headers, &state)?; verify_secret_key(&headers, &state)?;
// Special handling for model-limits
if query.key == "model-limits" { if query.key == "model-limits" {
let limits = ModelConfig::get_all_model_limits(); let limits = ModelConfig::get_all_model_limits();
return Ok(Json( return Ok(Json(
@@ -166,13 +159,10 @@ pub async fn read_config(
let config = Config::global(); let config = Config::global();
match config.get(&query.key, query.is_secret) { match config.get(&query.key, query.is_secret) {
// Always get the actual value
Ok(value) => { Ok(value) => {
if query.is_secret { if query.is_secret {
// If it's marked as secret, return a boolean indicating presence
Ok(Json(Value::Bool(true))) Ok(Json(Value::Bool(true)))
} else { } else {
// Return the actual value if not secret
Ok(Json(value)) Ok(Json(value))
} }
} }
@@ -197,7 +187,6 @@ pub async fn get_extensions(
match ExtensionConfigManager::get_all() { match ExtensionConfigManager::get_all() {
Ok(extensions) => Ok(Json(ExtensionResponse { extensions })), Ok(extensions) => Ok(Json(ExtensionResponse { extensions })),
Err(err) => { Err(err) => {
// Return UNPROCESSABLE_ENTITY only for DeserializeError, INTERNAL_SERVER_ERROR for everything else
if err if err
.downcast_ref::<goose::config::base::ConfigError>() .downcast_ref::<goose::config::base::ConfigError>()
.is_some_and(|e| matches!(e, goose::config::base::ConfigError::DeserializeError(_))) .is_some_and(|e| matches!(e, goose::config::base::ConfigError::DeserializeError(_)))
@@ -228,7 +217,6 @@ pub async fn add_extension(
) -> Result<Json<String>, StatusCode> { ) -> Result<Json<String>, StatusCode> {
verify_secret_key(&headers, &state)?; verify_secret_key(&headers, &state)?;
// Get existing extensions to check if this is an update
let extensions = let extensions =
ExtensionConfigManager::get_all().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; ExtensionConfigManager::get_all().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let key = name_to_key(&extension_query.name); let key = name_to_key(&extension_query.name);
@@ -284,12 +272,10 @@ pub async fn read_all_config(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Json<ConfigResponse>, StatusCode> { ) -> Result<Json<ConfigResponse>, StatusCode> {
// Use the helper function to verify the secret key
verify_secret_key(&headers, &state)?; verify_secret_key(&headers, &state)?;
let config = Config::global(); let config = Config::global();
// Load values from config file
let values = config let values = config
.load_values() .load_values()
.map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)?; .map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)?;
@@ -297,7 +283,6 @@ pub async fn read_all_config(
Ok(Json(ConfigResponse { config: values })) Ok(Json(ConfigResponse { config: values }))
} }
// Modified providers function using the new response type
#[utoipa::path( #[utoipa::path(
get, get,
path = "/config/providers", path = "/config/providers",
@@ -311,14 +296,11 @@ pub async fn providers(
) -> Result<Json<Vec<ProviderDetails>>, StatusCode> { ) -> Result<Json<Vec<ProviderDetails>>, StatusCode> {
verify_secret_key(&headers, &state)?; 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(); let providers_metadata = get_providers();
// Construct the response by checking configuration status for each provider
let providers_response: Vec<ProviderDetails> = providers_metadata let providers_response: Vec<ProviderDetails> = providers_metadata
.into_iter() .into_iter()
.map(|metadata| { .map(|metadata| {
// Check if the provider is configured (this will depend on how you track configuration status)
let is_configured = check_provider_configured(&metadata); let is_configured = check_provider_configured(&metadata);
ProviderDetails { ProviderDetails {
@@ -348,21 +330,16 @@ pub async fn init_config(
let config = Config::global(); let config = Config::global();
// 200 if config already exists
if config.exists() { if config.exists() {
return Ok(Json("Config already exists".to_string())); 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() { let workspace_root = match std::env::current_exe() {
Ok(mut exe_path) => { Ok(mut exe_path) => {
// Start from the executable's directory and traverse up
while let Some(parent) = exe_path.parent() { while let Some(parent) = exe_path.parent() {
let cargo_toml = parent.join("Cargo.toml"); let cargo_toml = parent.join("Cargo.toml");
if cargo_toml.exists() { if cargo_toml.exists() {
// Read the Cargo.toml file
if let Ok(content) = std::fs::read_to_string(&cargo_toml) { if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
// Check if it contains [workspace]
if content.contains("[workspace]") { if content.contains("[workspace]") {
exe_path = parent.to_path_buf(); exe_path = parent.to_path_buf();
break; break;
@@ -376,7 +353,6 @@ pub async fn init_config(
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), 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"); let init_config_path = workspace_root.join("init-config.yaml");
if !init_config_path.exists() { if !init_config_path.exists() {
return Ok(Json( 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) { let init_content = match std::fs::read_to_string(&init_config_path) {
Ok(content) => content, Ok(content) => content,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -394,7 +369,6 @@ pub async fn init_config(
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
}; };
// Save init-config.yaml to ~/.config/goose/config.yaml
match config.save_values(init_values) { match config.save_values(init_values) {
Ok(_) => Ok(Json("Config initialized successfully".to_string())), Ok(_) => Ok(Json("Config initialized successfully".to_string())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -418,7 +392,7 @@ pub async fn upsert_permissions(
verify_secret_key(&headers, &state)?; verify_secret_key(&headers, &state)?;
let mut permission_manager = PermissionManager::default(); let mut permission_manager = PermissionManager::default();
// Iterate over each tool permission and update permissions
for tool_permission in &query.tool_permissions { for tool_permission in &query.tool_permissions {
permission_manager.update_user_permission( permission_manager.update_user_permission(
&tool_permission.tool_name, &tool_permission.tool_name,
@@ -429,12 +403,6 @@ pub async fn upsert_permissions(
Ok(Json("Permissions updated successfully".to_string())) Ok(Json("Permissions updated successfully".to_string()))
} }
pub static APP_STRATEGY: Lazy<AppStrategyArgs> = Lazy::new(|| AppStrategyArgs {
top_level_domain: "Block".to_string(),
author: "Block".to_string(),
app_name: "goose".to_string(),
});
#[utoipa::path( #[utoipa::path(
post, post,
path = "/config/backup", path = "/config/backup",
@@ -460,11 +428,9 @@ pub async fn backup_config(
.file_name() .file_name()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
// Append ".bak" to the file name
let mut backup_name = file_name.to_os_string(); let mut backup_name = file_name.to_os_string();
backup_name.push(".bak"); backup_name.push(".bak");
// Construct the new path with the same parent directory
let backup = config_path.with_file_name(backup_name); let backup = config_path.with_file_name(backup_name);
match std::fs::rename(&config_path, &backup) { match std::fs::rename(&config_path, &backup) {
Ok(_) => Ok(Json(format!("Moved {:?} to {:?}", config_path, backup))), Ok(_) => Ok(Json(format!("Moved {:?} to {:?}", config_path, backup))),
@@ -483,7 +449,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/config/read", post(read_config)) .route("/config/read", post(read_config))
.route("/config/extensions", get(get_extensions)) .route("/config/extensions", get(get_extensions))
.route("/config/extensions", post(add_extension)) .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/providers", get(providers))
.route("/config/init", post(init_config)) .route("/config/init", post(init_config))
.route("/config/backup", post(backup_config)) .route("/config/backup", post(backup_config))
@@ -497,16 +463,22 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_read_model_limits() { async fn test_read_model_limits() {
// Create test state and headers
let test_state = AppState::new( let test_state = AppState::new(
Arc::new(goose::agents::Agent::default()), Arc::new(goose::agents::Agent::default()),
"test".to_string(), "test".to_string(),
) )
.await; .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(); let mut headers = HeaderMap::new();
headers.insert("X-Secret-Key", "test".parse().unwrap()); headers.insert("X-Secret-Key", "test".parse().unwrap());
// Execute
let result = read_config( let result = read_config(
State(test_state), State(test_state),
headers, headers,
@@ -517,16 +489,13 @@ mod tests {
) )
.await; .await;
// Assert
assert!(result.is_ok()); assert!(result.is_ok());
let response = result.unwrap(); let response = result.unwrap();
// Parse the response and check the contents
let limits: Vec<goose::model::ModelLimitConfig> = let limits: Vec<goose::model::ModelLimitConfig> =
serde_json::from_value(response.0).unwrap(); serde_json::from_value(response.0).unwrap();
assert!(!limits.is_empty()); assert!(!limits.is_empty());
// Check for some expected patterns
let gpt4_limit = limits.iter().find(|l| l.pattern == "gpt-4o"); let gpt4_limit = limits.iter().find(|l| l.pattern == "gpt-4o");
assert!(gpt4_limit.is_some()); assert!(gpt4_limit.is_some());
assert_eq!(gpt4_limit.unwrap().context_limit, 128_000); assert_eq!(gpt4_limit.unwrap().context_limit, 128_000);

View File

@@ -6,6 +6,7 @@ pub mod extension;
pub mod health; pub mod health;
pub mod recipe; pub mod recipe;
pub mod reply; pub mod reply;
pub mod schedule;
pub mod session; pub mod session;
pub mod utils; pub mod utils;
use std::sync::Arc; use std::sync::Arc;
@@ -23,4 +24,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
.merge(config_management::routes(state.clone())) .merge(config_management::routes(state.clone()))
.merge(recipe::routes(state.clone())) .merge(recipe::routes(state.clone()))
.merge(session::routes(state.clone())) .merge(session::routes(state.clone()))
.merge(schedule::routes(state.clone()))
} }

View File

@@ -35,7 +35,6 @@ use tokio::time::timeout;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use utoipa::ToSchema; use utoipa::ToSchema;
// Direct message serialization for the chat request
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ChatRequest { struct ChatRequest {
messages: Vec<Message>, messages: Vec<Message>,
@@ -43,7 +42,6 @@ struct ChatRequest {
session_working_dir: String, session_working_dir: String,
} }
// Custom SSE response type for streaming messages
pub struct SseResponse { pub struct SseResponse {
rx: ReceiverStream<String>, rx: ReceiverStream<String>,
} }
@@ -78,7 +76,6 @@ impl IntoResponse for SseResponse {
} }
} }
// Message event types for SSE streaming
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
enum MessageEvent { enum MessageEvent {
@@ -87,7 +84,6 @@ enum MessageEvent {
Finish { reason: String }, Finish { reason: String },
} }
// Stream a message as an SSE event
async fn stream_event( async fn stream_event(
event: MessageEvent, event: MessageEvent,
tx: &mpsc::Sender<String>, tx: &mpsc::Sender<String>,
@@ -108,19 +104,16 @@ async fn handler(
) -> Result<SseResponse, StatusCode> { ) -> Result<SseResponse, StatusCode> {
verify_secret_key(&headers, &state)?; verify_secret_key(&headers, &state)?;
// Create channel for streaming
let (tx, rx) = mpsc::channel(100); let (tx, rx) = mpsc::channel(100);
let stream = ReceiverStream::new(rx); let stream = ReceiverStream::new(rx);
let messages = request.messages; let messages = request.messages;
let session_working_dir = request.session_working_dir; let session_working_dir = request.session_working_dir;
// Generate a new session ID if not provided in the request
let session_id = request let session_id = request
.session_id .session_id
.unwrap_or_else(session::generate_session_id); .unwrap_or_else(session::generate_session_id);
// Spawn task to handle streaming
tokio::spawn(async move { tokio::spawn(async move {
let agent = state.get_agent().await; let agent = state.get_agent().await;
let agent = match agent { 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 provider = agent.provider().await;
let mut stream = match agent let mut stream = match agent
@@ -175,6 +167,7 @@ async fn handler(
Some(SessionConfig { Some(SessionConfig {
id: session::Identifier::Name(session_id.clone()), id: session::Identifier::Name(session_id.clone()),
working_dir: PathBuf::from(session_working_dir), working_dir: PathBuf::from(session_working_dir),
schedule_id: None,
}), }),
) )
.await .await
@@ -200,7 +193,6 @@ async fn handler(
} }
}; };
// Collect all messages for storage
let mut all_messages = messages.clone(); let mut all_messages = messages.clone();
let session_path = session::get_path(session::Identifier::Name(session_id.clone())); let session_path = session::get_path(session::Identifier::Name(session_id.clone()));
@@ -221,7 +213,7 @@ async fn handler(
break; break;
} }
// Store messages and generate description in background
let session_path = session_path.clone(); let session_path = session_path.clone();
let messages = all_messages.clone(); let messages = all_messages.clone();
let provider = Arc::clone(provider.as_ref().unwrap()); let provider = Arc::clone(provider.as_ref().unwrap());
@@ -255,7 +247,6 @@ async fn handler(
} }
} }
// Send finish event
let _ = stream_event( let _ = stream_event(
MessageEvent::Finish { MessageEvent::Finish {
reason: "stop".to_string(), reason: "stop".to_string(),
@@ -280,7 +271,6 @@ struct AskResponse {
response: String, response: String,
} }
// Simple ask an AI for a response, non streaming
async fn ask_handler( async fn ask_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
@@ -290,7 +280,6 @@ async fn ask_handler(
let session_working_dir = request.session_working_dir; let session_working_dir = request.session_working_dir;
// Generate a new session ID if not provided in the request
let session_id = request let session_id = request
.session_id .session_id
.unwrap_or_else(session::generate_session_id); .unwrap_or_else(session::generate_session_id);
@@ -300,13 +289,10 @@ async fn ask_handler(
.await .await
.map_err(|_| StatusCode::PRECONDITION_FAILED)?; .map_err(|_| StatusCode::PRECONDITION_FAILED)?;
// Get the provider first, before starting the reply stream
let provider = agent.provider().await; let provider = agent.provider().await;
// Create a single message for the prompt
let messages = vec![Message::user().with_text(request.prompt)]; let messages = vec![Message::user().with_text(request.prompt)];
// Get response from agent
let mut response_text = String::new(); let mut response_text = String::new();
let mut stream = match agent let mut stream = match agent
.reply( .reply(
@@ -314,6 +300,7 @@ async fn ask_handler(
Some(SessionConfig { Some(SessionConfig {
id: session::Identifier::Name(session_id.clone()), id: session::Identifier::Name(session_id.clone()),
working_dir: PathBuf::from(session_working_dir), working_dir: PathBuf::from(session_working_dir),
schedule_id: None,
}), }),
) )
.await .await
@@ -325,7 +312,6 @@ async fn ask_handler(
} }
}; };
// Collect all messages for storage
let mut all_messages = messages.clone(); let mut all_messages = messages.clone();
let mut response_message = Message::assistant(); 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() { if !response_message.content.is_empty() {
all_messages.push(response_message); 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())); 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 session_path = session_path.clone();
let messages = all_messages.clone(); let messages = all_messages.clone();
let provider = Arc::clone(provider.as_ref().unwrap()); let provider = Arc::clone(provider.as_ref().unwrap());
@@ -438,13 +421,11 @@ async fn submit_tool_result(
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
verify_secret_key(&headers, &state)?; verify_secret_key(&headers, &state)?;
// Log the raw request for debugging
tracing::info!( tracing::info!(
"Received tool result request: {}", "Received tool result request: {}",
serde_json::to_string_pretty(&raw.0).unwrap() 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()) { let payload: ToolResultRequest = match serde_json::from_value(raw.0.clone()) {
Ok(req) => req, Ok(req) => req,
Err(e) => { Err(e) => {
@@ -465,7 +446,6 @@ async fn submit_tool_result(
Ok(Json(json!({"status": "ok"}))) Ok(Json(json!({"status": "ok"})))
} }
// Configure routes for this module
pub fn routes(state: Arc<AppState>) -> Router { pub fn routes(state: Arc<AppState>) -> Router {
Router::new() Router::new()
.route("/reply", post(handler)) .route("/reply", post(handler))
@@ -488,7 +468,6 @@ mod tests {
}; };
use mcp_core::tool::Tool; use mcp_core::tool::Tool;
// Mock Provider implementation for testing
#[derive(Clone)] #[derive(Clone)]
struct MockProvider { struct MockProvider {
model_config: ModelConfig, model_config: ModelConfig,
@@ -523,10 +502,8 @@ mod tests {
use std::sync::Arc; use std::sync::Arc;
use tower::ServiceExt; use tower::ServiceExt;
// This test requires tokio runtime
#[tokio::test] #[tokio::test]
async fn test_ask_endpoint() { 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_model_config = ModelConfig::new("test-model".to_string());
let mock_provider = Arc::new(MockProvider { let mock_provider = Arc::new(MockProvider {
model_config: mock_model_config, model_config: mock_model_config,
@@ -534,11 +511,15 @@ mod tests {
let agent = Agent::new(); let agent = Agent::new();
let _ = agent.update_provider(mock_provider).await; let _ = agent.update_provider(mock_provider).await;
let state = AppState::new(Arc::new(agent), "test-secret".to_string()).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); let app = routes(state);
// Create request
let request = Request::builder() let request = Request::builder()
.uri("/ask") .uri("/ask")
.method("POST") .method("POST")
@@ -554,10 +535,8 @@ mod tests {
)) ))
.unwrap(); .unwrap();
// Send request
let response = app.oneshot(request).await.unwrap(); let response = app.oneshot(request).await.unwrap();
// Assert response status
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
} }
} }

View File

@@ -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<ScheduledJob>,
}
// 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<String>,
message_count: usize,
total_tokens: Option<i32>,
input_tokens: Option<i32>,
output_tokens: Option<i32>,
accumulated_total_tokens: Option<i32>,
accumulated_input_tokens: Option<i32>,
accumulated_output_tokens: Option<i32>,
}
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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<CreateScheduleRequest>,
) -> Result<Json<ScheduledJob>, 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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<ListSchedulesResponse>, 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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Json<RunNowResponse>, 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<SessionDisplayInfo>),
(status = 500, description = "Internal server error")
),
tag = "schedule"
)]
#[axum::debug_handler]
async fn sessions_handler(
State(state): State<Arc<AppState>>,
headers: HeaderMap, // Added this line
Path(schedule_id_param): Path<String>, // Renamed to avoid confusion with session_id
Query(query_params): Query<SessionsQuery>,
) -> Result<Json<Vec<SessionDisplayInfo>>, 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<SessionDisplayInfo> = 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<AppState>) -> 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)
}

View File

@@ -108,6 +108,6 @@ async fn get_session_history(
pub fn routes(state: Arc<AppState>) -> Router { pub fn routes(state: Arc<AppState>) -> Router {
Router::new() Router::new()
.route("/sessions", get(list_sessions)) .route("/sessions", get(list_sessions))
.route("/sessions/:session_id", get(get_session_history)) .route("/sessions/{session_id}", get(get_session_history))
.with_state(state) .with_state(state)
} }

View File

@@ -1,21 +1,15 @@
use goose::agents::Agent; use goose::agents::Agent;
use goose::scheduler::Scheduler;
use std::sync::Arc; 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<Agent>; pub type AgentRef = Arc<Agent>;
/// 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)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
// agent: SharedAgentStore,
agent: Option<AgentRef>, agent: Option<AgentRef>,
pub secret_key: String, pub secret_key: String,
pub scheduler: Arc<Mutex<Option<Arc<Scheduler>>>>,
} }
impl AppState { impl AppState {
@@ -23,6 +17,7 @@ impl AppState {
Arc::new(Self { Arc::new(Self {
agent: Some(agent.clone()), agent: Some(agent.clone()),
secret_key, secret_key,
scheduler: Arc::new(Mutex::new(None)),
}) })
} }
@@ -31,4 +26,17 @@ impl AppState {
.clone() .clone()
.ok_or_else(|| anyhow::anyhow!("Agent needs to be created first.")) .ok_or_else(|| anyhow::anyhow!("Agent needs to be created first."))
} }
pub async fn set_scheduler(&self, sched: Arc<Scheduler>) {
let mut guard = self.scheduler.lock().await;
*guard = Some(sched);
}
pub async fn scheduler(&self) -> Result<Arc<Scheduler>, anyhow::Error> {
self.scheduler
.lock()
.await
.clone()
.ok_or_else(|| anyhow::anyhow!("Scheduler not initialized"))
}
} }

View File

@@ -37,10 +37,10 @@
"/config/extension": { "/config/extension": {
"post": { "post": {
"tags": [ "tags": [
"super::routes::config_management" "config"
], ],
"summary": "Add an extension configuration", "summary": "Add an extension configuration",
"operationId": "add_extension", "operationId": "add_extension_config",
"requestBody": { "requestBody": {
"content": { "content": {
"application/json": { "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": { "components": {
@@ -273,7 +447,127 @@
"description": "The value to set for the configuration" "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."
}
}
} }
} }
} }
} }

View File

@@ -46,7 +46,7 @@ nanoid = "0.4"
sha2 = "0.10" sha2 = "0.10"
base64 = "0.21" base64 = "0.21"
url = "2.5" url = "2.5"
axum = "0.7" axum = "0.8.1"
webbrowser = "0.8" webbrowser = "0.8"
dotenv = "0.15" dotenv = "0.15"
lazy_static = "1.5" lazy_static = "1.5"
@@ -60,7 +60,8 @@ serde_yaml = "0.9.34"
once_cell = "1.20.2" once_cell = "1.20.2"
etcetera = "0.8.0" etcetera = "0.8.0"
rand = "0.8.5" rand = "0.8.5"
utoipa = "4.1" utoipa = { version = "4.1", features = ["chrono"] }
tokio-cron-scheduler = "0.14.0"
# For Bedrock provider # For Bedrock provider
aws-config = { version = "1.5.16", features = ["behavior-version-latest"] } aws-config = { version = "1.5.16", features = ["behavior-version-latest"] }

View File

@@ -205,18 +205,17 @@ impl Agent {
usage: &crate::providers::base::ProviderUsage, usage: &crate::providers::base::ProviderUsage,
messages_length: usize, messages_length: usize,
) -> Result<()> { ) -> Result<()> {
let session_file = session::get_path(session_config.id); let session_file_path = session::storage::get_path(session_config.id.clone());
let mut metadata = session::read_metadata(&session_file)?; 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.total_tokens = usage.usage.total_tokens;
metadata.input_tokens = usage.usage.input_tokens; metadata.input_tokens = usage.usage.input_tokens;
metadata.output_tokens = usage.usage.output_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; metadata.message_count = messages_length + 1;
// Keep running sum of tokens to track cost over the entire session
let accumulate = |a: Option<i32>, b: Option<i32>| -> Option<i32> { let accumulate = |a: Option<i32>, b: Option<i32>| -> Option<i32> {
match (a, b) { match (a, b) {
(Some(x), Some(y)) => Some(x + y), (Some(x), Some(y)) => Some(x + y),
@@ -231,7 +230,8 @@ impl Agent {
metadata.accumulated_output_tokens, metadata.accumulated_output_tokens,
usage.usage.output_tokens, usage.usage.output_tokens,
); );
session::update_metadata(&session_file, &metadata).await?;
session::storage::update_metadata(&session_file_path, &metadata).await?;
Ok(()) Ok(())
} }

View File

@@ -22,4 +22,6 @@ pub struct SessionConfig {
pub id: session::Identifier, pub id: session::Identifier,
/// Working directory for the session /// Working directory for the session
pub working_dir: PathBuf, pub working_dir: PathBuf,
/// ID of the schedule that triggered this session, if any
pub schedule_id: Option<String>, // NEW
} }

View File

@@ -7,6 +7,7 @@ pub mod permission;
pub mod prompt_template; pub mod prompt_template;
pub mod providers; pub mod providers;
pub mod recipe; pub mod recipe;
pub mod scheduler;
pub mod session; pub mod session;
pub mod token_counter; pub mod token_counter;
pub mod tool_monitor; pub mod tool_monitor;

View File

@@ -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<PathBuf, io::Error> {
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<PathBuf, SchedulerError> {
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<io::Error> for SchedulerError {
fn from(err: io::Error) -> Self {
SchedulerError::StorageError(err)
}
}
impl From<serde_json::Error> for SchedulerError {
fn from(err: serde_json::Error) -> Self {
SchedulerError::PersistError(err.to_string())
}
}
impl From<anyhow::Error> 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<DateTime<Utc>>,
}
async fn persist_jobs_from_arc(
storage_path: &Path,
jobs_arc: &Arc<Mutex<HashMap<String, (JobId, ScheduledJob)>>>,
) -> Result<(), SchedulerError> {
let jobs_guard = jobs_arc.lock().await;
let list: Vec<ScheduledJob> = 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<Mutex<HashMap<String, (JobId, ScheduledJob)>>>,
storage_path: PathBuf,
}
impl Scheduler {
pub async fn new(storage_path: PathBuf) -> Result<Arc<Self>, 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, &current_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<Self>) -> 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<ScheduledJob> = 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, &current_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<String, (JobId, ScheduledJob)>>,
) -> Result<(), SchedulerError> {
let list: Vec<ScheduledJob> = 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<ScheduledJob> {
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<Vec<(String, SessionMetadata)>, 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<String, SchedulerError> {
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<Arc<dyn GooseProvider>>, // New optional parameter
) -> std::result::Result<String, JobExecutionError> {
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>(&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>(&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<dyn GooseProvider>; // 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<Message> =
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<dyn GooseProvider> {
Arc::new(MockSchedulerTestProvider { model_config })
}
#[tokio::test]
async fn test_scheduled_session_has_schedule_id() -> Result<(), Box<dyn std::error::Error>> {
// 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(())
}
}

View File

@@ -25,6 +25,8 @@ pub struct SessionMetadata {
pub working_dir: PathBuf, pub working_dir: PathBuf,
/// A short description of the session, typically 3 words or less /// A short description of the session, typically 3 words or less
pub description: String, pub description: String,
/// ID of the schedule that triggered this session, if any
pub schedule_id: Option<String>,
/// Number of messages in the session /// Number of messages in the session
pub message_count: usize, pub message_count: usize,
/// The total number of tokens used in the session. Retrieved from the provider's last usage. /// 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 { struct Helper {
description: String, description: String,
message_count: usize, message_count: usize,
schedule_id: Option<String>, // For backward compatibility
total_tokens: Option<i32>, total_tokens: Option<i32>,
input_tokens: Option<i32>, input_tokens: Option<i32>,
output_tokens: Option<i32>, output_tokens: Option<i32>,
@@ -71,6 +74,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
Ok(SessionMetadata { Ok(SessionMetadata {
description: helper.description, description: helper.description,
message_count: helper.message_count, message_count: helper.message_count,
schedule_id: helper.schedule_id,
total_tokens: helper.total_tokens, total_tokens: helper.total_tokens,
input_tokens: helper.input_tokens, input_tokens: helper.input_tokens,
output_tokens: helper.output_tokens, output_tokens: helper.output_tokens,
@@ -94,6 +98,7 @@ impl SessionMetadata {
Self { Self {
working_dir, working_dir,
description: String::new(), description: String::new(),
schedule_id: None,
message_count: 0, message_count: 0,
total_tokens: None, total_tokens: None,
input_tokens: None, input_tokens: None,

View File

@@ -152,9 +152,26 @@ async fn run_truncate_test(
assert_eq!(responses[0].content.len(), 1); assert_eq!(responses[0].content.len(), 1);
let response_text = responses[0].content[0].as_text().unwrap(); match responses[0].content[0] {
assert!(response_text.to_lowercase().contains("no")); goose::message::MessageContent::Text(ref text_content) => {
assert!(!response_text.to_lowercase().contains("yes")); 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(()) Ok(())
} }

View File

@@ -10,7 +10,7 @@
"license": { "license": {
"name": "Apache-2.0" "name": "Apache-2.0"
}, },
"version": "1.0.23" "version": "1.0.24"
}, },
"paths": { "paths": {
"/agent/tools": { "/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": { "/sessions": {
"get": { "get": {
"tags": [ "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": { "EmbeddedResource": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -1030,6 +1219,20 @@
} }
} }
}, },
"ListSchedulesResponse": {
"type": "object",
"required": [
"jobs"
],
"properties": {
"jobs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduledJob"
}
}
}
},
"Message": { "Message": {
"type": "object", "type": "object",
"description": "A message to or from an LLM", "description": "A message to or from an LLM",
@@ -1334,15 +1537,13 @@
], ],
"properties": { "properties": {
"is_configured": { "is_configured": {
"type": "boolean", "type": "boolean"
"description": "Indicates whether the provider is fully configured"
}, },
"metadata": { "metadata": {
"$ref": "#/components/schemas/ProviderMetadata" "$ref": "#/components/schemas/ProviderMetadata"
}, },
"name": { "name": {
"type": "string", "type": "string"
"description": "Unique identifier and name of the provider"
} }
} }
}, },
@@ -1469,6 +1670,103 @@
"assistant" "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": { "SessionHistoryResponse": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -1579,6 +1877,11 @@
"description": "The number of output tokens used in the session. Retrieved from the provider's last usage.", "description": "The number of output tokens used in the session. Retrieved from the provider's last usage.",
"nullable": true "nullable": true
}, },
"schedule_id": {
"type": "string",
"description": "ID of the schedule that triggered this session, if any",
"nullable": true
},
"total_tokens": { "total_tokens": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
@@ -1592,6 +1895,16 @@
} }
} }
}, },
"SessionsQuery": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
},
"SummarizationRequested": { "SummarizationRequested": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -1757,8 +2070,7 @@
"$ref": "#/components/schemas/PermissionLevel" "$ref": "#/components/schemas/PermissionLevel"
}, },
"tool_name": { "tool_name": {
"type": "string", "type": "string"
"description": "Unique identifier and name of the tool, format <extension_name>__<tool_name>"
} }
} }
}, },

View File

@@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cronstrue": "^2.48.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"electron-log": "^5.2.2", "electron-log": "^5.2.2",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
@@ -6796,6 +6797,15 @@
"node": ">= 6" "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": { "node_modules/cross-dirname": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",

View File

@@ -102,6 +102,7 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cronstrue": "^2.48.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"electron-log": "^5.2.2", "electron-log": "^5.2.2",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",

View File

@@ -8,7 +8,6 @@ import { ToastContainer } from 'react-toastify';
import { toastService } from './toasts'; import { toastService } from './toasts';
import { extractExtensionName } from './components/settings/extensions/utils'; import { extractExtensionName } from './components/settings/extensions/utils';
import { GoosehintsModal } from './components/GoosehintsModal'; import { GoosehintsModal } from './components/GoosehintsModal';
import { SessionDetails } from './sessions';
import ChatView from './components/ChatView'; import ChatView from './components/ChatView';
import SuspenseLoader from './suspense-loader'; import SuspenseLoader from './suspense-loader';
@@ -18,6 +17,7 @@ import MoreModelsView from './components/settings/models/MoreModelsView';
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView'; import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
import SessionsView from './components/sessions/SessionsView'; import SessionsView from './components/sessions/SessionsView';
import SharedSessionView from './components/sessions/SharedSessionView'; import SharedSessionView from './components/sessions/SharedSessionView';
import SchedulesView from './components/schedule/SchedulesView';
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage'; import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
import RecipeEditor from './components/RecipeEditor'; import RecipeEditor from './components/RecipeEditor';
import { useChat } from './hooks/useChat'; import { useChat } from './hooks/useChat';
@@ -28,7 +28,8 @@ import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './compon
import { backupConfig, initConfig, readAllConfig } from './api/sdk.gen'; import { backupConfig, initConfig, readAllConfig } from './api/sdk.gen';
import PermissionSettingsView from './components/settings_v2/permission/PermissionSetting'; import PermissionSettingsView from './components/settings_v2/permission/PermissionSetting';
// Views and their options import { type SessionDetails } from './sessions';
export type View = export type View =
| 'welcome' | 'welcome'
| 'chat' | 'chat'
@@ -39,6 +40,7 @@ export type View =
| 'ConfigureProviders' | 'ConfigureProviders'
| 'settingsV2' | 'settingsV2'
| 'sessions' | 'sessions'
| 'schedules'
| 'sharedSession' | 'sharedSession'
| 'loading' | 'loading'
| 'recipeEditor' | 'recipeEditor'
@@ -47,8 +49,7 @@ export type View =
export type ViewOptions = export type ViewOptions =
| SettingsViewOptions | SettingsViewOptions
| { resumedSession?: SessionDetails } | { resumedSession?: SessionDetails }
// eslint-disable-next-line @typescript-eslint/no-explicit-any | Record<string, unknown>;
| Record<string, any>;
export type ViewConfig = { export type ViewConfig = {
view: View; view: View;
@@ -69,7 +70,6 @@ const getInitialView = (): ViewConfig => {
}; };
} }
// Any other URL-specified view
if (viewFromUrl) { if (viewFromUrl) {
return { return {
view: viewFromUrl as View, view: viewFromUrl as View,
@@ -77,7 +77,6 @@ const getInitialView = (): ViewConfig => {
}; };
} }
// Default case
return { return {
view: 'loading', view: 'loading',
viewOptions: {}, viewOptions: {},
@@ -93,10 +92,10 @@ export default function App() {
const [extensionConfirmLabel, setExtensionConfirmLabel] = useState<string>(''); const [extensionConfirmLabel, setExtensionConfirmLabel] = useState<string>('');
const [extensionConfirmTitle, setExtensionConfirmTitle] = useState<string>(''); const [extensionConfirmTitle, setExtensionConfirmTitle] = useState<string>('');
const [{ view, viewOptions }, setInternalView] = useState<ViewConfig>(getInitialView()); const [{ view, viewOptions }, setInternalView] = useState<ViewConfig>(getInitialView());
const { getExtensions, addExtension, read } = useConfig(); const { getExtensions, addExtension, read } = useConfig();
const initAttemptedRef = useRef(false); const initAttemptedRef = useRef(false);
// Utility function to extract the command from the link
function extractCommand(link: string): string { function extractCommand(link: string): string {
const url = new URL(link); const url = new URL(link);
const cmd = url.searchParams.get('cmd') || 'Unknown Command'; const cmd = url.searchParams.get('cmd') || 'Unknown Command';
@@ -104,7 +103,6 @@ export default function App() {
return `${cmd} ${args.join(' ')}`.trim(); return `${cmd} ${args.join(' ')}`.trim();
} }
// Utility function to extract the remote url from the link
function extractRemoteUrl(link: string): string { function extractRemoteUrl(link: string): string {
const url = new URL(link); const url = new URL(link);
return url.searchParams.get('url'); return url.searchParams.get('url');
@@ -116,7 +114,6 @@ export default function App() {
}; };
useEffect(() => { useEffect(() => {
// Guard against multiple initialization attempts
if (initAttemptedRef.current) { if (initAttemptedRef.current) {
console.log('Initialization already attempted, skipping...'); console.log('Initialization already attempted, skipping...');
return; return;
@@ -129,7 +126,6 @@ export default function App() {
const viewType = urlParams.get('view'); const viewType = urlParams.get('view');
const recipeConfig = window.appConfig.get('recipeConfig'); 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) {
if (viewType === 'recipeEditor' && recipeConfig) { if (viewType === 'recipeEditor' && recipeConfig) {
console.log('Setting view to recipeEditor with config:', recipeConfig); console.log('Setting view to recipeEditor with config:', recipeConfig);
@@ -142,39 +138,31 @@ export default function App() {
const initializeApp = async () => { const initializeApp = async () => {
try { try {
// checks if there is a config, and if not creates it
await initConfig(); await initConfig();
// now try to read config, if we fail and are migrating backup, then re-init config
try { try {
await readAllConfig({ throwOnError: true }); await readAllConfig({ throwOnError: true });
} catch (error) { } 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 configVersion = localStorage.getItem('configVersion');
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3; const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3;
if (shouldMigrateExtensions) { if (shouldMigrateExtensions) {
await backupConfig({ throwOnError: true }); await backupConfig({ throwOnError: true });
await initConfig(); await initConfig();
} else { } else {
// if we've migrated throw this back up
throw new Error('Unable to read config file, it may be malformed'); 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) { if (recipeConfig === null) {
setFatalError('Cannot read recipe config. Please check the deeplink and try again.'); setFatalError('Cannot read recipe config. Please check the deeplink and try again.');
return; return;
} }
const config = window.electron.getConfig(); const config = window.electron.getConfig();
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
if (provider && model) { if (provider && model) {
setView('chat'); setView('chat');
try { try {
await initializeSystem(provider, model, { await initializeSystem(provider, model, {
getExtensions, getExtensions,
@@ -182,13 +170,9 @@ export default function App() {
}); });
} catch (error) { } catch (error) {
console.error('Error in initialization:', 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) { if (error instanceof MalformedConfigError) {
throw error; throw error;
} }
setView('welcome'); setView('welcome');
} }
} else { } else {
@@ -201,8 +185,6 @@ export default function App() {
); );
setView('welcome'); setView('welcome');
} }
// Reset toast service after initialization
toastService.configure({ silent: false }); toastService.configure({ silent: false });
}; };
@@ -215,8 +197,7 @@ export default function App() {
setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`); setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`);
} }
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps }, [read, getExtensions, addExtension]);
}, []); // Empty dependency array since we only want this to run once
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(false); const [isLoadingSession, setIsLoadingSession] = useState(false);
@@ -236,32 +217,26 @@ export default function App() {
} }
}, []); }, []);
// Handle shared session deep links
useEffect(() => { useEffect(() => {
const handleOpenSharedSession = async (_event: IpcRendererEvent, link: string) => { const handleOpenSharedSession = async (_event: IpcRendererEvent, link: string) => {
window.electron.logInfo(`Opening shared session from deep link ${link}`); window.electron.logInfo(`Opening shared session from deep link ${link}`);
setIsLoadingSharedSession(true); setIsLoadingSharedSession(true);
setSharedSessionError(null); setSharedSessionError(null);
try { try {
await openSharedSessionFromDeepLink(link, setView); await openSharedSessionFromDeepLink(link, setView);
// No need to handle errors here as openSharedSessionFromDeepLink now handles them internally
} catch (error) { } catch (error) {
// This should not happen, but just in case
console.error('Unexpected error opening shared session:', error); console.error('Unexpected error opening shared session:', error);
setView('sessions'); // Fallback to sessions view setView('sessions');
} finally { } finally {
setIsLoadingSharedSession(false); setIsLoadingSharedSession(false);
} }
}; };
window.electron.on('open-shared-session', handleOpenSharedSession); window.electron.on('open-shared-session', handleOpenSharedSession);
return () => { return () => {
window.electron.off('open-shared-session', handleOpenSharedSession); window.electron.off('open-shared-session', handleOpenSharedSession);
}; };
}, []); }, []);
// Keyboard shortcut handler
useEffect(() => { useEffect(() => {
console.log('Setting up keyboard shortcuts'); console.log('Setting up keyboard shortcuts');
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -277,7 +252,6 @@ export default function App() {
} }
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
@@ -288,17 +262,15 @@ export default function App() {
console.log('Setting up fatal error handler'); console.log('Setting up fatal error handler');
const handleFatalError = (_event: IpcRendererEvent, errorMessage: string) => { const handleFatalError = (_event: IpcRendererEvent, errorMessage: string) => {
console.error('Encountered a fatal error: ', errorMessage); console.error('Encountered a fatal error: ', errorMessage);
// Log additional context that might help diagnose the issue
console.error('Current view:', view); console.error('Current view:', view);
console.error('Is loading session:', isLoadingSession); console.error('Is loading session:', isLoadingSession);
setFatalError(errorMessage); setFatalError(errorMessage);
}; };
window.electron.on('fatal-error', handleFatalError); window.electron.on('fatal-error', handleFatalError);
return () => { return () => {
window.electron.off('fatal-error', handleFatalError); window.electron.off('fatal-error', handleFatalError);
}; };
}, [view, isLoadingSession]); // Add dependencies to provide context in error logs }, [view, isLoadingSession]);
useEffect(() => { useEffect(() => {
console.log('Setting up view change handler'); console.log('Setting up view change handler');
@@ -306,14 +278,10 @@ export default function App() {
console.log(`Received view change request to: ${newView}`); console.log(`Received view change request to: ${newView}`);
setView(newView); setView(newView);
}; };
// Get initial view and config
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const viewFromUrl = urlParams.get('view'); const viewFromUrl = urlParams.get('view');
if (viewFromUrl) { if (viewFromUrl) {
// Get the config from the electron window config
const windowConfig = window.electron.getConfig(); const windowConfig = window.electron.getConfig();
if (viewFromUrl === 'recipeEditor') { if (viewFromUrl === 'recipeEditor') {
const initialViewOptions = { const initialViewOptions = {
recipeConfig: windowConfig?.recipeConfig, recipeConfig: windowConfig?.recipeConfig,
@@ -324,12 +292,10 @@ export default function App() {
setView(viewFromUrl); setView(viewFromUrl);
} }
} }
window.electron.on('set-view', handleSetView); window.electron.on('set-view', handleSetView);
return () => window.electron.off('set-view', handleSetView); return () => window.electron.off('set-view', handleSetView);
}, []); }, []);
// Add cleanup for session states when view changes
useEffect(() => { useEffect(() => {
console.log(`View changed to: ${view}`); console.log(`View changed to: ${view}`);
if (view !== 'chat' && view !== 'recipeEditor') { if (view !== 'chat' && view !== 'recipeEditor') {
@@ -338,10 +304,7 @@ export default function App() {
} }
}, [view]); }, [view]);
// Configuration for extension security
const config = window.electron.getConfig(); 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; const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true;
useEffect(() => { useEffect(() => {
@@ -354,35 +317,24 @@ export default function App() {
const extName = extractExtensionName(link); const extName = extractExtensionName(link);
window.electron.logInfo(`Adding extension from deep link ${link}`); window.electron.logInfo(`Adding extension from deep link ${link}`);
setPendingLink(link); setPendingLink(link);
// Default values for confirmation dialog
let warningMessage = ''; let warningMessage = '';
let label = 'OK'; let label = 'OK';
let title = 'Confirm Extension Installation'; let title = 'Confirm Extension Installation';
let isBlocked = false; let isBlocked = false;
let useDetailedMessage = false; let useDetailedMessage = false;
// For SSE extensions (with remoteUrl), always use detailed message
if (remoteUrl) { if (remoteUrl) {
useDetailedMessage = true; useDetailedMessage = true;
} else { } else {
// For command-based extensions, check against allowlist
try { try {
const allowedCommands = await window.electron.getAllowedExtensions(); const allowedCommands = await window.electron.getAllowedExtensions();
// Only check and show warning if we have a non-empty allowlist
if (allowedCommands && allowedCommands.length > 0) { if (allowedCommands && allowedCommands.length > 0) {
const isCommandAllowed = allowedCommands.some((allowedCmd) => const isCommandAllowed = allowedCommands.some((allowedCmd) =>
command.startsWith(allowedCmd) command.startsWith(allowedCmd)
); );
if (!isCommandAllowed) { if (!isCommandAllowed) {
// Not in allowlist - use detailed message and show warning/block
useDetailedMessage = true; useDetailedMessage = true;
title = '⛔️ Untrusted Extension ⛔️'; title = '⛔️ Untrusted Extension ⛔️';
if (STRICT_ALLOWLIST) { if (STRICT_ALLOWLIST) {
// Block installation completely unless override is active
isBlocked = true; isBlocked = true;
label = 'Extension Blocked'; label = 'Extension Blocked';
warningMessage = warningMessage =
@@ -390,7 +342,6 @@ export default function App() {
'Installation is blocked by your administrator. ' + 'Installation is blocked by your administrator. ' +
'Please contact your administrator if you need this extension.'; 'Please contact your administrator if you need this extension.';
} else { } else {
// Allow override (either because STRICT_ALLOWLIST is false or secret key combo was used)
label = 'Override and install'; label = 'Override and install';
warningMessage = warningMessage =
'\n\n⚠ WARNING: This extension command is not in the allowed list. ' + '\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.'; '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) { } catch (error) {
console.error('Error checking allowlist:', error); console.error('Error checking allowlist:', error);
} }
} }
// Set the appropriate message based on the extension type and allowlist status
if (useDetailedMessage) { if (useDetailedMessage) {
// Detailed message for SSE extensions or non-allowlisted command extensions
const detailedMessage = remoteUrl 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 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.`; : `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}`); setModalMessage(`${detailedMessage}${warningMessage}`);
} else { } else {
// Simple message for allowlisted command extensions or when no allowlist exists
const messageDetails = `Command: ${command}`; const messageDetails = `Command: ${command}`;
setModalMessage( setModalMessage(
`Are you sure you want to install the ${extName} extension?\n\n${messageDetails}` `Are you sure you want to install the ${extName} extension?\n\n${messageDetails}`
); );
} }
setExtensionConfirmLabel(label); setExtensionConfirmLabel(label);
setExtensionConfirmTitle(title); setExtensionConfirmTitle(title);
// If blocked, disable the confirmation button functionality by setting a special flag
if (isBlocked) { if (isBlocked) {
setPendingLink(null); // Clear the pending link so confirmation does nothing setPendingLink(null);
} }
setModalVisible(true); setModalVisible(true);
} catch (error) { } catch (error) {
console.error('Error handling add-extension event:', error); console.error('Error handling add-extension event:', error);
} }
}; };
window.electron.on('add-extension', handleAddExtension); window.electron.on('add-extension', handleAddExtension);
return () => { return () => {
window.electron.off('add-extension', handleAddExtension); window.electron.off('add-extension', handleAddExtension);
}; };
}, [STRICT_ALLOWLIST]); }, [STRICT_ALLOWLIST]);
// Focus the first found input field
useEffect(() => { useEffect(() => {
const handleFocusInput = (_event: IpcRendererEvent) => { const handleFocusInput = (_event: IpcRendererEvent) => {
const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement;
@@ -456,28 +394,24 @@ export default function App() {
}; };
}, []); }, []);
// TODO: modify
const handleConfirm = async () => { const handleConfirm = async () => {
if (pendingLink) { if (pendingLink) {
console.log(`Confirming installation of extension from: ${pendingLink}`); console.log(`Confirming installation of extension from: ${pendingLink}`);
setModalVisible(false); // Dismiss modal immediately setModalVisible(false);
try { try {
await addExtensionFromDeepLinkV2(pendingLink, addExtension, setView); await addExtensionFromDeepLinkV2(pendingLink, addExtension, setView);
console.log('Extension installation successful'); console.log('Extension installation successful');
} catch (error) { } catch (error) {
console.error('Failed to add extension:', error); console.error('Failed to add extension:', error);
// Consider showing a user-visible error notification here
} finally { } finally {
setPendingLink(null); setPendingLink(null);
} }
} else { } else {
// This case happens when pendingLink was cleared due to blocking
console.log('Extension installation blocked by allowlist restrictions'); console.log('Extension installation blocked by allowlist restrictions');
setModalVisible(false); setModalVisible(false);
} }
}; };
// TODO: modify
const handleCancel = () => { const handleCancel = () => {
console.log('Cancelled extension installation.'); console.log('Cancelled extension installation.');
setModalVisible(false); setModalVisible(false);
@@ -566,6 +500,7 @@ export default function App() {
/> />
)} )}
{view === 'sessions' && <SessionsView setView={setView} />} {view === 'sessions' && <SessionsView setView={setView} />}
{view === 'schedules' && <SchedulesView onClose={() => setView('chat')} />}
{view === 'sharedSession' && ( {view === 'sharedSession' && (
<SharedSessionView <SharedSessionView
session={viewOptions?.sessionDetails} session={viewOptions?.sessionDetails}

View File

@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen'; import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -144,6 +144,45 @@ export const manageContext = <ThrowOnError extends boolean = false>(options: Opt
}); });
}; };
export const createSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateScheduleData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<CreateScheduleResponse, unknown, ThrowOnError>({
url: '/schedule/create',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const deleteSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteScheduleData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).delete<DeleteScheduleResponse, unknown, ThrowOnError>({
url: '/schedule/delete/{id}',
...options
});
};
export const listSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListSchedulesData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ListSchedulesResponse2, unknown, ThrowOnError>({
url: '/schedule/list',
...options
});
};
export const runNowHandler = <ThrowOnError extends boolean = false>(options: Options<RunNowHandlerData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<RunNowHandlerResponse, unknown, ThrowOnError>({
url: '/schedule/{id}/run_now',
...options
});
};
export const sessionsHandler = <ThrowOnError extends boolean = false>(options: Options<SessionsHandlerData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<SessionsHandlerResponse, unknown, ThrowOnError>({
url: '/schedule/{id}/sessions',
...options
});
};
export const listSessions = <ThrowOnError extends boolean = false>(options?: Options<ListSessionsData, ThrowOnError>) => { export const listSessions = <ThrowOnError extends boolean = false>(options?: Options<ListSessionsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ListSessionsResponse, unknown, ThrowOnError>({ return (options?.client ?? _heyApiClient).get<ListSessionsResponse, unknown, ThrowOnError>({
url: '/sessions', url: '/sessions',

View File

@@ -62,6 +62,12 @@ export type ContextManageResponse = {
tokenCounts: Array<number>; tokenCounts: Array<number>;
}; };
export type CreateScheduleRequest = {
cron: string;
id: string;
recipe_source: string;
};
export type EmbeddedResource = { export type EmbeddedResource = {
annotations?: Annotations | null; annotations?: Annotations | null;
resource: ResourceContents; resource: ResourceContents;
@@ -166,6 +172,10 @@ export type ImageContent = {
mimeType: string; mimeType: string;
}; };
export type ListSchedulesResponse = {
jobs: Array<ScheduledJob>;
};
/** /**
* A message to or from an LLM * 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 PrincipalType = 'Extension' | 'Tool';
export type ProviderDetails = { export type ProviderDetails = {
/**
* Indicates whether the provider is fully configured
*/
is_configured: boolean; is_configured: boolean;
metadata: ProviderMetadata; metadata: ProviderMetadata;
/**
* Unique identifier and name of the provider
*/
name: string; name: string;
}; };
@@ -294,6 +298,32 @@ export type ResourceContents = {
export type Role = 'user' | 'assistant'; 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 = { export type SessionHistoryResponse = {
/** /**
* List of messages in the session conversation * 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. * The number of output tokens used in the session. Retrieved from the provider's last usage.
*/ */
output_tokens?: number | null; 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. * 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; working_dir: string;
}; };
export type SessionsQuery = {
limit?: number;
};
export type SummarizationRequested = { export type SummarizationRequested = {
msg: string; msg: string;
}; };
@@ -464,9 +502,6 @@ export type ToolInfo = {
export type ToolPermission = { export type ToolPermission = {
permission: PermissionLevel; permission: PermissionLevel;
/**
* Unique identifier and name of the tool, format <extension_name>__<tool_name>
*/
tool_name: string; tool_name: string;
}; };
@@ -849,6 +884,146 @@ export type ManageContextResponses = {
export type ManageContextResponse = ManageContextResponses[keyof 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<SessionDisplayInfo>;
};
export type SessionsHandlerResponse = SessionsHandlerResponses[keyof SessionsHandlerResponses];
export type ListSessionsData = { export type ListSessionsData = {
body?: never; body?: never;
path?: never; path?: never;

View File

@@ -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<globalThis.SVGSVGElement> {}
export const TrashIcon: React.FC<IconProps> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
{...props} // Allows passing className, w-5, h-5, etc.
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);

View File

@@ -125,17 +125,14 @@ export default function MoreMenu({
useEffect(() => { useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
// Handler for system theme changes
const handleThemeChange = (e: { matches: boolean }) => { const handleThemeChange = (e: { matches: boolean }) => {
if (themeMode === 'system') { if (themeMode === 'system') {
setDarkMode(e.matches); setDarkMode(e.matches);
} }
}; };
// Add listener for system theme changes
mediaQuery.addEventListener('change', handleThemeChange); mediaQuery.addEventListener('change', handleThemeChange);
// Initial setup
if (themeMode === 'system') { if (themeMode === 'system') {
setDarkMode(mediaQuery.matches); setDarkMode(mediaQuery.matches);
localStorage.setItem('use_system_theme', 'true'); localStorage.setItem('use_system_theme', 'true');
@@ -145,7 +142,6 @@ export default function MoreMenu({
localStorage.setItem('theme', themeMode); localStorage.setItem('theme', themeMode);
} }
// Cleanup
return () => mediaQuery.removeEventListener('change', handleThemeChange); return () => mediaQuery.removeEventListener('change', handleThemeChange);
}, [themeMode]); }, [themeMode]);
@@ -221,6 +217,16 @@ export default function MoreMenu({
Session history Session history
</MenuButton> </MenuButton>
{process.env.ALPHA && (
<MenuButton
onClick={() => setView('schedules')}
subtitle="Manage scheduled runs"
icon={<Time className="w-4 h-4" />}
>
Scheduler
</MenuButton>
)}
<MenuButton <MenuButton
onClick={() => setIsGoosehintsModalOpen(true)} onClick={() => setIsGoosehintsModalOpen(true)}
subtitle="Customize instructions" subtitle="Customize instructions"

View File

@@ -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<void>;
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<CreateScheduleModalProps> = ({
isOpen,
onClose,
onSubmit,
isLoadingExternally,
apiErrorExternally,
}) => {
const [scheduleId, setScheduleId] = useState<string>('');
const [recipeSourcePath, setRecipeSourcePath] = useState<string>('');
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
const [selectedDate, setSelectedDate] = useState<string>(
() => new Date().toISOString().split('T')[0]
);
const [selectedTime, setSelectedTime] = useState<string>('09:00');
const [selectedMinute, setSelectedMinute] = useState<string>('0');
const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState<Set<string>>(new Set(['1']));
const [selectedDayOfMonth, setSelectedDayOfMonth] = useState<string>('1');
const [derivedCronExpression, setDerivedCronExpression] = useState<string>('');
const [readableCronExpression, setReadableCronExpression] = useState<string>('');
const [internalValidationError, setInternalValidationError] = useState<string | null>(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 (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-bgApp shadow-xl rounded-lg z-50 flex flex-col max-h-[90vh] overflow-hidden">
<div className="px-6 pt-6 pb-4 flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Create New Schedule
</h2>
</div>
<form
id="new-schedule-form"
onSubmit={handleLocalSubmit}
className="px-6 py-4 space-y-4 flex-grow overflow-y-auto"
>
{apiErrorExternally && (
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
{apiErrorExternally}
</p>
)}
{internalValidationError && (
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
{internalValidationError}
</p>
)}
<div>
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
Schedule ID:
</label>
<Input
type="text"
id="scheduleId-modal"
value={scheduleId}
onChange={(e) => setScheduleId(e.target.value)}
placeholder="e.g., daily-summary-job"
required
/>
</div>
<div>
<label className={modalLabelClassName}>Recipe Source (YAML File):</label>
<Button
type="button"
variant="outline"
onClick={handleBrowseFile}
className="w-full justify-center"
>
Browse...
</Button>
{recipeSourcePath && (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">
Selected: {recipeSourcePath}
</p>
)}
</div>
<div>
<label htmlFor="frequency-modal" className={modalLabelClassName}>
Frequency:
</label>
<Select
instanceId="frequency-select-modal"
options={frequencies}
value={frequencies.find((f) => f.value === frequency)}
onChange={(selectedOption: FrequencyOption | null) => {
if (selectedOption) setFrequency(selectedOption.value);
}}
placeholder="Select frequency..."
/>
</div>
{frequency === 'once' && (
<>
<div>
<label htmlFor="onceDate-modal" className={modalLabelClassName}>
Date:
</label>
<Input
type="date"
id="onceDate-modal"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="onceTime-modal" className={modalLabelClassName}>
Time:
</label>
<Input
type="time"
id="onceTime-modal"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
required
/>
</div>
</>
)}
{frequency === 'hourly' && (
<div>
<label htmlFor="hourlyMinute-modal" className={modalLabelClassName}>
Minute of the hour (0-59):
</label>
<Input
type="number"
id="hourlyMinute-modal"
min="0"
max="59"
value={selectedMinute}
onChange={(e) => setSelectedMinute(e.target.value)}
required
/>
</div>
)}
{(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
<div>
<label htmlFor="commonTime-modal" className={modalLabelClassName}>
Time:
</label>
<Input
type="time"
id="commonTime-modal"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
required
/>
</div>
)}
{frequency === 'weekly' && (
<div>
<label className={modalLabelClassName}>Days of Week:</label>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-1">
{daysOfWeekOptions.map((day) => (
<label key={day.value} className={checkboxLabelClassName}>
<input
type="checkbox"
value={day.value}
checked={selectedDaysOfWeek.has(day.value)}
onChange={() => handleDayOfWeekChange(day.value)}
className={checkboxInputClassName}
/>
{day.label}
</label>
))}
</div>
</div>
)}
{frequency === 'monthly' && (
<div>
<label htmlFor="monthlyDay-modal" className={modalLabelClassName}>
Day of Month (1-31):
</label>
<Input
type="number"
id="monthlyDay-modal"
min="1"
max="31"
value={selectedDayOfMonth}
onChange={(e) => setSelectedDayOfMonth(e.target.value)}
required
/>
</div>
)}
<div className="mt-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-md border border-gray-200 dark:border-gray-600">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Generated Cron:{' '}
<code className="text-xs bg-gray-200 dark:bg-gray-600 p-1 rounded">
{derivedCronExpression}
</code>
</p>
<p className={`${cronPreviewTextColor} mt-2`}>
<b>Human Readable:</b> {readableCronExpression}
</p>
<p className={cronPreviewTextColor}>Syntax: S M H D M DoW. (S=0, DoW: 0/7=Sun)</p>
{frequency === 'once' && (
<p className={cronPreviewSpecialNoteColor}>
Note: "Once" schedules recur annually. True one-time tasks may need backend deletion
after execution.
</p>
)}
</div>
</form>
{/* Actions */}
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
<Button
type="button"
variant="ghost"
onClick={handleClose}
disabled={isLoadingExternally}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-gray-400 hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
>
Cancel
</Button>
<Button
type="submit"
form="new-schedule-form"
variant="default"
disabled={isLoadingExternally}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-lg hover:bg-gray-50 hover:dark:text-black dark:text-white dark:border-gray-600 font-regular"
>
{isLoadingExternally ? 'Creating...' : 'Create Schedule'}
</Button>
</div>
</Card>
</div>
);
};

View File

@@ -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<ScheduleDetailViewProps> = ({ scheduleId, onNavigateBack }) => {
const [sessions, setSessions] = useState<ScheduleSessionMeta[]>([]);
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
const [sessionsError, setSessionsError] = useState<string | null>(null);
const [runNowLoading, setRunNowLoading] = useState(false);
const [selectedSessionDetails, setSelectedSessionDetails] = useState<SessionDetails | null>(null);
const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false);
const [sessionDetailsError, setSessionDetailsError] = useState<string | null>(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 (
<SessionHistoryView
session={selectedSessionDetails}
isLoading={isLoadingSessionDetails}
error={sessionDetailsError}
onBack={() => {
setSelectedSessionDetails(null);
setSessionDetailsError(null);
}}
onResume={handleResumeViewedSession}
onRetry={() => loadAndShowSessionDetails(selectedSessionDetails.session_id)}
showActionButtons={true}
/>
);
}
if (!scheduleId) {
return (
<div className="h-screen w-full flex flex-col items-center justify-center bg-app text-textStandard p-8">
<MoreMenuLayout showMenu={false} />
<BackButton onClick={onNavigateBack} />
<h1 className="text-2xl font-medium text-gray-900 dark:text-white mt-4">
Schedule Not Found
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
No schedule ID was provided. Please return to the schedules list and select a schedule.
</p>
</div>
);
}
return (
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
<MoreMenuLayout showMenu={false} />
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
<BackButton onClick={onNavigateBack} />
<h1 className="text-3xl font-medium text-gray-900 dark:text-white mt-1">
Schedule Details
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Viewing Schedule ID: {scheduleId}
</p>
</div>
<ScrollArea className="flex-grow">
<div className="p-8 space-y-6">
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">Actions</h2>
<Button onClick={handleRunNow} disabled={runNowLoading} className="w-full md:w-auto">
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
</Button>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Recent Sessions for this Schedule
</h2>
{isLoadingSessions && (
<p className="text-gray-500 dark:text-gray-400">Loading sessions...</p>
)}
{sessionsError && (
<p className="text-red-500 dark:text-red-400 text-sm p-3 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
Error: {sessionsError}
</p>
)}
{!isLoadingSessions && !sessionsError && sessions.length === 0 && (
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
No sessions found for this schedule.
</p>
)}
{!isLoadingSessions && sessions.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sessions.map((session) => (
<Card
key={session.id}
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
onClick={() => handleSessionCardClick(session.id)}
role="button"
tabIndex={0}
onKeyPress={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleSessionCardClick(session.id);
}
}}
>
<h3
className="text-sm font-semibold text-gray-900 dark:text-white truncate"
title={session.name || session.id}
>
{session.name || `Session ID: ${session.id}`}{' '}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Created:{' '}
{session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'}
</p>
{session.messageCount !== undefined && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Messages: {session.messageCount}
</p>
)}
{session.workingDir && (
<p
className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"
title={session.workingDir}
>
Dir: {session.workingDir}
</p>
)}
{session.accumulatedTotalTokens !== undefined &&
session.accumulatedTotalTokens !== null && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Tokens: {session.accumulatedTotalTokens}
</p>
)}
<p className="text-xs text-gray-600 dark:text-gray-500 mt-1">
ID: <span className="font-mono">{session.id}</span>
</p>
</Card>
))}
</div>
)}
</section>
</div>
</ScrollArea>
</div>
);
};
export default ScheduleDetailView;

View File

@@ -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<SchedulesViewProps> = ({ onClose }) => {
const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const [submitApiError, setSubmitApiError] = useState<string | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(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 (
<ScheduleDetailView
scheduleId={viewingScheduleId}
onNavigateBack={handleNavigateBackFromDetail}
/>
);
}
return (
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
<MoreMenuLayout showMenu={false} />
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
<BackButton onClick={onClose} />
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white mt-2">
Schedules Management
</h1>
</div>
<ScrollArea className="flex-grow">
<div className="p-8">
<Button
onClick={handleOpenCreateModal}
className="w-full md:w-auto flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4 mb-8"
>
<Plus className="h-4 w-4" /> Create New Schedule
</Button>
{apiError && (
<p className="text-red-500 dark:text-red-400 text-sm p-4 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
Error: {apiError}
</p>
)}
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Existing Schedules
</h2>
{isLoading && schedules.length === 0 && (
<p className="text-gray-500 dark:text-gray-400">Loading schedules...</p>
)}
{!isLoading && !apiError && schedules.length === 0 && (
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
No schedules found. Create one to get started!
</p>
)}
{!isLoading && schedules.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{schedules.map((job) => (
<Card
key={job.id}
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
onClick={() => handleNavigateToScheduleDetail(job.id)}
>
<div className="flex justify-between items-start">
<div className="flex-grow mr-2 overflow-hidden">
<h3
className="text-base font-semibold text-gray-900 dark:text-white truncate"
title={job.id}
>
{job.id}
</h3>
<p
className="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all"
title={job.source}
>
Source: {job.source}
</p>
<p
className="text-xs text-gray-500 dark:text-gray-400 mt-1"
title={getReadableCron(job.cron)}
>
Schedule: {getReadableCron(job.cron)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Last Run:{' '}
{job.last_run ? new Date(job.last_run).toLocaleString() : 'Never'}
</p>
</div>
<div className="flex-shrink-0">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDeleteSchedule(job.id);
}}
className="text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-900/30"
title={`Delete schedule ${job.id}`}
disabled={isLoading}
>
<TrashIcon className="w-5 h-5" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</section>
</div>
</ScrollArea>
<CreateScheduleModal
isOpen={isCreateModalOpen}
onClose={handleCloseCreateModal}
onSubmit={handleCreateScheduleSubmit}
isLoadingExternally={isSubmitting}
apiErrorExternally={submitApiError}
/>
</div>
);
};
export default SchedulesView;

View File

@@ -26,6 +26,7 @@ interface SessionHistoryViewProps {
onBack: () => void; onBack: () => void;
onResume: () => void; onResume: () => void;
onRetry: () => void; onRetry: () => void;
showActionButtons?: boolean;
} }
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
@@ -35,6 +36,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
onBack, onBack,
onResume, onResume,
onRetry, onRetry,
showActionButtons = true,
}) => { }) => {
const [isShareModalOpen, setIsShareModalOpen] = useState(false); const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [shareLink, setShareLink] = useState<string>(''); const [shareLink, setShareLink] = useState<string>('');
@@ -47,7 +49,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
if (savedSessionConfig) { if (savedSessionConfig) {
try { try {
const config = JSON.parse(savedSessionConfig); const config = JSON.parse(savedSessionConfig);
// If config.enabled is true and config.baseUrl is non-empty, we can share
if (config.enabled && config.baseUrl) { if (config.enabled && config.baseUrl) {
setCanShare(true); setCanShare(true);
} }
@@ -61,7 +62,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
setIsSharing(true); setIsSharing(true);
try { try {
// Get the session sharing configuration from localStorage
const savedSessionConfig = localStorage.getItem('session_sharing_config'); const savedSessionConfig = localStorage.getItem('session_sharing_config');
if (!savedSessionConfig) { if (!savedSessionConfig) {
throw new Error('Session sharing is not configured. Please configure it in settings.'); throw new Error('Session sharing is not configured. Please configure it in settings.');
@@ -72,7 +72,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
throw new Error('Session sharing is not enabled or base URL is not configured.'); throw new Error('Session sharing is not enabled or base URL is not configured.');
} }
// Create a shared session
const shareToken = await createSharedSession( const shareToken = await createSharedSession(
config.baseUrl, config.baseUrl,
session.metadata.working_dir, session.metadata.working_dir,
@@ -81,7 +80,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
session.metadata.total_tokens session.metadata.total_tokens
); );
// Create the shareable link
const shareableLink = `goose://sessions/${shareToken}`; const shareableLink = `goose://sessions/${shareToken}`;
setShareLink(shareableLink); setShareLink(shareableLink);
setIsShareModalOpen(true); setIsShareModalOpen(true);
@@ -112,9 +110,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
<div className="h-screen w-full flex flex-col"> <div className="h-screen w-full flex flex-col">
<MoreMenuLayout showMenu={false} /> <MoreMenuLayout showMenu={false} />
{/* Top Row - back, info, reopen thread (fixed) */}
<SessionHeaderCard onBack={onBack}> <SessionHeaderCard onBack={onBack}>
{/* Session info row */}
<div className="ml-8"> <div className="ml-8">
<h1 className="text-lg text-textStandardInverse"> <h1 className="text-lg text-textStandardInverse">
{session.metadata.description || session.session_id} {session.metadata.description || session.session_id}
@@ -143,37 +139,39 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
</div> </div>
</div> </div>
<div className="ml-auto flex items-center space-x-4"> {showActionButtons && (
<button <div className="ml-auto flex items-center space-x-4">
onClick={handleShare} <button
title="Share Session" onClick={handleShare}
disabled={!canShare || isSharing} title="Share Session"
className={`flex items-center text-textStandardInverse px-2 py-1 ${ disabled={!canShare || isSharing}
canShare className={`flex items-center text-textStandardInverse px-2 py-1 ${
? 'hover:font-bold hover:scale-110 transition-all duration-150' canShare
: 'cursor-not-allowed opacity-50' ? 'hover:font-bold hover:scale-110 transition-all duration-150'
}`} : 'cursor-not-allowed opacity-50'
> }`}
{isSharing ? ( >
<> {isSharing ? (
<LoaderCircle className="w-7 h-7 animate-spin mr-2" /> <>
<span>Sharing...</span> <LoaderCircle className="w-7 h-7 animate-spin mr-2" />
</> <span>Sharing...</span>
) : ( </>
<> ) : (
<Share2 className="w-7 h-7" /> <>
</> <Share2 className="w-7 h-7" />
)} </>
</button> )}
</button>
<button <button
onClick={onResume} onClick={onResume}
title="Resume Session" title="Resume Session"
className="flex items-center text-textStandardInverse px-2 py-1 hover:font-bold hover:scale-110 transition-all duration-150" className="flex items-center text-textStandardInverse px-2 py-1 hover:font-bold hover:scale-110 transition-all duration-150"
> >
<Sparkles className="w-7 h-7" /> <Sparkles className="w-7 h-7" />
</button> </button>
</div> </div>
)}
</SessionHeaderCard> </SessionHeaderCard>
<SessionMessages <SessionMessages
@@ -183,20 +181,16 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
onRetry={onRetry} onRetry={onRetry}
/> />
{/* Share Link Modal */}
<Modal open={isShareModalOpen} onOpenChange={setIsShareModalOpen}> <Modal open={isShareModalOpen} onOpenChange={setIsShareModalOpen}>
<ModalContent className="sm:max-w-md p-0 bg-bgApp dark:bg-bgApp dark:border-borderSubtle"> <ModalContent className="sm:max-w-md p-0 bg-bgApp dark:bg-bgApp dark:border-borderSubtle">
{/* Share Icon */}
<div className="flex justify-center mt-4"> <div className="flex justify-center mt-4">
<Share2 className="w-6 h-6 text-textStandard" /> <Share2 className="w-6 h-6 text-textStandard" />
</div> </div>
{/* Centered Title */}
<div className="mt-2 px-6 text-center"> <div className="mt-2 px-6 text-center">
<h2 className="text-lg font-semibold text-textStandard">Share Session (beta)</h2> <h2 className="text-lg font-semibold text-textStandard">Share Session (beta)</h2>
</div> </div>
{/* Description & Link */}
<div className="px-6 flex flex-col gap-4 mt-2"> <div className="px-6 flex flex-col gap-4 mt-2">
<p className="text-sm text-center text-textSubtle"> <p className="text-sm text-center text-textSubtle">
Share this session link to give others a read only view of your goose chat. Share this session link to give others a read only view of your goose chat.
@@ -219,7 +213,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
</div> </div>
</div> </div>
{/* Footer */}
<div> <div>
<Button <Button
type="button" type="button"

View File

@@ -347,8 +347,8 @@ const createChat = async (
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
additionalArguments: [ additionalArguments: [
JSON.stringify({ JSON.stringify({
...appConfig, ...appConfig, // Use the potentially updated appConfig
GOOSE_PORT: port, GOOSE_PORT: port, // Ensure this specific window gets the correct port
GOOSE_WORKING_DIR: working_dir, GOOSE_WORKING_DIR: working_dir,
REQUEST_DIR: dir, REQUEST_DIR: dir,
GOOSE_BASE_URL_SHARE: sharingUrl, GOOSE_BASE_URL_SHARE: sharingUrl,
@@ -399,8 +399,8 @@ const createChat = async (
// Store config in localStorage for future windows // Store config in localStorage for future windows
const windowConfig = { const windowConfig = {
...appConfig, ...appConfig, // Use the potentially updated appConfig here as well
GOOSE_PORT: port, GOOSE_PORT: port, // Ensure this specific window's config gets the correct port
GOOSE_WORKING_DIR: working_dir, GOOSE_WORKING_DIR: working_dir,
REQUEST_DIR: dir, REQUEST_DIR: dir,
GOOSE_BASE_URL_SHARE: sharingUrl, GOOSE_BASE_URL_SHARE: sharingUrl,

View File

@@ -44,7 +44,7 @@ type ElectronAPI = {
fetchMetadata: (url: string) => Promise<string>; fetchMetadata: (url: string) => Promise<string>;
reloadApp: () => void; reloadApp: () => void;
checkForOllama: () => Promise<boolean>; checkForOllama: () => Promise<boolean>;
selectFileOrDirectory: () => Promise<string>; selectFileOrDirectory: () => Promise<string | null>;
startPowerSaveBlocker: () => Promise<number>; startPowerSaveBlocker: () => Promise<number>;
stopPowerSaveBlocker: () => Promise<void>; stopPowerSaveBlocker: () => Promise<void>;
getBinaryPath: (binaryName: string) => Promise<string>; getBinaryPath: (binaryName: string) => Promise<string>;

108
ui/desktop/src/schedule.ts Normal file
View File

@@ -0,0 +1,108 @@
import {
listSchedules as apiListSchedules,
createSchedule as apiCreateSchedule,
deleteSchedule as apiDeleteSchedule,
sessionsHandler as apiGetScheduleSessions,
runNowHandler as apiRunScheduleNow,
} from './api';
export interface ScheduledJob {
id: string;
source: string;
cron: string;
last_run?: string | null;
}
export interface ScheduleSession {
id: string;
name: string;
createdAt: string; // ISO 8601 date string
workingDir: string;
scheduleId: string;
messageCount: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
accumulatedTotalTokens: number;
accumulatedInputTokens: number;
accumulatedOutputTokens: number;
}
export async function listSchedules(): Promise<ScheduledJob[]> {
try {
const response = await apiListSchedules<true>();
if (response && response.data && Array.isArray(response.data.jobs)) {
return response.data.jobs as ScheduledJob[];
}
console.error('Unexpected response format from apiListSchedules', response);
throw new Error('Failed to list schedules: Unexpected response format');
} catch (error) {
console.error('Error listing schedules:', error);
throw error;
}
}
export async function createSchedule(request: {
id: string;
recipe_source: string;
cron: string;
}): Promise<ScheduledJob> {
try {
const response = await apiCreateSchedule<true>({ data: request });
if (response && response.data) {
return response.data as ScheduledJob;
}
console.error('Unexpected response format from apiCreateSchedule', response);
throw new Error('Failed to create schedule: Unexpected response format');
} catch (error) {
console.error('Error creating schedule:', error);
throw error;
}
}
export async function deleteSchedule(id: string): Promise<void> {
try {
await apiDeleteSchedule<true>({ path: { schedule_id: id } });
} catch (error) {
console.error(`Error deleting schedule ${id}:`, error);
throw error;
}
}
export async function getScheduleSessions(
scheduleId: string,
limit?: number
): Promise<ScheduleSession[]> {
try {
const response = await apiGetScheduleSessions<true>({
path: { id: scheduleId },
query: { limit },
});
if (response && response.data) {
return response.data as ScheduleSession[];
}
console.error('Unexpected response format from apiGetScheduleSessions', response);
throw new Error('Failed to get schedule sessions: Unexpected response format');
} catch (error) {
console.error(`Error fetching sessions for schedule ${scheduleId}:`, error);
throw error;
}
}
export async function runScheduleNow(scheduleId: string): Promise<string> {
try {
const response = await apiRunScheduleNow<true>({
path: { id: scheduleId },
});
if (response && response.data && response.data.session_id) {
return response.data.session_id;
}
console.error('Unexpected response format from apiRunScheduleNow', response);
throw new Error('Failed to run schedule now: Unexpected response format');
} catch (error) {
console.error(`Error running schedule ${scheduleId} now:`, error);
throw error;
}
}