From 58cb53d708b1a2fbbca348be9686c6c4c4f2066e Mon Sep 17 00:00:00 2001 From: Douglasymlai Date: Thu, 22 Oct 2026 19:41:48 +0100 Subject: [PATCH 1/6] fix: keep Automation schedules and run history in local time From 3c163db24ac66a7058081ad5494851116187c198 Mon Sep 17 00:00:00 2001 From: Douglasymlai Date: Thu, 22 Oct 2026 22:45:38 +0100 Subject: [PATCH 2/6] fix: preserve automation wall times across clock changes --- server/app/core/schedule_time.py | 31 ++++ .../trigger/service/trigger_crud_service.py | 2 +- .../service/trigger_schedule_service.py | 6 +- .../trigger/app_configs/schedule_config.py | 21 ++- server/tests/test_schedule_timezone.py | 115 +++++++++++++++ src/components/Trigger/SchedulePicker.tsx | 49 ++++-- src/components/Trigger/TriggerDialog.tsx | 17 +++ src/components/Trigger/automationSchedule.ts | 139 +++++++++++++++++- src/lib/triggerScheduleCompatibility.ts | 64 ++++++++ .../Trigger/automationSchedule.test.ts | 38 +++++ .../lib/triggerScheduleCompatibility.test.ts | 33 +++++ 11 files changed, 491 insertions(+), 24 deletions(-) create mode 100644 server/app/core/schedule_time.py create mode 100644 server/tests/test_schedule_timezone.py create mode 100644 src/lib/triggerScheduleCompatibility.ts create mode 100644 test/unit/lib/triggerScheduleCompatibility.test.ts diff --git a/server/app/core/schedule_time.py b/server/app/core/schedule_time.py new file mode 100644 index 0000000000..6623333a81 --- /dev/null +++ b/server/app/core/schedule_time.py @@ -0,0 +1,31 @@ +"""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 d2c9c9ba86..8e48c22951 100644 --- a/src/components/Trigger/SchedulePicker.tsx +++ b/src/components/Trigger/SchedulePicker.tsx @@ -37,6 +37,7 @@ import { getNextRun, isOneTimeTooFarAhead, nextOccurrence, + nextOccurrenceInZone, parseCron, parseScheduleNumber, scheduleToCron, @@ -51,12 +52,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; @@ -107,12 +110,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( @@ -131,7 +138,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. @@ -149,7 +156,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 * * *')) { @@ -209,7 +221,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. */ } @@ -230,7 +242,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. @@ -241,7 +253,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'); @@ -251,7 +265,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'); @@ -263,9 +279,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); @@ -275,8 +300,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; @@ -284,7 +310,7 @@ export const SchedulePicker: React.FC = ({ cursor = next; } return times; - }, [selectedSchedule, cron, oneTimeTooFarAhead]); + }, [selectedSchedule, cron, oneTimeTooFarAhead, timezone]); const firstRun = unsupportedMonthlyTime ? null @@ -326,6 +352,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 049c82c814..f2bc5fe9ba 100644 --- a/src/components/Trigger/TriggerDialog.tsx +++ b/src/components/Trigger/TriggerDialog.tsx @@ -47,6 +47,7 @@ import { useTriggerConfigQuery, } from '@/hooks/queries/useTriggerQueries'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { confirmScheduleTimezone } from '@/lib/triggerScheduleCompatibility'; import { proxyCreateTrigger, proxyUpdateTrigger } from '@/service/triggerApi'; import { ActivityType, useActivityLogStore } from '@/store/activityLogStore'; import { useSpaceStore } from '@/store/spaceStore'; @@ -358,6 +359,11 @@ export const TriggerDialog: React.FC = ({ } response = await proxyUpdateTrigger(selectedTrigger.id, updateData); + response = await confirmScheduleTimezone( + response, + updateData, + proxyUpdateTrigger + ); // Update trigger in store updateTrigger(selectedTrigger.id, response); @@ -408,6 +414,11 @@ export const TriggerDialog: React.FC = ({ } response = await proxyCreateTrigger(createData); + response = await confirmScheduleTimezone( + response, + createData, + proxyUpdateTrigger + ); // Add trigger to store addTrigger(response); @@ -546,6 +557,12 @@ 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); @@ -257,7 +304,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 ); } @@ -330,12 +378,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. @@ -344,7 +469,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; @@ -385,7 +510,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 date.toLocaleString(locale, { weekday: 'short', @@ -399,7 +525,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..e75c6cee11 --- /dev/null +++ b/src/lib/triggerScheduleCompatibility.ts @@ -0,0 +1,64 @@ +import { + nextOccurrenceInZone, + parseCron, +} from '@/components/Trigger/automationSchedule'; +import type { Trigger, TriggerInput, TriggerUpdate } from '@/types'; + +/** A server must echo the zone; otherwise restore the legacy UTC contract. */ +export async function confirmScheduleTimezone( + saved: Trigger, + submitted: Pick, + update: (id: number, payload: TriggerUpdate) => Promise +): Promise { + const zone = submitted.config?.timezone; + if (!zone || saved.config?.timezone === zone) return saved; + const config = { ...submitted.config }; + delete config.timezone; + const now = new Date(); + const schedule = parseCron( + submitted.custom_cron_expression, + now, + config.date, + zone + ); + const next = schedule && nextOccurrenceInZone(schedule, zone, now); + if (!schedule || !next) + throw new Error('Schedule could not be saved by this server'); + const localDate = new Intl.DateTimeFormat('en-CA', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(next); + const local = new Date(`${localDate}T00:00:00Z`); + const utcDay = Date.UTC( + next.getUTCFullYear(), + next.getUTCMonth(), + next.getUTCDate() + ); + const shift = Math.round((utcDay - local.getTime()) / 86400000); + let day = '*', + month = '*', + weekdays = '*'; + if (schedule.frequency === 'once') { + day = String(next.getUTCDate()); + month = String(next.getUTCMonth() + 1); + config.date = next.toISOString().slice(0, 10); + } else if (schedule.frequency === 'weekly') { + weekdays = (schedule.weekdays ?? [1]) + .map((value) => (value + shift + 7) % 7) + .join(','); + } else if (schedule.frequency === 'monthly') { + const shifted = (schedule.dayOfMonth ?? 1) + shift; + day = shifted === 0 ? 'L' : String(shifted); + } + if (config.expirationDate && shift) { + const expiration = new Date(`${config.expirationDate}T00:00:00Z`); + expiration.setUTCDate(expiration.getUTCDate() + shift); + config.expirationDate = expiration.toISOString().slice(0, 10); + } + return update(saved.id, { + custom_cron_expression: `${next.getUTCMinutes()} ${next.getUTCHours()} ${day} ${month} ${weekdays}`, + config, + }); +} 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..020083e8fb --- /dev/null +++ b/test/unit/lib/triggerScheduleCompatibility.test.ts @@ -0,0 +1,33 @@ +import { confirmScheduleTimezone } from '@/lib/triggerScheduleCompatibility'; +import type { Trigger } from '@/types'; +import { afterEach, expect, it, vi } from 'vitest'; +afterEach(() => vi.useRealTimers()); +it('restores UTC when a server fails to persist the timezone', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-01T00:00:00Z')); + const update = vi.fn().mockResolvedValue({ id: 1 }); + await confirmScheduleTimezone( + { id: 1, config: {} } as Trigger, + { + custom_cron_expression: '20 13 * * *', + config: { timezone: 'Europe/London' }, + }, + update + ); + expect(update).toHaveBeenCalledWith(1, { + custom_cron_expression: '20 12 * * *', + config: {}, + }); +}); +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(); + expect( + await confirmScheduleTimezone( + saved, + { custom_cron_expression: '20 13 * * *', config: saved.config }, + update + ) + ).toBe(saved); + expect(update).not.toHaveBeenCalled(); +}); From b3bb9e391d1a865dcf966284be977eb5ae30b0ef Mon Sep 17 00:00:00 2001 From: Douglas Date: Sun, 25 Oct 2026 17:53:23 +0000 Subject: [PATCH 3/6] test: preserve legacy UTC schedules with timezone capable servers --- .../TriggerDialog.scheduleValidation.test.tsx | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx b/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx index d6a1e096d6..f4a380087e 100644 --- a/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx +++ b/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx @@ -28,6 +28,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ supportsTimezone: false, + timezoneConfig: { schema_: { properties: { timezone: {} } } }, proxyCreateTrigger: vi.fn(), proxyUpdateTrigger: vi.fn(), toastError: vi.fn(), @@ -42,7 +43,9 @@ vi.mock('@/hooks/queries/useTriggerQueries', () => ({ useTriggerCacheInvalidation: () => ({ invalidateUserTriggerCount: vi.fn(), }), - useTriggerConfigQuery: () => ({ data: mocks.supportsTimezone ? { schema_: { properties: { timezone: {} } } } : undefined }), + useTriggerConfigQuery: () => ({ + data: mocks.supportsTimezone ? mocks.timezoneConfig : undefined, + }), })); vi.mock('@/hooks/useChatStoreAdapter', () => ({ @@ -284,15 +287,32 @@ 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 })); + mocks.proxyUpdateTrigger.mockImplementation(async (id, data) => ({ + id, + ...data, + })); const user = userEvent.setup(); - render(); - await user.click(screen.getByRole('button', { name: 'Save' })); + 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(); From e6f7b3a0c1786f42def7c2944546e1fc05c46242 Mon Sep 17 00:00:00 2001 From: Douglasymlai Date: Sun, 25 Oct 2026 18:24:30 +0000 Subject: [PATCH 4/6] chore: add license headers to schedule time zone files --- server/app/core/schedule_time.py | 14 ++++++++++++++ src/lib/triggerScheduleCompatibility.ts | 14 ++++++++++++++ test/unit/lib/triggerScheduleCompatibility.test.ts | 14 ++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/server/app/core/schedule_time.py b/server/app/core/schedule_time.py index 6623333a81..58e1c98859 100644 --- a/server/app/core/schedule_time.py +++ b/server/app/core/schedule_time.py @@ -1,3 +1,17 @@ +# ========= 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 diff --git a/src/lib/triggerScheduleCompatibility.ts b/src/lib/triggerScheduleCompatibility.ts index e75c6cee11..e0492ed250 100644 --- a/src/lib/triggerScheduleCompatibility.ts +++ b/src/lib/triggerScheduleCompatibility.ts @@ -1,3 +1,17 @@ +// ========= 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 { nextOccurrenceInZone, parseCron, diff --git a/test/unit/lib/triggerScheduleCompatibility.test.ts b/test/unit/lib/triggerScheduleCompatibility.test.ts index 020083e8fb..38943d2cd7 100644 --- a/test/unit/lib/triggerScheduleCompatibility.test.ts +++ b/test/unit/lib/triggerScheduleCompatibility.test.ts @@ -1,3 +1,17 @@ +// ========= 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, expect, it, vi } from 'vitest'; From f4e8742b1f350109edfc643c98423e9cecf8d9e6 Mon Sep 17 00:00:00 2001 From: Douglasymlai Date: Wed, 28 Oct 2026 00:41:11 +0100 Subject: [PATCH 5/6] fix: undo a schedule the server saved without its time zone when the UTC rewrite fails --- src/components/Trigger/TriggerDialog.tsx | 21 ++- src/lib/triggerScheduleCompatibility.ts | 99 +++++++------ .../TriggerDialog.scheduleValidation.test.tsx | 37 +++++ .../lib/triggerScheduleCompatibility.test.ts | 133 ++++++++++++++++-- 4 files changed, 226 insertions(+), 64 deletions(-) diff --git a/src/components/Trigger/TriggerDialog.tsx b/src/components/Trigger/TriggerDialog.tsx index b45fd5498f..6e7d33901b 100644 --- a/src/components/Trigger/TriggerDialog.tsx +++ b/src/components/Trigger/TriggerDialog.tsx @@ -51,7 +51,11 @@ import { import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import type { AutomationDraftV1 } from '@/lib/automationDraft'; import { confirmScheduleTimezone } from '@/lib/triggerScheduleCompatibility'; -import { proxyCreateTrigger, proxyUpdateTrigger } from '@/service/triggerApi'; +import { + proxyCreateTrigger, + proxyDeleteTrigger, + proxyUpdateTrigger, +} from '@/service/triggerApi'; import { ActivityType, useActivityLogStore } from '@/store/activityLogStore'; import { useSpaceStore } from '@/store/spaceStore'; import { useTriggerStore } from '@/store/triggerStore'; @@ -396,10 +400,17 @@ export const TriggerDialog: React.FC = ({ } response = await proxyUpdateTrigger(selectedTrigger.id, updateData); + const previous = selectedTrigger; response = await confirmScheduleTimezone( response, updateData, - proxyUpdateTrigger + 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 @@ -453,10 +464,12 @@ export const TriggerDialog: React.FC = ({ } response = await proxyCreateTrigger(createData); + const created = response; response = await confirmScheduleTimezone( - response, + created, createData, - proxyUpdateTrigger + proxyUpdateTrigger, + () => proxyDeleteTrigger(created.id) ); // Add trigger to store diff --git a/src/lib/triggerScheduleCompatibility.ts b/src/lib/triggerScheduleCompatibility.ts index e0492ed250..9fc02cb9bd 100644 --- a/src/lib/triggerScheduleCompatibility.ts +++ b/src/lib/triggerScheduleCompatibility.ts @@ -13,66 +13,75 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { - nextOccurrenceInZone, + isOneTimeTooFarAhead, parseCron, + scheduleToCron, } from '@/components/Trigger/automationSchedule'; +import { localTimeToUTC } from '@/lib/utils'; import type { Trigger, TriggerInput, TriggerUpdate } from '@/types'; +import { format } from 'date-fns'; -/** A server must echo the zone; otherwise restore the legacy UTC contract. */ +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: Pick, - update: (id: number, payload: TriggerUpdate) => Promise + 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 now = new Date(); const schedule = parseCron( submitted.custom_cron_expression, - now, + new Date(), config.date, zone ); - const next = schedule && nextOccurrenceInZone(schedule, zone, now); - if (!schedule || !next) + if ( + !schedule || + (schedule.frequency === 'once' && isOneTimeTooFarAhead(schedule)) + ) throw new Error('Schedule could not be saved by this server'); - const localDate = new Intl.DateTimeFormat('en-CA', { - timeZone: zone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(next); - const local = new Date(`${localDate}T00:00:00Z`); - const utcDay = Date.UTC( - next.getUTCFullYear(), - next.getUTCMonth(), - next.getUTCDate() - ); - const shift = Math.round((utcDay - local.getTime()) / 86400000); - let day = '*', - month = '*', - weekdays = '*'; - if (schedule.frequency === 'once') { - day = String(next.getUTCDate()); - month = String(next.getUTCMonth() + 1); - config.date = next.toISOString().slice(0, 10); - } else if (schedule.frequency === 'weekly') { - weekdays = (schedule.weekdays ?? [1]) - .map((value) => (value + shift + 7) % 7) - .join(','); - } else if (schedule.frequency === 'monthly') { - const shifted = (schedule.dayOfMonth ?? 1) + shift; - day = shifted === 0 ? 'L' : String(shifted); - } - if (config.expirationDate && shift) { - const expiration = new Date(`${config.expirationDate}T00:00:00Z`); - expiration.setUTCDate(expiration.getUTCDate() + shift); - config.expirationDate = expiration.toISOString().slice(0, 10); - } - return update(saved.id, { - custom_cron_expression: `${next.getUTCMinutes()} ${next.getUTCHours()} ${day} ${month} ${weekdays}`, - config, - }); + // 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 f4a380087e..76e6c76357 100644 --- a/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx +++ b/test/unit/components/Trigger/TriggerDialog.scheduleValidation.test.tsx @@ -30,12 +30,14 @@ 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, })); @@ -319,3 +321,38 @@ it('keeps an existing UTC schedule without a zone when the server supports local 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/lib/triggerScheduleCompatibility.test.ts b/test/unit/lib/triggerScheduleCompatibility.test.ts index 38943d2cd7..a94a43c2d2 100644 --- a/test/unit/lib/triggerScheduleCompatibility.test.ts +++ b/test/unit/lib/triggerScheduleCompatibility.test.ts @@ -14,34 +14,137 @@ import { confirmScheduleTimezone } from '@/lib/triggerScheduleCompatibility'; import type { Trigger } from '@/types'; -import { afterEach, expect, it, vi } from 'vitest'; -afterEach(() => vi.useRealTimers()); -it('restores UTC when a server fails to persist the timezone', async () => { +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('2026-07-01T00:00:00Z')); - const update = vi.fn().mockResolvedValue({ id: 1 }); - await confirmScheduleTimezone( + 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: '20 13 * * *', - config: { timezone: 'Europe/London' }, - }, - update + { custom_cron_expression: cron, config: { ...config, timezone } }, + update, + undo ); - expect(update).toHaveBeenCalledWith(1, { - custom_cron_expression: '20 12 * * *', - config: {}, + 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 + update, + undo ) ).toBe(saved); expect(update).not.toHaveBeenCalled(); + expect(undo).not.toHaveBeenCalled(); }); From 96d114c2766cb4d93636898c1e28d30913a3d38e Mon Sep 17 00:00:00 2001 From: Douglasymlai Date: Wed, 28 Oct 2026 00:52:56 +0100 Subject: [PATCH 6/6] chore: keep the task duration file as it is on main --- src/lib/taskDuration.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/lib/taskDuration.ts b/src/lib/taskDuration.ts index 4880b7a8ce..0f846cf175 100644 --- a/src/lib/taskDuration.ts +++ b/src/lib/taskDuration.ts @@ -1,17 +1,5 @@ // ========= 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. ========= -// Licensed under the Apache License, Version 2.0 (the "License"); import { AgentStep } from '@/types/constants';