mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-10 14:08:52 +00:00
feat(scheduler): add interval schedule type (#5291)
* feat(scheduler): add interval schedule type Allow scheduled tasks to fire every N seconds from last dispatch, not only wall-clock cron or a single run_at. Cadence is UTC now+N with no missed-beat catch-up, bounded by min_once_delay_seconds and 30 days. * fix(scheduler): let interval tasks create, edit, and keep next run Create/edit now keep every_seconds. Unchanged interval spec no longer resets next_run_at, including timezone-only PATCH. * fix(scheduler): keep non-minute intervals on edit Stop rounding every_seconds to whole minutes in the form. Values that are not whole minutes or hours now use a seconds unit so edit/duplicate round-trips the stored cadence instead of rewriting it and resetting next_run_at. Document that min_once_delay_seconds is also the interval floor. * fix(scheduler): clamp interval seconds to the default 60s floor The new seconds unit allowed 1–59, which the API rejects under the default min_once_delay_seconds. Clamp the form to >= 60 and show the floor next to the preview. Also mention interval in the scheduler field_doc, matching config.example.yaml. * fix(scheduler): do not clamp interval amount while typing Keystroke clamp made 90 become 9 -> 60, then 600, and backspace could not leave 60. Keep the raw field text and apply the 60s floor on blur and emit only. * test(scheduler): cover interval input editing * fix(frontend): preserve saved interval cadence until edited * style(tests): format scheduled task router tests --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
3a6e681dee
commit
48a8978b7b
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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`でバックグラウンドポーリングを有効にします。手動トリガーは同じスケジュールタスクリソースと実行パスを使用します。
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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 资源和执行路径。
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
```
|
||||
|
||||
@ -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<string | null>(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<string, string>)[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() {
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
const hasSchedule =
|
||||
Boolean(createSchedule.schedule_spec.cron) ||
|
||||
Boolean(createSchedule.schedule_spec.run_at);
|
||||
const hasSchedule = hasScheduleSpec(
|
||||
createSchedule.schedule_spec,
|
||||
);
|
||||
if (
|
||||
!title ||
|
||||
!prompt ||
|
||||
@ -433,8 +443,7 @@ export default function ScheduledTasksPage() {
|
||||
disabled={
|
||||
!title ||
|
||||
!prompt ||
|
||||
(!createSchedule.schedule_spec.cron &&
|
||||
!createSchedule.schedule_spec.run_at) ||
|
||||
!hasScheduleSpec(createSchedule.schedule_spec) ||
|
||||
(contextMode === "reuse_thread" && !targetThreadId) ||
|
||||
createTask.isPending
|
||||
}
|
||||
@ -512,6 +521,13 @@ export default function ScheduledTasksPage() {
|
||||
>
|
||||
{st.filters.once}
|
||||
</Button>
|
||||
<Button
|
||||
variant={typeFilter === "interval" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTypeFilter("interval")}
|
||||
>
|
||||
{st.filters.interval}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div
|
||||
|
||||
@ -14,24 +14,42 @@ import {
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
describeSchedule,
|
||||
clampIntervalAmount,
|
||||
intervalToSeconds,
|
||||
maxIntervalAmount,
|
||||
minIntervalAmount,
|
||||
pad2,
|
||||
parseCron,
|
||||
secondsToInterval,
|
||||
serializeCron,
|
||||
utcToZonedLocalInput,
|
||||
WEEKDAYS,
|
||||
zonedLocalToUtcIso,
|
||||
type CronParts,
|
||||
type CronPreset,
|
||||
type IntervalUnit,
|
||||
type ScheduleLocale,
|
||||
type ScheduleType,
|
||||
type Weekday,
|
||||
} from "@/core/scheduled-tasks/cron";
|
||||
|
||||
export type ScheduleValue = {
|
||||
schedule_type: "once" | "cron";
|
||||
schedule_spec: { cron?: string; run_at?: string };
|
||||
schedule_type: ScheduleType;
|
||||
schedule_spec: { cron?: string; run_at?: string; every_seconds?: number };
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
function parseInitialInterval(spec: { every_seconds?: number }): {
|
||||
amount: number;
|
||||
unit: IntervalUnit;
|
||||
} {
|
||||
const raw = spec.every_seconds;
|
||||
if (typeof raw === "number" && Number.isInteger(raw) && raw > 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<ScheduleType>(
|
||||
initial.schedule_type,
|
||||
);
|
||||
const [preset, setPreset] = useState<CronPreset>(
|
||||
@ -111,6 +129,15 @@ export function ScheduledTaskScheduleInput({
|
||||
const [timezone, setTimezone] = useState<string>(
|
||||
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<IntervalUnit>(
|
||||
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<CronParts>) {
|
||||
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}
|
||||
</Button>
|
||||
<Button
|
||||
variant={scheduleType === "interval" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setScheduleType("interval")}
|
||||
>
|
||||
{labels.scheduleType.interval}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -300,6 +367,66 @@ export function ScheduledTaskScheduleInput({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : scheduleType === "interval" ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={minIntervalAmount(intervalUnit)}
|
||||
max={maxIntervalAmount(intervalUnit)}
|
||||
value={intervalAmountText}
|
||||
onChange={(e) => {
|
||||
// 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}
|
||||
/>
|
||||
<Select
|
||||
value={intervalUnit}
|
||||
onValueChange={(value) => {
|
||||
const unit = value as IntervalUnit;
|
||||
setIntervalEdited(true);
|
||||
setIntervalUnit(unit);
|
||||
const next = clampIntervalAmount(intervalAmount, unit);
|
||||
setIntervalAmount(next);
|
||||
setIntervalAmountText(String(next));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-36"
|
||||
data-testid="schedule-interval-unit"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="seconds">
|
||||
{labels.fields.intervalUnitSeconds}
|
||||
</SelectItem>
|
||||
<SelectItem value="minutes">
|
||||
{labels.fields.intervalUnitMinutes}
|
||||
</SelectItem>
|
||||
<SelectItem value="hours">
|
||||
{labels.fields.intervalUnitHours}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
@ -328,6 +455,14 @@ export function ScheduledTaskScheduleInput({
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
{scheduleType === "interval" && intervalUnit === "seconds" && (
|
||||
<div
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="schedule-interval-min-hint"
|
||||
>
|
||||
{labels.fields.intervalMinHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "上下文模式",
|
||||
|
||||
@ -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<string, unknown>;
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
@ -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)}`;
|
||||
|
||||
|
||||
@ -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<string, unknown>;
|
||||
timezone: string;
|
||||
status:
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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<string, unknown>) ?? {},
|
||||
timezone,
|
||||
status: "enabled" as const,
|
||||
|
||||
@ -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(
|
||||
<I18nProvider initialLocale="en-US">
|
||||
<ScheduledTaskScheduleInput
|
||||
initial={{
|
||||
schedule_type: "interval",
|
||||
schedule_spec: { every_seconds: everySeconds },
|
||||
timezone: "UTC",
|
||||
}}
|
||||
onChange={(value) => emitted.push(value)}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const amountInput = screen.getByRole<HTMLInputElement>("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(
|
||||
<I18nProvider initialLocale="en-US">
|
||||
<ScheduledTaskScheduleInput
|
||||
initial={{
|
||||
schedule_type: "interval",
|
||||
schedule_spec: { every_seconds: 90 },
|
||||
timezone: "UTC",
|
||||
}}
|
||||
onChange={(value) => emitted.push(value)}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user