mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-22 03:14:06 -05:00
fix(craft): remove scheduled task timezones (#11362)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
56dc3e3909
commit
706eb8a66f
22 files changed
+673
-510
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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 * * <weekdays>` (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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgClock}
|
||||
title="Edit scheduled task"
|
||||
backButton={handleBack}
|
||||
divider
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<Text mainUiBody text03>
|
||||
@@ -50,27 +50,37 @@ export default function EditScheduledTaskPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || error || !data) {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgClock}
|
||||
title={data ? `Edit "${data.name}"` : "Edit scheduled task"}
|
||||
backButton={handleBack}
|
||||
divider
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<SimpleLoader className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
<Text mainUiBody text03>
|
||||
Failed to load scheduled task.
|
||||
</Text>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgClock}
|
||||
title={data ? `Edit "${data.name}"` : "Edit scheduled task"}
|
||||
backButton={handleBack}
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<SimpleLoader className="h-6 w-6" />
|
||||
</div>
|
||||
) : error || !data ? (
|
||||
<Text mainUiBody text03>
|
||||
Failed to load scheduled task.
|
||||
</Text>
|
||||
) : (
|
||||
<ScheduleTaskForm initial={toFormInitial(data)} isEdit />
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
<ScheduleTaskForm
|
||||
initial={toFormInitial(data)}
|
||||
isEdit
|
||||
title={`Edit "${data.name}"`}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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() {
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgClock}
|
||||
title={data?.name ?? "Scheduled task"}
|
||||
description={data?.human_readable_schedule}
|
||||
description={scheduleDescription}
|
||||
backButton={handleBack}
|
||||
rightChildren={
|
||||
data ? (
|
||||
|
||||
@@ -8,14 +8,12 @@ import SimpleTabs from "@/refresh-components/SimpleTabs";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { cn } from "@opal/utils";
|
||||
import type {
|
||||
AdvancedPayload,
|
||||
DailyWeeklyPayload,
|
||||
EditorMode,
|
||||
EditorPayload,
|
||||
IntervalPayload,
|
||||
IntervalUnit,
|
||||
} from "@/app/craft/v1/tasks/interfaces";
|
||||
import { compileToCron } from "@/app/craft/v1/tasks/schedule";
|
||||
|
||||
// 0=Sun..6=Sat (cron convention).
|
||||
const WEEKDAY_LABELS: ReadonlyArray<{ value: number; short: string }> = [
|
||||
@@ -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: (
|
||||
<AdvancedEditor
|
||||
payload={
|
||||
mode === "advanced"
|
||||
? (payload as AdvancedPayload)
|
||||
: DEFAULT_ADVANCED
|
||||
}
|
||||
onChange={onPayloadChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}),
|
||||
[mode, payload, onPayloadChange]
|
||||
);
|
||||
|
||||
const compiled = compileToCron(mode, payload);
|
||||
|
||||
return (
|
||||
<Section gap={0.5}>
|
||||
<SimpleTabs
|
||||
@@ -116,20 +98,14 @@ export default function ScheduleEditor({
|
||||
!isDailyWeeklyPayload(payload)
|
||||
) {
|
||||
onPayloadChange(DEFAULT_DAILY_WEEKLY);
|
||||
} else if (next === "advanced" && !isAdvancedPayload(payload)) {
|
||||
onPayloadChange(DEFAULT_ADVANCED);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
{error && (
|
||||
<Text mainUiBody text03 className="text-status-error-05">
|
||||
{error}
|
||||
</Text>
|
||||
) : compiled.ok ? (
|
||||
<Text secondaryBody text03>
|
||||
Cron: <code className="font-mono">{compiled.cron}</code>
|
||||
</Text>
|
||||
) : null}
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Section gap={0.5}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Text mainUiBody text05>
|
||||
Every
|
||||
</Text>
|
||||
<div className="w-20">
|
||||
<div className="w-28">
|
||||
<InputTypeIn
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -206,23 +176,6 @@ function IntervalEditor({ payload, onChange }: IntervalEditorProps) {
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
{showTimeOfDay && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Text mainUiBody text05>
|
||||
At
|
||||
</Text>
|
||||
<div className="w-32">
|
||||
<InputTypeIn
|
||||
type="time"
|
||||
value={payload.time_of_day ?? "09:00"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...payload, time_of_day: e.target.value })
|
||||
}
|
||||
data-testid="interval-time"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Section gap={0.5}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Text mainUiBody text05>
|
||||
At
|
||||
</Text>
|
||||
<div className="w-32">
|
||||
<div className="w-44">
|
||||
<InputTypeIn
|
||||
type="time"
|
||||
value={payload.time_of_day ?? "09:00"}
|
||||
@@ -257,7 +217,7 @@ function DailyWeeklyEditor({ payload, onChange }: DailyWeeklyEditorProps) {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text secondaryBody text03>
|
||||
On these days (leave all unchecked for every day):
|
||||
On these days
|
||||
</Text>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{WEEKDAY_LABELS.map((day) => {
|
||||
@@ -289,32 +249,10 @@ function DailyWeeklyEditor({ payload, onChange }: DailyWeeklyEditorProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Text secondaryBody text03>
|
||||
{scheduleNote}
|
||||
</Text>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Advanced (raw cron)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AdvancedEditorProps {
|
||||
payload: AdvancedPayload;
|
||||
onChange: (payload: AdvancedPayload) => void;
|
||||
}
|
||||
|
||||
function AdvancedEditor({ payload, onChange }: AdvancedEditorProps) {
|
||||
return (
|
||||
<Section gap={0.5}>
|
||||
<Text secondaryBody text03>
|
||||
Five-field cron expression (minute hour day-of-month month day-of-week).
|
||||
</Text>
|
||||
<InputTypeIn
|
||||
value={payload.cron ?? ""}
|
||||
onChange={(e) => onChange({ cron: e.target.value })}
|
||||
placeholder="0 9 * * 1"
|
||||
data-testid="advanced-cron"
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -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<EditorMode>(initial.mode);
|
||||
const [payload, setPayload] = useState<EditorPayload>(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 <button> is inert and never fires hover
|
||||
// events, so the tooltip must live on the (interactive) wrapper instead.
|
||||
const disabledReason = saving
|
||||
? "Saving..."
|
||||
: (nameError ?? promptError ?? scheduleError ?? undefined);
|
||||
|
||||
const submit = useCallback(
|
||||
async (runImmediately: boolean) => {
|
||||
if (!compiled.ok) return; // validation should already block, but typescript needs this
|
||||
setSaving(true);
|
||||
try {
|
||||
const storagePayload = localPayloadToUtcPayload(mode, payload);
|
||||
if (isEdit && initial.taskId) {
|
||||
const body: ScheduledTaskPatchBody = {
|
||||
name: trimmedName,
|
||||
prompt: trimmedPrompt,
|
||||
editor_mode: mode,
|
||||
editor_payload: payload,
|
||||
timezone,
|
||||
editor_payload: storagePayload,
|
||||
};
|
||||
const updated: ScheduledTaskDetail = await updateScheduledTask(
|
||||
initial.taskId,
|
||||
@@ -182,8 +192,7 @@ export default function ScheduleTaskForm({
|
||||
name: trimmedName,
|
||||
prompt: trimmedPrompt,
|
||||
editor_mode: mode,
|
||||
editor_payload: payload,
|
||||
timezone,
|
||||
editor_payload: storagePayload,
|
||||
run_immediately: runImmediately,
|
||||
};
|
||||
await createScheduledTask(body);
|
||||
@@ -209,165 +218,136 @@ export default function ScheduleTaskForm({
|
||||
mode,
|
||||
payload,
|
||||
router,
|
||||
timezone,
|
||||
trimmedName,
|
||||
trimmedPrompt,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Section gap={1}>
|
||||
{/* Name */}
|
||||
<Card>
|
||||
<Text mainUiAction text05>
|
||||
Name
|
||||
</Text>
|
||||
<InputTypeIn
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Weekly customer escalations digest"
|
||||
data-testid="task-name-input"
|
||||
variant={nameError ? "error" : undefined}
|
||||
/>
|
||||
{nameError && (
|
||||
<Text secondaryBody text03 className="text-status-error-05">
|
||||
{nameError}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgClock}
|
||||
title={title}
|
||||
description={description}
|
||||
backButton={onBack}
|
||||
divider
|
||||
rightChildren={
|
||||
<div className="flex gap-2 self-start">
|
||||
<Button
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
type="button"
|
||||
onClick={() => router.push(TASKS_PATH)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isEdit && (
|
||||
<Disabled
|
||||
disabled={!canSubmit}
|
||||
tooltip={disabledReason}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void submit(true)}
|
||||
data-testid="save-and-run-now"
|
||||
>
|
||||
Save and run now
|
||||
</Button>
|
||||
</Disabled>
|
||||
)}
|
||||
<Disabled
|
||||
disabled={!canSubmit}
|
||||
tooltip={disabledReason}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
prominence="primary"
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void submit(false)}
|
||||
data-testid="save-task"
|
||||
>
|
||||
{isEdit ? "Save changes" : "Save"}
|
||||
</Button>
|
||||
</Disabled>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Prompt */}
|
||||
<Card>
|
||||
<Text mainUiAction text05>
|
||||
Prompt
|
||||
</Text>
|
||||
<Text secondaryBody text03>
|
||||
This message is sent to Craft each time the task fires.
|
||||
</Text>
|
||||
<InputTextArea
|
||||
ref={promptTextareaRef}
|
||||
value={prompt}
|
||||
onChange={handlePromptChange}
|
||||
onKeyUp={handlePromptCursorChange}
|
||||
onClick={handlePromptCursorChange}
|
||||
placeholder="Describe what Craft should do on each run..."
|
||||
rows={6}
|
||||
autoResize
|
||||
maxRows={12}
|
||||
data-testid="task-prompt-input"
|
||||
variant={promptError ? "error" : undefined}
|
||||
/>
|
||||
<SkillPickerPopover
|
||||
open={skillPicker.open}
|
||||
anchorRect={skillPicker.anchorRect}
|
||||
query={skillPicker.query}
|
||||
skills={pickerSkills}
|
||||
onSelect={handleSkillPickerSelect}
|
||||
onClose={closeSkillPicker}
|
||||
/>
|
||||
{promptError && (
|
||||
<Text secondaryBody text03 className="text-status-error-05">
|
||||
{promptError}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
<SettingsLayouts.Body>
|
||||
<GeneralLayouts.Section>
|
||||
<InputVertical withLabel title="Name">
|
||||
<InputTypeIn
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => setNameTouched(true)}
|
||||
placeholder="e.g. Weekly customer escalations digest"
|
||||
data-testid="task-name-input"
|
||||
variant={shownNameError ? "error" : undefined}
|
||||
/>
|
||||
{shownNameError && (
|
||||
<Text secondaryBody text03 className="text-status-error-05">
|
||||
{shownNameError}
|
||||
</Text>
|
||||
)}
|
||||
</InputVertical>
|
||||
|
||||
{/* Schedule */}
|
||||
<Card>
|
||||
<Text mainUiAction text05>
|
||||
Schedule
|
||||
</Text>
|
||||
<ScheduleEditor
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
payload={payload}
|
||||
onPayloadChange={setPayload}
|
||||
error={scheduleError}
|
||||
/>
|
||||
<Divider />
|
||||
<Text mainUiAction text05>
|
||||
Timezone
|
||||
</Text>
|
||||
<div className="w-full max-w-[28rem]">
|
||||
<InputSelect value={timezone} onValueChange={setTimezone}>
|
||||
<InputSelect.Trigger placeholder="Select a timezone..." />
|
||||
<InputSelect.Content>
|
||||
{timezones.map((tz) => (
|
||||
<InputSelect.Item key={tz} value={tz}>
|
||||
{tz}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</div>
|
||||
{tzError && (
|
||||
<Text secondaryBody text03 className="text-status-error-05">
|
||||
{tzError}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Next runs preview */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<SvgClock size={16} className="text-text-03" />
|
||||
<Text mainUiAction text05>
|
||||
Next 3 runs
|
||||
</Text>
|
||||
</div>
|
||||
{nextRuns.length === 0 ? (
|
||||
<Text secondaryBody text03>
|
||||
{scheduleError
|
||||
? "Fix the schedule above to preview future fires."
|
||||
: "No upcoming fires for this expression."}
|
||||
</Text>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{nextRuns.map((iso, idx) => (
|
||||
<li key={iso} className="flex flex-col">
|
||||
<Text mainUiBody text05>
|
||||
{idx + 1}. {formatAbsolute(iso)}
|
||||
</Text>
|
||||
<Text secondaryBody text03>
|
||||
{formatRelativeShort(iso)} ({timezone})
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => router.push(TASKS_PATH)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isEdit && (
|
||||
<Button
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void submit(true)}
|
||||
data-testid="save-and-run-now"
|
||||
<InputVertical
|
||||
withLabel
|
||||
title="Prompt"
|
||||
description="This message is sent to Craft each time the task fires."
|
||||
>
|
||||
Save and run now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
prominence="primary"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void submit(false)}
|
||||
data-testid="save-task"
|
||||
>
|
||||
{isEdit ? "Save changes" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
<InputTextArea
|
||||
ref={promptTextareaRef}
|
||||
value={prompt}
|
||||
onChange={handlePromptChange}
|
||||
onKeyUp={handlePromptCursorChange}
|
||||
onClick={handlePromptCursorChange}
|
||||
onBlur={() => 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}
|
||||
/>
|
||||
<SkillPickerPopover
|
||||
open={skillPicker.open}
|
||||
anchorRect={skillPicker.anchorRect}
|
||||
query={skillPicker.query}
|
||||
skills={pickerSkills}
|
||||
onSelect={handleSkillPickerSelect}
|
||||
onClose={closeSkillPicker}
|
||||
/>
|
||||
{shownPromptError && (
|
||||
<Text secondaryBody text03 className="text-status-error-05">
|
||||
{shownPromptError}
|
||||
</Text>
|
||||
)}
|
||||
</InputVertical>
|
||||
</GeneralLayouts.Section>
|
||||
|
||||
<Divider paddingParallel="fit" paddingPerpendicular="fit" />
|
||||
|
||||
<GeneralLayouts.Section>
|
||||
<InputVertical title="Schedule">
|
||||
<ScheduleEditor
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
payload={payload}
|
||||
onPayloadChange={setPayload}
|
||||
error={scheduleError}
|
||||
/>
|
||||
</InputVertical>
|
||||
</GeneralLayouts.Section>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,6 +358,5 @@ export function defaultFormInitial(): ScheduleTaskFormInitial {
|
||||
prompt: "",
|
||||
mode: "interval",
|
||||
payload: { unit: "hours", every: 1 },
|
||||
timezone: getBrowserTimezone(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<EditorMode> = new Set<EditorMode>([
|
||||
"interval",
|
||||
"daily_weekly",
|
||||
"advanced",
|
||||
]);
|
||||
|
||||
export default function NewScheduledTaskPage() {
|
||||
@@ -56,21 +52,16 @@ export default function NewScheduledTaskPage() {
|
||||
prompt: promptParam ?? "",
|
||||
mode,
|
||||
payload,
|
||||
timezone: getBrowserTimezone(),
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgClock}
|
||||
title="New scheduled task"
|
||||
description="Save a prompt + schedule. Craft will run it on a timer."
|
||||
backButton={handleBack}
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<ScheduleTaskForm initial={initial} isEdit={false} />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
<ScheduleTaskForm
|
||||
initial={initial}
|
||||
isEdit={false}
|
||||
title="New Scheduled Task"
|
||||
description="Save a prompt + schedule. Craft will run it on a timer."
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<ScheduledTaskListItem[]>(
|
||||
() =>
|
||||
data?.items.map((task) => ({
|
||||
...task,
|
||||
human_readable_schedule: humanReadableScheduleFromCron(
|
||||
task.editor_mode,
|
||||
task.cron_expression
|
||||
),
|
||||
})) ?? [],
|
||||
[data?.items]
|
||||
);
|
||||
const [pendingDelete, setPendingDelete] =
|
||||
useState<ScheduledTaskListItem | null>(null);
|
||||
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
||||
@@ -169,7 +180,7 @@ export default function ScheduledTasksListPage() {
|
||||
href={NEW_TASK_PATH}
|
||||
data-testid="new-task-button"
|
||||
>
|
||||
New scheduled task
|
||||
New Scheduled Task
|
||||
</Button>
|
||||
),
|
||||
[]
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, number> = {
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user