Skip to content
45 changes: 45 additions & 0 deletions server/app/core/schedule_time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

"""Cron wall times are local only when a schedule explicitly names a zone."""

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from croniter import croniter


def next_schedule_time(expression: str, base: datetime, config: dict | None = None) -> datetime:
config = config or {}
name = config.get("timezone")
if not name:
return croniter(expression, base).get_next(datetime)
zone = ZoneInfo(name)
if base.tzinfo is None:
base = base.replace(tzinfo=timezone.utc)
local_base = base.astimezone(zone).replace(tzinfo=None)
chosen_date = config.get("date")
if chosen_date:
midnight = datetime.strptime(chosen_date, "%Y-%m-%d")
local_base = max(local_base, midnight - timedelta(seconds=1))
cron = croniter(expression, local_base)
# Iterate in wall time so a DST change does not shift the chosen hour.
# Nonexistent wall times are skipped; ambiguous times execute once.
for _ in range(400):
wall = cron.get_next(datetime)
if chosen_date and wall.date().isoformat() != chosen_date:
raise ValueError("One-time schedule has no future match on its date")
candidate = wall.replace(tzinfo=zone, fold=0).astimezone(timezone.utc)
if candidate > base and candidate.astimezone(zone).replace(tzinfo=None) == wall:
return candidate
raise ValueError("No valid local schedule occurrence")
2 changes: 1 addition & 1 deletion server/app/domains/trigger/service/trigger_crud_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ def update(trigger_id: int, data: TriggerUpdate, user_id: int, s: Session) -> di

# Recalculate next_run_at if cron expression or status changed for scheduled triggers
if trigger.trigger_type == TriggerType.schedule:
if "custom_cron_expression" in update_data or "status" in update_data:
if "custom_cron_expression" in update_data or "status" in update_data or "config" in update_data:
if trigger.status == TriggerStatus.active and trigger.custom_cron_expression:
schedule_service = TriggerScheduleService(s)
trigger.next_run_at = schedule_service.calculate_next_run_at(trigger)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from datetime import datetime, timedelta, timezone
from typing import List, Tuple, Optional
from loguru import logger
from croniter import croniter
from app.core.schedule_time import next_schedule_time
from uuid import uuid4
import asyncio
from sqlmodel import select
Expand Down Expand Up @@ -116,9 +116,7 @@ def calculate_next_run_at(
base_time = datetime.now(timezone.utc)

try:
cron = croniter(trigger.custom_cron_expression, base_time)
next_run = cron.get_next(datetime)
return next_run
return next_schedule_time(trigger.custom_cron_expression, base_time, trigger.config)
except Exception as e:
logger.error(
"Failed to calculate next run time",
Expand Down
21 changes: 19 additions & 2 deletions server/app/model/trigger/app_configs/schedule_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"""

from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from typing import Optional, Tuple
from pydantic import Field, field_validator

Expand Down Expand Up @@ -52,6 +53,18 @@ class ScheduleTriggerConfig(BaseTriggerConfig):
}
"""

timezone: Optional[str] = Field(default=None, description="IANA time zone for local cron times")

@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: Optional[str]) -> Optional[str]:
if value is not None:
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError, TypeError) as exc:
raise ValueError("Timezone must be an IANA time zone") from exc
return value

