diff --git a/backend/alembic/versions/d0e1f2a3b4c5_remove_scheduled_task_timezone.py b/backend/alembic/versions/d0e1f2a3b4c5_remove_scheduled_task_timezone.py new file mode 100644 index 0000000000..6ef5be73df --- /dev/null +++ b/backend/alembic/versions/d0e1f2a3b4c5_remove_scheduled_task_timezone.py @@ -0,0 +1,28 @@ +"""remove scheduled task timezone + +Revision ID: d0e1f2a3b4c5 +Revises: 39287906b97a +Create Date: 2026-05-25 09:10:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "d0e1f2a3b4c5" +down_revision = "39287906b97a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("scheduled_task", "timezone") + + +def downgrade() -> None: + op.add_column( + "scheduled_task", + sa.Column("timezone", sa.String(), nullable=False, server_default="UTC"), + ) + op.alter_column("scheduled_task", "timezone", server_default=None) diff --git a/backend/onyx/db/models.py b/backend/onyx/db/models.py index 94eabda9ee..027fa5e27b 100644 --- a/backend/onyx/db/models.py +++ b/backend/onyx/db/models.py @@ -5484,8 +5484,6 @@ class ScheduledTask(Base): # Canonical 5-field cron expression. The three UI editor modes (interval, # daily/weekly, advanced) all compile to this string on save. cron_expression: Mapped[str] = mapped_column(String, nullable=False) - # IANA timezone name (e.g. "America/Los_Angeles"). ZoneInfo handles DST. - timezone: Mapped[str] = mapped_column(String, nullable=False) # UI hint for which editor mode produced this schedule; the cron string # is the source of truth for execution. editor_mode: Mapped[str] = mapped_column(String, nullable=False) @@ -5497,7 +5495,7 @@ class ScheduledTask(Base): server_default="ACTIVE", ) # The dispatcher's only read field. NULL when paused; recomputed on - # every fire and every schedule/timezone edit. Stored UTC. + # every fire and every schedule edit. Stored UTC. next_run_at: Mapped[datetime.datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/backend/onyx/db/scheduled_task.py b/backend/onyx/db/scheduled_task.py index b569a39e08..ac4549c4d9 100644 --- a/backend/onyx/db/scheduled_task.py +++ b/backend/onyx/db/scheduled_task.py @@ -35,7 +35,6 @@ from onyx.error_handling.error_codes import OnyxErrorCode from onyx.error_handling.exceptions import OnyxError from onyx.server.features.build.scheduled_tasks.schedule import compute_next_run_at from onyx.server.features.build.scheduled_tasks.schedule import EditorMode -from onyx.server.features.build.scheduled_tasks.schedule import validate_timezone from onyx.utils.logger import setup_logger logger = setup_logger() @@ -53,31 +52,28 @@ def create_scheduled_task( name: str, prompt: str, cron_expression: str, - timezone_name: str, editor_mode: EditorMode, status: ScheduledTaskStatus = ScheduledTaskStatus.ACTIVE, now: datetime | None = None, ) -> ScheduledTask: """Insert a new ``ScheduledTask``. - Computes the initial ``next_run_at`` from ``cron_expression`` / - ``timezone_name`` if ``status`` is ACTIVE; PAUSED tasks store NULL. + Computes the initial ``next_run_at`` from ``cron_expression`` if + ``status`` is ACTIVE; PAUSED tasks store NULL. Raises: - OnyxError(INVALID_INPUT): if the cron or timezone is invalid. + OnyxError(INVALID_INPUT): if the cron is invalid. """ - validate_timezone(timezone_name) now = now or datetime.now(tz=timezone.utc) next_run_at: datetime | None = None if status == ScheduledTaskStatus.ACTIVE: - next_run_at = compute_next_run_at(cron_expression, timezone_name, now) + next_run_at = compute_next_run_at(cron_expression, now) task = ScheduledTask( user_id=user_id, name=name, prompt=prompt, cron_expression=cron_expression, - timezone=timezone_name, editor_mode=editor_mode, status=status, next_run_at=next_run_at, @@ -137,7 +133,6 @@ def update_scheduled_task( name: str | None = None, prompt: str | None = None, cron_expression: str | None = None, - timezone_name: str | None = None, editor_mode: EditorMode | None = None, status: ScheduledTaskStatus | None = None, now: datetime | None = None, @@ -145,15 +140,15 @@ def update_scheduled_task( """Apply a partial update to a scheduled task. Recompute rules: - - If ``cron_expression`` or ``timezone_name`` changed and the task is - (or becomes) ACTIVE, ``next_run_at`` is recomputed from ``now``. + - If ``cron_expression`` changed and the task is (or becomes) ACTIVE, + ``next_run_at`` is recomputed from ``now``. - If ``status`` transitions to PAUSED, ``next_run_at`` is set to NULL. - If ``status`` transitions to ACTIVE, ``next_run_at`` is recomputed. Raises: OnyxError(NOT_FOUND): the task does not exist or is not owned by the caller. - OnyxError(INVALID_INPUT): the new cron/timezone is invalid. + OnyxError(INVALID_INPUT): the new cron is invalid. """ task = get_scheduled_task(db_session=db_session, task_id=task_id, user_id=user_id) now = now or datetime.now(tz=timezone.utc) @@ -168,10 +163,6 @@ def update_scheduled_task( if cron_expression is not None and cron_expression != task.cron_expression: task.cron_expression = cron_expression schedule_changed = True - if timezone_name is not None and timezone_name != task.timezone: - validate_timezone(timezone_name) - task.timezone = timezone_name - schedule_changed = True if status is not None and status != task.status: task.status = status @@ -179,11 +170,9 @@ def update_scheduled_task( task.next_run_at = None else: # Becoming ACTIVE — recompute from now regardless of schedule change. - task.next_run_at = compute_next_run_at( - task.cron_expression, task.timezone, now - ) + task.next_run_at = compute_next_run_at(task.cron_expression, now) elif schedule_changed and task.status == ScheduledTaskStatus.ACTIVE: - task.next_run_at = compute_next_run_at(task.cron_expression, task.timezone, now) + task.next_run_at = compute_next_run_at(task.cron_expression, now) db_session.flush() return task @@ -280,7 +269,7 @@ def advance_next_run_at( Returns the new ``next_run_at`` (UTC). """ - next_run_at = compute_next_run_at(task.cron_expression, task.timezone, now) + next_run_at = compute_next_run_at(task.cron_expression, now) task.next_run_at = next_run_at db_session.flush() return next_run_at diff --git a/backend/onyx/server/features/build/scheduled_tasks/api.py b/backend/onyx/server/features/build/scheduled_tasks/api.py index 28c6c65761..efe14c473d 100644 --- a/backend/onyx/server/features/build/scheduled_tasks/api.py +++ b/backend/onyx/server/features/build/scheduled_tasks/api.py @@ -56,7 +56,6 @@ from onyx.server.features.build.scheduled_tasks.schedule import EditorMode from onyx.server.features.build.scheduled_tasks.schedule import EditorPayload from onyx.server.features.build.scheduled_tasks.schedule import human_readable from onyx.server.features.build.scheduled_tasks.schedule import next_n_fires -from onyx.server.features.build.scheduled_tasks.schedule import validate_timezone from onyx.utils.logger import setup_logger from shared_configs.contextvars import get_current_tenant_id @@ -129,7 +128,6 @@ class ScheduledTaskCreate(_Forbid): prompt: str = Field(..., min_length=1) editor_mode: EditorMode editor_payload: EditorPayload - timezone: str status: ScheduledTaskStatus = ScheduledTaskStatus.ACTIVE run_immediately: bool = False @@ -147,7 +145,6 @@ class ScheduledTaskPatch(_Forbid): prompt: str | None = Field(default=None, min_length=1) editor_mode: EditorMode | None = None editor_payload: EditorPayload | None = None - timezone: str | None = None status: ScheduledTaskStatus | None = None _dispatch = model_validator(mode="before")(_dispatch_editor_payload) @@ -196,7 +193,6 @@ class ScheduledTaskListItem(BaseModel): name: str human_readable_schedule: str cron_expression: str - timezone: str editor_mode: str status: ScheduledTaskStatus next_run_at: datetime | None @@ -215,7 +211,6 @@ class ScheduledTaskDetail(BaseModel): prompt: str human_readable_schedule: str cron_expression: str - timezone: str editor_mode: str status: ScheduledTaskStatus next_run_at: datetime | None @@ -273,9 +268,8 @@ def _list_item( return ScheduledTaskListItem( id=str(task.id), name=task.name, - human_readable_schedule=human_readable(task.cron_expression, task.timezone), + human_readable_schedule=human_readable(task.cron_expression), cron_expression=task.cron_expression, - timezone=task.timezone, editor_mode=task.editor_mode, status=task.status, next_run_at=task.next_run_at, @@ -295,7 +289,6 @@ def _detail( if task.status == ScheduledTaskStatus.ACTIVE: next_runs = next_n_fires( task.cron_expression, - task.timezone, datetime.now(tz=timezone.utc), NEXT_RUNS_PREVIEW_COUNT, ) @@ -303,9 +296,8 @@ def _detail( id=str(task.id), name=task.name, prompt=task.prompt, - human_readable_schedule=human_readable(task.cron_expression, task.timezone), + human_readable_schedule=human_readable(task.cron_expression), cron_expression=task.cron_expression, - timezone=task.timezone, editor_mode=task.editor_mode, status=task.status, next_run_at=task.next_run_at, @@ -381,14 +373,12 @@ def create_task( """Create a new scheduled task. Pipeline: - 1. Validate the timezone. - 2. Compile ``editor_mode`` + ``editor_payload`` to a canonical cron. - 3. Insert via ``create_scheduled_task`` (which computes ``next_run_at`` + 1. Compile ``editor_mode`` + ``editor_payload`` to a canonical cron. + 2. Insert via ``create_scheduled_task`` (which computes ``next_run_at`` when the task is created ACTIVE). - 4. If ``run_immediately`` is set, insert a ``manual_run_now`` run row + 3. If ``run_immediately`` is set, insert a ``manual_run_now`` run row and enqueue the executor. Does NOT touch ``next_run_at``. """ - validate_timezone(request.timezone) cron_expression = compile_to_cron(request.editor_payload) task = create_scheduled_task( @@ -397,7 +387,6 @@ def create_task( name=request.name, prompt=request.prompt, cron_expression=cron_expression, - timezone_name=request.timezone, editor_mode=request.editor_mode, status=request.status, ) @@ -442,8 +431,8 @@ def patch_task( If ``editor_mode`` + ``editor_payload`` are provided, the cron is recompiled before being handed to ``update_scheduled_task``. The DB op - handles ``next_run_at`` recomputation (schedule/timezone change, - pause/resume) on its own. + handles ``next_run_at`` recomputation (schedule change, pause/resume) on + its own. """ cron_expression: str | None = None if request.editor_payload is not None: @@ -458,7 +447,6 @@ def patch_task( name=request.name, prompt=request.prompt, cron_expression=cron_expression, - timezone_name=request.timezone, editor_mode=request.editor_mode, status=request.status, ) diff --git a/backend/onyx/server/features/build/scheduled_tasks/schedule.py b/backend/onyx/server/features/build/scheduled_tasks/schedule.py index 6767f59a57..741c51c0b8 100644 --- a/backend/onyx/server/features/build/scheduled_tasks/schedule.py +++ b/backend/onyx/server/features/build/scheduled_tasks/schedule.py @@ -1,14 +1,13 @@ """Pure helpers for compiling, validating, and reasoning about schedules. -Single source of truth for the cron/timezone semantics described in +Single source of truth for the cron semantics described in ``docs/craft/features/scheduled-tasks.md``: -- The DB stores a canonical 5-field cron string + IANA timezone + - ``editor_mode`` (UI hint). All three editor modes (interval, daily/weekly, - advanced) compile to the same cron form on save. -- ``compute_next_run_at`` returns UTC datetimes. Comparison happens in UTC; - ``ZoneInfo`` handles DST so a "9 AM PT weekly" task stays 9 AM local - across PST/PDT. +- The DB stores a canonical 5-field cron string + ``editor_mode`` (UI hint). + All three editor modes (interval, daily/weekly, advanced) compile to the + same cron form on save. +- ``compute_next_run_at`` returns UTC datetimes. Comparison happens in UTC, + and cron expressions are evaluated in UTC. - Editor payloads are strictly typed via the Pydantic models below. Anything reaching ``compile_to_cron`` has already been validated by Pydantic at the HTTP boundary, so the function is a pure transformation — no @@ -24,8 +23,6 @@ import re from datetime import datetime from datetime import timezone from typing import Literal -from zoneinfo import ZoneInfo -from zoneinfo import ZoneInfoNotFoundError from cron_descriptor import ExpressionDescriptor from croniter import croniter @@ -132,17 +129,6 @@ EDITOR_PAYLOAD_MODELS: dict[EditorMode, type[_PayloadBase]] = { } -def validate_timezone(tz: str) -> None: - """Raise ``OnyxError(INVALID_INPUT)`` if ``tz`` is not a valid IANA name.""" - try: - ZoneInfo(tz) - except (ZoneInfoNotFoundError, ValueError) as e: - raise OnyxError( - OnyxErrorCode.INVALID_INPUT, - f"Unknown timezone: {tz!r}", - ) from e - - def _validate_cron(cron: str) -> None: """Raise ``OnyxError(INVALID_INPUT)`` if ``cron`` is not a valid 5-field expression. @@ -208,49 +194,43 @@ def compile_to_cron(payload: EditorPayload) -> str: return f"{minute} {hour} * * {weekday_field}" -def compute_next_run_at(cron: str, tz: str, after: datetime) -> datetime: +def compute_next_run_at(cron: str, after: datetime) -> datetime: """Return the next UTC datetime ``cron`` fires after ``after``. Args: cron: 5-field cron expression. - tz: IANA timezone name (used to anchor the cron schedule). after: Reference time. Naive datetimes are treated as UTC. Returns: Aware UTC datetime of the next fire. Raises: - OnyxError(INVALID_INPUT): if ``cron``/``tz`` are invalid, or if no - future fire exists (e.g. an impossible expression). + OnyxError(INVALID_INPUT): if ``cron`` is invalid, or if no future fire + exists (e.g. an impossible expression). """ _validate_cron(cron) - validate_timezone(tz) if after.tzinfo is None: after = after.replace(tzinfo=timezone.utc) + else: + after = after.astimezone(timezone.utc) - zone = ZoneInfo(tz) - # croniter operates in the zone supplied via the start argument. - local_after = after.astimezone(zone) try: - itr = croniter(cron, local_after) - next_local = itr.get_next(datetime) + itr = croniter(cron, after) + next_fire = itr.get_next(datetime) except (ValueError, KeyError) as e: raise OnyxError( OnyxErrorCode.INVALID_INPUT, - f"Cron expression has no future fire: {cron!r} (tz={tz})", + f"Cron expression has no future fire: {cron!r}", ) from e - # croniter returns a naive datetime in the supplied tz on some versions; - # ensure it's aware in `zone` and convert back to UTC. - if next_local.tzinfo is None: - next_local = next_local.replace(tzinfo=zone) - return next_local.astimezone(timezone.utc) + if next_fire.tzinfo is None: + return next_fire.replace(tzinfo=timezone.utc) + return next_fire.astimezone(timezone.utc) def next_n_fires( cron: str, - tz: str, after: datetime, n: int, ) -> list[datetime]: @@ -262,14 +242,13 @@ def next_n_fires( if n <= 0: raise OnyxError(OnyxErrorCode.INVALID_INPUT, "n must be positive") _validate_cron(cron) - validate_timezone(tz) if after.tzinfo is None: after = after.replace(tzinfo=timezone.utc) + else: + after = after.astimezone(timezone.utc) - zone = ZoneInfo(tz) - local_after = after.astimezone(zone) - itr = croniter(cron, local_after) + itr = croniter(cron, after) fires: list[datetime] = [] for _ in range(n): try: @@ -277,26 +256,23 @@ def next_n_fires( except (ValueError, KeyError) as e: raise OnyxError( OnyxErrorCode.INVALID_INPUT, - f"Cron expression has no future fire: {cron!r} (tz={tz})", + f"Cron expression has no future fire: {cron!r}", ) from e if nxt.tzinfo is None: - nxt = nxt.replace(tzinfo=zone) + nxt = nxt.replace(tzinfo=timezone.utc) fires.append(nxt.astimezone(timezone.utc)) return fires -def human_readable(cron: str, tz: str) -> str: - """Render a human-readable description of ``cron`` with the tz appended. +def human_readable(cron: str) -> str: + """Render a human-readable description of ``cron``. Falls back to the raw cron expression if cron-descriptor can't render it - (rare; cron-descriptor is permissive). The tz is always included so the - user sees the schedule's anchor. + (rare; cron-descriptor is permissive). """ _validate_cron(cron) - validate_timezone(tz) try: descriptor = ExpressionDescriptor(cron, use_24hour_time_format=False) - text = descriptor.get_description() + return descriptor.get_description() except Exception: - text = cron - return f"{text} ({tz})" + return cron diff --git a/backend/tests/external_dependency_unit/craft/test_scheduled_task_executor.py b/backend/tests/external_dependency_unit/craft/test_scheduled_task_executor.py index c74d335ce8..823231ef09 100644 --- a/backend/tests/external_dependency_unit/craft/test_scheduled_task_executor.py +++ b/backend/tests/external_dependency_unit/craft/test_scheduled_task_executor.py @@ -71,7 +71,6 @@ def _seed_task_and_queued_run( name="nightly-report", prompt="Summarise yesterday's events", cron_expression="0 9 * * *", - timezone="UTC", editor_mode="advanced", status=ScheduledTaskStatus.ACTIVE, next_run_at=datetime.datetime.now(datetime.timezone.utc) @@ -163,7 +162,6 @@ def test_dispatch_uses_skip_locked_to_avoid_dupes( name=f"due-{i}", prompt=f"prompt-{i}", cron_expression="* * * * *", - timezone="UTC", editor_mode="advanced", status=ScheduledTaskStatus.ACTIVE, next_run_at=now - datetime.timedelta(seconds=10), @@ -255,7 +253,6 @@ def test_cleanup_stuck_runs_marks_queued_over_threshold_failed( name="stale", prompt="...", cron_expression="0 9 * * *", - timezone="UTC", editor_mode="advanced", status=ScheduledTaskStatus.ACTIVE, next_run_at=datetime.datetime.now(datetime.timezone.utc) @@ -301,7 +298,6 @@ def test_cleanup_stuck_runs_marks_running_over_threshold_failed( name="long-running", prompt="...", cron_expression="0 9 * * *", - timezone="UTC", editor_mode="advanced", status=ScheduledTaskStatus.ACTIVE, next_run_at=datetime.datetime.now(datetime.timezone.utc) diff --git a/backend/tests/integration/tests/craft/test_scheduled_tasks_api.py b/backend/tests/integration/tests/craft/test_scheduled_tasks_api.py index 4f9900d40b..792701b667 100644 --- a/backend/tests/integration/tests/craft/test_scheduled_tasks_api.py +++ b/backend/tests/integration/tests/craft/test_scheduled_tasks_api.py @@ -49,7 +49,6 @@ def _create_task( prompt: str = "Run the daily check.", editor_mode: str = "interval", editor_payload: dict[str, Any] | None = None, - timezone: str = "UTC", status: ScheduledTaskStatus = ScheduledTaskStatus.ACTIVE, run_immediately: bool = False, ) -> httpx.Response: @@ -58,7 +57,6 @@ def _create_task( "prompt": prompt, "editor_mode": editor_mode, "editor_payload": editor_payload or {"unit": "hours", "every": 1}, - "timezone": timezone, "status": status.value, "run_immediately": run_immediately, } @@ -148,7 +146,6 @@ def test_create_task_compiles_cron(admin_user: DATestUser) -> None: admin_user, editor_mode="interval", editor_payload={"unit": "hours", "every": 6}, - timezone="UTC", ) response.raise_for_status() body = response.json() diff --git a/backend/tests/unit/onyx/server/features/build/scheduled_tasks/__init__.py b/backend/tests/unit/onyx/server/features/build/scheduled_tasks/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/backend/tests/unit/onyx/server/features/build/scheduled_tasks/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/tests/unit/onyx/server/features/build/scheduled_tasks/test_schedule.py b/backend/tests/unit/onyx/server/features/build/scheduled_tasks/test_schedule.py new file mode 100644 index 0000000000..27293c0c24 --- /dev/null +++ b/backend/tests/unit/onyx/server/features/build/scheduled_tasks/test_schedule.py @@ -0,0 +1,40 @@ +from datetime import datetime +from datetime import timezone + +from onyx.server.features.build.scheduled_tasks.schedule import compute_next_run_at +from onyx.server.features.build.scheduled_tasks.schedule import human_readable +from onyx.server.features.build.scheduled_tasks.schedule import next_n_fires + + +def test_compute_next_run_at_uses_utc_cron_fields() -> None: + after = datetime(2026, 5, 25, 8, 59, tzinfo=timezone.utc) + + assert compute_next_run_at("0 9 * * *", after) == datetime( + 2026, 5, 25, 9, 0, tzinfo=timezone.utc + ) + + +def test_compute_next_run_at_treats_naive_after_as_utc() -> None: + after = datetime(2026, 5, 25, 9, 0) + + assert compute_next_run_at("0 9 * * *", after) == datetime( + 2026, 5, 26, 9, 0, tzinfo=timezone.utc + ) + + +def test_next_n_fires_returns_aware_utc_datetimes() -> None: + after = datetime(2026, 5, 25, 9, 0, tzinfo=timezone.utc) + + fires = next_n_fires("*/15 * * * *", after, 3) + + assert fires == [ + datetime(2026, 5, 25, 9, 15, tzinfo=timezone.utc), + datetime(2026, 5, 25, 9, 30, tzinfo=timezone.utc), + datetime(2026, 5, 25, 9, 45, tzinfo=timezone.utc), + ] + + +def test_human_readable_does_not_append_timezone() -> None: + description = human_readable("0 9 * * 1") + + assert "UTC" not in description diff --git a/docs/craft/features/scheduled-tasks/overview.md b/docs/craft/features/scheduled-tasks/overview.md index 04a98f9420..a74e05dd25 100644 --- a/docs/craft/features/scheduled-tasks/overview.md +++ b/docs/craft/features/scheduled-tasks/overview.md @@ -38,9 +38,9 @@ browser open, and clicks any past run to open the completed session. The dispatcher and stuck-run sweeper are pure DB coordination work and run on the **primary** queue instead — routing them to the dedicated pool would let a saturated executor stall dispatch. -- **Schedule storage:** `(cron_expression, IANA timezone, editor_mode)`. - All three editor modes compile to cron on save. `croniter` + - `ZoneInfo(timezone)` handles DST. `editor_mode` is a UI hint only. +- **Schedule storage:** `(cron_expression, editor_mode)`. + All three editor modes compile to cron on save. Cron expressions are + evaluated in UTC. `editor_mode` is a UI hint only. `next_run_at` recomputed on every fire and every edit; pause sets it to NULL. - **Concurrency:** `SKIP_IF_RUNNING` for recurring fires (prior run still @@ -90,7 +90,7 @@ dispatch_due_scheduled_tasks run_scheduled_task(run_id) │ → insert skipped row for event in _yield_acp_events( ├─ insert queued run session, task.prompt): ├─ next_run_at = croniter _persist_acp_events([event]) - │ .next(now, tz=task.tz) if budget exceeded → failed + │ .next(now) if budget exceeded → failed └─ enqueue run_scheduled_task ───► if approval required → (run_id, expires=900, awaiting_approval queue=scheduled_tasks) mark succeeded / failed @@ -118,7 +118,7 @@ class ScheduledTask(Base): __tablename__ = "scheduled_task" id, user_id (FK user, CASCADE) name (str), prompt (text) - cron_expression (str), timezone (IANA str), editor_mode (str) + cron_expression (str), editor_mode (str) status (enum, default ACTIVE) next_run_at (DateTime tz, nullable) # dispatcher's only read field deleted (bool, default False) @@ -158,11 +158,11 @@ admin view in V1. - `GET /scheduled-tasks` — list payload (id, name, human-readable schedule, status, next_run_at, last_run summary). - `POST /scheduled-tasks` — create. Compiles editor input → cron, - validates timezone via `ZoneInfo`. Optional `run_immediately`. + optional `run_immediately`. - `GET /scheduled-tasks/{id}` — task + next 3 fire times for UI preview. - `PATCH /scheduled-tasks/{id}` — partial edit. Recomputes `next_run_at` on - schedule/timezone change. `paused` → NULL; resume → recompute. In-flight - runs untouched. + schedule change. `paused` → NULL; resume → recompute. In-flight runs + untouched. - `DELETE /scheduled-tasks/{id}` — soft-delete. - `POST /scheduled-tasks/{id}/run-now` — inserts `manual_run_now` run, enqueues executor. Works when paused. Doesn't touch `next_run_at`. @@ -176,17 +176,15 @@ No live-attach endpoint in V1. ## Schedule Semantics -5-field cron + IANA timezone, stored canonical. Editor mode is a UI hint. +5-field cron, stored canonical and evaluated in UTC. Editor mode is a UI hint. - *Interval (N min/hr/day):* `*/N * * * *` / `0 */N * * *` / `M H */N * *` (M:H is the editor's required time-of-day for day-cadence). - *Daily/weekly:* `M H * * ` (e.g. `0 9 * * 1,3,5`). - *Advanced:* user-typed, validated via `croniter`. -`compute_next_run_at` = `croniter(cron, -after).get_next(datetime, tzinfo=ZoneInfo(tz))`. Stored UTC; compared UTC -in dispatch. `ZoneInfo` handles DST — a 9 AM PT weekly fire stays 9 AM -local across PST/PDT. +`compute_next_run_at` = `croniter(cron, after).get_next(datetime)`. +Stored UTC; compared UTC in dispatch. Pause mid-fire: in-flight run completes, `next_run_at`→NULL. Resume: recompute from resume time (no backfire). Past-due tasks fire once and diff --git a/docs/craft/features/scheduled-tasks/tests.md b/docs/craft/features/scheduled-tasks/tests.md index 122191d69a..074e27b5ba 100644 --- a/docs/craft/features/scheduled-tasks/tests.md +++ b/docs/craft/features/scheduled-tasks/tests.md @@ -51,7 +51,6 @@ This is a smoke test. It does not exercise: - The approval-required path. - Sidebar filtering of scheduled-run sessions. - Per-user ownership boundaries on the HTTP API. -- DST / IANA timezone behavior. Those properties rely on review and the manual checklist below. @@ -63,8 +62,8 @@ path: - **Every-2-min task vs an Onyx-search prompt.** Walk away for 6 minutes, come back, confirm three runs with complete sessions and sensible `summary` text. -- **`Europe/London` Mon/Wed/Fri 9 AM.** Verify `next_run_at` is correct - across the BST/GMT boundary and a force-tick at 9 AM local fires. +- **Mon/Wed/Fri 9 AM.** Verify `next_run_at` is correct and a force-tick + at 9 AM UTC fires. - **Pause mid-fire.** In-flight run completes, no new fire scheduled. Resume → next fire is computed forward from `now()` (no backfire). - **Approval boundary.** Run sits in `AWAITING_APPROVAL`, the diff --git a/docs/craft/product/scheduled-tasks.md b/docs/craft/product/scheduled-tasks.md index 5bef9aa26f..315f0be12a 100644 --- a/docs/craft/product/scheduled-tasks.md +++ b/docs/craft/product/scheduled-tasks.md @@ -36,7 +36,7 @@ Lives under `/craft/v1/tasks` (new section in the existing Craft left nav). The landing page. A table of the user's tasks: - Name -- Schedule (human-readable: "Every hour", "Mondays at 9:00 AM PT") +- Schedule (human-readable: "Every hour", "Mondays at 9:00 AM") - Status (active / paused) - Last run (relative time + success/failure indicator) - Next run (relative time) @@ -56,10 +56,8 @@ A single form, not a wizard. Fields: Craft chat input (multi-line, attachments come later). - **Schedule** — three modes via a tab/segmented control: - *Interval* — "Every N minutes/hours/days." - - *Daily/weekly* — pick days of week + time of day + timezone. + - *Daily/weekly* — pick days of week + time of day. - *Advanced* — raw cron expression with a live human-readable preview. -- **Timezone** — defaults to the user's timezone; explicit so weekly tasks behave - predictably across DST. - **Status** — active / paused toggle. Defaults to active. Save → returns to the list. Save and run now → returns to the list and immediately @@ -98,7 +96,7 @@ No new run-detail page is needed — the existing session view is the run detail 1. User clicks **Scheduled Tasks** in the Craft nav. 2. Clicks **Create scheduled task**. -3. Fills in name, prompt, schedule, timezone. Saves. +3. Fills in name, prompt, schedule. Saves. 4. Lands back on the list with the new task at the top and a next-run time computed. ### Editing a task diff --git a/web/src/app/craft/v1/tasks/[id]/edit/page.tsx b/web/src/app/craft/v1/tasks/[id]/edit/page.tsx index c992a938eb..42532c7e36 100644 --- a/web/src/app/craft/v1/tasks/[id]/edit/page.tsx +++ b/web/src/app/craft/v1/tasks/[id]/edit/page.tsx @@ -12,8 +12,7 @@ import ScheduleTaskForm, { } from "@/app/craft/v1/tasks/components/ScheduleTaskForm"; import type { ScheduledTaskDetail } from "@/app/craft/v1/tasks/interfaces"; import { TASKS_PATH, taskDetailPath } from "@/app/craft/v1/tasks/constants"; -import { decodeCronToPayload } from "@/app/craft/v1/tasks/schedule"; -import { getBrowserTimezone } from "@/app/craft/v1/tasks/utils"; +import { decodeUtcCronToLocalPayload } from "@/app/craft/v1/tasks/schedule"; import { SWR_KEYS } from "@/lib/swr-keys"; import { errorHandlingFetcher } from "@/lib/fetcher"; @@ -35,11 +34,12 @@ export default function EditScheduledTaskPage() { if (!taskId) { return ( - + @@ -50,27 +50,37 @@ export default function EditScheduledTaskPage() { ); } + if (isLoading || error || !data) { + return ( + + + + {isLoading ? ( +
+ +
+ ) : ( + + Failed to load scheduled task. + + )} +
+
+ ); + } + return ( - - - - {isLoading ? ( -
- -
- ) : error || !data ? ( - - Failed to load scheduled task. - - ) : ( - - )} -
-
+ ); } @@ -80,7 +90,7 @@ function toFormInitial(detail: ScheduledTaskDetail): ScheduleTaskFormInitial { // cron can't be decoded back into the chosen mode (rare, e.g. someone // hand-edited via the API), we fall back to ``advanced`` mode so the user // sees the raw expression. - const { mode, payload } = decodeCronToPayload( + const { mode, payload } = decodeUtcCronToLocalPayload( detail.editor_mode, detail.cron_expression ); @@ -90,6 +100,5 @@ function toFormInitial(detail: ScheduledTaskDetail): ScheduleTaskFormInitial { prompt: detail.prompt, mode, payload, - timezone: detail.timezone || getBrowserTimezone(), }; } diff --git a/web/src/app/craft/v1/tasks/[id]/page.tsx b/web/src/app/craft/v1/tasks/[id]/page.tsx index 4eb39483ad..023652e984 100644 --- a/web/src/app/craft/v1/tasks/[id]/page.tsx +++ b/web/src/app/craft/v1/tasks/[id]/page.tsx @@ -28,6 +28,7 @@ import type { ScheduledTaskDetail, ScheduledTaskStatus, } from "@/app/craft/v1/tasks/interfaces"; +import { humanReadableScheduleFromCron } from "@/app/craft/v1/tasks/schedule"; import { SWR_KEYS } from "@/lib/swr-keys"; import { errorHandlingFetcher } from "@/lib/fetcher"; @@ -46,6 +47,9 @@ export default function ScheduledTaskDetailPage() { const [busy, setBusy] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); + const scheduleDescription = data + ? humanReadableScheduleFromCron(data.editor_mode, data.cron_expression) + : undefined; const handleBack = useCallback(() => { router.push(TASKS_PATH); @@ -124,7 +128,7 @@ export default function ScheduledTaskDetailPage() { = [ @@ -31,7 +29,6 @@ const WEEKDAY_LABELS: ReadonlyArray<{ value: number; short: string }> = [ const INTERVAL_UNITS: ReadonlyArray<{ value: IntervalUnit; label: string }> = [ { value: "minutes", label: "minutes" }, { value: "hours", label: "hours" }, - { value: "days", label: "days" }, ]; export interface ScheduleEditorProps { @@ -80,25 +77,10 @@ export default function ScheduleEditor({ /> ), }, - advanced: { - name: "Advanced", - content: ( - - ), - }, }), [mode, payload, onPayloadChange] ); - const compiled = compileToCron(mode, payload); - return (
- {error ? ( + {error && ( {error} - ) : compiled.ok ? ( - - Cron: {compiled.cron} - - ) : null} + )}
); } @@ -143,7 +119,6 @@ const DEFAULT_DAILY_WEEKLY: DailyWeeklyPayload = { time_of_day: "09:00", weekdays: [1, 2, 3, 4, 5], }; -const DEFAULT_ADVANCED: AdvancedPayload = { cron: "0 9 * * 1" }; function isIntervalPayload(p: EditorPayload): p is IntervalPayload { return ( @@ -156,10 +131,6 @@ function isDailyWeeklyPayload(p: EditorPayload): p is DailyWeeklyPayload { return Array.isArray((p as DailyWeeklyPayload).weekdays); } -function isAdvancedPayload(p: EditorPayload): p is AdvancedPayload { - return typeof (p as AdvancedPayload).cron === "string"; -} - // --------------------------------------------------------------------------- // Interval // --------------------------------------------------------------------------- @@ -170,14 +141,13 @@ interface IntervalEditorProps { } function IntervalEditor({ payload, onChange }: IntervalEditorProps) { - const showTimeOfDay = payload.unit === "days"; return (
Every -
+
- {showTimeOfDay && ( -
- - At - -
- - onChange({ ...payload, time_of_day: e.target.value }) - } - data-testid="interval-time" - /> -
-
- )}
); } @@ -238,13 +191,20 @@ interface DailyWeeklyEditorProps { function DailyWeeklyEditor({ payload, onChange }: DailyWeeklyEditorProps) { const weekdaySet = new Set(payload.weekdays ?? []); + const selectedDays = WEEKDAY_LABELS.filter((d) => + weekdaySet.has(d.value) + ).map((d) => d.short); + const scheduleNote = + selectedDays.length === 0 + ? "Runs every day" + : `Runs on ${selectedDays.join(", ")}`; return (
At -
+
- On these days (leave all unchecked for every day): + On these days
{WEEKDAY_LABELS.map((day) => { @@ -289,32 +249,10 @@ function DailyWeeklyEditor({ payload, onChange }: DailyWeeklyEditorProps) { ); })}
+ + {scheduleNote} +
); } - -// --------------------------------------------------------------------------- -// Advanced (raw cron) -// --------------------------------------------------------------------------- - -interface AdvancedEditorProps { - payload: AdvancedPayload; - onChange: (payload: AdvancedPayload) => void; -} - -function AdvancedEditor({ payload, onChange }: AdvancedEditorProps) { - return ( -
- - Five-field cron expression (minute hour day-of-month month day-of-week). - - onChange({ cron: e.target.value })} - placeholder="0 9 * * 1" - data-testid="advanced-cron" - /> -
- ); -} diff --git a/web/src/app/craft/v1/tasks/components/ScheduleTaskForm.tsx b/web/src/app/craft/v1/tasks/components/ScheduleTaskForm.tsx index 753c0e3de5..d5661d336d 100644 --- a/web/src/app/craft/v1/tasks/components/ScheduleTaskForm.tsx +++ b/web/src/app/craft/v1/tasks/components/ScheduleTaskForm.tsx @@ -5,14 +5,17 @@ import { useRouter } from "next/navigation"; import Text from "@/refresh-components/texts/Text"; import { InputTypeIn } from "@opal/components"; import InputTextArea from "@/refresh-components/inputs/InputTextArea"; -import InputSelect from "@/refresh-components/inputs/InputSelect"; import { Button, Divider } from "@opal/components"; -import Card from "@/refresh-components/cards/Card"; -import { Section } from "@/layouts/general-layouts"; +import { Disabled } from "@opal/core"; +import { SettingsLayouts, InputVertical } from "@opal/layouts"; +import * as GeneralLayouts from "@/layouts/general-layouts"; import { toast } from "@/hooks/useToast"; import { SvgClock } from "@opal/icons"; import ScheduleEditor from "@/app/craft/v1/tasks/components/ScheduleEditor"; -import { compileToCron, computeNextRuns } from "@/app/craft/v1/tasks/schedule"; +import { + compileLocalPayloadToUtcCron, + localPayloadToUtcPayload, +} from "@/app/craft/v1/tasks/schedule"; import SkillPickerPopover from "@/sections/input/SkillPickerPopover"; import useUserSkills from "@/hooks/useUserSkills"; import { detectSlashTrigger, toPickerSkills } from "@/lib/skills/picker"; @@ -27,12 +30,6 @@ import { createScheduledTask, updateScheduledTask, } from "@/app/craft/v1/tasks/api"; -import { - formatAbsolute, - formatRelativeShort, - getBrowserTimezone, - getCommonTimezones, -} from "@/app/craft/v1/tasks/utils"; import { TASKS_PATH, taskDetailPath } from "@/app/craft/v1/tasks/constants"; export interface ScheduleTaskFormInitial { @@ -42,26 +39,35 @@ export interface ScheduleTaskFormInitial { prompt: string; mode: EditorMode; payload: EditorPayload; - timezone: string; } interface ScheduleTaskFormProps { initial: ScheduleTaskFormInitial; /** Used to title the page / customize the submit button. */ isEdit: boolean; + /** Title rendered in the settings header. */ + title: string; + /** Optional sub-title rendered beneath the header title. */ + description?: string; + /** Invoked when the back button is pressed. */ + onBack: () => void; } export default function ScheduleTaskForm({ initial, isEdit, + title, + description, + onBack, }: ScheduleTaskFormProps) { const router = useRouter(); const [name, setName] = useState(initial.name); const [prompt, setPrompt] = useState(initial.prompt); const [mode, setMode] = useState(initial.mode); const [payload, setPayload] = useState(initial.payload); - const [timezone, setTimezone] = useState(initial.timezone); const [saving, setSaving] = useState(false); + const [nameTouched, setNameTouched] = useState(false); + const [promptTouched, setPromptTouched] = useState(false); // `/` skill picker state for the prompt field. Scoped to the trigger // owner's accessible skills (same access query as `GET /skills`). @@ -138,38 +144,42 @@ export default function ScheduleTaskForm({ [prompt] ); - const timezones = useMemo(() => getCommonTimezones(), []); - const compiled = compileToCron(mode, payload); - - const nextRuns = useMemo(() => { - if (!compiled.ok) return []; - return computeNextRuns(compiled.cron, timezone, 3); - }, [compiled, timezone]); + const compiled = compileLocalPayloadToUtcCron(mode, payload); const trimmedName = name.trim(); const trimmedPrompt = prompt.trim(); - // Validation states surfaced to the user. + // Validation states. These gate submission regardless of interaction, but + // are only surfaced inline once the user has touched (blurred) the field so + // a pristine form doesn't render red on load. const nameError = trimmedName.length === 0 ? "Name is required." : null; const promptError = trimmedPrompt.length === 0 ? "Prompt is required." : null; - const tzError = !timezone ? "Timezone is required." : null; const scheduleError = !compiled.ok ? compiled.error : null; - const canSubmit = - !nameError && !promptError && !tzError && !scheduleError && !saving; + const shownNameError = nameTouched ? nameError : null; + const shownPromptError = promptTouched ? promptError : null; + + const canSubmit = !nameError && !promptError && !scheduleError && !saving; + + // Reason a submit button is blocked, surfaced via the `Disabled` wrapper's + // tooltip. A natively-disabled + {!isEdit && ( + + + + )} + + + + + } + /> - {/* Prompt */} - - - Prompt - - - This message is sent to Craft each time the task fires. - - - - {promptError && ( - - {promptError} - - )} - + + + + setName(e.target.value)} + onBlur={() => setNameTouched(true)} + placeholder="e.g. Weekly customer escalations digest" + data-testid="task-name-input" + variant={shownNameError ? "error" : undefined} + /> + {shownNameError && ( + + {shownNameError} + + )} + - {/* Schedule */} - - - Schedule - - - - - Timezone - -
- - - - {timezones.map((tz) => ( - - {tz} - - ))} - - -
- {tzError && ( - - {tzError} - - )} -
- - {/* Next runs preview */} - -
- - - Next 3 runs - -
- {nextRuns.length === 0 ? ( - - {scheduleError - ? "Fix the schedule above to preview future fires." - : "No upcoming fires for this expression."} - - ) : ( -
    - {nextRuns.map((iso, idx) => ( -
  • - - {idx + 1}. {formatAbsolute(iso)} - - - {formatRelativeShort(iso)} ({timezone}) - -
  • - ))} -
- )} -
- -
- - {!isEdit && ( - - )} - -
- + setPromptTouched(true)} + placeholder="Describe what Craft should do on each run..." + rows={6} + autoResize + maxRows={12} + data-testid="task-prompt-input" + variant={shownPromptError ? "error" : undefined} + /> + + {shownPromptError && ( + + {shownPromptError} + + )} + +
+ + + + + + + + +
+
); } @@ -378,6 +358,5 @@ export function defaultFormInitial(): ScheduleTaskFormInitial { prompt: "", mode: "interval", payload: { unit: "hours", every: 1 }, - timezone: getBrowserTimezone(), }; } diff --git a/web/src/app/craft/v1/tasks/interfaces.ts b/web/src/app/craft/v1/tasks/interfaces.ts index dc5b213d34..a1e674531c 100644 --- a/web/src/app/craft/v1/tasks/interfaces.ts +++ b/web/src/app/craft/v1/tasks/interfaces.ts @@ -59,7 +59,6 @@ export interface ScheduledTaskListItem { name: string; human_readable_schedule: string; cron_expression: string; - timezone: string; editor_mode: EditorMode; status: ScheduledTaskStatus; next_run_at: string | null; @@ -74,7 +73,6 @@ export interface ScheduledTaskDetail { prompt: string; human_readable_schedule: string; cron_expression: string; - timezone: string; editor_mode: EditorMode; status: ScheduledTaskStatus; next_run_at: string | null; @@ -89,7 +87,6 @@ export interface ScheduledTaskCreateBody { prompt: string; editor_mode: EditorMode; editor_payload: EditorPayload; - timezone: string; status?: ScheduledTaskStatus; run_immediately?: boolean; } @@ -99,7 +96,6 @@ export interface ScheduledTaskPatchBody { prompt?: string; editor_mode?: EditorMode; editor_payload?: EditorPayload; - timezone?: string; status?: ScheduledTaskStatus; } diff --git a/web/src/app/craft/v1/tasks/new/page.tsx b/web/src/app/craft/v1/tasks/new/page.tsx index f9d4d2886b..b88e2d74d3 100644 --- a/web/src/app/craft/v1/tasks/new/page.tsx +++ b/web/src/app/craft/v1/tasks/new/page.tsx @@ -2,8 +2,6 @@ import { useCallback, useMemo } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { SettingsLayouts } from "@opal/layouts"; -import { SvgClock } from "@opal/icons"; import ScheduleTaskForm, { defaultFormInitial, type ScheduleTaskFormInitial, @@ -13,12 +11,10 @@ import type { EditorPayload, } from "@/app/craft/v1/tasks/interfaces"; import { TASKS_PATH } from "@/app/craft/v1/tasks/constants"; -import { getBrowserTimezone } from "@/app/craft/v1/tasks/utils"; const VALID_MODES: ReadonlySet = new Set([ "interval", "daily_weekly", - "advanced", ]); export default function NewScheduledTaskPage() { @@ -56,21 +52,16 @@ export default function NewScheduledTaskPage() { prompt: promptParam ?? "", mode, payload, - timezone: getBrowserTimezone(), }; }, [searchParams]); return ( - - - - - - + ); } diff --git a/web/src/app/craft/v1/tasks/page.tsx b/web/src/app/craft/v1/tasks/page.tsx index fb0b982241..c4a3bb5ab9 100644 --- a/web/src/app/craft/v1/tasks/page.tsx +++ b/web/src/app/craft/v1/tasks/page.tsx @@ -31,6 +31,7 @@ import { formatAbsolute, formatRelativeShort, } from "@/app/craft/v1/tasks/utils"; +import { humanReadableScheduleFromCron } from "@/app/craft/v1/tasks/schedule"; import { SWR_KEYS } from "@/lib/swr-keys"; import { errorHandlingFetcher } from "@/lib/fetcher"; @@ -127,7 +128,17 @@ export default function ScheduledTasksListPage() { errorHandlingFetcher, { revalidateOnFocus: false } ); - const tasks = data?.items ?? []; + const tasks = useMemo( + () => + data?.items.map((task) => ({ + ...task, + human_readable_schedule: humanReadableScheduleFromCron( + task.editor_mode, + task.cron_expression + ), + })) ?? [], + [data?.items] + ); const [pendingDelete, setPendingDelete] = useState(null); const [busyTaskId, setBusyTaskId] = useState(null); @@ -169,7 +180,7 @@ export default function ScheduledTasksListPage() { href={NEW_TASK_PATH} data-testid="new-task-button" > - New scheduled task + New Scheduled Task ), [] diff --git a/web/src/app/craft/v1/tasks/schedule.test.tsx b/web/src/app/craft/v1/tasks/schedule.test.tsx new file mode 100644 index 0000000000..5bf1dcb91c --- /dev/null +++ b/web/src/app/craft/v1/tasks/schedule.test.tsx @@ -0,0 +1,116 @@ +import type { + DailyWeeklyPayload, + IntervalPayload, +} from "@/app/craft/v1/tasks/interfaces"; +import { + compileLocalPayloadToUtcCron, + decodeUtcCronToLocalPayload, + humanReadableScheduleFromCron, +} from "@/app/craft/v1/tasks/schedule"; + +const REFERENCE_DATE = new Date("2026-05-25T12:00:00.000Z"); + +describe("scheduled task browser-local schedule helpers", () => { + it("round-trips daily/weekly schedules through the stored cron", () => { + const payload: DailyWeeklyPayload = { + time_of_day: "09:30", + weekdays: [1, 3, 5], + }; + + const compiled = compileLocalPayloadToUtcCron( + "daily_weekly", + payload, + REFERENCE_DATE + ); + + if (!compiled.ok) throw new Error(compiled.error); + + const decoded = decodeUtcCronToLocalPayload( + "daily_weekly", + compiled.cron, + REFERENCE_DATE + ); + expect(decoded).toEqual({ mode: "daily_weekly", payload }); + expect( + humanReadableScheduleFromCron( + "daily_weekly", + compiled.cron, + REFERENCE_DATE + ) + ).toBe("Mon, Wed, Fri at 9:30 AM"); + }); + + it("converts daily/weekly schedules via a single shared day-shift", () => { + // A late-evening local time can roll into the next UTC day. The whole + // schedule must shift by ONE shared day-delta (derived from a single + // instant) rather than converting each weekday independently — otherwise + // weekdays could diverge across a DST boundary. We derive the expected + // cron from the host's own timezone instead of hard-coding a zone-specific + // string, so the assertion holds under any TZ (CI runs in UTC; dev + // machines often don't). + const payload: DailyWeeklyPayload = { + time_of_day: "23:00", + weekdays: [1, 3, 5], + }; + + // Independent reference conversion: one instant in, one day-shift out. + const instant = new Date(REFERENCE_DATE.getTime()); + instant.setHours(23, 0, 0, 0); + const localMidnight = Date.UTC( + instant.getFullYear(), + instant.getMonth(), + instant.getDate() + ); + const utcMidnight = Date.UTC( + instant.getUTCFullYear(), + instant.getUTCMonth(), + instant.getUTCDate() + ); + const dayDelta = Math.round((utcMidnight - localMidnight) / 86_400_000); + const expectedWeekdays = payload.weekdays + .map((weekday) => (((weekday + dayDelta) % 7) + 7) % 7) + .sort((a, b) => a - b); + const expectedCron = `${instant.getUTCMinutes()} ${instant.getUTCHours()} * * ${expectedWeekdays.join(",")}`; + + const compiled = compileLocalPayloadToUtcCron( + "daily_weekly", + payload, + REFERENCE_DATE + ); + if (!compiled.ok) throw new Error(compiled.error); + + // One shared time + a uniform weekday shift, with no weekdays dropped. + expect(compiled.cron).toBe(expectedCron); + expect(expectedWeekdays).toHaveLength(payload.weekdays.length); + + const decoded = decodeUtcCronToLocalPayload( + "daily_weekly", + compiled.cron, + REFERENCE_DATE + ); + expect(decoded).toEqual({ mode: "daily_weekly", payload }); + }); + + it("round-trips day interval schedules through the stored cron", () => { + const payload: IntervalPayload = { + unit: "days", + every: 2, + time_of_day: "23:15", + }; + + const compiled = compileLocalPayloadToUtcCron( + "interval", + payload, + REFERENCE_DATE + ); + + if (!compiled.ok) throw new Error(compiled.error); + + const decoded = decodeUtcCronToLocalPayload( + "interval", + compiled.cron, + REFERENCE_DATE + ); + expect(decoded).toEqual({ mode: "interval", payload }); + }); +}); diff --git a/web/src/app/craft/v1/tasks/schedule.ts b/web/src/app/craft/v1/tasks/schedule.ts index 1798f62bfb..356091356f 100644 --- a/web/src/app/craft/v1/tasks/schedule.ts +++ b/web/src/app/craft/v1/tasks/schedule.ts @@ -3,6 +3,8 @@ * * - ``compileToCron``: normalize the three editor modes into a single 5-field * cron expression (mirrors ``backend/.../scheduled_tasks/schedule.py``). + * - ``compileLocalPayloadToUtcCron``: compile browser-local editor state into + * the UTC cron persisted by the backend. * - ``computeNextRuns``: best-effort client-side preview of the next N fires, * used by the editor's "next 3 runs" panel before save. * - ``humanReadableSchedule``: lightweight summary fallback when we can only @@ -48,6 +50,154 @@ function parseTimeOfDay(value: string | null | undefined): { return { hour: Number(m[1]), minute: Number(m[2]) }; } +function timeOfDay(hour: number, minute: number): string { + return `${pad2(hour)}:${pad2(minute)}`; +} + +function localTimeOfDayToUtc( + value: string | null | undefined, + referenceDate: Date +): { hour: number; minute: number } | null { + const time = parseTimeOfDay(value); + if (!time) return null; + const date = new Date(referenceDate.getTime()); + date.setHours(time.hour, time.minute, 0, 0); + return { hour: date.getUTCHours(), minute: date.getUTCMinutes() }; +} + +function utcTimeOfDayToLocal( + value: string | null | undefined, + referenceDate: Date +): { hour: number; minute: number } | null { + const time = parseTimeOfDay(value); + if (!time) return null; + const date = new Date(referenceDate.getTime()); + date.setUTCHours(time.hour, time.minute, 0, 0); + return { hour: date.getHours(), minute: date.getMinutes() }; +} + +function uniqueSortedWeekdays(weekdays: number[]): number[] { + return Array.from(new Set(weekdays)).sort((a, b) => a - b); +} + +/** + * For a given absolute instant, how many calendar days its UTC date sits ahead + * of its local date (-1, 0, or +1). When a wall-clock time converts across + * midnight, the weekday shifts with it; this lets us apply that one shift + * uniformly across a daily/weekly schedule's weekday set. + */ +function utcMinusLocalDayDelta(instant: Date): number { + const localMidnight = Date.UTC( + instant.getFullYear(), + instant.getMonth(), + instant.getDate() + ); + const utcMidnight = Date.UTC( + instant.getUTCFullYear(), + instant.getUTCMonth(), + instant.getUTCDate() + ); + return Math.round((utcMidnight - localMidnight) / 86_400_000); +} + +function shiftWeekday(weekday: number, dayDelta: number): number { + return (((weekday + dayDelta) % 7) + 7) % 7; +} + +export function localPayloadToUtcPayload( + mode: EditorMode, + payload: EditorPayload, + referenceDate: Date = new Date() +): EditorPayload { + if (mode === "interval") { + const intervalPayload = payload as IntervalPayload; + if (intervalPayload.unit !== "days") return intervalPayload; + const utcTime = localTimeOfDayToUtc( + intervalPayload.time_of_day, + referenceDate + ); + if (!utcTime) return intervalPayload; + return { + ...intervalPayload, + time_of_day: timeOfDay(utcTime.hour, utcTime.minute), + }; + } + + if (mode === "daily_weekly") { + const dailyWeeklyPayload = payload as DailyWeeklyPayload; + const localTime = parseTimeOfDay(dailyWeeklyPayload.time_of_day); + if (!localTime) return dailyWeeklyPayload; + + // Convert the wall-clock time once on the reference date. A single + // `M H * * d1,d2,...` cron carries only one (hour, minute) and one + // day-shift, so we derive both from one instant and apply the same shift + // to every selected weekday. Converting each weekday's next occurrence + // independently would diverge across a DST boundary. + const instant = new Date(referenceDate.getTime()); + instant.setHours(localTime.hour, localTime.minute, 0, 0); + const dayDelta = utcMinusLocalDayDelta(instant); + + const weekdays = Array.isArray(dailyWeeklyPayload.weekdays) + ? dailyWeeklyPayload.weekdays + : []; + return { + ...dailyWeeklyPayload, + time_of_day: timeOfDay(instant.getUTCHours(), instant.getUTCMinutes()), + weekdays: uniqueSortedWeekdays( + weekdays.map((weekday) => shiftWeekday(weekday, dayDelta)) + ), + }; + } + + return payload; +} + +function utcPayloadToLocalPayload( + mode: EditorMode, + payload: EditorPayload, + referenceDate: Date +): EditorPayload { + if (mode === "interval") { + const intervalPayload = payload as IntervalPayload; + if (intervalPayload.unit !== "days") return intervalPayload; + const localTime = utcTimeOfDayToLocal( + intervalPayload.time_of_day, + referenceDate + ); + if (!localTime) return intervalPayload; + return { + ...intervalPayload, + time_of_day: timeOfDay(localTime.hour, localTime.minute), + }; + } + + if (mode === "daily_weekly") { + const dailyWeeklyPayload = payload as DailyWeeklyPayload; + const utcTime = parseTimeOfDay(dailyWeeklyPayload.time_of_day); + if (!utcTime) return dailyWeeklyPayload; + + // Mirror of the encode path: convert the stored UTC wall-clock once and + // apply the resulting day-shift uniformly to every weekday. See + // `localPayloadToUtcPayload`. + const instant = new Date(referenceDate.getTime()); + instant.setUTCHours(utcTime.hour, utcTime.minute, 0, 0); + const dayDelta = utcMinusLocalDayDelta(instant); + + const weekdays = Array.isArray(dailyWeeklyPayload.weekdays) + ? dailyWeeklyPayload.weekdays + : []; + return { + ...dailyWeeklyPayload, + time_of_day: timeOfDay(instant.getHours(), instant.getMinutes()), + weekdays: uniqueSortedWeekdays( + weekdays.map((weekday) => shiftWeekday(weekday, -dayDelta)) + ), + }; + } + + return payload; +} + // --------------------------------------------------------------------------- // Cron compilation // --------------------------------------------------------------------------- @@ -66,6 +216,17 @@ export function compileToCron( } } +export function compileLocalPayloadToUtcCron( + mode: EditorMode, + payload: EditorPayload, + referenceDate: Date = new Date() +): ScheduleValidation { + return compileToCron( + mode, + localPayloadToUtcPayload(mode, payload, referenceDate) + ); +} + function compileInterval(payload: IntervalPayload): ScheduleValidation { const every = Number(payload?.every); if (!Number.isFinite(every) || every < 1) { @@ -185,9 +346,8 @@ function expandField(field: string, min: number, max: number): number[] | null { } /** - * Compute the next ``count`` fires of ``cron`` in the given IANA timezone, - * starting strictly after ``after`` (default: now). Returns ISO strings in - * UTC. + * Compute the next ``count`` UTC fires of ``cron``, starting strictly after + * ``after`` (default: now). Returns ISO strings. * * Returns an empty array if the cron expression cannot be parsed by our * tiny client-side parser. @@ -198,12 +358,9 @@ function expandField(field: string, min: number, max: number): number[] | null { * matching *either* constraint. When one is ``*``, only the other is * enforced. * - Months follow 1-12 indexing. - * - Timezone handling uses ``Intl.DateTimeFormat`` to map a UTC instant - * into local components in the target zone. */ export function computeNextRuns( cron: string, - timezone: string, count: number, after: Date = new Date() ): string[] { @@ -228,8 +385,7 @@ export function computeNextRuns( const domRestricted = domF !== "*"; const dowRestricted = dowF !== "*"; - // Iterate UTC minutes; for each candidate, derive the local components in - // the target timezone and test the cron fields against those. + // Iterate UTC minutes and test each candidate against the cron fields. const startUtc = new Date(after.getTime()); // round up to the next whole minute so we never re-fire on the current // second. @@ -238,27 +394,6 @@ export function computeNextRuns( const SAFETY_LIMIT_MINUTES = 60 * 24 * 366 * 2; // ~2 years - const fmt = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - hourCycle: "h23", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - weekday: "short", - }); - - const weekdayMap: Record = { - Sun: 0, - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, - }; - const minutesSet = new Set(minutes); const hoursSet = new Set(hours); const domSet = new Set(dom); @@ -269,20 +404,11 @@ export function computeNextRuns( const cursor = new Date(startUtc.getTime()); for (let i = 0; i < SAFETY_LIMIT_MINUTES && results.length < count; i++) { - const parts = fmt.formatToParts(cursor); - const get = (type: string): string => - parts.find((p) => p.type === type)?.value ?? ""; - - const minute = Number(get("minute")); - const hour = Number(get("hour")); - const day = Number(get("day")); - const month = Number(get("month")); - const weekdayValue = weekdayMap[get("weekday")]; - if (weekdayValue === undefined) { - // Unknown weekday from the Intl formatter — skip this candidate. - cursor.setUTCMinutes(cursor.getUTCMinutes() + 1); - continue; - } + const minute = cursor.getUTCMinutes(); + const hour = cursor.getUTCHours(); + const day = cursor.getUTCDate(); + const month = cursor.getUTCMonth() + 1; + const weekdayValue = cursor.getUTCDay(); let dayOk: boolean; if (domRestricted && dowRestricted) { @@ -315,9 +441,8 @@ export function computeNextRuns( // --------------------------------------------------------------------------- /** - * Reconstruct a UI-friendly payload from a stored cron expression and the - * editor_mode hint. The backend stores cron + editor_mode but does not - * round-trip editor_payload, so we re-derive it on the edit page. + * Reconstruct the stored payload shape from a stored cron expression and the + * editor_mode hint. Use ``decodeUtcCronToLocalPayload`` for browser display. * * If we can't confidently decode the cron back into the chosen mode, we fall * back to ``advanced`` mode so the user sees the raw expression. @@ -418,6 +543,22 @@ export function decodeCronToPayload( return { mode: "advanced", payload: { cron } }; } +export function decodeUtcCronToLocalPayload( + mode: EditorMode, + cron: string, + referenceDate: Date = new Date() +): { mode: EditorMode; payload: EditorPayload } { + const decoded = decodeCronToPayload(mode, cron); + return { + mode: decoded.mode, + payload: utcPayloadToLocalPayload( + decoded.mode, + decoded.payload, + referenceDate + ), + }; +} + function parseStepStar(field: string): number | null { if (!field.startsWith("*/")) return null; const n = Number(field.slice(2)); @@ -474,3 +615,12 @@ export function humanReadableSchedule( } return "—"; } + +export function humanReadableScheduleFromCron( + mode: EditorMode, + cron: string, + referenceDate: Date = new Date() +): string { + const decoded = decodeUtcCronToLocalPayload(mode, cron, referenceDate); + return humanReadableSchedule(decoded.mode, decoded.payload, cron); +} diff --git a/web/src/app/craft/v1/tasks/utils.ts b/web/src/app/craft/v1/tasks/utils.ts index f94f57ff74..eb7d8340a5 100644 --- a/web/src/app/craft/v1/tasks/utils.ts +++ b/web/src/app/craft/v1/tasks/utils.ts @@ -5,45 +5,6 @@ import { formatDistanceToNowStrict, formatRelative } from "date-fns"; import type { ScheduledRunSummary } from "@/app/craft/v1/tasks/interfaces"; -export function getBrowserTimezone(): string { - try { - const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - return tz || "UTC"; - } catch { - return "UTC"; - } -} - -export function getCommonTimezones(): string[] { - // Use ``supportedValuesOf`` when available; fall back to a curated list. - type ExtendedIntl = typeof Intl & { - supportedValuesOf?: (key: string) => string[]; - }; - const ext = Intl as ExtendedIntl; - if (typeof ext.supportedValuesOf === "function") { - try { - const all = ext.supportedValuesOf("timeZone"); - if (Array.isArray(all) && all.length > 0) return all; - } catch { - // fall through - } - } - return [ - "UTC", - "America/Los_Angeles", - "America/Denver", - "America/Chicago", - "America/New_York", - "Europe/London", - "Europe/Berlin", - "Europe/Paris", - "Asia/Kolkata", - "Asia/Singapore", - "Asia/Tokyo", - "Australia/Sydney", - ]; -} - export function formatRelativeShort(isoOrDate: string | Date | null): string { if (!isoOrDate) return "—"; const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;