Backend plumbing for upcoming weekly + monthly Auto-Sync schedules.
PR 1 of 4 in the schedule-types feature — see
``memory/project_auto_sync_schedule_types.md`` for the full plan.
Net behaviour change in this PR: zero. The automation engine still
computes next_run via its existing inline ``_calc_delay_seconds`` /
``_next_weekly_occurrence`` helpers; this module is unused until PR 2
wires the engine through. Lands separately so the foundation can sit
on dev for a beat before the engine change.
``core/automation/schedule.py:next_run_at(trigger_type, trigger_config,
now_utc, default_tz)``:
- Pure function. ``now_utc`` injected (tests freeze time without
monkeypatching ``datetime.now``); ``default_tz`` injected (so daily /
weekly / monthly schedules compute against the USER's timezone, not
the server's — the same class of bug that produced the May 2026
"Auto-Sync next in 8h" timezone fix).
- Returns aware-UTC ``datetime`` ready to serialise to the DB
``next_run`` column, or ``None`` for unrecognised / event-based
triggers (callers should not write a next_run for those).
- Naive ``now_utc`` inputs are assumed UTC for defensive symmetry
with the engine's DB-string parser convention.
Trigger types covered:
- ``schedule``: ``{interval: N, unit: 'minutes'|'hours'|'days'|'weeks'}``
— matches engine's existing ``_calc_delay_seconds``. Unknown unit
defaults to hours; zero/negative interval clamps to 1 (preserves
the engine's guard against scheduling for the past); non-numeric
interval falls back to 1.
- ``daily_time``: ``{time: 'HH:MM', tz: '<IANA>'}`` — DST-aware via
``zoneinfo``; ``tz`` falls back to ``default_tz``; unknown IANA
string falls back to UTC; garbage ``time`` falls back to 00:00.
- ``weekly_time``: ``{time, days: ['mon',...], tz}`` — empty / all-
invalid ``days`` list means "every day" (matches engine fallback);
abbreviations case-insensitive; 8-day scan finds the next match.
- ``monthly_time``: ``{time, day_of_month: 1-31, tz}`` — NEW shape.
Day clamped to [1, 31]. Months too short for the target day clamp
to the LAST valid day rather than skipping a month (standard cron
convention; running a day early in February is less surprising
than missing the whole month). 12-iteration loop cap so a
pathological config can't infinite-loop.
Tests (36 cases, all passing):
- Interval: every unit, unknown-unit fallback, zero/negative/garbage
interval clamp, tz field ignored on interval (wall-clock-independent).
- Daily: today-at-future-time runs today, today-at-past-time rolls to
tomorrow, exact-match rolls to tomorrow (no schedule-now-then-schedule-
again-immediately), user-tz vs server-tz, default_tz fallback,
garbage time / unknown tz defensive returns.
- Weekly: same-day-still-future qualifies, same-day-past rolls to next
allowed day, wraps across week boundary, empty days = every day,
garbage abbreviations dropped, case-insensitive, tz across day
boundary (LA Wednesday evening is Thursday UTC).
- Monthly: target day this month, rolls to next month when passed,
Feb 31 → Feb 28 / Feb 29 leap year, day_of_month above 31 / below
1 clamp, Dec → Jan year roll, user-tz pre-midnight edge case.
- Result-shape contract: every returned datetime is aware UTC at
offset zero (engine relies on this when serialising to the
``next_run`` string column).
Added ``tzdata==2026.2`` to requirements.txt. Windows ``zoneinfo`` and
minimal Docker base images ship without the system tz database;
without ``tzdata`` ``ZoneInfo('America/Los_Angeles')`` raises
``ZoneInfoNotFoundError`` and the helper silently falls back to UTC.
No WHATS_NEW entry — no user-visible behaviour change in this PR.
PR 2 (engine wire-through) will land the user-facing changelog entry
when ``monthly_time`` becomes a real schedulable trigger.
303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""Pure functions for computing the next-run datetime of a scheduled
|
|
automation trigger.
|
|
|
|
The Auto-Sync schedule board currently exposes interval-based scheduling
|
|
(``every N hours``) backed by ``trigger_type='schedule'``. The
|
|
automation engine ALSO supports ``daily_time`` and ``weekly_time``
|
|
triggers via separate ``_setup_*_trigger`` methods inline on the engine
|
|
class. None of that logic is currently testable in isolation — the
|
|
engine's ``_finish_run`` reaches for ``datetime.now()``, threads it
|
|
through ``_next_weekly_occurrence``, and writes the result to the DB,
|
|
all on the same call.
|
|
|
|
This module lifts the "given a trigger config, what's the next run?"
|
|
question out of the engine into a pure function:
|
|
|
|
next_run_at(trigger_type, trigger_config, now_utc, default_tz)
|
|
-> Optional[datetime]
|
|
|
|
That means:
|
|
- ``now_utc`` is INJECTED, not pulled from the system clock. Tests
|
|
freeze time without monkeypatching ``datetime.now``.
|
|
- ``default_tz`` is INJECTED. Daily / weekly / monthly schedules are
|
|
inherently in the USER'S timezone (cron "every Monday at 9am" is
|
|
not UTC), and the historic engine implicitly used the server's
|
|
local tz via naive ``datetime.now()``. That broke for users on a
|
|
different tz than their server. The pure function takes the tz
|
|
explicitly so the caller controls it.
|
|
- Returns an aware UTC ``datetime`` ready to serialise to the DB's
|
|
``next_run`` string column, or ``None`` for unrecognised /
|
|
event-based triggers (engine should not store a next_run for those).
|
|
|
|
PR 1 of the schedule-types feature ships ONLY this module + tests.
|
|
The engine continues to compute next_run via its existing inline
|
|
helpers; PR 2 collapses those into a single ``next_run_at`` call.
|
|
Net behavior is identical until the engine is wired through — this
|
|
PR is pure plumbing.
|
|
|
|
Schedule types supported here:
|
|
|
|
- ``schedule`` (interval): ``{interval: N, unit: 'minutes'|'hours'|'days'}``
|
|
— adds the interval to ``now_utc``; no tz needed.
|
|
- ``daily_time``: ``{time: 'HH:MM', tz: '<IANA>'}`` — runs every day at
|
|
the given local time in the given timezone. ``tz`` falls back to
|
|
``default_tz`` when absent.
|
|
- ``weekly_time``: ``{time: 'HH:MM', days: ['mon','wed',...], tz: '<IANA>'}``
|
|
— runs on the matching weekday(s) at the given local time. Empty
|
|
``days`` list means "every day" (matches the engine's existing
|
|
fallback in ``_next_weekly_occurrence``).
|
|
- ``monthly_time``: ``{time: 'HH:MM', day_of_month: 1-31, tz: '<IANA>'}``
|
|
— runs on the given day each month. Days that don't exist in a
|
|
given month (Feb 30, Apr 31) clamp to the LAST valid day of that
|
|
month rather than skipping the run entirely; missing a whole
|
|
month silently because the schedule was over-eager is worse than
|
|
running a day early.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, Optional
|
|
|
|
try:
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
except ImportError: # pragma: no cover — zoneinfo ships with 3.9+
|
|
ZoneInfo = None
|
|
ZoneInfoNotFoundError = Exception
|
|
|
|
|
|
# Weekday abbreviation → ``datetime.weekday()`` index (Mon=0..Sun=6).
|
|
# Mirrors the engine's existing ``_next_weekly_occurrence`` mapping so
|
|
# schedules created against either implementation accept the same
|
|
# ``days`` strings.
|
|
_WEEKDAY_MAP = {
|
|
'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6,
|
|
}
|
|
|
|
_INTERVAL_MULTIPLIERS = {
|
|
'minutes': 60,
|
|
'hours': 60 * 60,
|
|
'days': 60 * 60 * 24,
|
|
'weeks': 60 * 60 * 24 * 7,
|
|
}
|
|
|
|
|
|
def next_run_at(
|
|
trigger_type: str,
|
|
trigger_config: Dict[str, Any],
|
|
now_utc: datetime,
|
|
default_tz: str = 'UTC',
|
|
) -> Optional[datetime]:
|
|
"""Compute the next-run timestamp (UTC, aware) for a scheduled
|
|
trigger. Returns ``None`` for unrecognised types or event-based
|
|
triggers — callers should not write a next_run for those.
|
|
|
|
See module docstring for supported trigger types + config shapes.
|
|
"""
|
|
if not isinstance(trigger_config, dict):
|
|
trigger_config = {}
|
|
|
|
if trigger_type == 'schedule':
|
|
return _next_interval(trigger_config, now_utc)
|
|
if trigger_type == 'daily_time':
|
|
return _next_daily(trigger_config, now_utc, default_tz)
|
|
if trigger_type == 'weekly_time':
|
|
return _next_weekly(trigger_config, now_utc, default_tz)
|
|
if trigger_type == 'monthly_time':
|
|
return _next_monthly(trigger_config, now_utc, default_tz)
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interval
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _next_interval(config: Dict[str, Any], now_utc: datetime) -> datetime:
|
|
"""``{interval: N, unit: 'hours'}`` → ``now_utc + N hours``.
|
|
|
|
Mirrors the engine's existing ``_calc_delay_seconds``. Unit defaults
|
|
to ``hours`` for backward compat with legacy DB rows that pre-date
|
|
the unit field being mandatory; interval defaults to 1 so a fully
|
|
empty config doesn't divide-by-zero or schedule for the past."""
|
|
try:
|
|
interval = max(int(config.get('interval', 1)), 1)
|
|
except (TypeError, ValueError):
|
|
interval = 1
|
|
unit = config.get('unit') or 'hours'
|
|
seconds = interval * _INTERVAL_MULTIPLIERS.get(unit, _INTERVAL_MULTIPLIERS['hours'])
|
|
return _ensure_utc(now_utc) + timedelta(seconds=seconds)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Daily
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _next_daily(
|
|
config: Dict[str, Any], now_utc: datetime, default_tz: str,
|
|
) -> datetime:
|
|
"""``{time: 'HH:MM', tz: '<IANA>'}`` → next occurrence of that
|
|
wall-clock time in the user's timezone, expressed as aware UTC.
|
|
|
|
DST-aware via ``zoneinfo``: when the local time falls during a
|
|
spring-forward gap, the ``replace`` lands on a non-existent
|
|
instant; ``zoneinfo`` resolves that to the gap's later side
|
|
(e.g. 02:30 on the DST-forward day becomes 03:30 local). Tests
|
|
pin both spring-forward and fall-back behaviour."""
|
|
tz = _resolve_tz(config.get('tz') or default_tz)
|
|
hour, minute = _parse_hhmm(config.get('time'))
|
|
now_local = _ensure_utc(now_utc).astimezone(tz)
|
|
target_local = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
if target_local <= now_local:
|
|
target_local = target_local + timedelta(days=1)
|
|
return target_local.astimezone(timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Weekly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _next_weekly(
|
|
config: Dict[str, Any], now_utc: datetime, default_tz: str,
|
|
) -> datetime:
|
|
"""``{time: 'HH:MM', days: ['mon',...], tz: '<IANA>'}`` → next
|
|
occurrence of that wall-clock time on any of the listed weekdays
|
|
in the user's timezone.
|
|
|
|
Empty ``days`` list ≡ every day, matching the engine's existing
|
|
fallback. Unrecognised day abbreviations are silently dropped
|
|
(an empty result-set then triggers the every-day fallback)."""
|
|
tz = _resolve_tz(config.get('tz') or default_tz)
|
|
hour, minute = _parse_hhmm(config.get('time'))
|
|
days = _parse_weekdays(config.get('days'))
|
|
|
|
now_local = _ensure_utc(now_utc).astimezone(tz)
|
|
# Scan today + next 7 days; the matching day with a future
|
|
# local time wins. 8-day scan is enough to handle the case where
|
|
# today already passed the time AND today is the only allowed
|
|
# weekday (next occurrence is exactly one week out).
|
|
for offset in range(8):
|
|
candidate = now_local + timedelta(days=offset)
|
|
if candidate.weekday() not in days:
|
|
continue
|
|
target = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
if target > now_local:
|
|
return target.astimezone(timezone.utc)
|
|
# Shouldn't reach: 8-day scan always finds a hit when ``days``
|
|
# is non-empty. Defensive fallback: next week, same weekday as today.
|
|
fallback = (now_local + timedelta(days=7)).replace(
|
|
hour=hour, minute=minute, second=0, microsecond=0,
|
|
)
|
|
return fallback.astimezone(timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Monthly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _next_monthly(
|
|
config: Dict[str, Any], now_utc: datetime, default_tz: str,
|
|
) -> datetime:
|
|
"""``{time: 'HH:MM', day_of_month: 1-31, tz: '<IANA>'}`` → next
|
|
occurrence in the user's timezone.
|
|
|
|
``day_of_month`` is clamped to ``[1, 31]``. When the target day
|
|
doesn't exist in a given month (Feb 30, Apr 31), the schedule
|
|
falls back to the LAST valid day of that month — running a day
|
|
or two early in short months is less surprising than skipping
|
|
a month entirely. This matches the convention every cron
|
|
implementation in the wild settled on."""
|
|
tz = _resolve_tz(config.get('tz') or default_tz)
|
|
hour, minute = _parse_hhmm(config.get('time'))
|
|
raw_day = config.get('day_of_month', 1)
|
|
try:
|
|
target_day = max(1, min(31, int(raw_day)))
|
|
except (TypeError, ValueError):
|
|
target_day = 1
|
|
|
|
now_local = _ensure_utc(now_utc).astimezone(tz)
|
|
# Try this month first; if the target day has already passed
|
|
# (or doesn't exist this month and the clamped day is in the
|
|
# past), advance to next month. Loop bounded to 12 iterations
|
|
# so a pathologically broken config can't infinite-loop us.
|
|
year, month = now_local.year, now_local.month
|
|
for _ in range(12):
|
|
day = min(target_day, _days_in_month(year, month))
|
|
target = now_local.replace(
|
|
year=year, month=month, day=day,
|
|
hour=hour, minute=minute, second=0, microsecond=0,
|
|
)
|
|
if target > now_local:
|
|
return target.astimezone(timezone.utc)
|
|
# Roll to next month.
|
|
if month == 12:
|
|
year, month = year + 1, 1
|
|
else:
|
|
month += 1
|
|
# Defensive — should be unreachable.
|
|
return (now_local + timedelta(days=30)).astimezone(timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ensure_utc(dt: datetime) -> datetime:
|
|
"""Coerce a possibly-naive datetime to aware UTC. Naive inputs
|
|
are assumed UTC (matches the convention the engine uses when
|
|
parsing the DB ``next_run`` column)."""
|
|
if dt.tzinfo is None:
|
|
return dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
|
|
def _resolve_tz(name: Optional[str]):
|
|
"""Look up an IANA tz by name. Falls back to UTC when zoneinfo
|
|
isn't available (3.8 / minimal images) or the name is unknown."""
|
|
if not name:
|
|
return timezone.utc
|
|
if ZoneInfo is None:
|
|
return timezone.utc
|
|
try:
|
|
return ZoneInfo(name)
|
|
except ZoneInfoNotFoundError:
|
|
return timezone.utc
|
|
|
|
|
|
def _parse_hhmm(time_str: Optional[str]) -> tuple:
|
|
"""Parse ``HH:MM`` → ``(hour, minute)``. Defaults to 00:00 on
|
|
garbage input — same defensive shape as the engine's existing
|
|
daily/weekly time parsing."""
|
|
if not isinstance(time_str, str):
|
|
return 0, 0
|
|
try:
|
|
h, m = time_str.split(':', 1)
|
|
return max(0, min(23, int(h))), max(0, min(59, int(m)))
|
|
except (ValueError, AttributeError):
|
|
return 0, 0
|
|
|
|
|
|
def _parse_weekdays(days) -> set:
|
|
"""``['mon', 'wed']`` → ``{0, 2}``. Empty / missing / all-invalid
|
|
list returns ``set(range(7))`` ("every day"), matching the
|
|
engine's existing ``_next_weekly_occurrence`` fallback."""
|
|
if not isinstance(days, (list, tuple)):
|
|
return set(range(7))
|
|
parsed = {_WEEKDAY_MAP[d.lower()] for d in days
|
|
if isinstance(d, str) and d.lower() in _WEEKDAY_MAP}
|
|
return parsed or set(range(7))
|
|
|
|
|
|
def _days_in_month(year: int, month: int) -> int:
|
|
"""Last calendar day of ``year-month``. Stdlib-only — no calendar
|
|
module import needed; cycle through the 12 months."""
|
|
if month == 12:
|
|
next_first = datetime(year + 1, 1, 1)
|
|
else:
|
|
next_first = datetime(year, month + 1, 1)
|
|
last_day = next_first - timedelta(days=1)
|
|
return last_day.day
|