# Date for one-time execution (YYYY-MM-DD format)
# Required when is_single_execution=True because cron has no year
date: Optional[str] = Field(
Expand Down Expand Up @@ -109,17 +122,21 @@ def is_expired(self, check_date: Optional[datetime] = None) -> bool:
if check_date is None:
check_date = datetime.now(timezone.utc)

zone = ZoneInfo(self.timezone) if self.timezone else timezone.utc
if check_date.tzinfo is None:
check_date = check_date.replace(tzinfo=timezone.utc)

# One-time execution: check if date has passed
if self.date:
execution_date = datetime.strptime(self.date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
hour=23, minute=59, second=59, microsecond=999999, tzinfo=zone
)
return check_date > execution_date

# Recurring with expiration: check if expiration date has passed
if self.expirationDate:
expiration = datetime.strptime(self.expirationDate, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
hour=23, minute=59, second=59, microsecond=999999, tzinfo=zone
)
return check_date > expiration

Expand Down
115 changes: 115 additions & 0 deletions server/tests/test_schedule_timezone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import importlib.util
from pathlib import Path
from datetime import datetime, timezone
import pytest

path = Path(__file__).parents[1] / "app/core/schedule_time.py"
spec = importlib.util.spec_from_file_location("schedule_time", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
next_schedule_time = module.next_schedule_time


@pytest.mark.parametrize(
"zone,before,expected",
[
("Europe/London", "2026-03-28T14:00:00+00:00", "2026-03-29T12:20:00+00:00"),
("Europe/London", "2026-10-24T14:00:00+00:00", "2026-10-25T13:20:00+00:00"),
("America/New_York", "2026-03-07T20:00:00+00:00", "2026-03-08T17:20:00+00:00"),
("America/New_York", "2026-10-31T20:00:00+00:00", "2026-11-01T18:20:00+00:00"),
],
)
def test_daily_wall_time_survives_dst(zone, before, expected):
assert next_schedule_time(
"20 13 * * *", datetime.fromisoformat(before), {"timezone": zone}
) == datetime.fromisoformat(expected)


def test_legacy_schedules_keep_utc():
assert next_schedule_time("20 13 * * *", datetime(2026, 10, 24, 14, tzinfo=timezone.utc)) == datetime(
2026, 10, 25, 13, 20, tzinfo=timezone.utc
)


def test_one_time_date_keeps_year_and_zone():
assert next_schedule_time(
"20 13 25 10 *", datetime(2026, 1, 1, tzinfo=timezone.utc), {"timezone": "Europe/London", "date": "2027-10-25"}
) == datetime(2027, 10, 25, 12, 20, tzinfo=timezone.utc)


def test_spring_gap_is_skipped_and_fall_hour_executes_once():
config = {"timezone": "Europe/London"}
assert next_schedule_time("30 1 * * *", datetime(2026, 3, 28, 2, tzinfo=timezone.utc), config) == datetime(
2026, 3, 30, 0, 30, tzinfo=timezone.utc
)
assert next_schedule_time("30 1 * * *", datetime(2026, 10, 25, 0, 30, tzinfo=timezone.utc), config) == datetime(
2026, 10, 26, 1, 30, tzinfo=timezone.utc
)


def load_schedule_config():
import ast
from typing import Optional, Tuple
from pydantic import BaseModel, Field, field_validator
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

path = Path(__file__).parents[1] / "app/model/trigger/app_configs/schedule_config.py"
tree = ast.parse(path.read_text())
cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "ScheduleTriggerConfig")
namespace = dict(
BaseTriggerConfig=BaseModel,
Field=Field,
field_validator=field_validator,
Optional=Optional,
Tuple=Tuple,
datetime=datetime,
timezone=timezone,
ZoneInfo=ZoneInfo,
ZoneInfoNotFoundError=ZoneInfoNotFoundError,
)
exec(compile(ast.fix_missing_locations(ast.Module(body=[cls], type_ignores=[])), str(path), "exec"), namespace)
return namespace["ScheduleTriggerConfig"]


def test_zone_validation_and_expiration_use_the_same_calendar():
Config = load_schedule_config()
with pytest.raises(ValueError):
Config(timezone="invalid/zone")
config = Config(timezone="America/New_York", expirationDate="2026-10-25")
assert not config.is_expired(datetime(2026, 10, 26, 3, tzinfo=timezone.utc))
assert config.is_expired(datetime(2026, 10, 26, 5, tzinfo=timezone.utc))
assert Config(expirationDate="2026-10-25").is_expired(datetime(2026, 10, 26, 0, 1, tzinfo=timezone.utc))
assert not Config(timezone="America/New_York", date="2026-10-25").is_expired(
datetime(2026, 10, 26, 3, tzinfo=timezone.utc)
)


@pytest.mark.parametrize(
"relative",
["app/domains/trigger/service/trigger_schedule_service.py"],
)
def test_both_services_evaluate_the_saved_zone(relative):
import ast
from typing import Optional
from types import SimpleNamespace

path = Path(__file__).parents[1] / relative
tree = ast.parse(path.read_text())
cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "TriggerScheduleService")
method = next(
node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == "calculate_next_run_at"
)
for arg in method.args.args:
arg.annotation = None
method.returns = None
namespace = dict(
datetime=datetime,
timezone=timezone,
next_schedule_time=next_schedule_time,
logger=SimpleNamespace(error=lambda *a, **k: None),
)
exec(compile(ast.fix_missing_locations(ast.Module(body=[method], type_ignores=[])), str(path), "exec"), namespace)
trigger = SimpleNamespace(id=1, custom_cron_expression="20 13 * * *", config={"timezone": "Europe/London"})
assert namespace["calculate_next_run_at"](
None, trigger, datetime(2026, 10, 24, 14, tzinfo=timezone.utc)
) == datetime(2026, 10, 25, 13, 20, tzinfo=timezone.utc)
Loading
Loading