diff --git a/CHANGELOG.md b/CHANGELOG.md index 03870f398..afba9fa06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,12 @@ This section accumulates work toward the **2.1.0** milestone ### Added +#### Scheduler +- **scheduler:** Scheduled tasks accept `interval` (`schedule_spec.every_seconds`) + in addition to `once` and `cron`. Cadence is UTC `now + N` with no missed-beat + catch-up. N is at least `scheduler.min_once_delay_seconds` (default 60s) and at + most 30 days. + #### Authentication - **auth:** Personal access tokens (PAT) for programmatic API access: `POST/GET/DELETE /api/v1/auth/pats` manage tokens (shown once, stored as diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index c813874e1..905d2fb21 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -76,6 +76,11 @@ ### 新增 +#### 调度器 +- **调度器:** 定时任务在 `once` 和 `cron` 之外新增 `interval` + (`schedule_spec.every_seconds`)。节奏为 UTC 的 `now + N`,不补跑错过的节拍。 + N 不小于 `scheduler.min_once_delay_seconds`(默认 60 秒),不大于 30 天。 + #### 认证 - **认证:** 新增用于程序化 API 访问的个人访问令牌(PAT): `POST/GET/DELETE /api/v1/auth/pats` 用于管理令牌(仅展示一次,以 SHA-256 diff --git a/README.md b/README.md index 606834a1e..415cbc51c 100644 --- a/README.md +++ b/README.md @@ -1535,7 +1535,8 @@ Current MVP capabilities: - Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run - Pin each task to `lead_agent` (default) or a custom agent the owner already has; unknown names are rejected - Duplicate an existing task into the create form as an editable draft without copying its run history -- Support `once` and `cron` schedules +- Support `once`, `cron`, and `interval` schedules +- Editing or duplicating an interval task preserves its saved cadence until the interval is explicitly changed, including sub-minute intervals allowed by the operator's scheduler configuration - Run background scheduled executions as non-interactive DeerFlow runs (`ask_clarification` is not exposed there) - Persist a due execution as `queued` when its reused thread or the global execution budget is busy, then launch it when capacity is available; queued occurrences survive Gateway restarts and fail after `scheduler.queue_timeout_seconds` - Freeze a task's definition while an occurrence is `queued`, `launching`, or `running`, so a durable occurrence cannot silently pick up a different prompt, thread, or schedule; transitioning a task to paused or deleting it cancels an existing waiting occurrence, while `launching`/`running` work must finish before those mutations are retried and an explicit manual trigger may still wait and run without resuming a paused schedule @@ -1547,7 +1548,6 @@ Current MVP limits: - No conversation-created `schedule_task` tool yet - No text-only notification jobs - No channel or GitHub dispatch targets -- No `interval` schedule type in this first cut Enable background polling with `config.yaml -> scheduler.enabled`. Manual trigger uses the same scheduled-task resource and execution path. diff --git a/README_fr.md b/README_fr.md index 6e5090971..7ce302bc3 100644 --- a/README_fr.md +++ b/README_fr.md @@ -683,7 +683,7 @@ Capacités actuelles du MVP : - Gérer les tâches depuis `/workspace/scheduled-tasks` - Choisir si chaque tâche planifiée réutilise un thread ou crée un nouveau thread à chaque exécution -- Prendre en charge les planifications `once` et `cron` +- Prendre en charge les planifications `once`, `cron` et `interval` - Exécuter les tâches planifiées en arrière-plan comme des exécutions DeerFlow non interactives (`ask_clarification` n'y est pas exposé) - Utiliser le comportement de chevauchement `skip` pour les exécutions cron dues qui entrent en collision avec une exécution active sur le même thread réutilisé - Mettre en pause, reprendre, déclencher, inspecter l'historique et supprimer les tâches @@ -694,7 +694,6 @@ Limites actuelles du MVP : - Pas encore d'outil `schedule_task` créable depuis la conversation - Pas de tâches de notification en texte seul - Pas de cibles de dispatch canal ou GitHub -- Pas de type de planification `interval` dans cette première version Activez le polling en arrière-plan avec `config.yaml -> scheduler.enabled`. Le déclenchement manuel utilise la même ressource scheduled-task et le même chemin d'exécution. diff --git a/README_ja.md b/README_ja.md index 241c13f42..f220537e6 100644 --- a/README_ja.md +++ b/README_ja.md @@ -670,7 +670,7 @@ DeerFlowには現在、ワークスペース内でファーストクラスのス - `/workspace/scheduled-tasks`でタスクを管理 - 各スケジュールタスクがスレッドを再利用するか、実行ごとに新しいスレッドを作成するかを選択可能 -- `once`と`cron`のスケジュールをサポート +- `once`、`cron`、`interval`のスケジュールをサポート - バックグラウンドのスケジュール実行を非対話型のDeerFlow runとして実行(`ask_clarification`はここでは公開されません) - 再利用された同じスレッド上でアクティブなrunと衝突する期限到来のcron実行に対して`skip`オーバーラップ挙動を使用 - タスクの一時停止、再開、トリガー、履歴確認、削除 @@ -681,7 +681,6 @@ DeerFlowには現在、ワークスペース内でファーストクラスのス - 会話で`schedule_task`ツールを作成する機能はまだありません - テキストのみの通知ジョブはありません - チャネルやGitHubのディスパッチターゲットはありません -- この最初のバージョンでは`interval`スケジュールタイプはありません `config.yaml -> scheduler.enabled`でバックグラウンドポーリングを有効にします。手動トリガーは同じスケジュールタスクリソースと実行パスを使用します。 diff --git a/README_ru.md b/README_ru.md index 6015ee09d..ceb380657 100644 --- a/README_ru.md +++ b/README_ru.md @@ -607,7 +607,7 @@ client.clear_goal("thread-1") - Управление задачами на `/workspace/scheduled-tasks` - Выбор: каждая запланированная задача переиспользует тред или создаёт новый тред для каждого запуска -- Поддержка расписаний `once` и `cron` +- Поддержка расписаний `once`, `cron` и `interval` - Фоновые запланированные запуски выполняются как неинтерактивные запуски DeerFlow (`ask_clarification` там не предоставляется) - При совпадении наступившего cron-запуска с активным запуском на том же переиспользуемом треде применяется поведение перекрытия `skip` - Приостановка, возобновление, ручной запуск, просмотр истории и удаление задач @@ -618,7 +618,6 @@ client.clear_goal("thread-1") - Пока нет инструмента `schedule_task`, создающего задачи в диалоге - Нет заданий с текстовыми уведомлениями - Нет каналов или целей отправки GitHub -- В этой первой версии нет типа расписания `interval` Включите фоновый опрос через `config.yaml -> scheduler.enabled`. Ручной запуск использует тот же ресурс и путь выполнения scheduled-task. diff --git a/README_zh.md b/README_zh.md index c9e50d699..cf3265402 100644 --- a/README_zh.md +++ b/README_zh.md @@ -818,7 +818,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled - 每个定时任务可以选择复用同一个 thread 及其历史对话,也可以选择每次运行新建一个 thread - 每个任务可以固定使用 `lead_agent`(默认)或当前用户已有的自定义 agent;未知名字会被拒绝 - 将现有任务复制到创建表单中作为可编辑草稿,不复制运行历史 -- 支持 `once` 和 `cron` 两种调度方式 +- 支持 `once`、`cron` 和 `interval` 三种调度方式 - 后台定时执行以非交互式 DeerFlow run 运行(那里不会暴露 `ask_clarification`) - 当所复用的 thread 或全局执行配额正忙时,到期执行会持久化为 `queued`,并在可用后启动;队列项在 Gateway 重启后保留,超过 `scheduler.queue_timeout_seconds` 后标记为失败 - 当某次执行处于 `queued`、`launching` 或 `running` 时冻结任务定义,避免持久化的执行意外换用新的 prompt、thread 或调度;将任务切换为暂停或删除任务会取消已在等待的执行,而 `launching`/`running` 执行结束后才能重试这些变更;显式手动触发在调度已暂停时仍可等待并执行,且不会自动恢复调度 @@ -830,7 +830,6 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled - 暂时还没有可在对话中创建任务的 `schedule_task` 工具 - 没有纯文本通知任务 - 没有渠道或 GitHub 分发目标 -- 第一版没有 `interval` 调度类型 通过 `config.yaml -> scheduler.enabled` 开启后台轮询。手动触发使用同样的 scheduled-task 资源和执行路径。 diff --git a/backend/app/gateway/routers/scheduled_tasks.py b/backend/app/gateway/routers/scheduled_tasks.py index 92baa6935..260b6eb75 100644 --- a/backend/app/gateway/routers/scheduled_tasks.py +++ b/backend/app/gateway/routers/scheduled_tasks.py @@ -20,11 +20,13 @@ from app.gateway.deps import ( from deerflow.config.agents_config import AGENT_NAME_PATTERN, load_agent_config from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict from deerflow.scheduler.schedules import ( - next_run_at as compute_next_run_at, + MAX_INTERVAL_SECONDS, + normalize_cron_expression, + parse_interval_seconds, + validate_timezone, ) from deerflow.scheduler.schedules import ( - normalize_cron_expression, - validate_timezone, + next_run_at as compute_next_run_at, ) from deerflow.utils.thread_id import ThreadId @@ -40,6 +42,21 @@ def _active_occurrence_conflict_detail(status: str) -> str: return detail +def _validate_interval_seconds(schedule_spec: dict[str, Any], min_seconds: int) -> int: + every_seconds = parse_interval_seconds(schedule_spec) + if every_seconds < min_seconds: + raise HTTPException( + status_code=422, + detail=f"interval schedule must be at least {min_seconds} seconds", + ) + if every_seconds > MAX_INTERVAL_SECONDS: + raise HTTPException( + status_code=422, + detail=f"interval schedule must be at most {MAX_INTERVAL_SECONDS} seconds", + ) + return every_seconds + + async def resolve_scheduled_task_assistant_id(raw: str | None, *, user_id: str) -> str: """Return a stored assistant id, defaulting to lead_agent. @@ -132,7 +149,7 @@ async def create_scheduled_task(request: Request, body: ScheduledTaskCreateReque raise HTTPException(status_code=422, detail="reuse_thread requires thread_id") if not await thread_store.check_access(body.thread_id, str(user.id), require_existing=True): raise HTTPException(status_code=404, detail="Thread not found") - if body.schedule_type not in {"once", "cron"}: + if body.schedule_type not in {"once", "cron", "interval"}: raise HTTPException(status_code=422, detail="Unsupported schedule_type") schedule_spec = dict(body.schedule_spec) @@ -143,6 +160,8 @@ async def create_scheduled_task(request: Request, body: ScheduledTaskCreateReque if not isinstance(raw_cron, str): raise HTTPException(status_code=422, detail="cron schedule requires schedule_spec.cron") schedule_spec["cron"] = normalize_cron_expression(raw_cron) + if body.schedule_type == "interval": + _validate_interval_seconds(schedule_spec, config.scheduler.min_once_delay_seconds) next_run_at = compute_next_run_at( body.schedule_type, schedule_spec, @@ -245,12 +264,31 @@ async def update_scheduled_task(task_id: str, request: Request, body: ScheduledT detail="cron schedule requires schedule_spec.cron", ) schedule_spec["cron"] = normalize_cron_expression(raw_cron) - next_run_at = compute_next_run_at( - existing["schedule_type"], - schedule_spec, - timezone, - now=datetime.now(UTC), - ) + if existing["schedule_type"] == "interval": + every_seconds = _validate_interval_seconds( + schedule_spec, + config.scheduler.min_once_delay_seconds, + ) + try: + previous_seconds = parse_interval_seconds(dict(existing["schedule_spec"])) + except ValueError: + previous_seconds = None + if previous_seconds == every_seconds and existing.get("next_run_at") is not None: + next_run_at = existing["next_run_at"] + else: + next_run_at = compute_next_run_at( + existing["schedule_type"], + schedule_spec, + timezone, + now=datetime.now(UTC), + ) + else: + next_run_at = compute_next_run_at( + existing["schedule_type"], + schedule_spec, + timezone, + now=datetime.now(UTC), + ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc if existing["schedule_type"] == "once" and next_run_at is None: diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 3d04c035e..895f8389f 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -386,7 +386,7 @@ Notes: - Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend, enable run ownership heartbeats, and set `run_events.backend: db`. SQLite silently ignores row-level locks, while memory and JSONL run-event stores are process-local and cannot enforce singleton delivery receipts across workers; startup rejects these combinations. The process-local agentic browser tool group is incompatible with multiple Gateway workers; keep `GATEWAY_WORKERS=1` while `browser_navigate` is enabled. Browser control also requires the backend `browser` extra (`cd backend && uv sync --extra browser && uv run playwright install chromium`); startup detects enabled browser config and fails fast when Playwright is missing, and `/api/features` reports `browser_control.enabled=false` until the runtime is available. - The MVP supports thread reuse and fresh-thread-per-run execution modes. - Create/update accept optional `assistant_id` (`lead_agent` by default, or an existing custom agent for the task owner). -- The MVP supports only `once` and `cron`. +- Create/update accept `once`, `cron`, and `interval`. Interval uses `schedule_spec.every_seconds` (UTC `now + N`, no missed-beat catch-up). N is at least `min_once_delay_seconds` (default 60) and at most 30 days. - Manual trigger uses the same scheduled-task resource and run lifecycle. - Scheduled task definitions and task-run history are persisted in the application database. diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index b03aef5ad..70efaa9ff 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -285,7 +285,7 @@ class AppConfig(BaseModel): default_factory=SchedulerConfig, description=format_field_description( "scheduler", - field_doc="Scheduled task runtime configuration (background poller for one-time and cron agent runs).", + field_doc="Scheduled task runtime configuration (background poller for one-time, cron, and interval agent runs).", ), ) mcp_tasks: McpTasksConfig = Field( diff --git a/backend/packages/harness/deerflow/scheduler/schedules.py b/backend/packages/harness/deerflow/scheduler/schedules.py index a7783e1a3..50092c60c 100644 --- a/backend/packages/harness/deerflow/scheduler/schedules.py +++ b/backend/packages/harness/deerflow/scheduler/schedules.py @@ -1,10 +1,12 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from croniter import croniter +MAX_INTERVAL_SECONDS = 30 * 24 * 60 * 60 + def validate_timezone(timezone_name: str) -> str: try: @@ -21,6 +23,13 @@ def normalize_cron_expression(expr: str) -> str: return " ".join(parts) +def parse_interval_seconds(schedule_spec: dict[str, object]) -> int: + raw = schedule_spec.get("every_seconds") + if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1: + raise ValueError("interval schedule requires every_seconds as a positive integer") + return raw + + def next_run_at( schedule_type: str, schedule_spec: dict[str, object], @@ -56,4 +65,8 @@ def next_run_at( next_local = next_local.replace(tzinfo=zone) return next_local.astimezone(UTC) + if schedule_type == "interval": + every_seconds = parse_interval_seconds(schedule_spec) + return now.astimezone(UTC) + timedelta(seconds=every_seconds) + raise ValueError(f"Unsupported schedule_type: {schedule_type}") diff --git a/backend/tests/test_scheduled_task_router_behavior.py b/backend/tests/test_scheduled_task_router_behavior.py index bcd8e49eb..ffce4e6bd 100644 --- a/backend/tests/test_scheduled_task_router_behavior.py +++ b/backend/tests/test_scheduled_task_router_behavior.py @@ -944,6 +944,63 @@ async def test_update_terminal_once_task_with_future_run_at_rearms_it(): assert result["next_run_at"] is not None +def _interval_create_request(**overrides): + kwargs = { + "title": "Every 90 minutes", + "prompt": "Ping", + "schedule_type": "interval", + "schedule_spec": {"every_seconds": 90}, + "timezone": "UTC", + } + kwargs.update(overrides) + return scheduled_tasks.ScheduledTaskCreateRequest(**kwargs) + + +async def _call_create(body, repo=None, config=None): + repo = repo or _Repo() + user = SimpleNamespace(id="user-1") + thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True)) + old_repo = scheduled_tasks.get_scheduled_task_repo + old_thread_store = scheduled_tasks.get_thread_store + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_thread_store = lambda _request: thread_store + scheduled_tasks.get_config = lambda: config or _Config() + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + return await call_unwrapped( + scheduled_tasks.create_scheduled_task, + request=SimpleNamespace(), + body=body, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_thread_store = old_thread_store + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + +async def _call_update(repo, task_id, body): + old_repo = scheduled_tasks.get_scheduled_task_repo + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_config = lambda: _Config() + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=SimpleNamespace(id="user-1")) + return await call_unwrapped( + scheduled_tasks.update_scheduled_task, + task_id=task_id, + request=SimpleNamespace(), + body=body, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + def _create_request(**overrides): kwargs = { "title": "Daily summary", @@ -956,29 +1013,164 @@ def _create_request(**overrides): return scheduled_tasks.ScheduledTaskCreateRequest(**kwargs) -async def _call_create(body, repo=None): - repo = repo or _Repo() - user = SimpleNamespace(id="user-1") - thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True)) - old_repo = scheduled_tasks.get_scheduled_task_repo - old_thread_store = scheduled_tasks.get_thread_store - old_config = scheduled_tasks.get_config - old_user = scheduled_tasks.get_optional_user_from_request - try: - scheduled_tasks.get_scheduled_task_repo = lambda _request: repo - scheduled_tasks.get_thread_store = lambda _request: thread_store - scheduled_tasks.get_config = lambda: _Config() - scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) - return await call_unwrapped( - scheduled_tasks.create_scheduled_task, - request=SimpleNamespace(), - body=body, +async def _seed_task(repo: _Repo, **overrides): + kwargs = { + "task_id": "task-1", + "user_id": "user-1", + "thread_id": None, + "context_mode": "fresh_thread_per_run", + "assistant_id": "lead_agent", + "title": "Daily summary", + "prompt": "Summarize thread", + "schedule_type": "cron", + "schedule_spec": {"cron": "0 9 * * *"}, + "timezone": "UTC", + "next_run_at": None, + } + kwargs.update(overrides) + return await repo.create(**kwargs) + + +@pytest.mark.asyncio +async def test_create_interval_task_sets_next_run_from_now(): + before = datetime.now(UTC) + created = await _call_create( + _interval_create_request( + schedule_spec={"every_seconds": 90}, + timezone="Asia/Shanghai", ) - finally: - scheduled_tasks.get_scheduled_task_repo = old_repo - scheduled_tasks.get_thread_store = old_thread_store - scheduled_tasks.get_config = old_config - scheduled_tasks.get_optional_user_from_request = old_user + ) + after = datetime.now(UTC) + assert created["schedule_type"] == "interval" + assert created["schedule_spec"] == {"every_seconds": 90} + assert created["timezone"] == "Asia/Shanghai" + assert before + timedelta(seconds=90) <= created["next_run_at"] <= after + timedelta(seconds=90) + assert created["next_run_at"].utcoffset() == timedelta(0) + + +@pytest.mark.asyncio +async def test_create_interval_task_rejects_below_minimum_delay(): + with pytest.raises(HTTPException) as exc_info: + await _call_create(_interval_create_request(schedule_spec={"every_seconds": 30})) + assert exc_info.value.status_code == 422 + assert "at least 60 seconds" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_create_interval_task_rejects_above_maximum(): + with pytest.raises(HTTPException) as exc_info: + await _call_create(_interval_create_request(schedule_spec={"every_seconds": 30 * 24 * 3600 + 1})) + assert exc_info.value.status_code == 422 + assert "at most" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_create_interval_task_rejects_missing_every_seconds(): + with pytest.raises(HTTPException) as exc_info: + await _call_create(_interval_create_request(schedule_spec={})) + assert exc_info.value.status_code == 422 + assert "every_seconds" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_update_interval_task_recomputes_next_run(): + repo = _Repo() + task = await repo.create( + task_id="task-interval", + user_id="user-1", + thread_id=None, + context_mode="fresh_thread_per_run", + assistant_id="lead_agent", + title="Interval", + prompt="p", + schedule_type="interval", + schedule_spec={"every_seconds": 90}, + timezone="UTC", + next_run_at=datetime(2026, 7, 1, 0, 0, tzinfo=UTC), + ) + before = datetime.now(UTC) + updated = await _call_update( + repo, + task["id"], + scheduled_tasks.ScheduledTaskUpdateRequest(schedule_spec={"every_seconds": 120}), + ) + after = datetime.now(UTC) + assert updated["schedule_spec"] == {"every_seconds": 120} + assert before + timedelta(seconds=120) <= updated["next_run_at"] <= after + timedelta(seconds=120) + + +@pytest.mark.asyncio +async def test_update_interval_task_rejects_below_minimum_delay(): + repo = _Repo() + task = await repo.create( + task_id="task-interval", + user_id="user-1", + thread_id=None, + context_mode="fresh_thread_per_run", + assistant_id="lead_agent", + title="Interval", + prompt="p", + schedule_type="interval", + schedule_spec={"every_seconds": 90}, + timezone="UTC", + next_run_at=datetime(2026, 7, 1, 0, 0, tzinfo=UTC), + ) + with pytest.raises(HTTPException) as exc_info: + await _call_update( + repo, + task["id"], + scheduled_tasks.ScheduledTaskUpdateRequest(schedule_spec={"every_seconds": 30}), + ) + assert exc_info.value.status_code == 422 + assert "at least 60 seconds" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_update_interval_task_keeps_next_run_when_spec_unchanged(): + repo = _Repo() + original_next = datetime(2026, 7, 1, 0, 0, tzinfo=UTC) + task = await repo.create( + task_id="task-interval", + user_id="user-1", + thread_id=None, + context_mode="fresh_thread_per_run", + assistant_id="lead_agent", + title="Interval", + prompt="p", + schedule_type="interval", + schedule_spec={"every_seconds": 90}, + timezone="UTC", + next_run_at=original_next, + ) + updated = await _call_update( + repo, + task["id"], + scheduled_tasks.ScheduledTaskUpdateRequest( + schedule_spec={"every_seconds": 90}, + timezone="Asia/Shanghai", + ), + ) + assert updated["timezone"] == "Asia/Shanghai" + assert updated["next_run_at"] == original_next + + +@pytest.mark.asyncio +async def test_create_interval_task_rejects_non_integer_every_seconds(): + with pytest.raises(HTTPException) as exc_info: + await _call_create(_interval_create_request(schedule_spec={"every_seconds": True})) + assert exc_info.value.status_code == 422 + assert "every_seconds" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_create_interval_task_uses_configured_minimum_delay(): + with pytest.raises(HTTPException) as exc_info: + await _call_create( + _interval_create_request(schedule_spec={"every_seconds": 90}), + config=_Config(min_once_delay_seconds=120), + ) + assert exc_info.value.status_code == 422 + assert "at least 120 seconds" in exc_info.value.detail @pytest.mark.asyncio @@ -1066,44 +1258,6 @@ async def test_update_custom_assistant_id_is_persisted(): assert updated["assistant_id"] == "triage-bot" -async def _seed_task(repo: _Repo, **overrides): - kwargs = { - "task_id": "task-1", - "user_id": "user-1", - "thread_id": None, - "context_mode": "fresh_thread_per_run", - "assistant_id": "lead_agent", - "title": "Daily summary", - "prompt": "Summarize thread", - "schedule_type": "cron", - "schedule_spec": {"cron": "0 9 * * *"}, - "timezone": "UTC", - "next_run_at": None, - } - kwargs.update(overrides) - return await repo.create(**kwargs) - - -async def _call_update(repo: _Repo, task_id: str, body): - old_repo = scheduled_tasks.get_scheduled_task_repo - old_config = scheduled_tasks.get_config - old_user = scheduled_tasks.get_optional_user_from_request - try: - scheduled_tasks.get_scheduled_task_repo = lambda _request: repo - scheduled_tasks.get_config = lambda: _Config() - scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=SimpleNamespace(id="user-1")) - return await call_unwrapped( - scheduled_tasks.update_scheduled_task, - task_id=task_id, - request=SimpleNamespace(), - body=body, - ) - finally: - scheduled_tasks.get_scheduled_task_repo = old_repo - scheduled_tasks.get_config = old_config - scheduled_tasks.get_optional_user_from_request = old_user - - @pytest.mark.asyncio async def test_update_unknown_assistant_id_is_rejected(): repo = _Repo() diff --git a/backend/tests/test_scheduled_task_schedules.py b/backend/tests/test_scheduled_task_schedules.py index 48644a9df..b02da11fa 100644 --- a/backend/tests/test_scheduled_task_schedules.py +++ b/backend/tests/test_scheduled_task_schedules.py @@ -5,6 +5,7 @@ import pytest from deerflow.scheduler.schedules import ( next_run_at, normalize_cron_expression, + parse_interval_seconds, validate_timezone, ) @@ -71,3 +72,60 @@ def test_next_run_at_for_cron_uses_timezone(): now=now, ) assert result == datetime(2026, 7, 1, 1, 0, tzinfo=UTC) + + +def test_next_run_at_for_interval_adds_seconds_in_utc(): + now = datetime(2026, 7, 1, 0, 0, tzinfo=UTC) + result = next_run_at( + "interval", + {"every_seconds": 90}, + "UTC", + now=now, + ) + assert result == datetime(2026, 7, 1, 0, 1, 30, tzinfo=UTC) + assert result.utcoffset() == timedelta(0) + + +def test_next_run_at_for_interval_ignores_timezone(): + now = datetime(2026, 7, 1, 0, 0, tzinfo=UTC) + shanghai = next_run_at( + "interval", + {"every_seconds": 5400}, + "Asia/Shanghai", + now=now, + ) + utc = next_run_at( + "interval", + {"every_seconds": 5400}, + "UTC", + now=now, + ) + assert shanghai == utc == datetime(2026, 7, 1, 1, 30, tzinfo=UTC) + + +def test_next_run_at_for_interval_does_not_catch_up_from_a_stale_now(): + # A late poller must schedule from the compute instant, not fill missed beats. + now = datetime(2026, 7, 1, 0, 10, tzinfo=UTC) + result = next_run_at( + "interval", + {"every_seconds": 60}, + "UTC", + now=now, + ) + assert result == datetime(2026, 7, 1, 0, 11, tzinfo=UTC) + + +@pytest.mark.parametrize( + "spec", + [ + {}, + {"every_seconds": "90"}, + {"every_seconds": 90.0}, + {"every_seconds": True}, + {"every_seconds": 0}, + {"every_seconds": -30}, + ], +) +def test_parse_interval_seconds_rejects_invalid_spec(spec): + with pytest.raises(ValueError, match="every_seconds"): + parse_interval_seconds(spec) diff --git a/config.example.yaml b/config.example.yaml index 9da47b505..aee154483 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2314,7 +2314,7 @@ agent_storage: # ============================================================================ # Scheduled Tasks Configuration # ============================================================================ -# Background scheduler for one-time and recurring (cron) agent runs. +# Background scheduler for one-time, cron, and interval agent runs. # Poller fields (enabled, multi_instance, poll_interval_seconds, lease_seconds, # max_concurrent_runs, min_once_delay_seconds) are restart-required. # recursion_limit is read at dispatch and applies to the next scheduled run @@ -2333,7 +2333,7 @@ agent_storage: # lease_seconds: 120 # Claim lease; a crashed process's task becomes reclaimable after this # max_concurrent_runs: 3 # Global cap on launching/running scheduled runs across multi-instance Pods # queue_timeout_seconds: 3600 # Maximum durable queue wait before an occurrence fails -# min_once_delay_seconds: 60 # Minimum future offset for one-time tasks at creation time +# min_once_delay_seconds: 60 # Floor for one-time run_at offset and interval every_seconds # recursion_limit: 1000 # LangGraph super-step cap for scheduled runs (matches the web UI) scheduler: enabled: false diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index d4b01bf7f..b739477eb 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -77,6 +77,13 @@ More specific `AGENTS.md` files under `src/` contain the frontend sections split ## Environment +Scheduled-task interval forms preserve the initial `every_seconds` on mount, +timezone changes, and untouched blur. The backend's configurable interval minimum +can be lower than the UI's default 60-second floor. Apply that UI floor only after +an explicit amount/unit edit so editing metadata or duplicating a task cannot +silently change its cadence. Component regressions live in +`tests/unit/components/workspace/scheduled-task-schedule-input.dom.test.tsx`. + Backend API URLs are optional; an nginx proxy is used by default: ``` diff --git a/frontend/src/app/workspace/scheduled-tasks/page.tsx b/frontend/src/app/workspace/scheduled-tasks/page.tsx index 348cfc675..7de386685 100644 --- a/frontend/src/app/workspace/scheduled-tasks/page.tsx +++ b/frontend/src/app/workspace/scheduled-tasks/page.tsx @@ -36,6 +36,7 @@ import { import { listAgents } from "@/core/agents/api"; import { useAgentsApiEnabled } from "@/core/agents/hooks"; import { useI18n } from "@/core/i18n/hooks"; +import { hasScheduleSpec } from "@/core/scheduled-tasks/cron"; import { useCreateScheduledTask, useUpdateScheduledTask, @@ -139,7 +140,9 @@ export default function ScheduledTasksPage() { const [statusFilter, setStatusFilter] = useState< "all" | "enabled" | "paused" | "running" | "completed" | "failed" >("all"); - const [typeFilter, setTypeFilter] = useState<"all" | "once" | "cron">("all"); + const [typeFilter, setTypeFilter] = useState< + "all" | "once" | "cron" | "interval" + >("all"); const [formError, setFormError] = useState(null); const [editing, setEditing] = useState(false); const [editTitle, setEditTitle] = useState(""); @@ -197,7 +200,9 @@ export default function ScheduledTasksPage() { ? st.scheduleType.cron : v === "once" ? st.scheduleType.once - : v; + : v === "interval" + ? st.scheduleType.interval + : v; const statusLabel = (v: string) => (st.status as Record)[v] ?? v; const contextModeLabel = (v: string) => @@ -270,12 +275,17 @@ export default function ScheduledTasksPage() { const spec = selectedTask.schedule_spec as { cron?: string; run_at?: string; + every_seconds?: number; }; setEditSchedule({ schedule_type: selectedTask.schedule_type, schedule_spec: { cron: typeof spec.cron === "string" ? spec.cron : undefined, run_at: typeof spec.run_at === "string" ? spec.run_at : undefined, + every_seconds: + typeof spec.every_seconds === "number" + ? spec.every_seconds + : undefined, }, timezone: selectedTask.timezone || "UTC", }); @@ -387,9 +397,9 @@ export default function ScheduledTasksPage() { )} +
0) { + return secondsToInterval(raw); + } + return { amount: 60, unit: "minutes" }; +} + const PRESETS: CronPreset[] = [ "hourly", "daily", @@ -91,7 +109,7 @@ export function ScheduledTaskScheduleInput({ const schedLocale: ScheduleLocale = locale.startsWith("zh") ? "zh" : "en"; const labels = t.scheduledTasks; - const [scheduleType, setScheduleType] = useState<"once" | "cron">( + const [scheduleType, setScheduleType] = useState( initial.schedule_type, ); const [preset, setPreset] = useState( @@ -111,6 +129,15 @@ export function ScheduledTaskScheduleInput({ const [timezone, setTimezone] = useState( initial.timezone || detectBrowserTimezone(), ); + const initialInterval = parseInitialInterval(initial.schedule_spec); + const [intervalAmount, setIntervalAmount] = useState(initialInterval.amount); + const [intervalAmountText, setIntervalAmountText] = useState( + String(initialInterval.amount), + ); + const [intervalUnit, setIntervalUnit] = useState( + initialInterval.unit, + ); + const [intervalEdited, setIntervalEdited] = useState(false); // Hold the latest onChange in a ref so the effect below does not depend on // it. This avoids a re-render loop: if the parent passes an inline @@ -132,6 +159,22 @@ export function ScheduledTaskScheduleInput({ }); return; } + if (scheduleType === "interval") { + // Existing API-approved intervals may be below the default UI floor. + // Preserve their cadence on edit/duplicate until the amount or unit is + // explicitly changed; the server owns the configurable minimum. + const amount = intervalEdited + ? clampIntervalAmount(intervalAmount, intervalUnit) + : intervalAmount; + onChangeRef.current({ + schedule_type: "interval", + schedule_spec: { + every_seconds: intervalToSeconds(amount, intervalUnit), + }, + timezone, + }); + return; + } const cron = preset === "custom" ? (parts.raw ?? "") : serializeCron(preset, parts); onChangeRef.current({ @@ -139,7 +182,16 @@ export function ScheduledTaskScheduleInput({ schedule_spec: cron ? { cron } : {}, timezone, }); - }, [scheduleType, preset, parts, runAtLocal, timezone]); + }, [ + scheduleType, + preset, + parts, + runAtLocal, + timezone, + intervalAmount, + intervalUnit, + intervalEdited, + ]); function updateParts(patch: Partial) { setParts((prev) => ({ ...prev, ...patch })); @@ -178,7 +230,15 @@ export function ScheduledTaskScheduleInput({ } const preview = describeSchedule( - { scheduleType, preset, parts, runAtLocal, timezone }, + { + scheduleType, + preset, + parts, + runAtLocal, + intervalAmount, + intervalUnit, + timezone, + }, schedLocale, ); @@ -200,6 +260,13 @@ export function ScheduledTaskScheduleInput({ > {labels.scheduleType.once} +
)} @@ -300,6 +367,66 @@ export function ScheduledTaskScheduleInput({
)} + ) : scheduleType === "interval" ? ( +
+ { + // Do not clamp on every keystroke: typing 90 would otherwise + // become 9 -> 60, then 600. Emit/blur still apply the floor. + const raw = e.target.value; + setIntervalEdited(true); + setIntervalAmountText(raw); + const next = Number(raw); + if (!Number.isInteger(next) || next <= 0) { + return; + } + setIntervalAmount(next); + }} + onBlur={() => { + if (!intervalEdited) return; + const next = clampIntervalAmount( + Number(intervalAmountText), + intervalUnit, + ); + setIntervalAmount(next); + setIntervalAmountText(String(next)); + }} + aria-label={labels.fields.intervalAmount} + /> + +
) : ( {preview} + {scheduleType === "interval" && intervalUnit === "seconds" && ( +
+ {labels.fields.intervalMinHint} +
+ )} ); } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index ca40475fd..f0c31bcaf 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -450,6 +450,7 @@ export const enUS: Translations = { scheduleType: { cron: "Recurring", once: "One-time", + interval: "Interval", }, preset: { label: "Repeat", @@ -468,6 +469,11 @@ export const enUS: Translations = { cronPlaceholder: "0 9 * * *", runAt: "Run at", timezone: "Timezone", + intervalAmount: "Every", + intervalUnitSeconds: "seconds", + intervalUnitMinutes: "minutes", + intervalUnitHours: "hours", + intervalMinHint: "Minimum 60 seconds (the default server floor).", }, weekdays: { mon: "Mon", @@ -506,6 +512,7 @@ export const enUS: Translations = { allTypes: "All types", cron: "Cron", once: "Once", + interval: "Interval", }, detail: { contextMode: "Context mode", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index b25261f7f..9d652958a 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -360,7 +360,7 @@ export interface Translations { // Scheduled tasks scheduledTasks: { - scheduleType: { cron: string; once: string }; + scheduleType: { cron: string; once: string; interval: string }; preset: { label: string; hourly: string; @@ -378,6 +378,11 @@ export interface Translations { cronPlaceholder: string; runAt: string; timezone: string; + intervalAmount: string; + intervalUnitSeconds: string; + intervalUnitMinutes: string; + intervalUnitHours: string; + intervalMinHint: string; }; weekdays: { mon: string; @@ -415,6 +420,7 @@ export interface Translations { allTypes: string; cron: string; once: string; + interval: string; }; detail: { contextMode: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 4f6aa3029..0842e4273 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -425,6 +425,7 @@ export const zhCN: Translations = { scheduleType: { cron: "重复", once: "单次", + interval: "间隔", }, preset: { label: "重复方式", @@ -443,6 +444,11 @@ export const zhCN: Translations = { cronPlaceholder: "0 9 * * *", runAt: "运行时间", timezone: "时区", + intervalAmount: "每", + intervalUnitSeconds: "秒", + intervalUnitMinutes: "分钟", + intervalUnitHours: "小时", + intervalMinHint: "最短 60 秒(默认服务端下限)。", }, weekdays: { mon: "周一", @@ -481,6 +487,7 @@ export const zhCN: Translations = { allTypes: "全部类型", cron: "定时", once: "单次", + interval: "间隔", }, detail: { contextMode: "上下文模式", diff --git a/frontend/src/core/scheduled-tasks/api.ts b/frontend/src/core/scheduled-tasks/api.ts index 1a5da3c2c..4a735eb8d 100644 --- a/frontend/src/core/scheduled-tasks/api.ts +++ b/frontend/src/core/scheduled-tasks/api.ts @@ -55,7 +55,7 @@ export type ScheduledTaskPayload = { assistant_id?: string | null; title: string; prompt: string; - schedule_type: "once" | "cron"; + schedule_type: "once" | "cron" | "interval"; schedule_spec: Record; timezone: string; }; diff --git a/frontend/src/core/scheduled-tasks/cron.ts b/frontend/src/core/scheduled-tasks/cron.ts index 3802154e4..0a1d3dd05 100644 --- a/frontend/src/core/scheduled-tasks/cron.ts +++ b/frontend/src/core/scheduled-tasks/cron.ts @@ -19,15 +19,105 @@ export type CronParts = { raw?: string; }; +export type ScheduleType = "once" | "cron" | "interval"; + +export type IntervalUnit = "seconds" | "minutes" | "hours"; + +export const MAX_INTERVAL_SECONDS = 30 * 24 * 60 * 60; +export const DEFAULT_INTERVAL_MIN_SECONDS = 60; + export type ScheduleFormState = { - scheduleType: "once" | "cron"; + scheduleType: ScheduleType; preset?: CronPreset; parts?: CronParts; /** datetime-local wall value "YYYY-MM-DDTHH:mm", interpreted in `timezone`. */ runAtLocal?: string; + intervalAmount?: number; + intervalUnit?: IntervalUnit; timezone: string; }; +export function intervalToSeconds(amount: number, unit: IntervalUnit): number { + if (!Number.isInteger(amount) || amount < 1) { + throw new Error("interval amount must be a positive integer"); + } + if (unit === "hours") { + return amount * 3600; + } + if (unit === "minutes") { + return amount * 60; + } + return amount; +} + +export function secondsToInterval(everySeconds: number): { + amount: number; + unit: IntervalUnit; +} { + if ( + Number.isInteger(everySeconds) && + everySeconds >= 3600 && + everySeconds % 3600 === 0 + ) { + return { amount: everySeconds / 3600, unit: "hours" }; + } + if ( + Number.isInteger(everySeconds) && + everySeconds >= 60 && + everySeconds % 60 === 0 + ) { + return { amount: everySeconds / 60, unit: "minutes" }; + } + return { + amount: Math.max(1, Math.trunc(everySeconds) || 1), + unit: "seconds", + }; +} + +export function maxIntervalAmount(unit: IntervalUnit): number { + if (unit === "hours") { + return MAX_INTERVAL_SECONDS / 3600; + } + if (unit === "minutes") { + return MAX_INTERVAL_SECONDS / 60; + } + return MAX_INTERVAL_SECONDS; +} + +export function minIntervalAmount(unit: IntervalUnit): number { + // Matches scheduler.min_once_delay_seconds default. Minutes/hours already + // start at 60s; seconds must not go below that or create/edit 422s. + if (unit === "seconds") { + return DEFAULT_INTERVAL_MIN_SECONDS; + } + return 1; +} + +export function clampIntervalAmount( + amount: number, + unit: IntervalUnit, +): number { + const min = minIntervalAmount(unit); + if (!Number.isFinite(amount)) { + return min; + } + return Math.min(Math.max(min, Math.trunc(amount)), maxIntervalAmount(unit)); +} + +export function hasScheduleSpec(spec: { + cron?: unknown; + run_at?: unknown; + every_seconds?: unknown; +}): boolean { + if (typeof spec.cron === "string" && spec.cron.trim()) { + return true; + } + if (typeof spec.run_at === "string" && spec.run_at.trim()) { + return true; + } + return typeof spec.every_seconds === "number" && spec.every_seconds > 0; +} + export type ScheduleLocale = "en" | "zh"; export const WEEKDAYS: Weekday[] = [ @@ -210,6 +300,27 @@ export function describeSchedule( return zh ? `单次 ${runAt} (${tz})` : `Once at ${runAt} (${tz})`; } + if (state.scheduleType === "interval") { + const amount = state.intervalAmount ?? 1; + const unit = state.intervalUnit ?? "minutes"; + if (zh) { + if (unit === "hours") { + return `每 ${amount} 小时`; + } + if (unit === "seconds") { + return `每 ${amount} 秒`; + } + return `每 ${amount} 分钟`; + } + if (unit === "hours") { + return amount === 1 ? "Every hour" : `Every ${amount} hours`; + } + if (unit === "seconds") { + return amount === 1 ? "Every second" : `Every ${amount} seconds`; + } + return amount === 1 ? "Every minute" : `Every ${amount} minutes`; + } + const parts = state.parts ?? {}; const hhmm = `${pad2(parts.hour ?? 0)}:${pad2(parts.minute ?? 0)}`; diff --git a/frontend/src/core/scheduled-tasks/types.ts b/frontend/src/core/scheduled-tasks/types.ts index b5c0acd27..2304bf150 100644 --- a/frontend/src/core/scheduled-tasks/types.ts +++ b/frontend/src/core/scheduled-tasks/types.ts @@ -5,7 +5,7 @@ export type ScheduledTask = { assistant_id: string | null; title: string; prompt: string; - schedule_type: "once" | "cron"; + schedule_type: "once" | "cron" | "interval"; schedule_spec: Record; timezone: string; status: diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index 5a3a04684..8f3f6d65f 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -75,7 +75,7 @@ export type MockAPIOptions = { last_thread_id?: string | null; title: string; prompt: string; - schedule_type: "once" | "cron"; + schedule_type: "once" | "cron" | "interval"; schedule_spec: Record; timezone: string; status: @@ -492,7 +492,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { last_thread_id: null, title, prompt, - schedule_type: payload.schedule_type as "once" | "cron", + schedule_type: payload.schedule_type as "once" | "cron" | "interval", schedule_spec: (payload.schedule_spec as Record) ?? {}, timezone, status: "enabled" as const, diff --git a/frontend/tests/unit/components/workspace/scheduled-task-schedule-input.dom.test.tsx b/frontend/tests/unit/components/workspace/scheduled-task-schedule-input.dom.test.tsx new file mode 100644 index 000000000..94616130b --- /dev/null +++ b/frontend/tests/unit/components/workspace/scheduled-task-schedule-input.dom.test.tsx @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test } from "@rstest/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +import { + ScheduledTaskScheduleInput, + type ScheduleValue, +} from "@/components/workspace/scheduled-task-schedule-input"; +import { I18nProvider } from "@/core/i18n/context"; + +afterEach(cleanup); + +describe("ScheduledTaskScheduleInput", () => { + test.each([1, 30, 59])( + "preserves an existing %s-second interval until explicitly edited", + (everySeconds) => { + const emitted: ScheduleValue[] = []; + render( + + emitted.push(value)} + /> + , + ); + + const amountInput = screen.getByRole("spinbutton"); + expect(amountInput.value).toBe(String(everySeconds)); + expect(emitted.at(-1)?.schedule_spec.every_seconds).toBe(everySeconds); + + fireEvent.focus(amountInput); + fireEvent.blur(amountInput); + expect(amountInput.value).toBe(String(everySeconds)); + expect(emitted.at(-1)?.schedule_spec.every_seconds).toBe(everySeconds); + + fireEvent.change(amountInput, { target: { value: "9" } }); + expect(amountInput.value).toBe("9"); + expect(emitted.at(-1)?.schedule_spec.every_seconds).toBe(60); + fireEvent.blur(amountInput); + expect(amountInput.value).toBe("60"); + }, + ); + + test("keeps interval text editable until blur applies the floor", () => { + const emitted: ScheduleValue[] = []; + + render( + + emitted.push(value)} + /> + , + ); + + const amountInput = screen.getByRole("spinbutton"); + expect((amountInput as HTMLInputElement).value).toBe("90"); + expect(emitted.at(-1)?.schedule_spec.every_seconds).toBe(90); + + fireEvent.change(amountInput, { target: { value: "9" } }); + expect((amountInput as HTMLInputElement).value).toBe("9"); + expect(emitted.at(-1)?.schedule_spec.every_seconds).toBe(60); + + fireEvent.change(amountInput, { target: { value: "" } }); + expect((amountInput as HTMLInputElement).value).toBe(""); + expect(emitted.at(-1)?.schedule_spec.every_seconds).toBe(60); + + fireEvent.blur(amountInput); + expect((amountInput as HTMLInputElement).value).toBe("60"); + expect(emitted.at(-1)?.schedule_spec.every_seconds).toBe(60); + }); +}); diff --git a/frontend/tests/unit/core/scheduled-tasks/cron.test.ts b/frontend/tests/unit/core/scheduled-tasks/cron.test.ts index eb5574be0..bbf684737 100644 --- a/frontend/tests/unit/core/scheduled-tasks/cron.test.ts +++ b/frontend/tests/unit/core/scheduled-tasks/cron.test.ts @@ -2,7 +2,12 @@ import { describe, expect, test } from "@rstest/core"; import { describeSchedule, + hasScheduleSpec, + clampIntervalAmount, + intervalToSeconds, + minIntervalAmount, parseCron, + secondsToInterval, serializeCron, utcToZonedLocalInput, zonedLocalToUtcIso, @@ -259,6 +264,108 @@ describe("describeSchedule", () => { ), ).toBe("Custom: */5 * * * * (UTC)"); }); + + test("interval minutes en/zh omit timezone", () => { + expect( + describeSchedule( + { + scheduleType: "interval", + intervalAmount: 90, + intervalUnit: "minutes", + timezone: "Asia/Shanghai", + }, + "en", + ), + ).toBe("Every 90 minutes"); + expect( + describeSchedule( + { + scheduleType: "interval", + intervalAmount: 90, + intervalUnit: "minutes", + timezone: "Asia/Shanghai", + }, + "zh", + ), + ).toBe("每 90 分钟"); + }); + + test("interval singular hour en", () => { + expect( + describeSchedule( + { + scheduleType: "interval", + intervalAmount: 1, + intervalUnit: "hours", + timezone: "UTC", + }, + "en", + ), + ).toBe("Every hour"); + }); + + test("interval seconds en/zh omit timezone", () => { + expect( + describeSchedule( + { + scheduleType: "interval", + intervalAmount: 90, + intervalUnit: "seconds", + timezone: "Asia/Shanghai", + }, + "en", + ), + ).toBe("Every 90 seconds"); + expect( + describeSchedule( + { + scheduleType: "interval", + intervalAmount: 90, + intervalUnit: "seconds", + timezone: "Asia/Shanghai", + }, + "zh", + ), + ).toBe("每 90 秒"); + }); +}); + +describe("interval conversion", () => { + test("minutes and hours convert to seconds", () => { + expect(intervalToSeconds(90, "seconds")).toBe(90); + expect(intervalToSeconds(90, "minutes")).toBe(5400); + expect(intervalToSeconds(2, "hours")).toBe(7200); + }); + + test("whole hours stay in hours; whole minutes stay in minutes", () => { + expect(secondsToInterval(7200)).toEqual({ amount: 2, unit: "hours" }); + expect(secondsToInterval(5400)).toEqual({ amount: 90, unit: "minutes" }); + expect(secondsToInterval(120)).toEqual({ amount: 2, unit: "minutes" }); + }); + + test("edit/duplicate round-trip keeps intervals that are not whole minutes", () => { + const stored = 90; + const displayed = secondsToInterval(stored); + expect(displayed).toEqual({ amount: 90, unit: "seconds" }); + expect(intervalToSeconds(displayed.amount, displayed.unit)).toBe(stored); + }); + + test("seconds unit clamps below the default 60s server floor", () => { + expect(minIntervalAmount("seconds")).toBe(60); + expect(minIntervalAmount("minutes")).toBe(1); + expect(minIntervalAmount("hours")).toBe(1); + expect(clampIntervalAmount(30, "seconds")).toBe(60); + expect(clampIntervalAmount(90, "seconds")).toBe(90); + expect(clampIntervalAmount(1, "minutes")).toBe(1); + }); + + test("hasScheduleSpec accepts interval every_seconds", () => { + expect(hasScheduleSpec({ every_seconds: 90 })).toBe(true); + expect(hasScheduleSpec({ cron: "0 9 * * *" })).toBe(true); + expect(hasScheduleSpec({ run_at: "2026-07-02T01:00:00+00:00" })).toBe(true); + expect(hasScheduleSpec({})).toBe(false); + expect(hasScheduleSpec({ every_seconds: 0 })).toBe(false); + }); }); describe("zonedLocalToUtcIso", () => {