diff --git a/Cargo.lock b/Cargo.lock index e3971a622..c29d5e3bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -602,6 +602,7 @@ dependencies = [ "chrono-tz", "cron", "dashmap", + "iana-time-zone", "serde", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 5eebaf92c..1296ef780 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,6 +141,7 @@ zstd = "0.13" cron = "0.15" chrono = "0.4" chrono-tz = "0.10" +iana-time-zone = "0.1" # Shell open = "5" diff --git a/crates/aionui-cron/Cargo.toml b/crates/aionui-cron/Cargo.toml index 040398696..1b5b47ee7 100644 --- a/crates/aionui-cron/Cargo.toml +++ b/crates/aionui-cron/Cargo.toml @@ -16,6 +16,7 @@ axum.workspace = true cron.workspace = true chrono.workspace = true chrono-tz.workspace = true +iana-time-zone.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 43d7fe580..69292314c 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -22,7 +22,9 @@ use crate::events::CronEventEmitter; use crate::error::CronError; use crate::executor::{ExecutionResult, JobExecutor, PreparedRunNow, RETRY_INTERVAL_MS}; -use crate::scheduler::{CronScheduler, compute_next_run, compute_next_run_after_occurrence, validate_schedule}; +use crate::scheduler::{ + CronScheduler, compute_next_run, compute_next_run_after_occurrence, validate_schedule, validate_timezone, +}; use crate::skill_file::{delete_skill_file, has_skill_file, write_raw_skill_file, write_skill_file}; use crate::types::{ CreatedBy, CronAgentConfig, CronJob, CronSchedule, ExecutionMode, cron_job_from_row, cron_job_to_response, @@ -101,11 +103,7 @@ impl CronService { .verify_conversation_helper_context(user_id, conversation_id) .await?; - let schedule_dto = CronScheduleDto::Cron { - expr: req.schedule, - tz: None, - description: Some(req.schedule_description), - }; + let schedule_dto = conversation_cron_schedule(req.schedule, req.schedule_description); let conversation_title = Some(row.name.clone()); let (agent_type, agent_config, assistant_backend_override) = @@ -2049,6 +2047,41 @@ fn schedule_from_dto_with_existing_timezone(dto: &CronScheduleDto, existing: &Cr } } +/// Resolve the host's IANA timezone name (e.g. `"Asia/Shanghai"`). +/// +/// Returns `None` when the local zone cannot be detected or is not present in +/// the tz database; the caller then leaves the schedule without a timezone and +/// the scheduler falls back to UTC. +fn local_timezone_name() -> Option { + let tz = match iana_time_zone::get_timezone() { + Ok(tz) => tz, + Err(err) => { + warn!(error = %err, "Failed to detect local timezone; conversation cron will use UTC"); + return None; + } + }; + if validate_timezone(&tz).is_err() { + warn!(timezone = %tz, "Local timezone not recognized by tz database; conversation cron will use UTC"); + return None; + } + Some(tz) +} + +/// Build the schedule for a conversation cron created via `cron current create`. +/// +/// The request carries only a bare cron expression whose human-readable +/// description refers to local wall-clock time (e.g. "every day at 12:00"), so +/// the host's local timezone is stamped onto the schedule. Without it the +/// scheduler treats an absent tz as UTC and fires the job at the wrong hour on +/// any non-UTC host. +fn conversation_cron_schedule(expr: String, description: String) -> CronScheduleDto { + CronScheduleDto::Cron { + expr, + tz: local_timezone_name(), + description: Some(description), + } +} + fn schedule_to_row_fields(schedule: &CronSchedule) -> (String, String, Option, Option) { match schedule { CronSchedule::At { at_ms, description } => ("at".to_owned(), at_ms.to_string(), None, description.clone()), @@ -2069,6 +2102,51 @@ fn schedule_to_row_fields(schedule: &CronSchedule) -> (String, String, Option