fix(web): recompute cron next_fire_at when re-enabling routines (#1080)

This commit is contained in:
Nige
2026-03-12 15:03:38 -07:00
committed by GitHub
parent d5828b271d
commit 442a42d996
3 changed files with 133 additions and 0 deletions
+10
View File
@@ -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
+10
View File
@@ -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
+113
View File
@@ -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::<serde_json::Value>()
.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;
}
}