diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index c784b37fec..1031eccedd 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -6,6 +6,8 @@ use std::collections::HashMap; mod recipe; pub use recipe::*; +mod schedule; +pub use schedule::*; /// Schema descriptor for a single custom method, produced by the /// `#[custom_methods]` macro's generated `custom_method_schemas()` function. diff --git a/crates/goose-sdk-types/src/custom_requests/schedule.rs b/crates/goose-sdk-types/src/custom_requests/schedule.rs new file mode 100644 index 0000000000..1ec1d91926 --- /dev/null +++ b/crates/goose-sdk-types/src/custom_requests/schedule.rs @@ -0,0 +1,169 @@ +use agent_client_protocol::schema::SessionInfo; +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{EmptyResponse, RecipeDto}; + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledJobDto { + pub id: String, + pub source: String, + pub cron: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_run: Option, + pub currently_running: bool, + pub paused: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_start_time: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/list", + response = ListSchedulesResponse +)] +pub struct ListSchedulesRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ListSchedulesResponse { + pub jobs: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/create", + response = CreateScheduleResponse +)] +pub struct CreateScheduleRequest { + pub id: String, + pub recipe: RecipeDto, + pub cron: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct CreateScheduleResponse { + pub job: ScheduledJobDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/schedules/delete", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DeleteScheduleRequest { + pub schedule_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/update", + response = UpdateScheduleResponse +)] +#[serde(rename_all = "camelCase")] +pub struct UpdateScheduleRequest { + pub schedule_id: String, + pub cron: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct UpdateScheduleResponse { + pub job: ScheduledJobDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/run-now", + response = RunScheduleNowResponse +)] +#[serde(rename_all = "camelCase")] +pub struct RunScheduleNowRequest { + pub schedule_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct RunScheduleNowResponse { + pub status: RunScheduleNowStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum RunScheduleNowStatus { + #[default] + Completed, + Cancelled, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/sessions/list", + response = ListScheduleSessionsResponse +)] +#[serde(rename_all = "camelCase")] +pub struct ListScheduleSessionsRequest { + pub schedule_id: String, + pub limit: usize, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ListScheduleSessionsResponse { + pub sessions: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/schedules/pause", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct PauseScheduleRequest { + pub schedule_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/unpause", + response = EmptyResponse +)] +#[serde(rename_all = "camelCase")] +pub struct UnpauseScheduleRequest { + pub schedule_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/running-job/kill", + response = KillRunningJobResponse +)] +#[serde(rename_all = "camelCase")] +pub struct KillRunningJobRequest { + pub job_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct KillRunningJobResponse { + pub message: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/running-job/inspect", + response = InspectRunningJobResponse +)] +#[serde(rename_all = "camelCase")] +pub struct InspectRunningJobRequest { + pub job_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct InspectRunningJobResponse { + pub running: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_start_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub running_duration_seconds: Option, +} diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 32a7744062..05425f540a 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -250,6 +250,56 @@ "requestType": "RecipeToYamlRequest_unstable", "responseType": "RecipeToYamlResponse_unstable" }, + { + "method": "_goose/unstable/schedules/list", + "requestType": "ListSchedulesRequest_unstable", + "responseType": "ListSchedulesResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/sessions/list", + "requestType": "ListScheduleSessionsRequest_unstable", + "responseType": "ListScheduleSessionsResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/create", + "requestType": "CreateScheduleRequest_unstable", + "responseType": "CreateScheduleResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/delete", + "requestType": "DeleteScheduleRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/schedules/pause", + "requestType": "PauseScheduleRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/schedules/unpause", + "requestType": "UnpauseScheduleRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/schedules/update", + "requestType": "UpdateScheduleRequest_unstable", + "responseType": "UpdateScheduleResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/run-now", + "requestType": "RunScheduleNowRequest_unstable", + "responseType": "RunScheduleNowResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/running-job/kill", + "requestType": "KillRunningJobRequest_unstable", + "responseType": "KillRunningJobResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/running-job/inspect", + "requestType": "InspectRunningJobRequest_unstable", + "responseType": "InspectRunningJobResponse_unstable" + }, { "method": "_goose/unstable/session/info", "requestType": "GetSessionInfoRequest_unstable", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 3ec1bc974c..35b6951a1b 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -3520,32 +3520,105 @@ "x-side": "agent", "x-method": "_goose/unstable/recipes/to-yaml" }, - "GetSessionInfoRequest_unstable": { + "ListSchedulesRequest_unstable": { "type": "object", - "properties": { - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId" - ], - "description": "Return list-style metadata for a single session without loading the conversation.", "x-side": "agent", - "x-method": "_goose/unstable/session/info" + "x-method": "_goose/unstable/schedules/list" }, - "GetSessionInfoResponse_unstable": { + "ListSchedulesResponse_unstable": { "type": "object", "properties": { - "session": { - "$ref": "#/$defs/SessionInfo" + "jobs": { + "type": "array", + "items": { + "$ref": "#/$defs/ScheduledJobDto" + } } }, "required": [ - "session" + "jobs" ], "x-side": "agent", - "x-method": "_goose/unstable/session/info" + "x-method": "_goose/unstable/schedules/list" + }, + "ScheduledJobDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "cron": { + "type": "string" + }, + "lastRun": { + "type": [ + "string", + "null" + ] + }, + "currentlyRunning": { + "type": "boolean" + }, + "paused": { + "type": "boolean" + }, + "currentSessionId": { + "type": [ + "string", + "null" + ] + }, + "jobStartTime": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "source", + "cron", + "currentlyRunning", + "paused" + ] + }, + "ListScheduleSessionsRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "scheduleId", + "limit" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/sessions/list" + }, + "ListScheduleSessionsResponse_unstable": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionInfo" + } + } + }, + "required": [ + "sessions" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/sessions/list" }, "SessionInfo": { "type": "object", @@ -3598,6 +3671,245 @@ "type": "string", "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)" }, + "CreateScheduleRequest_unstable": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "recipe": { + "$ref": "#/$defs/RecipeDto" + }, + "cron": { + "type": "string" + } + }, + "required": [ + "id", + "recipe", + "cron" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/create" + }, + "CreateScheduleResponse_unstable": { + "type": "object", + "properties": { + "job": { + "$ref": "#/$defs/ScheduledJobDto" + } + }, + "required": [ + "job" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/create" + }, + "DeleteScheduleRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + } + }, + "required": [ + "scheduleId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/delete" + }, + "PauseScheduleRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + } + }, + "required": [ + "scheduleId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/pause" + }, + "UnpauseScheduleRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + } + }, + "required": [ + "scheduleId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/unpause" + }, + "UpdateScheduleRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + }, + "cron": { + "type": "string" + } + }, + "required": [ + "scheduleId", + "cron" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/update" + }, + "UpdateScheduleResponse_unstable": { + "type": "object", + "properties": { + "job": { + "$ref": "#/$defs/ScheduledJobDto" + } + }, + "required": [ + "job" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/update" + }, + "RunScheduleNowRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + } + }, + "required": [ + "scheduleId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/run-now" + }, + "RunScheduleNowResponse_unstable": { + "type": "object", + "properties": { + "status": { + "$ref": "#/$defs/RunScheduleNowStatus" + }, + "sessionId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/run-now" + }, + "RunScheduleNowStatus": { + "type": "string", + "enum": [ + "completed", + "cancelled" + ] + }, + "KillRunningJobRequest_unstable": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/running-job/kill" + }, + "KillRunningJobResponse_unstable": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/running-job/kill" + }, + "InspectRunningJobRequest_unstable": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/running-job/inspect" + }, + "InspectRunningJobResponse_unstable": { + "type": "object", + "properties": { + "running": { + "type": "boolean" + }, + "sessionId": { + "type": [ + "string", + "null" + ] + }, + "jobStartTime": { + "type": [ + "string", + "null" + ] + }, + "runningDurationSeconds": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "running" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/running-job/inspect" + }, + "GetSessionInfoRequest_unstable": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "description": "Return list-style metadata for a single session without loading the conversation.", + "x-side": "agent", + "x-method": "_goose/unstable/session/info" + }, + "GetSessionInfoResponse_unstable": { + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionInfo" + } + }, + "required": [ + "session" + ], + "x-side": "agent", + "x-method": "_goose/unstable/session/info" + }, "TruncateSessionConversationRequest_unstable": { "type": "object", "properties": { @@ -5195,6 +5507,96 @@ "description": "Params for _goose/unstable/recipes/to-yaml", "title": "RecipeToYamlRequest_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSchedulesRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/list", + "title": "ListSchedulesRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListScheduleSessionsRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/sessions/list", + "title": "ListScheduleSessionsRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/create", + "title": "CreateScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DeleteScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/delete", + "title": "DeleteScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/PauseScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/pause", + "title": "PauseScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UnpauseScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/unpause", + "title": "UnpauseScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/update", + "title": "UpdateScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RunScheduleNowRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/run-now", + "title": "RunScheduleNowRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/KillRunningJobRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/running-job/kill", + "title": "KillRunningJobRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/InspectRunningJobRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/running-job/inspect", + "title": "InspectRunningJobRequest_unstable" + }, { "allOf": [ { @@ -5721,6 +6123,62 @@ ], "title": "RecipeToYamlResponse_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSchedulesResponse_unstable" + } + ], + "title": "ListSchedulesResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListScheduleSessionsResponse_unstable" + } + ], + "title": "ListScheduleSessionsResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateScheduleResponse_unstable" + } + ], + "title": "CreateScheduleResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateScheduleResponse_unstable" + } + ], + "title": "UpdateScheduleResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RunScheduleNowResponse_unstable" + } + ], + "title": "RunScheduleNowResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/KillRunningJobResponse_unstable" + } + ], + "title": "KillRunningJobResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/InspectRunningJobResponse_unstable" + } + ], + "title": "InspectRunningJobResponse_unstable" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index bb14040dbd..cd31af4316 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -99,6 +99,7 @@ mod onboarding; mod providers; mod recipe; mod resources; +mod schedule; mod slash_commands; mod sources; mod tool_notifications; diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index df4a89e8be..8f5ff7bbd9 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -416,6 +416,86 @@ impl GooseAcpAgent { self.on_recipe_to_yaml(req).await } + #[custom_method(ListSchedulesRequest)] + async fn dispatch_list_schedules( + &self, + req: ListSchedulesRequest, + ) -> Result { + self.on_list_schedules(req).await + } + + #[custom_method(ListScheduleSessionsRequest)] + async fn dispatch_list_schedule_sessions( + &self, + req: ListScheduleSessionsRequest, + ) -> Result { + self.on_list_schedule_sessions(req).await + } + + #[custom_method(CreateScheduleRequest)] + async fn dispatch_create_schedule( + &self, + req: CreateScheduleRequest, + ) -> Result { + self.on_create_schedule(req).await + } + + #[custom_method(DeleteScheduleRequest)] + async fn dispatch_delete_schedule( + &self, + req: DeleteScheduleRequest, + ) -> Result { + self.on_delete_schedule(req).await + } + + #[custom_method(PauseScheduleRequest)] + async fn dispatch_pause_schedule( + &self, + req: PauseScheduleRequest, + ) -> Result { + self.on_pause_schedule(req).await + } + + #[custom_method(UnpauseScheduleRequest)] + async fn dispatch_unpause_schedule( + &self, + req: UnpauseScheduleRequest, + ) -> Result { + self.on_unpause_schedule(req).await + } + + #[custom_method(UpdateScheduleRequest)] + async fn dispatch_update_schedule( + &self, + req: UpdateScheduleRequest, + ) -> Result { + self.on_update_schedule(req).await + } + + #[custom_method(RunScheduleNowRequest)] + async fn dispatch_run_schedule_now( + &self, + req: RunScheduleNowRequest, + ) -> Result { + self.on_run_schedule_now(req).await + } + + #[custom_method(KillRunningJobRequest)] + async fn dispatch_kill_running_job( + &self, + req: KillRunningJobRequest, + ) -> Result { + self.on_kill_running_job(req).await + } + + #[custom_method(InspectRunningJobRequest)] + async fn dispatch_inspect_running_job( + &self, + req: InspectRunningJobRequest, + ) -> Result { + self.on_inspect_running_job(req).await + } + #[custom_method(GetSessionInfoRequest)] async fn dispatch_get_session_info( &self, diff --git a/crates/goose/src/acp/server/schedule.rs b/crates/goose/src/acp/server/schedule.rs new file mode 100644 index 0000000000..62b43c8ca7 --- /dev/null +++ b/crates/goose/src/acp/server/schedule.rs @@ -0,0 +1,343 @@ +use goose_sdk_types::custom_requests::{ + CreateScheduleRequest, CreateScheduleResponse, DeleteScheduleRequest, EmptyResponse, + InspectRunningJobRequest, InspectRunningJobResponse, KillRunningJobRequest, + KillRunningJobResponse, ListScheduleSessionsRequest, ListScheduleSessionsResponse, + ListSchedulesRequest, ListSchedulesResponse, PauseScheduleRequest, RunScheduleNowRequest, + RunScheduleNowResponse, RunScheduleNowStatus, ScheduledJobDto, UnpauseScheduleRequest, + UpdateScheduleRequest, UpdateScheduleResponse, +}; +use tokio::fs; + +use super::{build_session_info, GooseAcpAgent, ResultExt}; +use crate::recipe::validate_recipe::validate_recipe_template_from_content; +use crate::recipe::Recipe; +use crate::scheduler::{get_default_scheduled_recipes_dir, ScheduledJob, SchedulerError}; + +fn validate_schedule_id(id: &str) -> Result<(), agent_client_protocol::Error> { + let is_valid = !id.is_empty() + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' '); + + if !is_valid { + return Err(agent_client_protocol::Error::invalid_params().data( + "Schedule name must use only alphanumeric characters, hyphens, underscores, or spaces", + )); + } + + Ok(()) +} + +fn validate_schedule_recipe(recipe: &Recipe) -> Result<(), agent_client_protocol::Error> { + let recipe_yaml = recipe + .to_yaml() + .map_err(|e| agent_client_protocol::Error::invalid_params().data(e.to_string()))?; + + validate_recipe_template_from_content(&recipe_yaml, None) + .map_err(|e| agent_client_protocol::Error::invalid_params().data(e.to_string()))?; + + Ok(()) +} + +fn schedule_not_found_or_internal(error: SchedulerError) -> agent_client_protocol::Error { + match error { + SchedulerError::JobNotFound(id) => { + agent_client_protocol::Error::resource_not_found(Some(id)) + } + error => agent_client_protocol::Error::internal_error().data(error.to_string()), + } +} + +fn create_schedule_error(error: SchedulerError) -> agent_client_protocol::Error { + match error { + SchedulerError::CronParseError(message) => agent_client_protocol::Error::invalid_params() + .data(format!("Invalid cron expression: {message}")), + SchedulerError::RecipeLoadError(message) => agent_client_protocol::Error::invalid_params() + .data(format!("Recipe load error: {message}")), + SchedulerError::JobIdExists(id) => agent_client_protocol::Error::invalid_params() + .data(format!("Job ID already exists: {id}")), + error => agent_client_protocol::Error::internal_error() + .data(format!("Error creating schedule: {error}")), + } +} + +fn schedule_state_error(error: SchedulerError) -> agent_client_protocol::Error { + match error { + SchedulerError::JobNotFound(id) => { + agent_client_protocol::Error::resource_not_found(Some(id)) + } + SchedulerError::AnyhowError(error) => { + agent_client_protocol::Error::invalid_params().data(error.to_string()) + } + error => agent_client_protocol::Error::internal_error().data(error.to_string()), + } +} + +fn update_schedule_error(error: SchedulerError) -> agent_client_protocol::Error { + match error { + SchedulerError::JobNotFound(id) => { + agent_client_protocol::Error::resource_not_found(Some(id)) + } + SchedulerError::AnyhowError(error) => { + agent_client_protocol::Error::invalid_params().data(error.to_string()) + } + SchedulerError::CronParseError(message) => agent_client_protocol::Error::invalid_params() + .data(format!("Invalid cron expression: {message}")), + error => agent_client_protocol::Error::internal_error().data(error.to_string()), + } +} + +fn run_schedule_now_error( + error: SchedulerError, +) -> Result { + match error { + SchedulerError::JobNotFound(id) => { + Err(agent_client_protocol::Error::resource_not_found(Some(id))) + } + SchedulerError::AnyhowError(error) + if error.to_string().contains("was successfully cancelled") => + { + Ok(RunScheduleNowResponse { + status: RunScheduleNowStatus::Cancelled, + session_id: None, + }) + } + error => Err(agent_client_protocol::Error::internal_error() + .data(format!("Error running schedule: {error}"))), + } +} + +fn scheduled_job_to_dto(job: ScheduledJob) -> ScheduledJobDto { + ScheduledJobDto { + id: job.id, + source: job.source, + cron: job.cron, + last_run: job.last_run.map(|value| value.to_rfc3339()), + currently_running: job.currently_running, + paused: job.paused, + current_session_id: job.current_session_id, + job_start_time: job.process_start_time.map(|value| value.to_rfc3339()), + } +} + +impl GooseAcpAgent { + pub(super) async fn on_list_schedules( + &self, + _req: ListSchedulesRequest, + ) -> Result { + let jobs = self + .agent_manager + .scheduler() + .list_scheduled_jobs() + .await + .into_iter() + .map(scheduled_job_to_dto) + .collect(); + + Ok(ListSchedulesResponse { jobs }) + } + + pub(super) async fn on_list_schedule_sessions( + &self, + req: ListScheduleSessionsRequest, + ) -> Result { + let sessions = self + .agent_manager + .scheduler() + .sessions(&req.schedule_id, req.limit) + .await + .internal_err_ctx("Failed to fetch schedule sessions")? + .into_iter() + .map(|(_, session)| build_session_info(session)) + .collect(); + + Ok(ListScheduleSessionsResponse { sessions }) + } + + pub(super) async fn on_create_schedule( + &self, + req: CreateScheduleRequest, + ) -> Result { + let id = req.id.trim().to_string(); + validate_schedule_id(&id)?; + + let recipe = Recipe::try_from(req.recipe).map_err(|e| { + agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")) + })?; + + if recipe.check_for_security_warnings() { + return Err(agent_client_protocol::Error::invalid_params().data( + "This recipe contains hidden characters that could be malicious. Please remove them before trying to save.", + )); + } + validate_schedule_recipe(&recipe)?; + + let scheduled_recipes_dir = get_default_scheduled_recipes_dir().map_err(|e| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to get scheduled recipes directory: {e}")) + })?; + + let recipe_path = scheduled_recipes_dir.join(format!("{id}.yaml")); + let yaml_content = recipe.to_yaml().map_err(|e| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to convert recipe to YAML: {e}")) + })?; + fs::write(&recipe_path, yaml_content).await.map_err(|e| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to save recipe file: {e}")) + })?; + + let job = ScheduledJob { + id, + source: recipe_path.to_string_lossy().into_owned(), + cron: req.cron, + last_run: None, + currently_running: false, + paused: false, + current_session_id: None, + process_start_time: None, + parameters: vec![], + recipe_base_dir: None, + }; + + self.agent_manager + .scheduler() + .add_scheduled_job(job.clone(), false) + .await + .map_err(create_schedule_error)?; + + Ok(CreateScheduleResponse { + job: scheduled_job_to_dto(job), + }) + } + + pub(super) async fn on_delete_schedule( + &self, + req: DeleteScheduleRequest, + ) -> Result { + self.agent_manager + .scheduler() + .remove_scheduled_job(&req.schedule_id, false) + .await + .map_err(schedule_not_found_or_internal)?; + + Ok(EmptyResponse {}) + } + + pub(super) async fn on_pause_schedule( + &self, + req: PauseScheduleRequest, + ) -> Result { + self.agent_manager + .scheduler() + .pause_schedule(&req.schedule_id) + .await + .map_err(schedule_state_error)?; + + Ok(EmptyResponse {}) + } + + pub(super) async fn on_unpause_schedule( + &self, + req: UnpauseScheduleRequest, + ) -> Result { + self.agent_manager + .scheduler() + .unpause_schedule(&req.schedule_id) + .await + .map_err(schedule_not_found_or_internal)?; + + Ok(EmptyResponse {}) + } + + pub(super) async fn on_update_schedule( + &self, + req: UpdateScheduleRequest, + ) -> Result { + let schedule_id = req.schedule_id; + let cron = req.cron; + let scheduler = self.agent_manager.scheduler(); + scheduler + .update_schedule(&schedule_id, cron) + .await + .map_err(update_schedule_error)?; + + let job = scheduler + .list_scheduled_jobs() + .await + .into_iter() + .find(|job| job.id == schedule_id) + .ok_or_else(|| { + agent_client_protocol::Error::internal_error() + .data("Schedule not found after update") + })?; + + Ok(UpdateScheduleResponse { + job: scheduled_job_to_dto(job), + }) + } + + pub(super) async fn on_run_schedule_now( + &self, + req: RunScheduleNowRequest, + ) -> Result { + match self + .agent_manager + .scheduler() + .run_now(&req.schedule_id) + .await + { + Ok(session_id) => Ok(RunScheduleNowResponse { + status: RunScheduleNowStatus::Completed, + session_id: Some(session_id), + }), + Err(error) => run_schedule_now_error(error), + } + } + + pub(super) async fn on_kill_running_job( + &self, + req: KillRunningJobRequest, + ) -> Result { + self.agent_manager + .scheduler() + .kill_running_job(&req.job_id) + .await + .map_err(schedule_state_error)?; + + Ok(KillRunningJobResponse { + message: format!("Successfully killed running job '{}'", req.job_id), + }) + } + + pub(super) async fn on_inspect_running_job( + &self, + req: InspectRunningJobRequest, + ) -> Result { + let job = self + .agent_manager + .scheduler() + .list_scheduled_jobs() + .await + .into_iter() + .find(|job| job.id == req.job_id) + .ok_or_else(|| agent_client_protocol::Error::resource_not_found(Some(req.job_id)))?; + + if !job.currently_running { + return Ok(InspectRunningJobResponse::default()); + } + + let running_duration_seconds = job.process_start_time.map(|start_time| { + chrono::Utc::now() + .signed_duration_since(start_time) + .num_seconds() + }); + + Ok(InspectRunningJobResponse { + running: true, + session_id: job.current_session_id, + job_start_time: job.process_start_time.map(|value| value.to_rfc3339()), + running_duration_seconds, + }) + } +} diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 8466893ca0..0d328dc184 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -139,6 +139,16 @@ async fn persist_jobs( Ok(()) } +fn clear_running_state(job: &mut ScheduledJob) -> bool { + let changed = job.currently_running + || job.current_session_id.is_some() + || job.process_start_time.is_some(); + job.currently_running = false; + job.current_session_id = None; + job.process_start_time = None; + changed +} + pub struct Scheduler { tokio_scheduler: TokioJobScheduler, jobs: Arc>, @@ -432,7 +442,7 @@ impl Scheduler { return; } - let list: Vec = match serde_json::from_str(&data) { + let mut list: Vec = match serde_json::from_str(&data) { Ok(jobs) => jobs, Err(e) => { tracing::error!( @@ -444,6 +454,20 @@ impl Scheduler { } }; + let reset_stale_running_state = list + .iter_mut() + .fold(false, |changed, job| clear_running_state(job) || changed); + if reset_stale_running_state { + match serde_json::to_string_pretty(&list) { + Ok(data) => { + if let Err(e) = fs::write(&self.storage_path, data) { + tracing::error!("Failed to persist scheduler startup state: {}", e); + } + } + Err(e) => tracing::error!("Failed to serialize scheduler startup state: {}", e), + } + } + for job_to_load in list { if !Path::new(&job_to_load.source).exists() { tracing::warn!( @@ -554,12 +578,15 @@ impl Scheduler { pub async fn list_scheduled_jobs(&self) -> Vec { self.sync_from_storage().await; - self.jobs + let mut jobs: Vec = self + .jobs .lock() .await .values() .map(|(_, j)| j.clone()) - .collect() + .collect(); + jobs.sort_by(|a, b| a.id.cmp(&b.id)); + jobs } pub async fn remove_scheduled_job( @@ -648,6 +675,7 @@ impl Scheduler { cancel_token.clone(), ) .await; + let was_cancelled = cancel_token.is_cancelled(); { let mut tasks = self.running_tasks.lock().await; @@ -667,6 +695,10 @@ impl Scheduler { persist_jobs(&self.storage_path, &self.jobs).await?; match result { + _ if was_cancelled => Err(SchedulerError::AnyhowError(anyhow!( + "Job '{}' was successfully cancelled", + sched_id + ))), Ok(session_id) => Ok(session_id), Err(e) => Err(SchedulerError::AnyhowError(anyhow!( "Job '{}' failed: {}", @@ -770,14 +802,25 @@ impl Scheduler { } } + let token = { + let mut tasks = self.running_tasks.lock().await; + tasks.remove(sched_id) + }; + if let Some(token) = token { + token.cancel(); + } + { - let tasks = self.running_tasks.lock().await; - if let Some(token) = tasks.get(sched_id) { - token.cancel(); + let mut jobs_guard = self.jobs.lock().await; + match jobs_guard.get_mut(sched_id) { + Some((_, job)) => { + clear_running_state(job); + } + None => return Err(SchedulerError::JobNotFound(sched_id.to_string())), } } - Ok(()) + persist_jobs(&self.storage_path, &self.jobs).await } pub async fn get_running_job_info( @@ -1227,6 +1270,112 @@ mod tests { ); } + #[tokio::test] + async fn test_kill_running_job_clears_state_and_persists() { + let temp_dir = tempdir().unwrap(); + let storage_path = temp_dir.path().join("schedule.json"); + let recipe_path = create_test_recipe(temp_dir.path(), "running_job"); + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let scheduler = Scheduler::new(storage_path.clone(), session_manager) + .await + .unwrap(); + + let job = ScheduledJob { + id: "running_job".to_string(), + source: recipe_path.to_string_lossy().to_string(), + cron: "0 0 0 1 1 *".to_string(), + last_run: None, + currently_running: false, + paused: false, + current_session_id: None, + process_start_time: None, + parameters: vec![], + recipe_base_dir: None, + }; + + scheduler.add_scheduled_job(job, false).await.unwrap(); + { + let mut jobs_guard = scheduler.jobs.lock().await; + let (_, job) = jobs_guard.get_mut("running_job").unwrap(); + job.currently_running = true; + job.current_session_id = Some("session-id".to_string()); + job.process_start_time = Some(Utc::now()); + } + { + let mut tasks = scheduler.running_tasks.lock().await; + tasks.insert("running_job".to_string(), CancellationToken::new()); + } + persist_jobs(&storage_path, &scheduler.jobs).await.unwrap(); + + scheduler.kill_running_job("running_job").await.unwrap(); + + let jobs = scheduler.list_scheduled_jobs().await; + let killed_job = jobs.iter().find(|job| job.id == "running_job").unwrap(); + assert!(!killed_job.currently_running); + assert!(killed_job.current_session_id.is_none()); + assert!(killed_job.process_start_time.is_none()); + assert!(scheduler.running_tasks.lock().await.is_empty()); + + let persisted_jobs: Vec = + serde_json::from_str(&fs::read_to_string(storage_path).unwrap()).unwrap(); + let persisted_job = persisted_jobs + .iter() + .find(|job| job.id == "running_job") + .unwrap(); + assert!(!persisted_job.currently_running); + assert!(persisted_job.current_session_id.is_none()); + assert!(persisted_job.process_start_time.is_none()); + } + + #[tokio::test] + async fn test_load_jobs_from_storage_clears_stale_running_state() { + let temp_dir = tempdir().unwrap(); + let storage_path = temp_dir.path().join("schedule.json"); + let recipe_path = create_test_recipe(temp_dir.path(), "stale_running_job"); + let started_at = Utc::now(); + let stale_job = ScheduledJob { + id: "stale_running_job".to_string(), + source: recipe_path.to_string_lossy().to_string(), + cron: "0 0 0 1 1 *".to_string(), + last_run: None, + currently_running: true, + paused: false, + current_session_id: Some("stale-session-id".to_string()), + process_start_time: Some(started_at), + parameters: vec![], + recipe_base_dir: None, + }; + fs::write( + &storage_path, + serde_json::to_string_pretty(&vec![stale_job]).unwrap(), + ) + .unwrap(); + + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let scheduler = Scheduler::new(storage_path.clone(), session_manager) + .await + .unwrap(); + + let jobs = scheduler.list_scheduled_jobs().await; + let loaded_job = jobs + .iter() + .find(|job| job.id == "stale_running_job") + .unwrap(); + assert!(!loaded_job.currently_running); + assert!(loaded_job.current_session_id.is_none()); + assert!(loaded_job.process_start_time.is_none()); + + let persisted_jobs: Vec = + serde_json::from_str(&fs::read_to_string(storage_path).unwrap()).unwrap(); + let persisted_job = persisted_jobs + .iter() + .find(|job| job.id == "stale_running_job") + .unwrap(); + assert!(!persisted_job.currently_running); + assert!(persisted_job.current_session_id.is_none()); + assert!(persisted_job.process_start_time.is_none()); + } + #[tokio::test] async fn test_job_with_no_prompt_does_not_panic() { let _guard = env_lock::lock_env([ diff --git a/ui/desktop/src/acp/schedules.ts b/ui/desktop/src/acp/schedules.ts new file mode 100644 index 0000000000..8248af1c70 --- /dev/null +++ b/ui/desktop/src/acp/schedules.ts @@ -0,0 +1,193 @@ +import type { + CreateScheduleRequest_unstable, + InspectRunningJobResponse_unstable, + KillRunningJobResponse_unstable, + RunScheduleNowResponse_unstable, + ScheduledJobDto, + SessionInfo, +} from '@aaif/goose-sdk'; +import { getAcpClient } from './acpConnection'; + +let inFlightListSchedules: Promise | null = null; +const inFlightListScheduleSessions = new Map>(); + +function acpErrorMessage(error: unknown): string | null { + if (typeof error !== 'object' || error === null) { + return null; + } + + const candidate = 'error' in error && isRecord(error.error) ? error.error : error; + if (!isRecord(candidate)) { + return null; + } + if (typeof candidate.data === 'string') { + return candidate.data; + } + return typeof candidate.message === 'string' ? candidate.message : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function normalizeAcpError(error: unknown, fallback: string): Error { + const message = acpErrorMessage(error); + if (message) { + return new Error(message); + } + if (error instanceof Error) { + return error; + } + return new Error(fallback); +} + +function clearInFlightScheduleReads(): void { + inFlightListSchedules = null; + inFlightListScheduleSessions.clear(); +} + +export async function acpListSchedules(): Promise { + const pending = inFlightListSchedules; + if (pending) { + return pending; + } + + const listPromise = (async () => { + const client = await getAcpClient(); + const response = await client.goose.schedulesList_unstable({}); + return response.jobs; + })().catch((error) => { + throw normalizeAcpError(error, 'Failed to list schedules'); + }); + + inFlightListSchedules = listPromise; + + try { + return await listPromise; + } finally { + if (inFlightListSchedules === listPromise) { + inFlightListSchedules = null; + } + } +} + +export async function acpCreateSchedule( + request: CreateScheduleRequest_unstable +): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.schedulesCreate_unstable(request); + clearInFlightScheduleReads(); + return response.job; + } catch (error) { + throw normalizeAcpError(error, 'Failed to create schedule'); + } +} + +export async function acpDeleteSchedule(scheduleId: string): Promise { + try { + const client = await getAcpClient(); + await client.goose.schedulesDelete_unstable({ scheduleId }); + clearInFlightScheduleReads(); + } catch (error) { + throw normalizeAcpError(error, 'Failed to delete schedule'); + } +} + +export async function acpListScheduleSessions( + scheduleId: string, + limit: number +): Promise { + const key = `${scheduleId}:${limit}`; + const pending = inFlightListScheduleSessions.get(key); + if (pending) { + return pending; + } + + const listPromise = (async () => { + const client = await getAcpClient(); + const response = await client.goose.schedulesSessionsList_unstable({ scheduleId, limit }); + return response.sessions; + })().catch((error) => { + throw normalizeAcpError(error, 'Failed to list schedule sessions'); + }); + + inFlightListScheduleSessions.set(key, listPromise); + + try { + return await listPromise; + } finally { + if (inFlightListScheduleSessions.get(key) === listPromise) { + inFlightListScheduleSessions.delete(key); + } + } +} + +export async function acpRunScheduleNow( + scheduleId: string +): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.schedulesRunNow_unstable({ scheduleId }); + clearInFlightScheduleReads(); + return response; + } catch (error) { + throw normalizeAcpError(error, 'Failed to run schedule now'); + } +} + +export async function acpPauseSchedule(scheduleId: string): Promise { + try { + const client = await getAcpClient(); + await client.goose.schedulesPause_unstable({ scheduleId }); + clearInFlightScheduleReads(); + } catch (error) { + throw normalizeAcpError(error, 'Failed to pause schedule'); + } +} + +export async function acpUnpauseSchedule(scheduleId: string): Promise { + try { + const client = await getAcpClient(); + await client.goose.schedulesUnpause_unstable({ scheduleId }); + clearInFlightScheduleReads(); + } catch (error) { + throw normalizeAcpError(error, 'Failed to unpause schedule'); + } +} + +export async function acpUpdateSchedule( + scheduleId: string, + cron: string +): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.schedulesUpdate_unstable({ scheduleId, cron }); + clearInFlightScheduleReads(); + return response.job; + } catch (error) { + throw normalizeAcpError(error, 'Failed to update schedule'); + } +} + +export async function acpKillRunningJob(jobId: string): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.schedulesRunningJobKill_unstable({ jobId }); + clearInFlightScheduleReads(); + return response; + } catch (error) { + throw normalizeAcpError(error, 'Failed to kill running job'); + } +} + +export async function acpInspectRunningJob( + jobId: string +): Promise { + try { + const client = await getAcpClient(); + return await client.goose.schedulesRunningJobInspect_unstable({ jobId }); + } catch (error) { + throw normalizeAcpError(error, 'Failed to inspect running job'); + } +} diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index 9b4689b785..62c921ceee 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -991,8 +991,8 @@ export default function RecipesView() { id: scheduleRecipeManifest.id, source: '', cron: scheduleRecipeManifest.schedule_cron, - last_run: null, - currently_running: false, + lastRun: null, + currentlyRunning: false, paused: false, } : null diff --git a/ui/desktop/src/components/schedule/CronPicker.tsx b/ui/desktop/src/components/schedule/CronPicker.tsx index a34985bf4f..1c5521cab3 100644 --- a/ui/desktop/src/components/schedule/CronPicker.tsx +++ b/ui/desktop/src/components/schedule/CronPicker.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { ScheduledJob } from '../../schedule'; +import type { ScheduledJobDto } from '@aaif/goose-sdk'; import { errorMessage } from '../../utils/conversionUtils'; import { defineMessages, useIntl } from '../../i18n'; import { @@ -61,7 +61,7 @@ const i18n = defineMessages({ }); interface CronPickerProps { - schedule: ScheduledJob | null; + schedule: ScheduledJobDto | null; onChange: (cron: string) => void; isValid: (valid: boolean) => void; } diff --git a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx index 2cffe9f416..8d57a0acc0 100644 --- a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx +++ b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx @@ -1,29 +1,28 @@ import React, { useState, useEffect, useCallback } from 'react'; +import type { ScheduledJobDto, SessionInfo } from '@aaif/goose-sdk'; import { Button } from '../ui/button'; import { ScrollArea } from '../ui/scroll-area'; import BackButton from '../ui/BackButton'; import { Card } from '../ui/card'; import { - getScheduleSessions, - runScheduleNow, - pauseSchedule, - unpauseSchedule, - updateSchedule, - listSchedules, - killRunningJob, - inspectRunningJob, - ScheduledJob, -} from '../../schedule'; -import SessionHistoryView from '../sessions/SessionHistoryView'; + acpListScheduleSessions, + acpRunScheduleNow, + acpPauseSchedule, + acpUnpauseSchedule, + acpUpdateSchedule, + acpListSchedules, + acpKillRunningJob, + acpInspectRunningJob, +} from '../../acp/schedules'; import { ScheduleModal, NewSchedulePayload } from './ScheduleModal'; import { toastError, toastSuccess } from '../../toasts'; import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react'; import cronstrue from 'cronstrue'; import { formatToLocalDateWithTimezone } from '../../utils/date'; -import { getSession, Session } from '../../api'; import { trackScheduleRunNow, getErrorType } from '../../utils/analytics'; import { errorMessage } from '../../utils/conversionUtils'; import { defineMessages, useIntl } from '../../i18n'; +import { useNavigation } from '../../hooks/useNavigation'; const i18n = defineMessages({ scheduleNotFound: { id: 'scheduleDetailView.scheduleNotFound', defaultMessage: 'Schedule Not Found' }, @@ -57,12 +56,11 @@ const i18n = defineMessages({ created: { id: 'scheduleDetailView.created', defaultMessage: 'Created: {date}' }, messages: { id: 'scheduleDetailView.messages', defaultMessage: 'Messages: {count}' }, dir: { id: 'scheduleDetailView.dir', defaultMessage: 'Dir: {path}' }, - tokens: { id: 'scheduleDetailView.tokens', defaultMessage: 'Tokens: {count}' }, idLabel: { id: 'scheduleDetailView.idLabel', defaultMessage: 'ID:' }, jobCancelled: { id: 'scheduleDetailView.jobCancelled', defaultMessage: 'Job Cancelled' }, jobCancelledMsg: { id: 'scheduleDetailView.jobCancelledMsg', defaultMessage: 'The job was cancelled while starting up.' }, - scheduleTriggered: { id: 'scheduleDetailView.scheduleTriggered', defaultMessage: 'Schedule Triggered' }, - newSession: { id: 'scheduleDetailView.newSession', defaultMessage: 'New session: {sessionId}' }, + scheduleCompleted: { id: 'scheduleDetailView.scheduleCompleted', defaultMessage: 'Run completed' }, + completedSession: { id: 'scheduleDetailView.completedSession', defaultMessage: 'Session: {sessionId}' }, runScheduleError: { id: 'scheduleDetailView.runScheduleError', defaultMessage: 'Run Schedule Error' }, scheduleUnpaused: { id: 'scheduleDetailView.scheduleUnpaused', defaultMessage: 'Schedule Unpaused' }, unpausedMsg: { id: 'scheduleDetailView.unpausedMsg', defaultMessage: 'Unpaused "{id}"' }, @@ -77,53 +75,48 @@ const i18n = defineMessages({ scheduleUpdated: { id: 'scheduleDetailView.scheduleUpdated', defaultMessage: 'Schedule Updated' }, updatedMsg: { id: 'scheduleDetailView.updatedMsg', defaultMessage: 'Updated "{id}"' }, updateScheduleError: { id: 'scheduleDetailView.updateScheduleError', defaultMessage: 'Update Schedule Error' }, - failedToLoadSession: { id: 'scheduleDetailView.failedToLoadSession', defaultMessage: 'Failed to load session' }, scheduleNotFoundError: { id: 'scheduleDetailView.scheduleNotFoundError', defaultMessage: 'Schedule not found' }, }); -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; } +function sessionMeta(session: SessionInfo): Record { + return typeof session._meta === 'object' && session._meta !== null ? session._meta : {}; +} + +function metaString(session: SessionInfo, key: string): string | undefined { + const value = sessionMeta(session)[key]; + return typeof value === 'string' ? value : undefined; +} + +function metaNumber(session: SessionInfo, key: string): number | undefined { + const value = sessionMeta(session)[key]; + return typeof value === 'number' ? value : undefined; +} + const ScheduleDetailView: React.FC = ({ scheduleId, onNavigateBack }) => { const intl = useIntl(); - const [sessions, setSessions] = useState([]); + const setView = useNavigation(); + const [sessions, setSessions] = useState([]); const [isLoadingSessions, setIsLoadingSessions] = useState(false); const [sessionsError, setSessionsError] = useState(null); - const [scheduleDetails, setScheduleDetails] = useState(null); + const [scheduleDetails, setScheduleDetails] = useState(null); const [isLoadingSchedule, setIsLoadingSchedule] = useState(false); const [scheduleError, setScheduleError] = useState(null); const [isActionLoading, setIsActionLoading] = useState(false); - const [selectedSession, setSelectedSession] = useState(null); - const [isLoadingSession, setIsLoadingSession] = useState(false); - const [sessionError, setSessionError] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); const fetchSessions = async (sId: string) => { setIsLoadingSessions(true); setSessionsError(null); try { - const data = await getScheduleSessions(sId, 20); + const data = await acpListScheduleSessions(sId, 20); setSessions(data); } catch (err) { setSessionsError(errorMessage(err, 'Failed to fetch sessions')); @@ -136,7 +129,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN setIsLoadingSchedule(true); setScheduleError(null); try { - const allSchedules = await listSchedules(); + const allSchedules = await acpListSchedules(); const schedule = allSchedules.find((s) => s.id === sId); if (schedule) { setScheduleDetails(schedule); @@ -151,22 +144,27 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN }, [intl]); useEffect(() => { - if (scheduleId && !selectedSession) { + if (scheduleId) { fetchSessions(scheduleId); fetchSchedule(scheduleId); } - }, [scheduleId, selectedSession, fetchSchedule]); + }, [scheduleId, fetchSchedule]); + + const openSession = useCallback((sessionId: string) => { + setView('pair', { + disableAnimation: true, + resumeSessionId: sessionId, + }); + }, [setView]); const handleRunNow = async () => { if (!scheduleId) return; setIsActionLoading(true); try { - const newSessionId = await runScheduleNow(scheduleId); + const result = await acpRunScheduleNow(scheduleId); trackScheduleRunNow(true); - if (newSessionId === 'CANCELLED') { - toastSuccess({ title: intl.formatMessage(i18n.jobCancelled), msg: intl.formatMessage(i18n.jobCancelledMsg) }); - } else { - toastSuccess({ title: intl.formatMessage(i18n.scheduleTriggered), msg: intl.formatMessage(i18n.newSession, { sessionId: newSessionId }) }); + if (result.status === 'completed' && result.sessionId) { + toastSuccess({ title: intl.formatMessage(i18n.scheduleCompleted), msg: intl.formatMessage(i18n.completedSession, { sessionId: result.sessionId }) }); } await fetchSessions(scheduleId); await fetchSchedule(scheduleId); @@ -187,10 +185,10 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN setIsActionLoading(true); try { if (scheduleDetails.paused) { - await unpauseSchedule(scheduleId); + await acpUnpauseSchedule(scheduleId); toastSuccess({ title: intl.formatMessage(i18n.scheduleUnpaused), msg: intl.formatMessage(i18n.unpausedMsg, { id: scheduleId }) }); } else { - await pauseSchedule(scheduleId); + await acpPauseSchedule(scheduleId); toastSuccess({ title: intl.formatMessage(i18n.schedulePaused), msg: intl.formatMessage(i18n.pausedMsg, { id: scheduleId }) }); } await fetchSchedule(scheduleId); @@ -209,7 +207,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN if (!scheduleId) return; setIsActionLoading(true); try { - const result = await killRunningJob(scheduleId); + const result = await acpKillRunningJob(scheduleId); toastSuccess({ title: intl.formatMessage(i18n.jobKilled), msg: result.message }); await fetchSchedule(scheduleId); } catch (err) { @@ -227,7 +225,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN if (!scheduleId) return; setIsActionLoading(true); try { - const result = await inspectRunningJob(scheduleId); + const result = await acpInspectRunningJob(scheduleId); if (result.sessionId) { const duration = result.runningDurationSeconds ? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s` @@ -254,7 +252,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN if (!scheduleId) return; setIsActionLoading(true); try { - await updateSchedule(scheduleId, payload as string); + await acpUpdateSchedule(scheduleId, payload as string); toastSuccess({ title: intl.formatMessage(i18n.scheduleUpdated), msg: intl.formatMessage(i18n.updatedMsg, { id: scheduleId }) }); await fetchSchedule(scheduleId); setIsModalOpen(false); @@ -269,37 +267,6 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN } }; - const loadSession = async (sessionId: string) => { - setIsLoadingSession(true); - setSessionError(null); - try { - const response = await getSession({ - path: { session_id: sessionId }, - throwOnError: true, - }); - setSelectedSession(response.data); - } catch (err) { - const msg = errorMessage(err, 'Failed to load session'); - setSessionError(msg); - toastError({ title: intl.formatMessage(i18n.failedToLoadSession), msg }); - } finally { - setIsLoadingSession(false); - } - }; - - if (selectedSession) { - return ( - setSelectedSession(null)} - onRetry={() => loadSession(selectedSession.id)} - showActionButtons={true} - /> - ); - } - if (!scheduleId) { return (
@@ -352,7 +319,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN {scheduleDetails.id}
- {scheduleDetails.currently_running && ( + {scheduleDetails.currentlyRunning && (
{intl.formatMessage(i18n.currentlyRunning)} @@ -377,18 +344,18 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN

{intl.formatMessage(i18n.lastRun)}{' '} - {formatToLocalDateWithTimezone(scheduleDetails.last_run)} + {formatToLocalDateWithTimezone(scheduleDetails.lastRun)}

- {scheduleDetails.currently_running && scheduleDetails.current_session_id && ( + {scheduleDetails.currentlyRunning && scheduleDetails.currentSessionId && (

{intl.formatMessage(i18n.currentSession)}{' '} - {scheduleDetails.current_session_id} + {scheduleDetails.currentSessionId}

)} - {scheduleDetails.currently_running && scheduleDetails.process_start_time && ( + {scheduleDetails.currentlyRunning && scheduleDetails.jobStartTime && (

{intl.formatMessage(i18n.processStarted)}{' '} - {formatToLocalDateWithTimezone(scheduleDetails.process_start_time)} + {formatToLocalDateWithTimezone(scheduleDetails.jobStartTime)}

)}
@@ -401,13 +368,13 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN
- {scheduleDetails && !scheduleDetails.currently_running && ( + {scheduleDetails && !scheduleDetails.currentlyRunning && ( <>
- {scheduleDetails?.currently_running && ( + {scheduleDetails?.currentlyRunning && (

{intl.formatMessage(i18n.cannotModifyRunning)}

@@ -496,45 +463,46 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN {sessions.length > 0 && (
- {sessions.map((session) => ( - loadSession(session.id)} - > -

{ + const sessionId = String(session.sessionId); + const sessionName = session.title ?? sessionId; + const createdAt = metaString(session, 'createdAt') ?? session.updatedAt; + const messageCount = metaNumber(session, 'messageCount'); + + return ( + openSession(sessionId)} > - {session.name || intl.formatMessage(i18n.sessionId, { id: session.id })} -

-

- {intl.formatMessage(i18n.created, { date: session.createdAt ? formatToLocalDateWithTimezone(session.createdAt) : 'N/A' })} -

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

- {intl.formatMessage(i18n.messages, { count: session.messageCount })} -

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

- {intl.formatMessage(i18n.dir, { path: session.workingDir })} + {sessionName || intl.formatMessage(i18n.sessionId, { id: sessionId })} + +

+ {intl.formatMessage(i18n.created, { date: createdAt ? formatToLocalDateWithTimezone(createdAt) : 'N/A' })}

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

- {intl.formatMessage(i18n.tokens, { count: session.accumulatedTotalTokens })} + {intl.formatMessage(i18n.messages, { count: messageCount })}

)} -

- {intl.formatMessage(i18n.idLabel)} {session.id} -

-
- ))} + {session.cwd && ( +

+ {intl.formatMessage(i18n.dir, { path: session.cwd })} +

+ )} +

+ {intl.formatMessage(i18n.idLabel)} {sessionId} +

+ + ); + })}
)} diff --git a/ui/desktop/src/components/schedule/ScheduleModal.tsx b/ui/desktop/src/components/schedule/ScheduleModal.tsx index 3b2f96f563..a88ca54ade 100644 --- a/ui/desktop/src/components/schedule/ScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/ScheduleModal.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect, FormEvent, useCallback } from 'react'; +import type { ScheduledJobDto } from '@aaif/goose-sdk'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; -import { ScheduledJob } from '../../schedule'; import { CronPicker } from './CronPicker'; import { Recipe, parseDeeplink, parseRecipeFromFile } from '../../recipe'; import { getStorageDirectory } from '../../recipe/recipe_management'; @@ -47,7 +47,7 @@ interface ScheduleModalProps { isOpen: boolean; onClose: () => void; onSubmit: (payload: NewSchedulePayload | string) => Promise; - schedule: ScheduledJob | null; + schedule: ScheduledJobDto | null; isLoadingExternally: boolean; apiErrorExternally: string | null; initialDeepLink: string | null; diff --git a/ui/desktop/src/components/schedule/SchedulesView.tsx b/ui/desktop/src/components/schedule/SchedulesView.tsx index 4b4608b9ec..93cdc23a5e 100644 --- a/ui/desktop/src/components/schedule/SchedulesView.tsx +++ b/ui/desktop/src/components/schedule/SchedulesView.tsx @@ -1,16 +1,16 @@ import React, { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; +import type { ScheduledJobDto } from '@aaif/goose-sdk'; import { - listSchedules, - createSchedule, - deleteSchedule, - pauseSchedule, - unpauseSchedule, - updateSchedule, - killRunningJob, - inspectRunningJob, - ScheduledJob, -} from '../../schedule'; + acpListSchedules, + acpCreateSchedule, + acpDeleteSchedule, + acpPauseSchedule, + acpUnpauseSchedule, + acpUpdateSchedule, + acpKillRunningJob, + acpInspectRunningJob, +} from '../../acp/schedules'; import { ScrollArea } from '../ui/scroll-area'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; @@ -67,9 +67,9 @@ interface SchedulesViewProps { } const ScheduleCard: React.FC<{ - job: ScheduledJob; + job: ScheduledJobDto; onNavigateToDetail: (id: string) => void; - onEdit: (job: ScheduledJob) => void; + onEdit: (job: ScheduledJobDto) => void; onPause: (id: string) => void; onUnpause: (id: string) => void; onKill: (id: string) => void; @@ -95,7 +95,7 @@ const ScheduleCard: React.FC<{ readableCron = job.cron; } - const formattedLastRun = formatToLocalDateWithTimezone(job.last_run); + const formattedLastRun = formatToLocalDateWithTimezone(job.lastRun); return ( {job.id} - {job.currently_running && ( + {job.currentlyRunning && ( {intl.formatMessage(i18n.running)} @@ -130,7 +130,7 @@ const ScheduleCard: React.FC<{
- {!job.currently_running && ( + {!job.currentlyRunning && ( <> )} - {job.currently_running && ( + {job.currentlyRunning && ( <>