mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-21 08:04:20 +01:00
Add basic cron scheduler to goose-server (#2621)
This commit is contained in:
173
Cargo.lock
generated
173
Cargo.lock
generated
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
184
crates/goose-cli/src/commands/schedule.rs
Normal file
184
crates/goose-cli/src/commands/schedule.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
270
crates/goose-server/src/routes/schedule.rs
Normal file
270
crates/goose-server/src/routes/schedule.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +447,126 @@
|
|||||||
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
850
crates/goose/src/scheduler.rs
Normal file
850
crates/goose/src/scheduler.rs
Normal 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, ¤t_jobs_arc).await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to persist last_run update for job {}: {}",
|
||||||
|
&task_job_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pass None for provider_override in normal execution
|
||||||
|
if let Err(e) = run_scheduled_job_internal(job_to_execute, None).await {
|
||||||
|
tracing::error!(
|
||||||
|
"Scheduled job '{}' execution failed: {}",
|
||||||
|
&e.job_id,
|
||||||
|
e.error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| SchedulerError::CronParseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let job_uuid = self
|
||||||
|
.internal_scheduler
|
||||||
|
.add(cron_task)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
|
||||||
|
|
||||||
|
jobs_guard.insert(stored_job.id.clone(), (job_uuid, stored_job));
|
||||||
|
// Pass the jobs_guard by reference for the initial persist after adding a job
|
||||||
|
self.persist_jobs_to_storage_with_guard(&jobs_guard).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_jobs_from_storage(self: &Arc<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, ¤t_jobs_arc).await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to persist last_run update for loaded job {}: {}",
|
||||||
|
&task_job_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pass None for provider_override in normal execution
|
||||||
|
if let Err(e) = run_scheduled_job_internal(job_to_execute, None).await {
|
||||||
|
tracing::error!(
|
||||||
|
"Scheduled job '{}' execution failed: {}",
|
||||||
|
&e.job_id,
|
||||||
|
e.error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| SchedulerError::CronParseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let job_uuid = self
|
||||||
|
.internal_scheduler
|
||||||
|
.add(cron_task)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
|
||||||
|
jobs_guard.insert(job_to_load.id.clone(), (job_uuid, job_to_load));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renamed and kept for direct use when a guard is already held (e.g. add/remove)
|
||||||
|
async fn persist_jobs_to_storage_with_guard(
|
||||||
|
&self,
|
||||||
|
jobs_guard: &tokio::sync::MutexGuard<'_, HashMap<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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
10
ui/desktop/package-lock.json
generated
10
ui/desktop/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
19
ui/desktop/src/components/icons/TrashIcon.tsx
Normal file
19
ui/desktop/src/components/icons/TrashIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
|
|||||||
439
ui/desktop/src/components/schedule/CreateScheduleModal.tsx
Normal file
439
ui/desktop/src/components/schedule/CreateScheduleModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
260
ui/desktop/src/components/schedule/ScheduleDetailView.tsx
Normal file
260
ui/desktop/src/components/schedule/ScheduleDetailView.tsx
Normal 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;
|
||||||
230
ui/desktop/src/components/schedule/SchedulesView.tsx
Normal file
230
ui/desktop/src/components/schedule/SchedulesView.tsx
Normal 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;
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
108
ui/desktop/src/schedule.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user