diff --git a/server/app/core/schedule_time.py b/server/app/core/schedule_time.py new file mode 100644 index 0000000000..58e1c98859 --- /dev/null +++ b/server/app/core/schedule_time.py @@ -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") diff --git a/server/app/domains/trigger/service/trigger_crud_service.py b/server/app/domains/trigger/service/trigger_crud_service.py index 36329e8b30..2d700d4364 100644 --- a/server/app/domains/trigger/service/trigger_crud_service.py +++ b/server/app/domains/trigger/service/trigger_crud_service.py @@ -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) diff --git a/server/app/domains/trigger/service/trigger_schedule_service.py b/server/app/domains/trigger/service/trigger_schedule_service.py index bfae64e329..73bbf4a8ba 100644 --- a/server/app/domains/trigger/service/trigger_schedule_service.py +++ b/server/app/domains/trigger/service/trigger_schedule_service.py @@ -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 @@ -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", diff --git a/server/app/model/trigger/app_configs/schedule_config.py b/server/app/model/trigger/app_configs/schedule_config.py index f0a5e7d26d..19dc7644b1 100644 --- a/server/app/model/trigger/app_configs/schedule_config.py +++ b/server/app/model/trigger/app_configs/schedule_config.py @@ -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 @@ -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( @@ -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 diff --git a/server/tests/test_schedule_timezone.py b/server/tests/test_schedule_timezone.py new file mode 100644 index 0000000000..bed90d42e3 --- /dev/null +++ b/server/tests/test_schedule_timezone.py @@ -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) diff --git a/src/components/Trigger/SchedulePicker.tsx b/src/components/Trigger/SchedulePicker.tsx index 8fb3332fc7..3cf350d070 100644 --- a/src/components/Trigger/SchedulePicker.tsx +++ b/src/components/Trigger/SchedulePicker.tsx @@ -38,6 +38,7 @@ import { getNextRun, isOneTimeTooFarAhead, nextOccurrence, + nextOccurrenceInZone, parseCron, parseScheduleNumber, scheduleToCron, @@ -52,12 +53,14 @@ const MINUTE_RANGE: ScheduleNumberRange = { min: 0, max: 59 }; const DAY_OF_MONTH_RANGE: ScheduleNumberRange = { min: 1, max: 31 }; export type ScheduleConfig = { + timezone?: string; date?: string; // YYYY-MM-DD format for one-time schedules expirationDate?: string; // YYYY-MM-DD format for recurring expiration max_failure_count?: number; }; type SchedulePickerProps = { + timezone?: string; value: string; // cron expression onChange: (cronExpression: string) => void; onConfigChange?: (config: ScheduleConfig) => void; @@ -108,12 +111,16 @@ export const SchedulePicker: React.FC = ({ initialConfig, isEditing = false, useDefaultTime = false, + timezone, }) => { const { t, i18n } = useTranslation(); // A stored schedule the editor cannot show selects no frequency, so it is // kept until the user picks one. const [frequency, setFrequency] = useState(() => - value && !parseCron(value, new Date(), initialConfig?.date) ? null : 'daily' + value && + !parseCron(value, new Date(), initialConfig?.date, initialConfig?.timezone) + ? null + : 'daily' ); const [defaultStart] = useState(nextFullHour); const [hour, setHour] = useState( @@ -132,7 +139,7 @@ export const SchedulePicker: React.FC = ({ const minuteOptions = useMemo(() => generateMinuteOptions(), []); const previousCronRef = useRef(''); const originalSchedule = useRef( - parseCron(value, new Date(), initialConfig?.date) + parseCron(value, new Date(), initialConfig?.date, initialConfig?.timezone) ).current; // One-time dates are initialized from the shared parser with the persisted UTC year. @@ -150,7 +157,12 @@ export const SchedulePicker: React.FC = ({ // The picker, examples and details share the same UTC conversion contract. useEffect(() => { if (!value || value === previousCronRef.current) return; - const parsed = parseCron(value, new Date(), initialConfig?.date); + const parsed = parseCron( + value, + new Date(), + initialConfig?.date, + initialConfig?.timezone + ); if (!parsed) { setFrequency(null); } else if (!(useDefaultTime && value === '0 0 * * *')) { @@ -164,7 +176,7 @@ export const SchedulePicker: React.FC = ({ setDayOfMonth(String(parsed.dayOfMonth)); } previousCronRef.current = value; - }, [value, useDefaultTime, initialConfig?.date]); + }, [value, useDefaultTime, initialConfig?.date, initialConfig?.timezone]); // The hour, minute and day fields accept free text, so only these parsed // values may build the schedule; Date would roll 25:54 over to 01:54. @@ -210,7 +222,7 @@ export const SchedulePicker: React.FC = ({ let cron: string | null = null; if (selectedSchedule) { try { - cron = scheduleToCron(selectedSchedule); + cron = scheduleToCron(selectedSchedule, new Date(), timezone); } catch { /* Cannot represent this local monthly day in a UTC cron. */ } @@ -231,7 +243,7 @@ export const SchedulePicker: React.FC = ({ // The UTC date offsets below need a valid time; an invalid one cannot be // saved, and the config is emitted again once it is corrected. if (hourNum === null || minuteNum === null) return; - const config: ScheduleConfig = {}; + const config: ScheduleConfig = timezone ? { timezone } : {}; if (frequency === null) { // The kept schedule keeps its stored dates too. @@ -242,7 +254,9 @@ export const SchedulePicker: React.FC = ({ if (frequency === 'one-time' && oneTimeDate) { // Apply UTC dayOffset so config.date matches the UTC date in the cron expression - const { dayOffset } = localTimeToUTC(hourNum, minuteNum, oneTimeDate); + const { dayOffset } = timezone + ? { dayOffset: 0 } + : localTimeToUTC(hourNum, minuteNum, oneTimeDate); const utcDate = new Date(oneTimeDate); utcDate.setDate(utcDate.getDate() + dayOffset); config.date = format(utcDate, 'yyyy-MM-dd'); @@ -252,7 +266,9 @@ export const SchedulePicker: React.FC = ({ // Apply UTC dayOffset so expiration date aligns with the UTC date the cron actually fires on. // e.g. if local 23:00 in UTC-5 becomes 04:00 UTC next day (dayOffset=+1), // the "last allowed UTC run date" must also shift forward by 1. - const { dayOffset } = localTimeToUTC(hourNum, minuteNum, expiredAt); + const { dayOffset } = timezone + ? { dayOffset: 0 } + : localTimeToUTC(hourNum, minuteNum, expiredAt); const utcExpiredAt = new Date(expiredAt); utcExpiredAt.setDate(utcExpiredAt.getDate() + dayOffset); config.expirationDate = format(utcExpiredAt, 'yyyy-MM-dd'); @@ -264,9 +280,18 @@ export const SchedulePicker: React.FC = ({ onConfigChange?.(config); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [frequency, oneTimeDate, expiredAt, maxFailureCount, hourNum, minuteNum]); + }, [ + frequency, + oneTimeDate, + expiredAt, + maxFailureCount, + hourNum, + minuteNum, + timezone, + ]); const oneTimeTooFarAhead = + !timezone && selectedSchedule?.frequency === 'once' && isOneTimeTooFarAhead(selectedSchedule); @@ -276,8 +301,9 @@ export const SchedulePicker: React.FC = ({ const times: Date[] = []; let cursor = new Date(); for (let index = 0; index < 5; index++) { - const next = - selectedSchedule.frequency === 'once' + const next = timezone + ? nextOccurrenceInZone(selectedSchedule, timezone, cursor) + : selectedSchedule.frequency === 'once' ? nextOccurrence(selectedSchedule, cursor) : getNextRun({ custom_cron_expression: cron }, cursor); if (!next) break; @@ -285,7 +311,7 @@ export const SchedulePicker: React.FC = ({ cursor = next; } return times; - }, [selectedSchedule, cron, oneTimeTooFarAhead]); + }, [selectedSchedule, cron, oneTimeTooFarAhead, timezone]); const firstRun = unsupportedMonthlyTime ? null @@ -327,6 +353,7 @@ export const SchedulePicker: React.FC = ({ minute: '2-digit', hour12: true, timeZoneName: 'short', + ...(timezone ? { timeZone: timezone } : {}), }); }; diff --git a/src/components/Trigger/TriggerDialog.tsx b/src/components/Trigger/TriggerDialog.tsx index 61a37a332a..6e7d33901b 100644 --- a/src/components/Trigger/TriggerDialog.tsx +++ b/src/components/Trigger/TriggerDialog.tsx @@ -50,7 +50,12 @@ import { } from '@/hooks/queries/useTriggerQueries'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import type { AutomationDraftV1 } from '@/lib/automationDraft'; -import { proxyCreateTrigger, proxyUpdateTrigger } from '@/service/triggerApi'; +import { confirmScheduleTimezone } from '@/lib/triggerScheduleCompatibility'; +import { + proxyCreateTrigger, + proxyDeleteTrigger, + proxyUpdateTrigger, +} from '@/service/triggerApi'; import { ActivityType, useActivityLogStore } from '@/store/activityLogStore'; import { useSpaceStore } from '@/store/spaceStore'; import { useTriggerStore } from '@/store/triggerStore'; @@ -395,6 +400,18 @@ export const TriggerDialog: React.FC = ({ } response = await proxyUpdateTrigger(selectedTrigger.id, updateData); + const previous = selectedTrigger; + response = await confirmScheduleTimezone( + response, + updateData, + proxyUpdateTrigger, + () => + proxyUpdateTrigger(previous.id, { + custom_cron_expression: previous.custom_cron_expression, + is_single_execution: previous.is_single_execution, + config: previous.config ?? {}, + }) + ); // Update trigger in store updateTrigger(selectedTrigger.id, response); @@ -447,6 +464,13 @@ export const TriggerDialog: React.FC = ({ } response = await proxyCreateTrigger(createData); + const created = response; + response = await confirmScheduleTimezone( + created, + createData, + proxyUpdateTrigger, + () => proxyDeleteTrigger(created.id) + ); // Add trigger to store addTrigger(response); @@ -617,6 +641,13 @@ export const TriggerDialog: React.FC = ({ 23 || minute > 59) return null; + if (isOneTimeCron(cron)) + return { + frequency: 'once', + hour, + minute, + date: new Date( + `${oneTimeDate?.slice(0, 10) || `${reference.getFullYear()}-${monthPart.padStart(2, '0')}-${dayPart.padStart(2, '0')}`}T12:00:00` + ), + }; + if (dayPart === '*' && monthPart === '*' && weekdayPart === '*') + return { frequency: 'daily', hour, minute }; + if ( + dayPart === '*' && + monthPart === '*' && + /^\d+(,\d+)*$/.test(weekdayPart) + ) + return { + frequency: 'weekly', + hour, + minute, + weekdays: weekdayPart.split(',').map(Number), + }; + if (/^\d+$/.test(dayPart) && monthPart === '*' && weekdayPart === '*') + return { + frequency: 'monthly', + hour, + minute, + dayOfMonth: Number(dayPart), + }; + return null; + } const utcMinute = Number(minutePart); const utcHour = Number(hourPart); @@ -258,7 +305,8 @@ export function parseTriggerSchedule( return parseCron( trigger.custom_cron_expression, reference, - trigger.config?.date || trigger.next_run_at + trigger.config?.date || trigger.next_run_at, + trigger.config?.timezone ); } @@ -335,12 +383,89 @@ export function nextOccurrence( return null; } +function wallParts(date: Date, timezone: string): number[] { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hourCycle: 'h23', + }).formatToParts(date); + return ['year', 'month', 'day', 'hour', 'minute'].map((key) => + Number(parts.find((part) => part.type === key)!.value) + ); +} + +/** Resolve calendar matches in the saved IANA zone, including clock changes. */ +export function nextOccurrenceInZone( + schedule: LocalSchedule, + timezone: string, + from = new Date() +): Date | null { + const [year, month, day] = wallParts(from, timezone); + for (let offset = 0; offset < 400; offset++) { + const calendar = + schedule.frequency === 'once' + ? new Date( + Date.UTC( + schedule.date.getFullYear(), + schedule.date.getMonth(), + schedule.date.getDate() + ) + ) + : new Date(Date.UTC(year, month - 1, day + offset)); + if ( + schedule.frequency === 'weekly' && + !(schedule.weekdays ?? [1]).includes(calendar.getUTCDay()) + ) + continue; + if ( + schedule.frequency === 'monthly' && + calendar.getUTCDate() !== (schedule.dayOfMonth ?? 1) + ) + continue; + const target = Date.UTC( + calendar.getUTCFullYear(), + calendar.getUTCMonth(), + calendar.getUTCDate(), + schedule.hour, + schedule.minute + ); + let instant = target; + for (let pass = 0; pass < 4; pass++) { + const [y, m, d, h, min] = wallParts(new Date(instant), timezone); + instant += target - Date.UTC(y, m - 1, d, h, min); + } + // On a repeated hour use the first occurrence, matching the scheduler. + for (const delta of [7200000, 3600000, 1800000]) { + const earlier = new Date(instant - delta); + const [ey, em, ed, eh, emin] = wallParts(earlier, timezone); + if (Date.UTC(ey, em - 1, ed, eh, emin) === target) instant -= delta; + } + const candidate = new Date(instant); + const [y, m, d, h, min] = wallParts(candidate, timezone); + if (Date.UTC(y, m - 1, d, h, min) === target && candidate > from) + return candidate; + if (schedule.frequency === 'once') return null; + } + return null; +} + /** Prefer a future scheduler time; calculate fallbacks in UTC, as the server does. */ export function getNextRun( trigger: Pick, from: Date = new Date() ): Date | null { const schedule = parseTriggerSchedule(trigger, from); + if (trigger.config?.timezone) { + const stored = new Date(parseServerTimestamp(trigger.next_run_at)); + if (stored > from) return stored; + return schedule + ? nextOccurrenceInZone(schedule, trigger.config.timezone, from) + : null; + } if (schedule?.frequency === 'once') { const chosen = nextOccurrence(schedule, from); // A date saved a year or more ahead still runs at the cron's next match. @@ -349,7 +474,7 @@ export function getNextRun( return match && match < chosen ? match : chosen; } if (trigger.next_run_at) { - const date = new Date(trigger.next_run_at); + const date = new Date(parseServerTimestamp(trigger.next_run_at)); if (!Number.isNaN(date.getTime()) && date > from) return date; } if (!schedule) return null; @@ -390,7 +515,8 @@ export function getNextRun( /** Short local date and time, e.g. "Mon, Oct 5, 09:00". */ export function formatRunTime(value: Date | string, locale?: string): string { - const date = typeof value === 'string' ? new Date(value) : value; + const date = + typeof value === 'string' ? new Date(parseServerTimestamp(value)) : value; if (Number.isNaN(date.getTime())) return ''; return formatAppDate( date, @@ -408,7 +534,8 @@ export function formatRunTime(value: Date | string, locale?: string): string { /** The time of day of a run, e.g. "09:30". */ export function formatTimeOfDay(value: Date | string): string { - const date = typeof value === 'string' ? new Date(value) : value; + const date = + typeof value === 'string' ? new Date(parseServerTimestamp(value)) : value; if (Number.isNaN(date.getTime())) return ''; return formatScheduleTime({ hour: date.getHours(), diff --git a/src/lib/triggerScheduleCompatibility.ts b/src/lib/triggerScheduleCompatibility.ts new file mode 100644 index 0000000000..9fc02cb9bd --- /dev/null +++ b/src/lib/triggerScheduleCompatibility.ts @@ -0,0 +1,87 @@ +// ========= 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. ========= + +import { + isOneTimeTooFarAhead, + parseCron, + scheduleToCron, +} from '@/components/Trigger/automationSchedule'; +import { localTimeToUTC } from '@/lib/utils'; +import type { Trigger, TriggerInput, TriggerUpdate } from '@/types'; +import { format } from 'date-fns'; + +type SubmittedSchedule = Pick< + TriggerInput, + 'custom_cron_expression' | 'config' +>; + +/** + * A server must echo the zone; otherwise restore the legacy UTC contract. + * The first save already stored wall-time fields without a zone, which the + * server reads as UTC, so `undo` removes them when the rewrite fails. + */ +export async function confirmScheduleTimezone( + saved: Trigger, + submitted: SubmittedSchedule, + update: (id: number, payload: TriggerUpdate) => Promise, + undo: () => Promise +): Promise { + const zone = submitted.config?.timezone; + if (!zone || saved.config?.timezone === zone) return saved; + try { + return await update(saved.id, legacyUtcSchedule(submitted, zone)); + } catch (error) { + await undo().catch((undoError) => + console.error('Failed to undo the schedule:', undoError) + ); + throw error; + } +} + +/** Builds the schedule the way SchedulePicker does without a zone. */ +function legacyUtcSchedule( + submitted: SubmittedSchedule, + zone: string +): TriggerUpdate { + // The legacy contract converts with this machine's offset. + if (zone !== Intl.DateTimeFormat().resolvedOptions().timeZone) + throw new Error('Schedule could not be saved by this server'); + const config = { ...submitted.config }; + delete config.timezone; + const schedule = parseCron( + submitted.custom_cron_expression, + new Date(), + config.date, + zone + ); + if ( + !schedule || + (schedule.frequency === 'once' && isOneTimeTooFarAhead(schedule)) + ) + throw new Error('Schedule could not be saved by this server'); + // Throws for a monthly day a single UTC cron cannot repeat on. + const cron = scheduleToCron(schedule); + const utcDate = (localDate: Date) => { + const date = new Date(localDate); + const { dayOffset } = localTimeToUTC(schedule.hour, schedule.minute, date); + date.setDate(date.getDate() + dayOffset); + return format(date, 'yyyy-MM-dd'); + }; + if (schedule.frequency === 'once') config.date = utcDate(schedule.date); + if (config.expirationDate) + config.expirationDate = utcDate( + new Date(`${config.expirationDate.slice(0, 10)}T00:00:00`) + ); + return { custom_cron_expression: cron, config }; +} diff --git a/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx b/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx index 558e8a2453..76e6c76357 100644 --- a/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx +++ b/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx @@ -27,13 +27,17 @@ import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + supportsTimezone: false, + timezoneConfig: { schema_: { properties: { timezone: {} } } }, proxyCreateTrigger: vi.fn(), + proxyDeleteTrigger: vi.fn(), proxyUpdateTrigger: vi.fn(), toastError: vi.fn(), })); vi.mock('@/service/triggerApi', () => ({ proxyCreateTrigger: mocks.proxyCreateTrigger, + proxyDeleteTrigger: mocks.proxyDeleteTrigger, proxyUpdateTrigger: mocks.proxyUpdateTrigger, })); @@ -41,7 +45,9 @@ vi.mock('@/hooks/queries/useTriggerQueries', () => ({ useTriggerCacheInvalidation: () => ({ invalidateUserTriggerCount: vi.fn(), }), - useTriggerConfigQuery: () => ({ data: undefined }), + useTriggerConfigQuery: () => ({ + data: mocks.supportsTimezone ? mocks.timezoneConfig : undefined, + }), })); vi.mock('@/hooks/useChatStoreAdapter', () => ({ @@ -282,3 +288,71 @@ describe('TriggerDialog opened from a Session', () => { ); }); }); + +it('keeps an existing UTC schedule without a zone when the server supports local schedules', async () => { + mocks.supportsTimezone = true; + mocks.proxyUpdateTrigger.mockImplementation(async (id, data) => ({ + id, + ...data, + })); + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole('button', { name: 'Update' })); + await vi.waitFor(() => expect(mocks.proxyUpdateTrigger).toHaveBeenCalled()); + const submitted = mocks.proxyUpdateTrigger.mock.calls.at(-1)![1]; + expect(submitted.config?.timezone).toBeUndefined(); + expect(submitted.custom_cron_expression).toBe('0 9 * * *'); + mocks.supportsTimezone = false; +}); + +it('removes a new Automation whose zone the server dropped when the UTC rewrite fails', async () => { + vi.clearAllMocks(); + mocks.supportsTimezone = true; + mocks.proxyCreateTrigger.mockImplementation(async (data) => ({ + id: 3, + ...data, + config: {}, + })); + mocks.proxyUpdateTrigger.mockRejectedValue(new Error('offline')); + mocks.proxyDeleteTrigger.mockResolvedValue(undefined); + const user = userEvent.setup(); + render( + + ); + await user.type( + screen.getByPlaceholderText('Enter automation name'), + 'Nightly' + ); + await user.type( + screen.getByPlaceholderText('What should this automation do?'), + 'Say hello' + ); + await user.click(screen.getByRole('button', { name: 'Create' })); + await vi.waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith('Failed to create automation') + ); + expect(mocks.proxyCreateTrigger.mock.calls[0][0].config?.timezone).toBe( + Intl.DateTimeFormat().resolvedOptions().timeZone + ); + expect(mocks.proxyUpdateTrigger).toHaveBeenCalledTimes(1); + expect(mocks.proxyDeleteTrigger).toHaveBeenCalledWith(3); + mocks.supportsTimezone = false; + mocks.proxyUpdateTrigger.mockReset(); +}); diff --git a/test/unit/components/Trigger/automationSchedule.test.ts b/test/unit/components/Trigger/automationSchedule.test.ts index 5d4f8399ae..331f9a24b9 100644 --- a/test/unit/components/Trigger/automationSchedule.test.ts +++ b/test/unit/components/Trigger/automationSchedule.test.ts @@ -472,3 +472,41 @@ describe('one-time dates a year or more ahead', () => { ); }); }); + +describe('schedules with an explicit timezone', () => { + it.each([ + ['Europe/London', '2026-03-28T14:00:00Z', '2026-03-29T12:20:00.000Z'], + ['Europe/London', '2026-10-24T14:00:00Z', '2026-10-25T13:20:00.000Z'], + ['America/New_York', '2026-03-07T20:00:00Z', '2026-03-08T17:20:00.000Z'], + ['America/New_York', '2026-10-31T20:00:00Z', '2026-11-01T18:20:00.000Z'], + ])('keeps 13:20 in %s across clock changes', (timezone, from, expected) => { + expect( + getNextRun( + { custom_cron_expression: '20 13 * * *', config: { timezone } }, + new Date(from) + )?.toISOString() + ).toBe(expected); + }); + it('stores wall time without applying the current UTC offset', () => { + expect( + scheduleToCron( + { frequency: 'daily', hour: 13, minute: 20 }, + new Date('2026-07-01'), + 'Europe/London' + ) + ).toBe('20 13 * * *'); + }); +}); + +it('matches the server policy for nonexistent and repeated local hours', () => { + const trigger = { + custom_cron_expression: '30 1 * * *', + config: { timezone: 'Europe/London' }, + }; + expect( + getNextRun(trigger, new Date('2026-03-28T02:00:00Z'))?.toISOString() + ).toBe('2026-03-30T00:30:00.000Z'); + expect( + getNextRun(trigger, new Date('2026-10-25T00:30:00Z'))?.toISOString() + ).toBe('2026-10-26T01:30:00.000Z'); +}); diff --git a/test/unit/lib/triggerScheduleCompatibility.test.ts b/test/unit/lib/triggerScheduleCompatibility.test.ts new file mode 100644 index 0000000000..a94a43c2d2 --- /dev/null +++ b/test/unit/lib/triggerScheduleCompatibility.test.ts @@ -0,0 +1,150 @@ +// ========= 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. ========= + +import { confirmScheduleTimezone } from '@/lib/triggerScheduleCompatibility'; +import type { Trigger } from '@/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const originalTimezone = process.env.TZ; + +function inZone(timezone: string, now: string) { + process.env.TZ = timezone; + vi.useFakeTimers(); + vi.setSystemTime(new Date(now)); +} + +afterEach(() => { + vi.useRealTimers(); + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; +}); + +function dropZone( + cron: string, + timezone: string, + config: Record = {} +) { + const update = vi.fn().mockImplementation(async (id, data) => ({ + id, + ...data, + })); + const undo = vi.fn().mockResolvedValue(undefined); + const result = confirmScheduleTimezone( + { id: 1, config: {} } as Trigger, + { custom_cron_expression: cron, config: { ...config, timezone } }, + update, + undo + ); + return { result, update, undo }; +} + +describe('a server that drops the schedule zone', () => { + it.each([['2026-07-01T00:00:00Z'], ['2026-10-20T00:00:00Z']])( + 'rewrites daily 13:20 London to the UTC cron on %s', + async (now) => { + inZone('Europe/London', now); + const { result, update, undo } = dropZone('20 13 * * *', 'Europe/London'); + await result; + expect(update).toHaveBeenCalledWith(1, { + custom_cron_expression: '20 12 * * *', + config: {}, + }); + expect(undo).not.toHaveBeenCalled(); + } + ); + + it('refuses monthly day 31 at 20:00 in Los Angeles instead of saving day 32', async () => { + inZone('America/Los_Angeles', '2026-10-20T12:00:00Z'); + const { result, update, undo } = dropZone( + '0 20 31 * *', + 'America/Los_Angeles' + ); + await expect(result).rejects.toThrow(RangeError); + expect(update).not.toHaveBeenCalled(); + expect(undo).toHaveBeenCalledTimes(1); + }); + + it('builds monthly day 1 at 01:00 in Tokyo with the legacy converter', async () => { + inZone('Asia/Tokyo', '2026-10-20T00:00:00Z'); + const { result, update } = dropZone('0 1 1 * *', 'Asia/Tokyo'); + await result; + expect(update).toHaveBeenCalledWith(1, { + custom_cron_expression: '0 16 L * *', + config: {}, + }); + }); + + it('moves the one-time date and expiration date to their UTC days', async () => { + inZone('America/Los_Angeles', '2026-10-20T12:00:00Z'); + const once = dropZone('0 20 5 11 *', 'America/Los_Angeles', { + date: '2026-11-05', + }); + await once.result; + expect(once.update).toHaveBeenCalledWith(1, { + custom_cron_expression: '0 4 6 11 *', + config: { date: '2026-11-06' }, + }); + + const daily = dropZone('0 20 * * *', 'America/Los_Angeles', { + expirationDate: '2026-12-01', + }); + await daily.result; + expect(daily.update).toHaveBeenCalledWith(1, { + custom_cron_expression: '0 3 * * *', + config: { expirationDate: '2026-12-02' }, + }); + }); + + it('undoes the first save when the UTC rewrite is rejected', async () => { + inZone('Europe/London', '2026-10-20T00:00:00Z'); + const update = vi.fn().mockRejectedValue(new Error('offline')); + const undo = vi.fn().mockResolvedValue(undefined); + await expect( + confirmScheduleTimezone( + { id: 1, config: {} } as Trigger, + { + custom_cron_expression: '20 13 * * *', + config: { timezone: 'Europe/London' }, + }, + update, + undo + ) + ).rejects.toThrow('offline'); + expect(undo).toHaveBeenCalledTimes(1); + }); + + it('undoes the first save when the zone is not this machine zone', async () => { + inZone('Europe/London', '2026-10-20T00:00:00Z'); + const { result, update, undo } = dropZone('0 9 * * *', 'Asia/Tokyo'); + await expect(result).rejects.toThrow(); + expect(update).not.toHaveBeenCalled(); + expect(undo).toHaveBeenCalledTimes(1); + }); +}); + +it('keeps the wall-time schedule after the server confirms its zone', async () => { + const saved = { id: 1, config: { timezone: 'Europe/London' } } as Trigger; + const update = vi.fn(); + const undo = vi.fn(); + expect( + await confirmScheduleTimezone( + saved, + { custom_cron_expression: '20 13 * * *', config: saved.config }, + update, + undo + ) + ).toBe(saved); + expect(update).not.toHaveBeenCalled(); + expect(undo).not.toHaveBeenCalled(); +});