Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/aionui-cron/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 84 additions & 6 deletions crates/aionui-cron/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) =
Expand Down Expand Up @@ -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<String> {
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<String>, Option<String>) {
match schedule {
CronSchedule::At { at_ms, description } => ("at".to_owned(), at_ms.to_string(), None, description.clone()),
Expand All @@ -2069,6 +2102,51 @@ fn schedule_to_row_fields(schedule: &CronSchedule) -> (String, String, Option<St
mod tests {
use super::*;

// -- conversation cron timezone --------------------------------------------

#[test]
fn conversation_cron_wires_local_timezone() {
let dto = conversation_cron_schedule("0 0 12 * * *".into(), "every day at 12:00".into());
let CronScheduleDto::Cron { expr, tz, description } = dto else {
panic!("conversation cron must build a Cron schedule");
};
assert_eq!(expr, "0 0 12 * * *");
assert_eq!(description.as_deref(), Some("every day at 12:00"));
// The bare request has no timezone; the builder must stamp the host's
// local zone (previously hardcoded to None, which the scheduler treats
// as UTC).
assert_eq!(tz, local_timezone_name());
if let Some(tz) = tz {
assert!(validate_timezone(&tz).is_ok(), "stamped timezone must be valid: {tz}");
}
}

#[test]
fn conversation_cron_fires_at_local_wall_clock() {
use chrono::{Local, TimeZone, Timelike};

// Only meaningful when the host's zone is detectable; otherwise the
// schedule intentionally degrades to UTC (covered by the wiring test).
if local_timezone_name().is_none() {
return;
}

// "every day at 12:00" must fire at 12:00 local wall-clock. Before the
// fix the schedule carried no tz and the scheduler resolved it in UTC,
// firing at the wrong hour on any non-UTC host.
let dto = conversation_cron_schedule("0 0 12 * * *".into(), "every day at 12:00".into());
let schedule = schedule_from_dto(&dto);
let now = Local.with_ymd_and_hms(2026, 7, 21, 8, 0, 0).unwrap().timestamp_millis();

let next = compute_next_run(&schedule, now).expect("cron should produce a next run");
let next_hour = Local.timestamp_millis_opt(next).single().unwrap().hour();

assert_eq!(
next_hour, 12,
"conversation cron must fire at 12:00 local wall-clock, not UTC"
);
}

// -- validate_skill_body_content -------------------------------------------

#[test]
Expand Down
Loading