Add basic cron scheduler to goose-server (#2621)
This commit is contained in:
@@ -9,6 +9,11 @@ use crate::commands::info::handle_info;
|
||||
use crate::commands::mcp::run_server;
|
||||
use crate::commands::project::{handle_project_default, handle_projects_interactive};
|
||||
use crate::commands::recipe::{handle_deeplink, handle_validate};
|
||||
// Import the new handlers from commands::schedule
|
||||
use crate::commands::schedule::{
|
||||
handle_schedule_add, handle_schedule_list, handle_schedule_remove, handle_schedule_run_now,
|
||||
handle_schedule_sessions,
|
||||
};
|
||||
use crate::commands::session::{handle_session_list, handle_session_remove};
|
||||
use crate::logging::setup_logging;
|
||||
use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_as_template};
|
||||
@@ -99,6 +104,46 @@ enum SessionCommand {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SchedulerCommand {
|
||||
#[command(about = "Add a new scheduled job")]
|
||||
Add {
|
||||
#[arg(long, help = "Unique ID for the job")]
|
||||
id: String,
|
||||
#[arg(long, help = "Cron string for the schedule (e.g., '0 0 * * * *')")]
|
||||
cron: String,
|
||||
#[arg(
|
||||
long,
|
||||
help = "Recipe source (path to file, or base64 encoded recipe string)"
|
||||
)]
|
||||
recipe_source: String,
|
||||
},
|
||||
#[command(about = "List all scheduled jobs")]
|
||||
List {},
|
||||
#[command(about = "Remove a scheduled job by ID")]
|
||||
Remove {
|
||||
#[arg(long, help = "ID of the job to remove")] // Changed from positional to named --id
|
||||
id: String,
|
||||
},
|
||||
/// List sessions created by a specific schedule
|
||||
#[command(about = "List sessions created by a specific schedule")]
|
||||
Sessions {
|
||||
/// ID of the schedule
|
||||
#[arg(long, help = "ID of the schedule")] // Explicitly make it --id
|
||||
id: String,
|
||||
/// Maximum number of sessions to return
|
||||
#[arg(long, help = "Maximum number of sessions to return")]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Run a scheduled job immediately
|
||||
#[command(about = "Run a scheduled job immediately")]
|
||||
RunNow {
|
||||
/// ID of the schedule to run
|
||||
#[arg(long, help = "ID of the schedule to run")] // Explicitly make it --id
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum BenchCommand {
|
||||
#[command(name = "init-config", about = "Create a new starter-config")]
|
||||
@@ -418,6 +463,13 @@ enum Command {
|
||||
command: RecipeCommand,
|
||||
},
|
||||
|
||||
/// Manage scheduled jobs
|
||||
#[command(about = "Manage scheduled jobs", visible_alias = "sched")]
|
||||
Schedule {
|
||||
#[command(subcommand)]
|
||||
command: SchedulerCommand,
|
||||
},
|
||||
|
||||
/// Update the Goose CLI version
|
||||
#[command(about = "Update the goose CLI version")]
|
||||
Update {
|
||||
@@ -638,6 +690,32 @@ pub async fn cli() -> Result<()> {
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
Some(Command::Schedule { command }) => {
|
||||
match command {
|
||||
SchedulerCommand::Add {
|
||||
id,
|
||||
cron,
|
||||
recipe_source,
|
||||
} => {
|
||||
handle_schedule_add(id, cron, recipe_source).await?;
|
||||
}
|
||||
SchedulerCommand::List {} => {
|
||||
handle_schedule_list().await?;
|
||||
}
|
||||
SchedulerCommand::Remove { id } => {
|
||||
handle_schedule_remove(id).await?;
|
||||
}
|
||||
SchedulerCommand::Sessions { id, limit } => {
|
||||
// New arm
|
||||
handle_schedule_sessions(id, limit).await?;
|
||||
}
|
||||
SchedulerCommand::RunNow { id } => {
|
||||
// New arm
|
||||
handle_schedule_run_now(id).await?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Some(Command::Update {
|
||||
canary,
|
||||
reconfigure,
|
||||
|
||||
@@ -4,5 +4,6 @@ pub mod info;
|
||||
pub mod mcp;
|
||||
pub mod project;
|
||||
pub mod recipe;
|
||||
pub mod schedule;
|
||||
pub mod session;
|
||||
pub mod update;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine};
|
||||
use goose::scheduler::{
|
||||
get_default_scheduled_recipes_dir, get_default_scheduler_storage_path, ScheduledJob, Scheduler,
|
||||
SchedulerError,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
// Base64 decoding function - might be needed if recipe_source_arg can be base64
|
||||
// For now, handle_schedule_add will assume it's a path.
|
||||
async fn _decode_base64_recipe(source: &str) -> Result<String> {
|
||||
let bytes = BASE64_STANDARD
|
||||
.decode(source.as_bytes())
|
||||
.with_context(|| "Recipe source is not a valid path and not valid Base64.")?;
|
||||
String::from_utf8(bytes).with_context(|| "Decoded Base64 recipe source is not valid UTF-8.")
|
||||
}
|
||||
|
||||
pub async fn handle_schedule_add(
|
||||
id: String,
|
||||
cron: String,
|
||||
recipe_source_arg: String, // This is expected to be a file path by the Scheduler
|
||||
) -> Result<()> {
|
||||
println!(
|
||||
"[CLI Debug] Scheduling job ID: {}, Cron: {}, Recipe Source Path: {}",
|
||||
id, cron, recipe_source_arg
|
||||
);
|
||||
|
||||
// The Scheduler's add_scheduled_job will handle copying the recipe from recipe_source_arg
|
||||
// to its internal storage and validating the path.
|
||||
let job = ScheduledJob {
|
||||
id: id.clone(),
|
||||
source: recipe_source_arg.clone(), // Pass the original user-provided path
|
||||
cron,
|
||||
last_run: None,
|
||||
};
|
||||
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
match scheduler.add_scheduled_job(job).await {
|
||||
Ok(_) => {
|
||||
// The scheduler has copied the recipe to its internal directory.
|
||||
// We can reconstruct the likely path for display if needed, or adjust success message.
|
||||
let scheduled_recipes_dir = get_default_scheduled_recipes_dir()
|
||||
.unwrap_or_else(|_| Path::new("./.goose_scheduled_recipes").to_path_buf()); // Fallback for display
|
||||
let extension = Path::new(&recipe_source_arg)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("yaml");
|
||||
let final_recipe_path = scheduled_recipes_dir.join(format!("{}.{}", id, extension));
|
||||
|
||||
println!(
|
||||
"Scheduled job '{}' added. Recipe expected at {:?}",
|
||||
id, final_recipe_path
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// No local file to clean up by the CLI in this revised flow.
|
||||
match e {
|
||||
SchedulerError::JobIdExists(job_id) => {
|
||||
bail!("Error: Job with ID '{}' already exists.", job_id);
|
||||
}
|
||||
SchedulerError::RecipeLoadError(msg) => {
|
||||
bail!(
|
||||
"Error with recipe source: {}. Path: {}",
|
||||
msg,
|
||||
recipe_source_arg
|
||||
);
|
||||
}
|
||||
_ => Err(anyhow::Error::new(e))
|
||||
.context(format!("Failed to add job '{}' to scheduler", id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_schedule_list() -> Result<()> {
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
let jobs = scheduler.list_scheduled_jobs().await;
|
||||
if jobs.is_empty() {
|
||||
println!("No scheduled jobs found.");
|
||||
} else {
|
||||
println!("Scheduled Jobs:");
|
||||
for job in jobs {
|
||||
println!(
|
||||
"- ID: {}\n Cron: {}\n Recipe Source (in store): {}\n Last Run: {}",
|
||||
job.id,
|
||||
job.cron,
|
||||
job.source, // This source is now the path within scheduled_recipes_dir
|
||||
job.last_run
|
||||
.map_or_else(|| "Never".to_string(), |dt| dt.to_rfc3339())
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_schedule_remove(id: String) -> Result<()> {
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
match scheduler.remove_scheduled_job(&id).await {
|
||||
Ok(_) => {
|
||||
println!("Scheduled job '{}' and its associated recipe removed.", id);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => match e {
|
||||
SchedulerError::JobNotFound(job_id) => {
|
||||
bail!("Error: Job with ID '{}' not found.", job_id);
|
||||
}
|
||||
_ => Err(anyhow::Error::new(e))
|
||||
.context(format!("Failed to remove job '{}' from scheduler", id)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_schedule_sessions(id: String, limit: Option<u32>) -> Result<()> {
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
match scheduler.sessions(&id, limit.unwrap_or(50) as usize).await {
|
||||
Ok(sessions) => {
|
||||
if sessions.is_empty() {
|
||||
println!("No sessions found for schedule ID '{}'.", id);
|
||||
} else {
|
||||
println!("Sessions for schedule ID '{}':", id);
|
||||
// sessions is now Vec<(String, SessionMetadata)>
|
||||
for (session_name, metadata) in sessions {
|
||||
println!(
|
||||
" - Session ID: {}, Working Dir: {}, Description: \"{}\", Messages: {}, Schedule ID: {:?}",
|
||||
session_name, // Display the session_name as Session ID
|
||||
metadata.working_dir.display(),
|
||||
metadata.description,
|
||||
metadata.message_count,
|
||||
metadata.schedule_id.as_deref().unwrap_or("N/A")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
bail!("Failed to get sessions for schedule '{}': {:?}", id, e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_schedule_run_now(id: String) -> Result<()> {
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
match scheduler.run_now(&id).await {
|
||||
Ok(session_id) => {
|
||||
println!(
|
||||
"Successfully triggered schedule '{}'. New session ID: {}",
|
||||
id, session_id
|
||||
);
|
||||
}
|
||||
Err(e) => match e {
|
||||
SchedulerError::JobNotFound(job_id) => {
|
||||
bail!("Error: Job with ID '{}' not found.", job_id);
|
||||
}
|
||||
_ => bail!("Failed to run schedule '{}' now: {:?}", id, e),
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -688,6 +688,7 @@ impl Session {
|
||||
id: session_id.clone(),
|
||||
working_dir: std::env::current_dir()
|
||||
.expect("failed to get current session working directory"),
|
||||
schedule_id: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -793,6 +794,7 @@ impl Session {
|
||||
id: session_id.clone(),
|
||||
working_dir: std::env::current_dir()
|
||||
.expect("failed to get current session working directory"),
|
||||
schedule_id: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -12,9 +12,10 @@ goose = { path = "../goose" }
|
||||
mcp-core = { path = "../mcp-core" }
|
||||
goose-mcp = { path = "../goose-mcp" }
|
||||
mcp-server = { path = "../mcp-server" }
|
||||
axum = { version = "0.7.2", features = ["ws", "macros"] }
|
||||
axum = { version = "0.8.1", features = ["ws", "macros"] }
|
||||
tokio = { version = "1.43", features = ["full"] }
|
||||
chrono = "0.4"
|
||||
tokio-cron-scheduler = "0.14.0"
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -26,6 +27,7 @@ tokio-stream = "0.1"
|
||||
anyhow = "1.0"
|
||||
bytes = "1.5"
|
||||
http = "1.0"
|
||||
base64 = "0.21"
|
||||
config = { version = "0.14.1", features = ["toml"] }
|
||||
thiserror = "1.0"
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
@@ -33,7 +35,7 @@ once_cell = "1.20.2"
|
||||
etcetera = "0.8.0"
|
||||
serde_yaml = "0.9.34"
|
||||
axum-extra = "0.10.0"
|
||||
utoipa = { version = "4.1", features = ["axum_extras"] }
|
||||
utoipa = { version = "4.1", features = ["axum_extras", "chrono"] }
|
||||
dirs = "6.0.0"
|
||||
reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking"], default-features = false }
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ use std::sync::Arc;
|
||||
use crate::configuration;
|
||||
use crate::state;
|
||||
use anyhow::Result;
|
||||
use etcetera::{choose_app_strategy, AppStrategy};
|
||||
use goose::agents::Agent;
|
||||
use goose::config::APP_STRATEGY;
|
||||
use goose::scheduler::Scheduler as GooseScheduler;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::info;
|
||||
|
||||
@@ -11,27 +14,30 @@ pub async fn run() -> Result<()> {
|
||||
// Initialize logging
|
||||
crate::logging::setup_logging(Some("goosed"))?;
|
||||
|
||||
// Load configuration
|
||||
let settings = configuration::Settings::new()?;
|
||||
|
||||
// load secret key from GOOSE_SERVER__SECRET_KEY environment variable
|
||||
let secret_key =
|
||||
std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string());
|
||||
|
||||
let new_agent = Agent::new();
|
||||
let agent_ref = Arc::new(new_agent);
|
||||
|
||||
// Create app state with agent
|
||||
let state = state::AppState::new(Arc::new(new_agent), secret_key.clone()).await;
|
||||
let app_state = state::AppState::new(agent_ref.clone(), secret_key.clone()).await;
|
||||
|
||||
let schedule_file_path = choose_app_strategy(APP_STRATEGY.clone())?
|
||||
.data_dir()
|
||||
.join("schedules.json");
|
||||
|
||||
let scheduler_instance = GooseScheduler::new(schedule_file_path).await?;
|
||||
app_state.set_scheduler(scheduler_instance).await;
|
||||
|
||||
// Create router with CORS support
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = crate::routes::configure(state).layer(cors);
|
||||
let app = crate::routes::configure(app_state).layer(cors);
|
||||
|
||||
// Run server
|
||||
let listener = tokio::net::TcpListener::bind(settings.socket_addr()).await?;
|
||||
info!("listening on {}", listener.local_addr()?);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
@@ -8,6 +8,7 @@ use tracing_subscriber::{
|
||||
Registry,
|
||||
};
|
||||
|
||||
use goose::config::APP_STRATEGY;
|
||||
use goose::tracing::langfuse_layer;
|
||||
|
||||
/// Returns the directory where log files should be stored.
|
||||
@@ -17,8 +18,8 @@ fn get_log_directory() -> Result<PathBuf> {
|
||||
// - macOS/Linux: ~/.local/state/goose/logs/server
|
||||
// - Windows: ~\AppData\Roaming\Block\goose\data\logs\server
|
||||
// - Windows has no convention for state_dir, use data_dir instead
|
||||
let home_dir = choose_app_strategy(crate::APP_STRATEGY.clone())
|
||||
.context("HOME environment variable not set")?;
|
||||
let home_dir =
|
||||
choose_app_strategy(APP_STRATEGY.clone()).context("HOME environment variable not set")?;
|
||||
|
||||
let base_log_dir = home_dir
|
||||
.in_state_dir("logs/server")
|
||||
|
||||
@@ -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 configuration;
|
||||
mod error;
|
||||
|
||||
@@ -37,7 +37,12 @@ use utoipa::OpenApi;
|
||||
super::routes::reply::confirm_permission,
|
||||
super::routes::context::manage_context,
|
||||
super::routes::session::list_sessions,
|
||||
super::routes::session::get_session_history
|
||||
super::routes::session::get_session_history,
|
||||
super::routes::schedule::create_schedule,
|
||||
super::routes::schedule::list_schedules,
|
||||
super::routes::schedule::delete_schedule,
|
||||
super::routes::schedule::run_now_handler,
|
||||
super::routes::schedule::sessions_handler
|
||||
),
|
||||
components(schemas(
|
||||
super::routes::config_management::UpsertConfigQuery,
|
||||
@@ -85,6 +90,12 @@ use utoipa::OpenApi;
|
||||
ModelInfo,
|
||||
SessionInfo,
|
||||
SessionMetadata,
|
||||
super::routes::schedule::CreateScheduleRequest,
|
||||
goose::scheduler::ScheduledJob,
|
||||
super::routes::schedule::RunNowResponse,
|
||||
super::routes::schedule::ListSchedulesResponse,
|
||||
super::routes::schedule::SessionsQuery,
|
||||
super::routes::schedule::SessionDisplayInfo,
|
||||
))
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
@@ -6,8 +6,9 @@ use axum::{
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
||||
use etcetera::{choose_app_strategy, AppStrategy};
|
||||
use goose::config::Config;
|
||||
use goose::config::APP_STRATEGY;
|
||||
use goose::config::{extensions::name_to_key, PermissionManager};
|
||||
use goose::config::{ExtensionConfigManager, ExtensionEntry};
|
||||
use goose::model::ModelConfig;
|
||||
@@ -15,7 +16,6 @@ use goose::providers::base::ProviderMetadata;
|
||||
use goose::providers::providers as get_providers;
|
||||
use goose::{agents::ExtensionConfig, config::permission::PermissionLevel};
|
||||
use http::{HeaderMap, StatusCode};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_yaml;
|
||||
@@ -52,14 +52,12 @@ pub struct ConfigResponse {
|
||||
pub config: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
// Define a new structure to encapsulate the provider details along with configuration status
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProviderDetails {
|
||||
/// Unique identifier and name of the provider
|
||||
pub name: String,
|
||||
/// Metadata about the provider
|
||||
|
||||
pub metadata: ProviderMetadata,
|
||||
/// Indicates whether the provider is fully configured
|
||||
|
||||
pub is_configured: bool,
|
||||
}
|
||||
|
||||
@@ -70,7 +68,6 @@ pub struct ProvidersResponse {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ToolPermission {
|
||||
/// Unique identifier and name of the tool, format <extension_name>__<tool_name>
|
||||
pub tool_name: String,
|
||||
pub permission: PermissionLevel,
|
||||
}
|
||||
@@ -94,7 +91,6 @@ pub async fn upsert_config(
|
||||
headers: HeaderMap,
|
||||
Json(query): Json<UpsertConfigQuery>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
// Use the helper function to verify the secret key
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
let config = Config::global();
|
||||
@@ -121,12 +117,10 @@ pub async fn remove_config(
|
||||
headers: HeaderMap,
|
||||
Json(query): Json<ConfigKeyQuery>,
|
||||
) -> Result<Json<String>, StatusCode> {
|
||||
// Use the helper function to verify the secret key
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
let config = Config::global();
|
||||
|
||||
// Check if the secret flag is true and call the appropriate method
|
||||
let result = if query.is_secret {
|
||||
config.delete_secret(&query.key)
|
||||
} else {
|
||||
@@ -142,7 +136,7 @@ pub async fn remove_config(
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/config/read",
|
||||
request_body = ConfigKeyQuery, // Switch back to request_body
|
||||
request_body = ConfigKeyQuery,
|
||||
responses(
|
||||
(status = 200, description = "Configuration value retrieved successfully", body = Value),
|
||||
(status = 404, description = "Configuration key not found")
|
||||
@@ -155,7 +149,6 @@ pub async fn read_config(
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
// Special handling for model-limits
|
||||
if query.key == "model-limits" {
|
||||
let limits = ModelConfig::get_all_model_limits();
|
||||
return Ok(Json(
|
||||
@@ -166,13 +159,10 @@ pub async fn read_config(
|
||||
let config = Config::global();
|
||||
|
||||
match config.get(&query.key, query.is_secret) {
|
||||
// Always get the actual value
|
||||
Ok(value) => {
|
||||
if query.is_secret {
|
||||
// If it's marked as secret, return a boolean indicating presence
|
||||
Ok(Json(Value::Bool(true)))
|
||||
} else {
|
||||
// Return the actual value if not secret
|
||||
Ok(Json(value))
|
||||
}
|
||||
}
|
||||
@@ -197,7 +187,6 @@ pub async fn get_extensions(
|
||||
match ExtensionConfigManager::get_all() {
|
||||
Ok(extensions) => Ok(Json(ExtensionResponse { extensions })),
|
||||
Err(err) => {
|
||||
// Return UNPROCESSABLE_ENTITY only for DeserializeError, INTERNAL_SERVER_ERROR for everything else
|
||||
if err
|
||||
.downcast_ref::<goose::config::base::ConfigError>()
|
||||
.is_some_and(|e| matches!(e, goose::config::base::ConfigError::DeserializeError(_)))
|
||||
@@ -228,7 +217,6 @@ pub async fn add_extension(
|
||||
) -> Result<Json<String>, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
// Get existing extensions to check if this is an update
|
||||
let extensions =
|
||||
ExtensionConfigManager::get_all().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let key = name_to_key(&extension_query.name);
|
||||
@@ -284,12 +272,10 @@ pub async fn read_all_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<ConfigResponse>, StatusCode> {
|
||||
// Use the helper function to verify the secret key
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
let config = Config::global();
|
||||
|
||||
// Load values from config file
|
||||
let values = config
|
||||
.load_values()
|
||||
.map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)?;
|
||||
@@ -297,7 +283,6 @@ pub async fn read_all_config(
|
||||
Ok(Json(ConfigResponse { config: values }))
|
||||
}
|
||||
|
||||
// Modified providers function using the new response type
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/config/providers",
|
||||
@@ -311,14 +296,11 @@ pub async fn providers(
|
||||
) -> Result<Json<Vec<ProviderDetails>>, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
// Fetch the list of providers, which are likely stored in the AppState or can be retrieved via a function call
|
||||
let providers_metadata = get_providers();
|
||||
|
||||
// Construct the response by checking configuration status for each provider
|
||||
let providers_response: Vec<ProviderDetails> = providers_metadata
|
||||
.into_iter()
|
||||
.map(|metadata| {
|
||||
// Check if the provider is configured (this will depend on how you track configuration status)
|
||||
let is_configured = check_provider_configured(&metadata);
|
||||
|
||||
ProviderDetails {
|
||||
@@ -348,21 +330,16 @@ pub async fn init_config(
|
||||
|
||||
let config = Config::global();
|
||||
|
||||
// 200 if config already exists
|
||||
if config.exists() {
|
||||
return Ok(Json("Config already exists".to_string()));
|
||||
}
|
||||
|
||||
// Find the workspace root (where the top-level Cargo.toml with [workspace] is)
|
||||
let workspace_root = match std::env::current_exe() {
|
||||
Ok(mut exe_path) => {
|
||||
// Start from the executable's directory and traverse up
|
||||
while let Some(parent) = exe_path.parent() {
|
||||
let cargo_toml = parent.join("Cargo.toml");
|
||||
if cargo_toml.exists() {
|
||||
// Read the Cargo.toml file
|
||||
if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
|
||||
// Check if it contains [workspace]
|
||||
if content.contains("[workspace]") {
|
||||
exe_path = parent.to_path_buf();
|
||||
break;
|
||||
@@ -376,7 +353,6 @@ pub async fn init_config(
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
// Check if init-config.yaml exists at workspace root
|
||||
let init_config_path = workspace_root.join("init-config.yaml");
|
||||
if !init_config_path.exists() {
|
||||
return Ok(Json(
|
||||
@@ -384,7 +360,6 @@ pub async fn init_config(
|
||||
));
|
||||
}
|
||||
|
||||
// Read init-config.yaml and validate
|
||||
let init_content = match std::fs::read_to_string(&init_config_path) {
|
||||
Ok(content) => content,
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
@@ -394,7 +369,6 @@ pub async fn init_config(
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
// Save init-config.yaml to ~/.config/goose/config.yaml
|
||||
match config.save_values(init_values) {
|
||||
Ok(_) => Ok(Json("Config initialized successfully".to_string())),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
@@ -418,7 +392,7 @@ pub async fn upsert_permissions(
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
let mut permission_manager = PermissionManager::default();
|
||||
// Iterate over each tool permission and update permissions
|
||||
|
||||
for tool_permission in &query.tool_permissions {
|
||||
permission_manager.update_user_permission(
|
||||
&tool_permission.tool_name,
|
||||
@@ -429,12 +403,6 @@ pub async fn upsert_permissions(
|
||||
Ok(Json("Permissions updated successfully".to_string()))
|
||||
}
|
||||
|
||||
pub static APP_STRATEGY: Lazy<AppStrategyArgs> = Lazy::new(|| AppStrategyArgs {
|
||||
top_level_domain: "Block".to_string(),
|
||||
author: "Block".to_string(),
|
||||
app_name: "goose".to_string(),
|
||||
});
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/config/backup",
|
||||
@@ -460,11 +428,9 @@ pub async fn backup_config(
|
||||
.file_name()
|
||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Append ".bak" to the file name
|
||||
let mut backup_name = file_name.to_os_string();
|
||||
backup_name.push(".bak");
|
||||
|
||||
// Construct the new path with the same parent directory
|
||||
let backup = config_path.with_file_name(backup_name);
|
||||
match std::fs::rename(&config_path, &backup) {
|
||||
Ok(_) => Ok(Json(format!("Moved {:?} to {:?}", config_path, backup))),
|
||||
@@ -483,7 +449,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
||||
.route("/config/read", post(read_config))
|
||||
.route("/config/extensions", get(get_extensions))
|
||||
.route("/config/extensions", post(add_extension))
|
||||
.route("/config/extensions/:name", delete(remove_extension))
|
||||
.route("/config/extensions/{name}", delete(remove_extension))
|
||||
.route("/config/providers", get(providers))
|
||||
.route("/config/init", post(init_config))
|
||||
.route("/config/backup", post(backup_config))
|
||||
@@ -497,16 +463,22 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_model_limits() {
|
||||
// Create test state and headers
|
||||
let test_state = AppState::new(
|
||||
Arc::new(goose::agents::Agent::default()),
|
||||
"test".to_string(),
|
||||
)
|
||||
.await;
|
||||
let sched_storage_path = choose_app_strategy(APP_STRATEGY.clone())
|
||||
.unwrap()
|
||||
.data_dir()
|
||||
.join("schedules.json");
|
||||
let sched = goose::scheduler::Scheduler::new(sched_storage_path)
|
||||
.await
|
||||
.unwrap();
|
||||
test_state.set_scheduler(sched).await;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("X-Secret-Key", "test".parse().unwrap());
|
||||
|
||||
// Execute
|
||||
let result = read_config(
|
||||
State(test_state),
|
||||
headers,
|
||||
@@ -517,16 +489,13 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Assert
|
||||
assert!(result.is_ok());
|
||||
let response = result.unwrap();
|
||||
|
||||
// Parse the response and check the contents
|
||||
let limits: Vec<goose::model::ModelLimitConfig> =
|
||||
serde_json::from_value(response.0).unwrap();
|
||||
assert!(!limits.is_empty());
|
||||
|
||||
// Check for some expected patterns
|
||||
let gpt4_limit = limits.iter().find(|l| l.pattern == "gpt-4o");
|
||||
assert!(gpt4_limit.is_some());
|
||||
assert_eq!(gpt4_limit.unwrap().context_limit, 128_000);
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod extension;
|
||||
pub mod health;
|
||||
pub mod recipe;
|
||||
pub mod reply;
|
||||
pub mod schedule;
|
||||
pub mod session;
|
||||
pub mod utils;
|
||||
use std::sync::Arc;
|
||||
@@ -23,4 +24,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
|
||||
.merge(config_management::routes(state.clone()))
|
||||
.merge(recipe::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 utoipa::ToSchema;
|
||||
|
||||
// Direct message serialization for the chat request
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatRequest {
|
||||
messages: Vec<Message>,
|
||||
@@ -43,7 +42,6 @@ struct ChatRequest {
|
||||
session_working_dir: String,
|
||||
}
|
||||
|
||||
// Custom SSE response type for streaming messages
|
||||
pub struct SseResponse {
|
||||
rx: ReceiverStream<String>,
|
||||
}
|
||||
@@ -78,7 +76,6 @@ impl IntoResponse for SseResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// Message event types for SSE streaming
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum MessageEvent {
|
||||
@@ -87,7 +84,6 @@ enum MessageEvent {
|
||||
Finish { reason: String },
|
||||
}
|
||||
|
||||
// Stream a message as an SSE event
|
||||
async fn stream_event(
|
||||
event: MessageEvent,
|
||||
tx: &mpsc::Sender<String>,
|
||||
@@ -108,19 +104,16 @@ async fn handler(
|
||||
) -> Result<SseResponse, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
// Create channel for streaming
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
let stream = ReceiverStream::new(rx);
|
||||
|
||||
let messages = request.messages;
|
||||
let session_working_dir = request.session_working_dir;
|
||||
|
||||
// Generate a new session ID if not provided in the request
|
||||
let session_id = request
|
||||
.session_id
|
||||
.unwrap_or_else(session::generate_session_id);
|
||||
|
||||
// Spawn task to handle streaming
|
||||
tokio::spawn(async move {
|
||||
let agent = state.get_agent().await;
|
||||
let agent = match agent {
|
||||
@@ -166,7 +159,6 @@ async fn handler(
|
||||
}
|
||||
};
|
||||
|
||||
// Get the provider first, before starting the reply stream
|
||||
let provider = agent.provider().await;
|
||||
|
||||
let mut stream = match agent
|
||||
@@ -175,6 +167,7 @@ async fn handler(
|
||||
Some(SessionConfig {
|
||||
id: session::Identifier::Name(session_id.clone()),
|
||||
working_dir: PathBuf::from(session_working_dir),
|
||||
schedule_id: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -200,7 +193,6 @@ async fn handler(
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all messages for storage
|
||||
let mut all_messages = messages.clone();
|
||||
let session_path = session::get_path(session::Identifier::Name(session_id.clone()));
|
||||
|
||||
@@ -221,7 +213,7 @@ async fn handler(
|
||||
break;
|
||||
}
|
||||
|
||||
// Store messages and generate description in background
|
||||
|
||||
let session_path = session_path.clone();
|
||||
let messages = all_messages.clone();
|
||||
let provider = Arc::clone(provider.as_ref().unwrap());
|
||||
@@ -255,7 +247,6 @@ async fn handler(
|
||||
}
|
||||
}
|
||||
|
||||
// Send finish event
|
||||
let _ = stream_event(
|
||||
MessageEvent::Finish {
|
||||
reason: "stop".to_string(),
|
||||
@@ -280,7 +271,6 @@ struct AskResponse {
|
||||
response: String,
|
||||
}
|
||||
|
||||
// Simple ask an AI for a response, non streaming
|
||||
async fn ask_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -290,7 +280,6 @@ async fn ask_handler(
|
||||
|
||||
let session_working_dir = request.session_working_dir;
|
||||
|
||||
// Generate a new session ID if not provided in the request
|
||||
let session_id = request
|
||||
.session_id
|
||||
.unwrap_or_else(session::generate_session_id);
|
||||
@@ -300,13 +289,10 @@ async fn ask_handler(
|
||||
.await
|
||||
.map_err(|_| StatusCode::PRECONDITION_FAILED)?;
|
||||
|
||||
// Get the provider first, before starting the reply stream
|
||||
let provider = agent.provider().await;
|
||||
|
||||
// Create a single message for the prompt
|
||||
let messages = vec![Message::user().with_text(request.prompt)];
|
||||
|
||||
// Get response from agent
|
||||
let mut response_text = String::new();
|
||||
let mut stream = match agent
|
||||
.reply(
|
||||
@@ -314,6 +300,7 @@ async fn ask_handler(
|
||||
Some(SessionConfig {
|
||||
id: session::Identifier::Name(session_id.clone()),
|
||||
working_dir: PathBuf::from(session_working_dir),
|
||||
schedule_id: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -325,7 +312,6 @@ async fn ask_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all messages for storage
|
||||
let mut all_messages = messages.clone();
|
||||
let mut response_message = Message::assistant();
|
||||
|
||||
@@ -349,15 +335,12 @@ async fn ask_handler(
|
||||
}
|
||||
}
|
||||
|
||||
// Add the complete response message to the conversation history
|
||||
if !response_message.content.is_empty() {
|
||||
all_messages.push(response_message);
|
||||
}
|
||||
|
||||
// Get the session path - file will be created when needed
|
||||
let session_path = session::get_path(session::Identifier::Name(session_id.clone()));
|
||||
|
||||
// Store messages and generate description in background
|
||||
let session_path = session_path.clone();
|
||||
let messages = all_messages.clone();
|
||||
let provider = Arc::clone(provider.as_ref().unwrap());
|
||||
@@ -438,13 +421,11 @@ async fn submit_tool_result(
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
// Log the raw request for debugging
|
||||
tracing::info!(
|
||||
"Received tool result request: {}",
|
||||
serde_json::to_string_pretty(&raw.0).unwrap()
|
||||
);
|
||||
|
||||
// Try to parse into our struct
|
||||
let payload: ToolResultRequest = match serde_json::from_value(raw.0.clone()) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
@@ -465,7 +446,6 @@ async fn submit_tool_result(
|
||||
Ok(Json(json!({"status": "ok"})))
|
||||
}
|
||||
|
||||
// Configure routes for this module
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/reply", post(handler))
|
||||
@@ -488,7 +468,6 @@ mod tests {
|
||||
};
|
||||
use mcp_core::tool::Tool;
|
||||
|
||||
// Mock Provider implementation for testing
|
||||
#[derive(Clone)]
|
||||
struct MockProvider {
|
||||
model_config: ModelConfig,
|
||||
@@ -523,10 +502,8 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
// This test requires tokio runtime
|
||||
#[tokio::test]
|
||||
async fn test_ask_endpoint() {
|
||||
// Create a mock app state with mock provider
|
||||
let mock_model_config = ModelConfig::new("test-model".to_string());
|
||||
let mock_provider = Arc::new(MockProvider {
|
||||
model_config: mock_model_config,
|
||||
@@ -534,11 +511,15 @@ mod tests {
|
||||
let agent = Agent::new();
|
||||
let _ = agent.update_provider(mock_provider).await;
|
||||
let state = AppState::new(Arc::new(agent), "test-secret".to_string()).await;
|
||||
let scheduler_path = goose::scheduler::get_default_scheduler_storage_path()
|
||||
.expect("Failed to get default scheduler storage path");
|
||||
let scheduler = goose::scheduler::Scheduler::new(scheduler_path)
|
||||
.await
|
||||
.unwrap();
|
||||
state.set_scheduler(scheduler).await;
|
||||
|
||||
// Build router
|
||||
let app = routes(state);
|
||||
|
||||
// Create request
|
||||
let request = Request::builder()
|
||||
.uri("/ask")
|
||||
.method("POST")
|
||||
@@ -554,10 +535,8 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Send request
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
|
||||
// Assert response status
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
Router::new()
|
||||
.route("/sessions", get(list_sessions))
|
||||
.route("/sessions/:session_id", get(get_session_history))
|
||||
.route("/sessions/{session_id}", get(get_session_history))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
use goose::agents::Agent;
|
||||
use goose::scheduler::Scheduler;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Shared reference to an Agent that can be cloned cheaply
|
||||
/// without cloning the underlying Agent object
|
||||
pub type AgentRef = Arc<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)]
|
||||
pub struct AppState {
|
||||
// agent: SharedAgentStore,
|
||||
agent: Option<AgentRef>,
|
||||
pub secret_key: String,
|
||||
pub scheduler: Arc<Mutex<Option<Arc<Scheduler>>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -23,6 +17,7 @@ impl AppState {
|
||||
Arc::new(Self {
|
||||
agent: Some(agent.clone()),
|
||||
secret_key,
|
||||
scheduler: Arc::new(Mutex::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,4 +26,17 @@ impl AppState {
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Agent needs to be created first."))
|
||||
}
|
||||
|
||||
pub async fn set_scheduler(&self, sched: Arc<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": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
"config"
|
||||
],
|
||||
"summary": "Add an extension configuration",
|
||||
"operationId": "add_extension",
|
||||
"operationId": "add_extension_config",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -208,6 +208,180 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schedule/create": {
|
||||
"post": {
|
||||
"tags": ["schedule"],
|
||||
"summary": "Create a new scheduled job",
|
||||
"operationId": "create_schedule",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateScheduleRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Scheduled job created successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ScheduledJob"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schedule/list": {
|
||||
"get": {
|
||||
"tags": ["schedule"],
|
||||
"summary": "List all scheduled jobs",
|
||||
"operationId": "list_schedules",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A list of scheduled jobs",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jobs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScheduledJob"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schedule/delete/{id}": {
|
||||
"delete": {
|
||||
"tags": ["schedule"],
|
||||
"summary": "Delete a scheduled job by ID",
|
||||
"operationId": "delete_schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "ID of the schedule to delete",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Scheduled job deleted successfully"
|
||||
},
|
||||
"404": {
|
||||
"description": "Scheduled job not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schedule/{id}/run_now": {
|
||||
"post": {
|
||||
"tags": ["schedule"],
|
||||
"summary": "Run a scheduled job immediately",
|
||||
"operationId": "run_schedule_now",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "ID of the schedule to run",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Scheduled job triggered successfully, returns new session ID",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RunNowResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Scheduled job not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error when trying to run the job"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schedule/{id}/sessions": {
|
||||
"get": {
|
||||
"tags": ["schedule"],
|
||||
"summary": "List sessions created by a specific schedule",
|
||||
"operationId": "list_schedule_sessions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "ID of the schedule",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Maximum number of sessions to return",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"default": 50
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A list of session metadata",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SessionMetadata"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -273,7 +447,127 @@
|
||||
"description": "The value to set for the configuration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateScheduleRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"recipe_source",
|
||||
"cron"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique ID for the new schedule."
|
||||
},
|
||||
"recipe_source": {
|
||||
"type": "string",
|
||||
"description": "Path to the recipe file to be executed by this schedule."
|
||||
},
|
||||
"cron": {
|
||||
"type": "string",
|
||||
"description": "Cron string defining when the job should run."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScheduledJob": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"source",
|
||||
"cron"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the scheduled job."
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Path to the recipe file for this job."
|
||||
},
|
||||
"cron": {
|
||||
"type": "string",
|
||||
"description": "Cron string defining the schedule."
|
||||
},
|
||||
"last_run": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Timestamp of the last time the job was run.",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionMetadata": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"working_dir",
|
||||
"description",
|
||||
"message_count"
|
||||
],
|
||||
"properties": {
|
||||
"working_dir": {
|
||||
"type": "string",
|
||||
"description": "Working directory for the session."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A short description of the session."
|
||||
},
|
||||
"schedule_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the schedule that triggered this session, if any.",
|
||||
"nullable": true
|
||||
},
|
||||
"message_count": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Number of messages in the session."
|
||||
},
|
||||
"total_tokens": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"input_tokens": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"output_tokens": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"accumulated_total_tokens": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"accumulated_input_tokens": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"accumulated_output_tokens": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunNowResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"session_id"
|
||||
],
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the newly created session."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ nanoid = "0.4"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.21"
|
||||
url = "2.5"
|
||||
axum = "0.7"
|
||||
axum = "0.8.1"
|
||||
webbrowser = "0.8"
|
||||
dotenv = "0.15"
|
||||
lazy_static = "1.5"
|
||||
@@ -60,7 +60,8 @@ serde_yaml = "0.9.34"
|
||||
once_cell = "1.20.2"
|
||||
etcetera = "0.8.0"
|
||||
rand = "0.8.5"
|
||||
utoipa = "4.1"
|
||||
utoipa = { version = "4.1", features = ["chrono"] }
|
||||
tokio-cron-scheduler = "0.14.0"
|
||||
|
||||
# For Bedrock provider
|
||||
aws-config = { version = "1.5.16", features = ["behavior-version-latest"] }
|
||||
|
||||
@@ -205,18 +205,17 @@ impl Agent {
|
||||
usage: &crate::providers::base::ProviderUsage,
|
||||
messages_length: usize,
|
||||
) -> Result<()> {
|
||||
let session_file = session::get_path(session_config.id);
|
||||
let mut metadata = session::read_metadata(&session_file)?;
|
||||
let session_file_path = session::storage::get_path(session_config.id.clone());
|
||||
let mut metadata = session::storage::read_metadata(&session_file_path)?;
|
||||
|
||||
metadata.schedule_id = session_config.schedule_id.clone();
|
||||
|
||||
metadata.working_dir = session_config.working_dir.clone();
|
||||
metadata.total_tokens = usage.usage.total_tokens;
|
||||
metadata.input_tokens = usage.usage.input_tokens;
|
||||
metadata.output_tokens = usage.usage.output_tokens;
|
||||
// The message count is the number of messages in the session + 1 for the response
|
||||
// The message count does not include the tool response till next iteration
|
||||
|
||||
metadata.message_count = messages_length + 1;
|
||||
|
||||
// Keep running sum of tokens to track cost over the entire session
|
||||
let accumulate = |a: Option<i32>, b: Option<i32>| -> Option<i32> {
|
||||
match (a, b) {
|
||||
(Some(x), Some(y)) => Some(x + y),
|
||||
@@ -231,7 +230,8 @@ impl Agent {
|
||||
metadata.accumulated_output_tokens,
|
||||
usage.usage.output_tokens,
|
||||
);
|
||||
session::update_metadata(&session_file, &metadata).await?;
|
||||
|
||||
session::storage::update_metadata(&session_file_path, &metadata).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -22,4 +22,6 @@ pub struct SessionConfig {
|
||||
pub id: session::Identifier,
|
||||
/// Working directory for the session
|
||||
pub working_dir: PathBuf,
|
||||
/// ID of the schedule that triggered this session, if any
|
||||
pub schedule_id: Option<String>, // NEW
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod permission;
|
||||
pub mod prompt_template;
|
||||
pub mod providers;
|
||||
pub mod recipe;
|
||||
pub mod scheduler;
|
||||
pub mod session;
|
||||
pub mod token_counter;
|
||||
pub mod tool_monitor;
|
||||
|
||||
@@ -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,
|
||||
/// A short description of the session, typically 3 words or less
|
||||
pub description: String,
|
||||
/// ID of the schedule that triggered this session, if any
|
||||
pub schedule_id: Option<String>,
|
||||
/// Number of messages in the session
|
||||
pub message_count: usize,
|
||||
/// The total number of tokens used in the session. Retrieved from the provider's last usage.
|
||||
@@ -51,6 +53,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
|
||||
struct Helper {
|
||||
description: String,
|
||||
message_count: usize,
|
||||
schedule_id: Option<String>, // For backward compatibility
|
||||
total_tokens: Option<i32>,
|
||||
input_tokens: Option<i32>,
|
||||
output_tokens: Option<i32>,
|
||||
@@ -71,6 +74,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
|
||||
Ok(SessionMetadata {
|
||||
description: helper.description,
|
||||
message_count: helper.message_count,
|
||||
schedule_id: helper.schedule_id,
|
||||
total_tokens: helper.total_tokens,
|
||||
input_tokens: helper.input_tokens,
|
||||
output_tokens: helper.output_tokens,
|
||||
@@ -94,6 +98,7 @@ impl SessionMetadata {
|
||||
Self {
|
||||
working_dir,
|
||||
description: String::new(),
|
||||
schedule_id: None,
|
||||
message_count: 0,
|
||||
total_tokens: None,
|
||||
input_tokens: None,
|
||||
|
||||
@@ -152,9 +152,26 @@ async fn run_truncate_test(
|
||||
|
||||
assert_eq!(responses[0].content.len(), 1);
|
||||
|
||||
let response_text = responses[0].content[0].as_text().unwrap();
|
||||
assert!(response_text.to_lowercase().contains("no"));
|
||||
assert!(!response_text.to_lowercase().contains("yes"));
|
||||
match responses[0].content[0] {
|
||||
goose::message::MessageContent::Text(ref text_content) => {
|
||||
assert!(text_content.text.to_lowercase().contains("no"));
|
||||
assert!(!text_content.text.to_lowercase().contains("yes"));
|
||||
}
|
||||
goose::message::MessageContent::ContextLengthExceeded(_) => {
|
||||
// This is an acceptable outcome for providers that don't truncate themselves
|
||||
// and correctly report that the context length was exceeded.
|
||||
println!(
|
||||
"Received ContextLengthExceeded as expected for {:?}",
|
||||
provider_type
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
panic!(
|
||||
"Unexpected message content type: {:?}",
|
||||
responses[0].content[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user