From 442a42d996fbb91a2924e247e9a679f9585968f4 Mon Sep 17 00:00:00 2001 From: Nige Date: Thu, 12 Mar 2026 22:03:38 +0000 Subject: [PATCH] fix(web): recompute cron next_fire_at when re-enabling routines (#1080) --- src/channels/web/handlers/routines.rs | 10 +++ src/channels/web/server.rs | 10 +++ tests/gateway_workflow_integration.rs | 113 ++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs index d8803efa..f49d7fe8 100644 --- a/src/channels/web/handlers/routines.rs +++ b/src/channels/web/handlers/routines.rs @@ -10,6 +10,7 @@ use axum::{ use serde::Deserialize; use uuid::Uuid; +use crate::agent::routine::{Trigger, next_cron_fire}; use crate::channels::web::server::GatewayState; use crate::channels::web::types::*; use crate::error::RoutineError; @@ -182,12 +183,21 @@ pub async fn routines_toggle_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; + let was_enabled = routine.enabled; // If a specific value was provided, use it; otherwise toggle. routine.enabled = match body { Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled), None => !routine.enabled, }; + if routine.enabled + && !was_enabled + && let Trigger::Cron { schedule, timezone } = &routine.trigger + { + routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref()) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + store .update_routine(&routine) .await diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 904971fc..5039ad82 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -26,6 +26,7 @@ use tower_http::set_header::SetResponseHeaderLayer; use uuid::Uuid; use crate::agent::SessionManager; +use crate::agent::routine::{Trigger, next_cron_fire}; use crate::bootstrap::ironclaw_base_dir; use crate::channels::IncomingMessage; use crate::channels::relay::DEFAULT_RELAY_NAME; @@ -2416,12 +2417,21 @@ async fn routines_toggle_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; + let was_enabled = routine.enabled; // If a specific value was provided, use it; otherwise toggle. routine.enabled = match body { Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled), None => !routine.enabled, }; + if routine.enabled + && !was_enabled + && let Trigger::Cron { schedule, timezone } = &routine.trigger + { + routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref()) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + store .update_routine(&routine) .await diff --git a/tests/gateway_workflow_integration.rs b/tests/gateway_workflow_integration.rs index 2f1353a5..187cc751 100644 --- a/tests/gateway_workflow_integration.rs +++ b/tests/gateway_workflow_integration.rs @@ -13,6 +13,8 @@ mod support; mod tests { use std::time::Duration; + use uuid::Uuid; + use crate::support::gateway_workflow_harness::GatewayWorkflowHarness; use crate::support::mock_openai_server::{ MockOpenAiResponse, MockOpenAiRule, MockOpenAiServerBuilder, MockToolCall, @@ -147,4 +149,115 @@ mod tests { harness.shutdown().await; mock.shutdown().await; } + + #[tokio::test] + async fn routines_toggle_reenable_cron_recomputes_next_fire_at() { + let mock = MockOpenAiServerBuilder::new() + .with_rule(MockOpenAiRule::on_user_contains( + "create cron routine", + MockOpenAiResponse::ToolCalls(vec![MockToolCall::new( + "call_create_cron_1", + "routine_create", + serde_json::json!({ + "name": "wf-cron-toggle-reenable", + "description": "Cron toggle regression test", + "trigger_type": "cron", + "schedule": "0 */5 * * * *", + "timezone": "UTC", + "action_type": "lightweight", + "prompt": "noop" + }), + )]), + )) + .with_default_response(MockOpenAiResponse::Text("ack".to_string())) + .start() + .await; + + let harness = + GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model") + .await; + + let thread_id = harness.create_thread().await; + harness.send_chat(&thread_id, "create cron routine").await; + harness + .wait_for_turns(&thread_id, 1, Duration::from_secs(10)) + .await; + + let routine = harness + .routine_by_name("wf-cron-toggle-reenable") + .await + .expect("routine should exist"); + let routine_id = routine + .get("id") + .and_then(|v| v.as_str()) + .expect("routine id missing"); + + let routine_uuid = Uuid::parse_str(routine_id).expect("valid routine uuid"); + + // Disable through the web toggle endpoint. + harness + .client + .post(format!( + "{}/api/routines/{routine_id}/toggle", + harness.base_url() + )) + .bearer_auth(&harness.auth_token) + .json(&serde_json::json!({ "enabled": false })) + .send() + .await + .expect("disable toggle request failed") + .error_for_status() + .expect("disable toggle non-2xx"); + + // Simulate an unscheduled disabled cron routine (next_fire_at missing). + let mut stored = harness + .db + .get_routine(routine_uuid) + .await + .expect("db get_routine") + .expect("routine should still exist"); + stored.next_fire_at = None; + harness + .db + .update_routine(&stored) + .await + .expect("db update_routine"); + + // Re-enable through the web toggle endpoint. + harness + .client + .post(format!( + "{}/api/routines/{routine_id}/toggle", + harness.base_url() + )) + .bearer_auth(&harness.auth_token) + .json(&serde_json::json!({ "enabled": true })) + .send() + .await + .expect("enable toggle request failed") + .error_for_status() + .expect("enable toggle non-2xx"); + + let detail = harness + .client + .get(format!("{}/api/routines/{routine_id}", harness.base_url())) + .bearer_auth(&harness.auth_token) + .send() + .await + .expect("detail request failed") + .error_for_status() + .expect("detail non-2xx") + .json::() + .await + .expect("invalid detail response"); + + assert_eq!(detail["enabled"].as_bool(), Some(true)); + assert!( + detail["next_fire_at"].as_str().is_some(), + "expected next_fire_at to be recomputed when re-enabling cron routine, got {detail}" + ); + + harness.shutdown().await; + mock.shutdown().await; + } }