feat: add scheduled tasks MVP (#3898)

* feat: add scheduled tasks MVP

* fix: harden scheduled task execution semantics

* feat(scheduled-tasks): preset-driven schedule form with timezone and live preview

Replace the raw cron input with a preset Select (hourly/daily/weekly/monthly/custom)
plus structured inputs (time picker, weekday toggles, day-of-month), datetime-local
for one-time tasks, a timezone selector defaulting to the browser timezone, and a
live human-readable preview. Reuses one ScheduledTaskScheduleInput for create and
edit; backend contract unchanged; zero new deps (pure Intl + DST-safe offset helpers).

* feat(scheduled-tasks): full-page i18n + recipe templates + E2E locale pin

Localize the rest of the scheduled-tasks page (filters, detail pane, actions,
edit form, run list, enum values) via t.scheduledTasks.* in en/zh. Add four
built-in recipe templates (GitHub Trending, news digest, issue triage, weekly
report) exposed as a chip row that pre-fills title + prompt + schedule. Pin
Playwright locale to en-US so E2E selectors stay stable against i18n. No backend
change, no new deps.

* fix(scheduled-tasks): idempotent 0003 migration, update head constants, future-date once test

Merge with main surfaced three CI failures:
- 0003_scheduled_tasks create_table collided with legacy test seeds that
  build from full metadata; guard with inspector.has_table so the revision
  no-ops when the table already exists (0004/0005 are already idempotent via
  _helpers.py).
- persistence bootstrap concurrency/regression tests pinned HEAD to main's
  0002_runs_token_usage; bump to the new head 0005_scheduled_task_thread_nullable.
- once-task router test used a fixed past run_at and tripped the
  must-be-in-the-future validation; use a future date.

* address review: ok-check, 502 for trigger failure, mock fields, migration filename, doc fences

- fetchThreadScheduledTasks now checks response.ok like the other fetchers.
- trigger endpoint returns 502 (not 409) when dispatch fails outright, so
  clients can distinguish a real conflict from a server-side failure.
- E2E mock normalizes scheduled-task objects with context_mode/last_thread_id
  and nullable thread_id, matching the backend contract the UI renders against.
- Rename 0002_scheduled_tasks.py -> 0003_scheduled_tasks.py to match its
  revision id (file was renamed in spirit already; filename now follows).
- CONFIGURATION.md: close the Tool Groups yaml fence and drop the stray fence
  after the Scheduler notes so the sections render correctly.

* fix(scheduled-tasks): harden lease, poller, config, and frontend UX after review

* fix(scheduled-tasks): harden run lifecycle, overlap skip, non_interactive gating, and DST conversion after review

- defer a once task's terminal status to the run-completion hook; the task
  stays running until the real outcome, and a startup sweep cancels once
  tasks orphaned by a crash (launch-time 'completed' could stick forever)
- record interrupted runs as a distinct 'interrupted' run status with a
  readable message; an interrupted once task ends 'cancelled', not 'failed'
- enforce overlap_policy=skip for fresh_thread_per_run via an active-run
  pre-check (same-thread ConflictError can never fire across fresh threads)
- protect terminal run statuses from the late launch-path 'running' write
- honor context.non_interactive only for internally-authenticated callers;
  arbitrary clients can no longer strip ask_clarification
- fix DST-stale timezone offset in zonedLocalToUtcIso by re-deriving the
  offset at the resolved instant (once tasks fired an hour late around
  spring-forward and the create->edit round-trip diverged)
- drop dead ScheduledTaskRunRepository.update_by_run_id; share one Gateway
  API error helper between channels and scheduled-tasks frontends

* fix(scheduled-tasks): close review round-3 gaps in guards, concurrency, and API ergonomics

- scrub internal-only context keys (non_interactive) from the assembled run
  config for non-internal callers: gating body.context alone left the same
  key smuggle-able through the free-form body.config copied verbatim by
  build_run_config
- guard update_after_launch with protect_terminal so the launch bookkeeping
  write cannot clobber a once task already finalized by a fast-failing run's
  completion hook (parent-row sibling of the run-row guard)
- reject a manual trigger while the task has an active run (409) instead of
  launching a duplicate concurrent run on fresh_thread_per_run
- re-arm a terminal once task to enabled when PATCH pushes run_at into the
  future; previously the endpoint returned 200 with a next_run_at that could
  never be claimed
- make max_concurrent_runs a real global cap: each poll claims only into the
  remaining budget of active (queued/running) scheduled runs
- paginate GET /scheduled-tasks/{id}/runs (limit<=200, offset) and push the
  thread filter of /threads/{id}/scheduled-tasks into SQL
- stamp context.user_id on scheduler-launched runs, matching IM channels, so
  user-scoped guardrail providers see the owning user

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Xinmin Zeng 2026-07-04 21:51:57 +08:00 committed by GitHub
parent 6060d95ee0
commit 4fc08b4f15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 9392 additions and 29 deletions

View file

@ -62,6 +62,10 @@ servers + skills). Both real files are gitignored and may be edited at runtime v
Gateway API. Config schema and resolution order are documented in Gateway API. Config schema and resolution order are documented in
[backend/AGENTS.md](backend/AGENTS.md). [backend/AGENTS.md](backend/AGENTS.md).
Scheduled-task note:
- The scheduled-task MVP adds a workspace page at `/workspace/scheduled-tasks` plus a background scheduler service gated by `config.yaml -> scheduler.enabled`.
- Scheduled background runs are intentionally non-interactive: they execute through the normal run lifecycle, but the lead-agent toolset excludes `ask_clarification` when `context.non_interactive=true`. The key is honored only for internally-authenticated callers (the scheduler launch path); client-supplied `context.non_interactive` is dropped.
## Commands: Root vs. Module ## Commands: Root vs. Module
**Root `make` targets drive the whole stack** (run from the repo root): **Root `make` targets drive the whole stack** (run from the repo root):

View file

@ -70,6 +70,7 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe
- [Long-Term Memory](#long-term-memory) - [Long-Term Memory](#long-term-memory)
- [Recommended Models](#recommended-models) - [Recommended Models](#recommended-models)
- [Embedded Python Client](#embedded-python-client) - [Embedded Python Client](#embedded-python-client)
- [Scheduled Tasks](#scheduled-tasks)
- [Terminal Workbench (TUI)](#terminal-workbench-tui) - [Terminal Workbench (TUI)](#terminal-workbench-tui)
- [Documentation](#documentation) - [Documentation](#documentation)
- [⚠️ Security Notice](#-security-notice) - [⚠️ Security Notice](#-security-notice)
@ -767,6 +768,29 @@ client.clear_goal("thread-1")
All dict-returning methods are validated against Gateway Pydantic response models in CI (`TestGatewayConformance`), ensuring the embedded client stays in sync with the HTTP API schemas. See `backend/packages/harness/deerflow/client.py` for full API documentation. All dict-returning methods are validated against Gateway Pydantic response models in CI (`TestGatewayConformance`), ensuring the embedded client stays in sync with the HTTP API schemas. See `backend/packages/harness/deerflow/client.py` for full API documentation.
## Scheduled Tasks
DeerFlow now includes a first-class scheduled-task MVP in the workspace.
Current MVP capabilities:
- Manage tasks at `/workspace/scheduled-tasks`
- Choose whether each scheduled task reuses a thread or creates a fresh thread per run
- Support `once` and `cron` schedules
- Run background scheduled executions as non-interactive DeerFlow runs (`ask_clarification` is not exposed there)
- Use `skip` overlap behavior for due cron executions that collide with an active run on the same reused thread
- Pause, resume, trigger, inspect history, and delete tasks
- Execute scheduled work through the normal DeerFlow run lifecycle
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.
## Terminal Workbench (TUI) ## Terminal Workbench (TUI)
`deerflow` is a terminal-native workbench for people who live in the shell. It runs **embedded** over `DeerFlowClient` — no Gateway, frontend, nginx, or Docker required — while honoring the same `config.yaml`, checkpointer, skills, memory, MCP, and sandbox settings as the rest of DeerFlow. `deerflow` is a terminal-native workbench for people who live in the shell. It runs **embedded** over `DeerFlowClient` — no Gateway, frontend, nginx, or Docker required — while honoring the same `config.yaml`, checkpointer, skills, memory, MCP, and sandbox settings as the rest of DeerFlow.

View file

@ -14,6 +14,7 @@ DeerFlow is a LangGraph-based AI super agent system with a full-stack architectu
**Runtime**: **Runtime**:
- `make dev`, Docker dev, and production all run the agent runtime in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Nginx exposes that runtime at `/api/langgraph/*` and rewrites it to Gateway's native `/api/*` routers. - `make dev`, Docker dev, and production all run the agent runtime in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Nginx exposes that runtime at `/api/langgraph/*` and rewrites it to Gateway's native `/api/*` routers.
- Scheduled-task executions must reuse that same Gateway run lifecycle. The scheduler may decide *when* work runs, but it must dispatch through the existing run path rather than introducing a parallel execution stack.
**Project Structure**: **Project Structure**:
``` ```
@ -367,6 +368,9 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
4. **Subagent tool** (if enabled): 4. **Subagent tool** (if enabled):
- `task` - Delegate to subagent (description, prompt, subagent_type) - `task` - Delegate to subagent (description, prompt, subagent_type)
Scheduled-task runtime note:
- Scheduled background runs set `context.non_interactive=true` and therefore exclude `ask_clarification` from the lead-agent tool list. This keeps scheduler-triggered runs from stalling on human confirmation mid-execution. `non_interactive` is an internal-only context key: it is merged from `body.context` only when the request authenticated as the process-internal user (the scheduler path), never from arbitrary HTTP/IM clients.
**Community tools** (`packages/harness/deerflow/community/`): optional integrations, each in its own subpackage and wired through `config.yaml`. Documented examples: **Community tools** (`packages/harness/deerflow/community/`): optional integrations, each in its own subpackage and wired through `config.yaml`. Documented examples:
- `tavily/` - Web search (5 results default) and web fetch (4KB limit) - `tavily/` - Web search (5 results default) and web fetch (4KB limit)
- `jina_ai/` - Web fetch via Jina reader API with readability extraction - `jina_ai/` - Web fetch via Jina reader API with readability extraction

View file

@ -24,6 +24,7 @@ from app.gateway.routers import (
memory, memory,
models, models,
runs, runs,
scheduled_tasks,
skills, skills,
suggestions, suggestions,
thread_runs, thread_runs,
@ -234,6 +235,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception: except Exception:
logger.exception("No IM channels configured or channel service failed to start") logger.exception("No IM channels configured or channel service failed to start")
try:
from app.gateway.services import launch_scheduled_thread_run
from app.scheduler import ScheduledTaskService
if getattr(app.state, "scheduled_task_repo", None) is not None and getattr(app.state, "scheduled_task_run_repo", None) is not None:
scheduled_task_service = ScheduledTaskService(
task_repo=app.state.scheduled_task_repo,
task_run_repo=app.state.scheduled_task_run_repo,
launch_run=lambda **kwargs: launch_scheduled_thread_run(app=app, **kwargs),
poll_interval_seconds=startup_config.scheduler.poll_interval_seconds,
lease_seconds=startup_config.scheduler.lease_seconds,
max_concurrent_runs=startup_config.scheduler.max_concurrent_runs,
)
app.state.scheduled_task_service = scheduled_task_service
if startup_config.scheduler.enabled:
await scheduled_task_service.start()
except Exception:
logger.exception("Failed to initialize scheduled task service")
yield yield
try: try:
@ -257,6 +277,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception: except Exception:
logger.exception("Failed to stop channel service") logger.exception("Failed to stop channel service")
if getattr(app.state, "scheduled_task_service", None) is not None:
try:
await app.state.scheduled_task_service.stop()
except Exception:
logger.exception("Failed to stop scheduled task service")
logger.info("Shutting down API Gateway") logger.info("Shutting down API Gateway")
@ -405,6 +431,9 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Thread cleanup API is mounted at /api/threads/{thread_id} # Thread cleanup API is mounted at /api/threads/{thread_id}
app.include_router(threads.router) app.include_router(threads.router)
# Scheduled tasks API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router)
# Agents API is mounted at /api/agents # Agents API is mounted at /api/agents
app.include_router(agents.router) app.include_router(agents.router)

View file

@ -238,6 +238,17 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
from deerflow.persistence.thread_meta import make_thread_store from deerflow.persistence.thread_meta import make_thread_store
app.state.thread_store = make_thread_store(sf, app.state.store) app.state.thread_store = make_thread_store(sf, app.state.store)
if sf is not None:
from deerflow.persistence.scheduled_task_runs import (
ScheduledTaskRunRepository,
)
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
app.state.scheduled_task_repo = ScheduledTaskRepository(sf)
app.state.scheduled_task_run_repo = ScheduledTaskRunRepository(sf)
else:
app.state.scheduled_task_repo = None
app.state.scheduled_task_run_repo = None
# Run event store. The store and the matching ``run_events_config`` are # Run event store. The store and the matching ``run_events_config`` are
# both frozen at startup so ``get_run_context`` does not combine a # both frozen at startup so ``get_run_context`` does not combine a
@ -316,6 +327,27 @@ def get_thread_store(request: Request) -> ThreadMetaStore:
return val return val
def get_scheduled_task_repo(request: Request):
val = getattr(request.app.state, "scheduled_task_repo", None)
if val is None:
raise HTTPException(status_code=503, detail="Scheduled task repo not available")
return val
def get_scheduled_task_run_repo(request: Request):
val = getattr(request.app.state, "scheduled_task_run_repo", None)
if val is None:
raise HTTPException(status_code=503, detail="Scheduled task run repo not available")
return val
def get_scheduled_task_service(request: Request):
val = getattr(request.app.state, "scheduled_task_service", None)
if val is None:
raise HTTPException(status_code=503, detail="Scheduled task service not available")
return val
def get_run_context(request: Request) -> RunContext: def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons. """Build a :class:`RunContext` from ``app.state`` singletons.
@ -333,6 +365,7 @@ def get_run_context(request: Request) -> RunContext:
run_events_config=getattr(request.app.state, "run_events_config", None), run_events_config=getattr(request.app.state, "run_events_config", None),
thread_store=get_thread_store(request), thread_store=get_thread_store(request),
app_config=get_config(), app_config=get_config(),
on_run_completed=getattr(request.app.state, "scheduled_task_service", None).handle_run_completion if getattr(request.app.state, "scheduled_task_service", None) is not None else None,
) )

View file

@ -1,3 +1,25 @@
from . import artifacts, assistants_compat, mcp, models, skills, suggestions, thread_runs, threads, uploads from . import (
artifacts,
assistants_compat,
mcp,
models,
scheduled_tasks,
skills,
suggestions,
thread_runs,
threads,
uploads,
)
__all__ = ["artifacts", "assistants_compat", "mcp", "models", "skills", "suggestions", "threads", "thread_runs", "uploads"] __all__ = [
"artifacts",
"assistants_compat",
"mcp",
"models",
"scheduled_tasks",
"skills",
"suggestions",
"threads",
"thread_runs",
"uploads",
]

View file

@ -0,0 +1,310 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.deps import (
get_config,
get_optional_user_from_request,
get_scheduled_task_repo,
get_scheduled_task_run_repo,
get_scheduled_task_service,
get_thread_store,
)
from deerflow.scheduler.schedules import (
next_run_at as compute_next_run_at,
)
from deerflow.scheduler.schedules import (
normalize_cron_expression,
validate_timezone,
)
router = APIRouter(prefix="/api", tags=["scheduled-tasks"])
def _ensure_task_mutable(task: dict[str, Any]) -> None:
if task.get("status") == "running":
raise HTTPException(
status_code=409,
detail="Scheduled task is currently running; retry after the active execution finishes",
)
class ScheduledTaskCreateRequest(BaseModel):
thread_id: str | None = None
context_mode: str = "fresh_thread_per_run"
title: str = Field(min_length=1)
prompt: str = Field(min_length=1)
schedule_type: str
schedule_spec: dict[str, Any]
timezone: str
class ScheduledTaskUpdateRequest(BaseModel):
context_mode: str | None = None
thread_id: str | None = None
title: str | None = Field(default=None, min_length=1)
prompt: str | None = Field(default=None, min_length=1)
schedule_spec: dict[str, Any] | None = None
timezone: str | None = None
@router.get("/scheduled-tasks")
@require_permission("threads", "read")
async def list_scheduled_tasks(request: Request):
repo = get_scheduled_task_repo(request)
user = await get_optional_user_from_request(request)
if user is None:
return []
return await repo.list_by_user(str(user.id))
@router.post("/scheduled-tasks")
@require_permission("threads", "write")
async def create_scheduled_task(request: Request, body: ScheduledTaskCreateRequest):
config = get_config()
repo = get_scheduled_task_repo(request)
thread_store = get_thread_store(request)
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
if body.context_mode not in {"fresh_thread_per_run", "reuse_thread"}:
raise HTTPException(status_code=422, detail="Unsupported context_mode")
if body.context_mode == "reuse_thread":
if not body.thread_id:
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"}:
raise HTTPException(status_code=422, detail="Unsupported schedule_type")
schedule_spec = dict(body.schedule_spec)
try:
validate_timezone(body.timezone)
if body.schedule_type == "cron":
raw_cron = schedule_spec.get("cron")
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)
next_run_at = compute_next_run_at(
body.schedule_type,
schedule_spec,
body.timezone,
now=datetime.now(UTC),
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if body.schedule_type == "once" and next_run_at is None:
raise HTTPException(status_code=422, detail="once schedule must be in the future")
if body.schedule_type == "once" and next_run_at is not None and (next_run_at - datetime.now(UTC)).total_seconds() < config.scheduler.min_once_delay_seconds:
raise HTTPException(
status_code=422,
detail=(f"once schedule must be at least {config.scheduler.min_once_delay_seconds} seconds in the future"),
)
return await repo.create(
task_id=f"task-{uuid.uuid4().hex}",
user_id=str(user.id),
thread_id=body.thread_id,
context_mode=body.context_mode,
assistant_id="lead_agent",
title=body.title,
prompt=body.prompt,
schedule_type=body.schedule_type,
schedule_spec=schedule_spec,
timezone=body.timezone,
next_run_at=next_run_at,
)
@router.get("/scheduled-tasks/{task_id}")
@require_permission("threads", "read")
async def get_scheduled_task(task_id: str, request: Request):
repo = get_scheduled_task_repo(request)
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
task = await repo.get(task_id, user_id=str(user.id))
if task is None:
raise HTTPException(status_code=404, detail="Scheduled task not found")
return task
@router.patch("/scheduled-tasks/{task_id}")
@require_permission("threads", "write")
async def update_scheduled_task(task_id: str, request: Request, body: ScheduledTaskUpdateRequest):
config = get_config()
repo = get_scheduled_task_repo(request)
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
existing = await repo.get(task_id, user_id=str(user.id))
if existing is None:
raise HTTPException(status_code=404, detail="Scheduled task not found")
_ensure_task_mutable(existing)
updates = body.model_dump(exclude_none=True)
if "context_mode" in updates:
if updates["context_mode"] not in {"fresh_thread_per_run", "reuse_thread"}:
raise HTTPException(status_code=422, detail="Unsupported context_mode")
effective_context_mode = str(updates.get("context_mode", existing["context_mode"]))
effective_thread_id = updates.get("thread_id", existing.get("thread_id"))
if effective_context_mode == "reuse_thread":
if not effective_thread_id:
raise HTTPException(status_code=422, detail="reuse_thread requires thread_id")
thread_store = get_thread_store(request)
if not await thread_store.check_access(str(effective_thread_id), str(user.id), require_existing=True):
raise HTTPException(status_code=404, detail="Thread not found")
elif effective_context_mode == "fresh_thread_per_run":
updates["thread_id"] = None
if "timezone" in updates:
try:
validate_timezone(str(updates["timezone"]))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if "schedule_spec" in updates or "timezone" in updates:
schedule_spec = dict(existing["schedule_spec"])
if "schedule_spec" in updates and isinstance(updates["schedule_spec"], dict):
schedule_spec = dict(updates["schedule_spec"])
timezone = str(updates.get("timezone", existing["timezone"]))
try:
if existing["schedule_type"] == "cron":
raw_cron = schedule_spec.get("cron")
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)
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:
raise HTTPException(status_code=422, detail="once schedule must be in the future")
if existing["schedule_type"] == "once" and next_run_at is not None and (next_run_at - datetime.now(UTC)).total_seconds() < config.scheduler.min_once_delay_seconds:
raise HTTPException(
status_code=422,
detail=(f"once schedule must be at least {config.scheduler.min_once_delay_seconds} seconds in the future"),
)
updates["schedule_spec"] = schedule_spec
updates["next_run_at"] = next_run_at
# A terminal task (completed/failed/cancelled) whose schedule was just
# pushed into the future must be re-armed: claim_due_tasks only admits
# "enabled" rows, so leaving the terminal status would return 200 with
# a next_run_at that silently never fires.
if next_run_at is not None and existing["status"] in {"completed", "failed", "cancelled"}:
updates["status"] = "enabled"
updated = await repo.update(
task_id,
user_id=str(user.id),
updates=updates,
)
return updated
@router.post("/scheduled-tasks/{task_id}/pause")
@require_permission("threads", "write")
async def pause_scheduled_task(task_id: str, request: Request):
repo = get_scheduled_task_repo(request)
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
existing = await repo.get(task_id, user_id=str(user.id))
if existing is None:
raise HTTPException(status_code=404, detail="Scheduled task not found")
_ensure_task_mutable(existing)
updated = await repo.update(task_id, user_id=str(user.id), updates={"status": "paused"})
if updated is None:
raise HTTPException(status_code=404, detail="Scheduled task not found")
return updated
@router.post("/scheduled-tasks/{task_id}/resume")
@require_permission("threads", "write")
async def resume_scheduled_task(task_id: str, request: Request):
repo = get_scheduled_task_repo(request)
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
existing = await repo.get(task_id, user_id=str(user.id))
if existing is None:
raise HTTPException(status_code=404, detail="Scheduled task not found")
_ensure_task_mutable(existing)
updated = await repo.update(task_id, user_id=str(user.id), updates={"status": "enabled"})
if updated is None:
raise HTTPException(status_code=404, detail="Scheduled task not found")
return updated
@router.post("/scheduled-tasks/{task_id}/trigger")
@require_permission("threads", "write")
async def trigger_scheduled_task(task_id: str, request: Request):
repo = get_scheduled_task_repo(request)
service = get_scheduled_task_service(request)
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
task = await repo.get(task_id, user_id=str(user.id))
if task is None:
raise HTTPException(status_code=404, detail="Scheduled task not found")
result = await service.dispatch_task(task, now=datetime.now(UTC), trigger="manual")
if result["outcome"] == "conflict":
raise HTTPException(status_code=409, detail=result["error"] or "Scheduled task trigger conflicted with an active run")
if result["outcome"] == "failed":
raise HTTPException(status_code=502, detail=result["error"] or "Scheduled task trigger failed")
return {"id": task_id, "triggered": True}
@router.delete("/scheduled-tasks/{task_id}")
@require_permission("threads", "write")
async def delete_scheduled_task(task_id: str, request: Request):
repo = get_scheduled_task_repo(request)
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
deleted = await repo.delete(task_id, user_id=str(user.id))
if not deleted:
raise HTTPException(status_code=404, detail="Scheduled task not found")
return {"id": task_id, "deleted": deleted}
@router.get("/scheduled-tasks/{task_id}/runs")
@require_permission("threads", "read")
async def list_scheduled_task_runs(
task_id: str,
request: Request,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
):
task_repo = get_scheduled_task_repo(request)
run_repo = get_scheduled_task_run_repo(request)
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
task = await task_repo.get(task_id, user_id=str(user.id))
if task is None:
raise HTTPException(status_code=404, detail="Scheduled task not found")
return await run_repo.list_by_task(task_id, limit=limit, offset=offset)
@router.get("/threads/{thread_id}/scheduled-tasks")
@require_permission("threads", "read", owner_check=True)
async def list_thread_scheduled_tasks(thread_id: str, request: Request):
repo = get_scheduled_task_repo(request)
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
return await repo.list_by_user_and_thread(str(user.id), thread_id)

View file

@ -20,8 +20,14 @@ from langchain_core.messages import BaseMessage
from langchain_core.messages.utils import convert_to_messages from langchain_core.messages.utils import convert_to_messages
from langgraph.types import Command from langgraph.types import Command
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL
from app.gateway.deps import get_checkpointer, get_run_context, get_run_manager, get_stream_bridge from app.gateway.deps import get_checkpointer, get_run_context, get_run_manager, get_stream_bridge
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE, get_trusted_internal_owner_user_id from app.gateway.internal_auth import (
INTERNAL_OWNER_USER_ID_HEADER_NAME,
INTERNAL_SYSTEM_ROLE,
get_internal_user,
get_trusted_internal_owner_user_id,
)
from app.gateway.utils import sanitize_log_param from app.gateway.utils import sanitize_log_param
from deerflow.config.app_config import get_app_config from deerflow.config.app_config import get_app_config
from deerflow.runtime import ( from deerflow.runtime import (
@ -169,8 +175,28 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
} }
) )
# Keys honored only for internally-authenticated callers (the scheduler path).
# ``non_interactive`` strips ``ask_clarification`` from the lead-agent toolset;
# arbitrary HTTP/IM clients must not be able to force autonomous execution.
_INTERNAL_ONLY_CONTEXT_KEYS: frozenset[str] = frozenset({"non_interactive"})
def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, Any] | None) -> None:
def strip_internal_context_keys(config: dict[str, Any]) -> None:
"""Drop internal-only keys a non-internal caller smuggled into the run config.
Gating :func:`merge_run_context_overrides` is not enough on its own:
``build_run_config`` copies a client-supplied ``body.config['context']`` /
``body.config['configurable']`` verbatim, so the same keys must be scrubbed
from both sections after the config is assembled.
"""
for section in ("context", "configurable"):
value = config.get(section)
if isinstance(value, dict):
for key in _INTERNAL_ONLY_CONTEXT_KEYS:
value.pop(key, None)
def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, Any] | None, *, internal: bool = False) -> None:
"""Merge whitelisted keys from ``body.context`` into both ``config['configurable']`` """Merge whitelisted keys from ``body.context`` into both ``config['configurable']``
and ``config['context']`` so they are visible to legacy configurable readers and and ``config['context']`` so they are visible to legacy configurable readers and
to LangGraph ``ToolRuntime.context`` consumers (e.g. the ``setup_agent`` tool to LangGraph ``ToolRuntime.context`` consumers (e.g. the ``setup_agent`` tool
@ -181,12 +207,18 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
``body.context`` keep it on ``ToolRuntime.context``. It is merged with ``body.context`` keep it on ``ToolRuntime.context``. It is merged with
``setdefault`` so a server-authenticated id stamped by ``setdefault`` so a server-authenticated id stamped by
:func:`inject_authenticated_user_context` always wins over the client-supplied one. :func:`inject_authenticated_user_context` always wins over the client-supplied one.
``internal=True`` (the request authenticated as the process-internal user,
e.g. the scheduler's ``launch_scheduled_thread_run``) additionally honors
:data:`_INTERNAL_ONLY_CONTEXT_KEYS`; those keys are dropped from client
requests.
""" """
if not context: if not context:
return return
configurable = config.setdefault("configurable", {}) configurable = config.setdefault("configurable", {})
runtime_context = config.setdefault("context", {}) runtime_context = config.setdefault("context", {})
for key in _CONTEXT_CONFIGURABLE_KEYS: keys = _CONTEXT_CONFIGURABLE_KEYS | _INTERNAL_ONLY_CONTEXT_KEYS if internal else _CONTEXT_CONFIGURABLE_KEYS
for key in keys:
if key in context: if key in context:
if isinstance(configurable, dict): if isinstance(configurable, dict):
configurable.setdefault(key, context[key]) configurable.setdefault(key, context[key])
@ -566,7 +598,12 @@ async def start_run(
# The ``context`` field is a custom extension for the langgraph-compat layer # The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.). # that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored. # Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None)) is_internal_caller = getattr(getattr(request, "state", None), "auth_source", None) == AUTH_SOURCE_INTERNAL
merge_run_context_overrides(config, getattr(body, "context", None), internal=is_internal_caller)
if not is_internal_caller:
# ``body.config`` is free-form and copied verbatim by
# ``build_run_config``; scrub internal-only keys smuggled there.
strip_internal_context_keys(config)
inject_authenticated_user_context(config, request) inject_authenticated_user_context(config, request)
stream_modes = normalize_stream_modes(body.stream_mode) stream_modes = normalize_stream_modes(body.stream_mode)
@ -598,6 +635,61 @@ async def start_run(
reset_current_user(owner_context_token) reset_current_user(owner_context_token)
async def launch_scheduled_thread_run(
*,
thread_id: str,
assistant_id: str | None,
prompt: str,
request: Request | None = None,
app: Any | None = None,
owner_user_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
if request is None:
if app is None:
raise ValueError("launch_scheduled_thread_run requires request or app")
request = SimpleNamespace(
app=app,
headers=({INTERNAL_OWNER_USER_ID_HEADER_NAME: owner_user_id} if owner_user_id else {}),
state=SimpleNamespace(
user=get_internal_user(),
auth_source=AUTH_SOURCE_INTERNAL,
),
cookies={},
)
# SimpleNamespace stands in for the Pydantic run-request body that the
# HTTP path parses. If start_run gains a new body.* attribute that it reads
# directly, add the matching field here so the scheduler path stays in sync.
body = SimpleNamespace(
assistant_id=assistant_id,
input={"messages": [{"role": "user", "content": prompt}]},
command=None,
metadata=metadata or {},
config=None,
# ``user_id`` mirrors what IM channels put in ``body.context`` so
# runtime-context consumers without a ContextVar fallback (e.g.
# user-scoped GuardrailMiddleware providers) see the owning user;
# ``inject_authenticated_user_context`` skips the internal user.
context=({"non_interactive": True, "user_id": owner_user_id} if owner_user_id else {"non_interactive": True}),
webhook=None,
checkpoint_id=None,
checkpoint=None,
interrupt_before=None,
interrupt_after=None,
stream_mode=None,
stream_subgraphs=False,
stream_resumable=None,
on_disconnect="continue",
on_completion="keep",
multitask_strategy="reject",
after_seconds=None,
if_not_exists="reject",
feedback_keys=None,
)
record = await start_run(body, thread_id, request)
return {"run_id": record.run_id, "thread_id": record.thread_id}
async def sse_consumer( async def sse_consumer(
bridge: StreamBridge, bridge: StreamBridge,
record: RunRecord, record: RunRecord,

View file

@ -0,0 +1,3 @@
from .service import ScheduledTaskService
__all__ = ["ScheduledTaskService"]

View file

@ -0,0 +1,346 @@
from __future__ import annotations
import asyncio
import logging
import socket
import uuid
from datetime import UTC, datetime
from typing import Any, Literal
from fastapi import HTTPException
from deerflow.runtime import ConflictError, RunRecord
from deerflow.scheduler.schedules import next_run_at
logger = logging.getLogger(__name__)
class ScheduledTaskService:
def __init__(
self,
*,
task_repo,
task_run_repo,
launch_run,
poll_interval_seconds: int,
lease_seconds: int,
max_concurrent_runs: int,
) -> None:
self._task_repo = task_repo
self._task_run_repo = task_run_repo
self._launch_run = launch_run
self._poll_interval_seconds = poll_interval_seconds
self._lease_seconds = lease_seconds
self._max_concurrent_runs = max_concurrent_runs
self._lease_owner = f"{socket.gethostname()}:{uuid.uuid4().hex}"
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
async def run_once(self, *, now: datetime) -> None:
# ``max_concurrent_runs`` is a global cap on active scheduled runs, not
# just a per-poll claim batch: long runs accumulate across poll cycles,
# so each cycle only claims into the remaining budget.
active = await self._task_run_repo.count_active_runs()
budget = self._max_concurrent_runs - active
if budget <= 0:
return
claimed = await self._task_repo.claim_due_tasks(
now=now,
lease_owner=self._lease_owner,
lease_seconds=self._lease_seconds,
limit=budget,
)
for task in claimed:
await self.dispatch_task(task, now=now, trigger="scheduled")
@staticmethod
def _is_overlap_conflict(exc: Exception) -> bool:
if isinstance(exc, ConflictError):
return True
return isinstance(exc, HTTPException) and exc.status_code == 409
@staticmethod
def _task_status_for_failure(task: dict[str, Any], *, trigger: str) -> str:
if trigger == "manual":
# A failed manual trigger must not consume the task's scheduled
# future: a `once` task with run_at still ahead would otherwise be
# flipped to "failed" and never claimed again.
return task.get("status") or "enabled"
if task["schedule_type"] == "once":
return "failed"
return "enabled"
@staticmethod
def _task_status_for_skip(task: dict[str, Any]) -> str:
if task["schedule_type"] == "once":
# The single occurrence was lost to an overlapping run; "completed"
# would claim an execution that never happened.
return "failed"
return "enabled"
async def dispatch_task(
self,
task: dict[str, Any],
*,
now: datetime,
trigger: str,
) -> dict[str, Any]:
execution_thread_id = task.get("thread_id")
if task.get("context_mode") == "fresh_thread_per_run" or not execution_thread_id:
execution_thread_id = str(uuid.uuid4())
# "skip" must hold for fresh-thread runs too, where every run gets a new
# thread and the same-thread multitask ConflictError below can never
# fire. Checked before creating this dispatch's own run row so the row
# does not count itself as the active run. A manual trigger against an
# active run is rejected outright (409 at the router) instead of being
# recorded as a skipped occurrence — nothing was scheduled to happen.
skip_error: str | None = None
if task.get("overlap_policy", "skip") == "skip" and await self._task_run_repo.has_active_runs(task["id"]):
if trigger == "manual":
return {
"outcome": "conflict",
"task_run_id": None,
"run_id": None,
"thread_id": execution_thread_id,
"error": "task already has an active run",
}
skip_error = "skipped: a previous run of this task is still active"
task_run_id = f"task-run-{uuid.uuid4().hex}"
await self._task_run_repo.create(
run_record_id=task_run_id,
task_id=task["id"],
thread_id=execution_thread_id,
scheduled_for=now,
trigger=trigger,
status="queued",
)
if skip_error is not None:
return await self._finalize_skip(task, task_run_id=task_run_id, thread_id=execution_thread_id, now=now, error=skip_error)
try:
result = await self._launch_run(
thread_id=execution_thread_id,
assistant_id=task.get("assistant_id"),
prompt=task["prompt"],
owner_user_id=task.get("user_id"),
metadata={
"scheduled_task_id": task["id"],
"scheduled_task_run_id": task_run_id,
"scheduled_trigger": trigger,
},
)
next_at = next_run_at(
task["schedule_type"],
task["schedule_spec"],
task["timezone"],
now=now,
)
if task["schedule_type"] == "once":
# Stay "running" until handle_run_completion sees the real
# terminal outcome; declaring "completed" at launch would stick
# if the run fails or the process dies (startup reconciliation
# is cancel_stuck_once_tasks).
task_status = "running"
elif trigger == "manual" and task.get("status") == "paused":
task_status = "paused"
else:
task_status = "enabled"
await self._task_run_repo.update_status(
task_run_id,
status="running",
run_id=result["run_id"],
started_at=now,
# A fast-failing run can reach handle_run_completion before this
# write resumes; never clobber its terminal status.
protect_terminal=True,
)
await self._task_repo.update_after_launch(
task["id"],
status=task_status,
next_run_at=next_at,
last_run_at=now,
last_run_id=result["run_id"],
last_thread_id=result["thread_id"],
last_error=None,
increment_run_count=True,
# Same race as the run-row write above: a fast-failing run's
# completion hook may have already finalized a `once` task.
protect_terminal=True,
)
return {
"outcome": "launched",
"task_run_id": task_run_id,
"run_id": result["run_id"],
"thread_id": result["thread_id"],
"error": None,
}
except Exception as exc:
next_at = next_run_at(
task["schedule_type"],
task["schedule_spec"],
task["timezone"],
now=now,
)
if self._is_overlap_conflict(exc) and trigger == "scheduled" and task.get("overlap_policy", "skip") == "skip":
return await self._finalize_skip(task, task_run_id=task_run_id, thread_id=execution_thread_id, now=now, error=str(exc))
task_status = self._task_status_for_failure(task, trigger=trigger)
await self._task_run_repo.update_status(
task_run_id,
status="failed",
error=str(exc),
started_at=now,
finished_at=now,
)
await self._task_repo.update_after_launch(
task["id"],
status=task_status,
next_run_at=next_at,
last_run_at=now,
last_run_id=None,
last_thread_id=execution_thread_id,
last_error=str(exc),
increment_run_count=False,
)
return {
"outcome": "conflict" if self._is_overlap_conflict(exc) else "failed",
"task_run_id": task_run_id,
"run_id": None,
"thread_id": execution_thread_id,
"error": str(exc),
}
async def _finalize_skip(
self,
task: dict[str, Any],
*,
task_run_id: str,
thread_id: str,
now: datetime,
error: str,
) -> dict[str, Any]:
next_at = next_run_at(
task["schedule_type"],
task["schedule_spec"],
task["timezone"],
now=now,
)
await self._task_run_repo.update_status(
task_run_id,
status="skipped",
error=error,
started_at=now,
finished_at=now,
)
await self._task_repo.update_after_launch(
task["id"],
status=self._task_status_for_skip(task),
next_run_at=next_at,
last_run_at=task.get("last_run_at"),
last_run_id=task.get("last_run_id"),
last_thread_id=task.get("last_thread_id"),
last_error=error if task["schedule_type"] == "once" else None,
increment_run_count=False,
)
return {
"outcome": "skipped",
"task_run_id": task_run_id,
"run_id": None,
"thread_id": thread_id,
"error": error,
}
async def handle_run_completion(self, record: RunRecord) -> None:
metadata = record.metadata or {}
task_id = metadata.get("scheduled_task_id")
task_run_id = metadata.get("scheduled_task_run_id")
user_id = record.user_id
if not isinstance(task_id, str) or not isinstance(task_run_id, str) or not user_id:
return
terminal_status: Literal["success", "failed", "interrupted"] | None
if record.status.value == "success":
terminal_status = "success"
error = None
elif record.status.value == "interrupted":
# Distinct from "failed": an interrupt (user cancel, same-thread
# takeover) carries no error and is not an execution failure.
terminal_status = "interrupted"
error = record.error or "run was interrupted before completion"
elif record.status.value in {"error", "timeout"}:
terminal_status = "failed"
error = record.error
else:
terminal_status = None
error = record.error
if terminal_status is None:
return
await self._task_run_repo.update_status(
task_run_id,
status=terminal_status,
run_id=record.run_id,
error=error,
finished_at=datetime.now(UTC),
)
task = await self._task_repo.get(task_id, user_id=user_id)
if task is None:
return
updates: dict[str, Any] = {"last_error": error}
if task["schedule_type"] == "once":
# The single occurrence is consumed either way (the run did launch,
# so re-arming risks duplicate side effects), but an interrupt ends
# as "cancelled", not "failed".
if terminal_status == "success":
updates["status"] = "completed"
elif terminal_status == "interrupted":
updates["status"] = "cancelled"
else:
updates["status"] = "failed"
await self._task_repo.update(task_id, user_id=user_id, updates=updates)
async def start(self) -> None:
if self._task is not None:
return
restart_error = "interrupted: gateway restarted before the run reached a terminal state"
try:
stale = await self._task_run_repo.mark_stale_active_runs(error=restart_error)
if stale:
logger.warning("Marked %d stale scheduled task run(s) as interrupted after restart", stale)
except Exception:
logger.exception("Failed to sweep stale scheduled task runs at startup")
try:
# The run rows above are only half the story: a launched `once`
# task is parked in "running" until the (now dead) completion hook
# would have finalized it, so reconcile the parent rows too.
stuck = await self._task_repo.cancel_stuck_once_tasks(error=restart_error)
if stuck:
logger.warning("Cancelled %d stuck once task(s) after restart", stuck)
except Exception:
logger.exception("Failed to reconcile stuck once tasks at startup")
self._stop.clear()
self._task = asyncio.create_task(self._run_loop())
async def stop(self) -> None:
if self._task is None:
return
self._stop.set()
await self._task
self._task = None
async def _run_loop(self) -> None:
while not self._stop.is_set():
try:
await self.run_once(now=datetime.now(UTC))
except Exception:
# A transient DB error (e.g. SQLite "database is locked") must
# not kill the poller task for the rest of the process life.
logger.exception("Scheduled task poll failed; retrying next interval")
try:
await asyncio.wait_for(
self._stop.wait(),
timeout=self._poll_interval_seconds,
)
except TimeoutError:
continue

View file

@ -220,6 +220,30 @@ tool_groups:
- name: bash # Shell command execution - name: bash # Shell command execution
``` ```
### Scheduler
The scheduled-task MVP adds a scheduler section to `config.yaml`:
```yaml
scheduler:
enabled: false
poll_interval_seconds: 5
lease_seconds: 120
max_concurrent_runs: 3
min_once_delay_seconds: 60
```
Notes:
- `enabled: false` keeps background polling off by default.
- `max_concurrent_runs` is a global cap on active scheduled runs (queued/running run rows); each poll cycle claims only into the remaining budget, so long runs accumulating across cycles cannot exceed it.
- All scheduler fields are restart-required; edits need a Gateway restart.
- Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend. SQLite silently ignores row-level locks, so multiple workers can double-fire the same task.
- The MVP supports thread reuse and fresh-thread-per-run execution modes.
- The MVP supports only `once` and `cron`.
- Manual trigger uses the same scheduled-task resource and run lifecycle.
- Scheduled task definitions and task-run history are persisted in the application database.
### Tools ### Tools
Configure specific tools available to the agent: Configure specific tools available to the agent:

View file

@ -50,6 +50,7 @@ from deerflow.tracing import build_tracing_callbacks
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_BOOTSTRAP_SKILL_NAMES = {"bootstrap"} _BOOTSTRAP_SKILL_NAMES = {"bootstrap"}
_NON_INTERACTIVE_DISABLED_TOOL_NAMES = frozenset({"ask_clarification"})
def _get_runtime_config(config: RunnableConfig) -> dict: def _get_runtime_config(config: RunnableConfig) -> dict:
@ -449,6 +450,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
subagent_enabled = cfg.get("subagent_enabled", False) subagent_enabled = cfg.get("subagent_enabled", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False) is_bootstrap = cfg.get("is_bootstrap", False)
non_interactive = bool(cfg.get("non_interactive", False))
agent_name = validate_agent_name(cfg.get("agent_name")) agent_name = validate_agent_name(cfg.get("agent_name"))
agent_config = load_agent_config(agent_name) if not is_bootstrap else None agent_config = load_agent_config(agent_name) if not is_bootstrap else None
@ -516,6 +518,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# remains deterministic before the custom agent's own config exists. # remains deterministic before the custom agent's own config exists.
raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent] raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent]
filtered = filter_tools_by_skill_allowed_tools(raw_tools, skills_for_tool_policy, always_allowed_tool_names=SKILL_LOADING_TOOL_NAMES) filtered = filter_tools_by_skill_allowed_tools(raw_tools, skills_for_tool_policy, always_allowed_tool_names=SKILL_LOADING_TOOL_NAMES)
if non_interactive:
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled) final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
return create_agent( return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False), model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
@ -545,6 +549,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Default lead agent (unchanged behavior) # Default lead agent (unchanged behavior)
raw_tools = get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled, app_config=resolved_app_config) raw_tools = get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled, app_config=resolved_app_config)
filtered = filter_tools_by_skill_allowed_tools(raw_tools + extra_tools, skills_for_tool_policy, always_allowed_tool_names=SKILL_LOADING_TOOL_NAMES) filtered = filter_tools_by_skill_allowed_tools(raw_tools + extra_tools, skills_for_tool_policy, always_allowed_tool_names=SKILL_LOADING_TOOL_NAMES)
if non_interactive:
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled) final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
return create_agent( return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False), model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False),

View file

@ -27,6 +27,7 @@ from deerflow.config.run_events_config import RunEventsConfig
from deerflow.config.runtime_paths import existing_project_file from deerflow.config.runtime_paths import existing_project_file
from deerflow.config.safety_finish_reason_config import SafetyFinishReasonConfig from deerflow.config.safety_finish_reason_config import SafetyFinishReasonConfig
from deerflow.config.sandbox_config import SandboxConfig from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.scheduler_config import SchedulerConfig
from deerflow.config.skill_evolution_config import SkillEvolutionConfig from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skills_config import SkillsConfig from deerflow.config.skills_config import SkillsConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
@ -191,6 +192,13 @@ class AppConfig(BaseModel):
field_doc="Run-event store backend (memory for dev, db for production queries, jsonl for lightweight single-node persistence).", field_doc="Run-event store backend (memory for dev, db for production queries, jsonl for lightweight single-node persistence).",
), ),
) )
scheduler: SchedulerConfig = Field(
default_factory=SchedulerConfig,
description=format_field_description(
"scheduler",
field_doc="Scheduled task runtime configuration (background poller for one-time and cron agent runs).",
),
)
checkpointer: CheckpointerConfig | None = Field( checkpointer: CheckpointerConfig | None = Field(
default=None, default=None,
description=format_field_description( description=format_field_description(

View file

@ -64,6 +64,10 @@ STARTUP_ONLY_FIELDS: dict[str, str] = {
"channel_connections": ( "channel_connections": (
"start_channel_service() wires the connection repository and channel workers once at startup, and the channel-connections router caches the merged provider config on app.state; channel_connections.* edits need a restart." "start_channel_service() wires the connection repository and channel workers once at startup, and the channel-connections router caches the merged provider config on app.state; channel_connections.* edits need a restart."
), ),
"scheduler": (
"ScheduledTaskService is constructed and started once during Gateway lifespan startup; enabled, poll_interval_seconds, lease_seconds, "
"and max_concurrent_runs are captured into the service instance and the background poller task is not rebuilt on config.yaml edits."
),
} }

View file

@ -0,0 +1,9 @@
from pydantic import BaseModel, Field
class SchedulerConfig(BaseModel):
enabled: bool = Field(default=False)
poll_interval_seconds: int = Field(default=5, ge=1, le=300)
lease_seconds: int = Field(default=120, ge=5, le=3600)
max_concurrent_runs: int = Field(default=3, ge=1, le=32)
min_once_delay_seconds: int = Field(default=60, ge=1, le=86400)

View file

@ -0,0 +1,94 @@
"""scheduled tasks.
Revision ID: 0003_scheduled_tasks
Revises: 0002_runs_token_usage
Create Date: 2026-07-01
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003_scheduled_tasks"
down_revision: str | Sequence[str] | None = "0002_runs_token_usage"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if inspector.has_table("scheduled_tasks"):
# Idempotent: a DB whose full-metadata create_all already provisioned
# both scheduled-task tables (e.g. legacy test seeds) must not have them
# re-created here.
return
op.create_table(
"scheduled_tasks",
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("thread_id", sa.String(length=64), nullable=True),
sa.Column("context_mode", sa.String(length=32), nullable=False),
sa.Column("assistant_id", sa.String(length=128), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("prompt", sa.Text(), nullable=False),
sa.Column("schedule_type", sa.String(length=16), nullable=False),
sa.Column("schedule_spec", sa.JSON(), nullable=False),
sa.Column("timezone", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("overlap_policy", sa.String(length=16), nullable=False),
sa.Column("next_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_run_id", sa.String(length=64), nullable=True),
sa.Column("last_thread_id", sa.String(length=64), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("lease_owner", sa.String(length=128), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("run_count", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("scheduled_tasks", schema=None) as batch_op:
batch_op.create_index("ix_scheduled_tasks_user_id", ["user_id"], unique=False)
batch_op.create_index("ix_scheduled_tasks_thread_id", ["thread_id"], unique=False)
batch_op.create_index("ix_scheduled_tasks_status", ["status"], unique=False)
batch_op.create_index("ix_scheduled_tasks_next_run_at", ["next_run_at"], unique=False)
op.create_table(
"scheduled_task_runs",
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("task_id", sa.String(length=64), nullable=False),
sa.Column("thread_id", sa.String(length=64), nullable=False),
sa.Column("run_id", sa.String(length=64), nullable=True),
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=False),
sa.Column("trigger", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("scheduled_task_runs", schema=None) as batch_op:
batch_op.create_index("ix_scheduled_task_runs_task_id", ["task_id"], unique=False)
batch_op.create_index("ix_scheduled_task_runs_thread_id", ["thread_id"], unique=False)
batch_op.create_index("ix_scheduled_task_runs_status", ["status"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("scheduled_task_runs", schema=None) as batch_op:
batch_op.drop_index("ix_scheduled_task_runs_status")
batch_op.drop_index("ix_scheduled_task_runs_thread_id")
batch_op.drop_index("ix_scheduled_task_runs_task_id")
op.drop_table("scheduled_task_runs")
with op.batch_alter_table("scheduled_tasks", schema=None) as batch_op:
batch_op.drop_index("ix_scheduled_tasks_next_run_at")
batch_op.drop_index("ix_scheduled_tasks_status")
batch_op.drop_index("ix_scheduled_tasks_thread_id")
batch_op.drop_index("ix_scheduled_tasks_user_id")
op.drop_table("scheduled_tasks")

View file

@ -23,6 +23,8 @@ from deerflow.persistence.channel_connections.model import (
from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.run.model import RunRow from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.user.model import UserRow from deerflow.persistence.user.model import UserRow
@ -34,6 +36,8 @@ __all__ = [
"FeedbackRow", "FeedbackRow",
"RunEventRow", "RunEventRow",
"RunRow", "RunRow",
"ScheduledTaskRow",
"ScheduledTaskRunRow",
"ThreadMetaRow", "ThreadMetaRow",
"UserRow", "UserRow",
] ]

View file

@ -0,0 +1,4 @@
from .model import ScheduledTaskRunRow
from .sql import ScheduledTaskRunRepository
__all__ = ["ScheduledTaskRunRow", "ScheduledTaskRunRepository"]

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class ScheduledTaskRunRow(Base):
__tablename__ = "scheduled_task_runs"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
task_id: Mapped[str] = mapped_column(String(64), index=True)
thread_id: Mapped[str] = mapped_column(String(64), index=True)
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True))
trigger: Mapped[str] = mapped_column(String(16))
status: Mapped[str] = mapped_column(String(16), index=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))

View file

@ -0,0 +1,139 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow
from deerflow.utils.time import coerce_iso
TERMINAL_RUN_STATUSES: frozenset[str] = frozenset({"success", "failed", "skipped", "interrupted"})
ACTIVE_RUN_STATUSES: tuple[str, ...] = ("queued", "running")
class ScheduledTaskRunRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ScheduledTaskRunRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("scheduled_for", "started_at", "finished_at", "created_at"):
if data.get(key) is not None:
data[key] = coerce_iso(data[key])
return data
async def create(
self,
*,
run_record_id: str,
task_id: str,
thread_id: str,
scheduled_for: datetime,
trigger: str,
status: str,
) -> dict[str, Any]:
row = ScheduledTaskRunRow(
id=run_record_id,
task_id=task_id,
thread_id=thread_id,
scheduled_for=scheduled_for,
trigger=trigger,
status=status,
created_at=datetime.now(UTC),
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def list_by_task(self, task_id: str, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
stmt = (
select(ScheduledTaskRunRow)
.where(ScheduledTaskRunRow.task_id == task_id)
.order_by(
ScheduledTaskRunRow.created_at.desc(),
ScheduledTaskRunRow.id.desc(),
)
.limit(limit)
.offset(offset)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(row) for row in result.scalars()]
async def count_active_runs(self) -> int:
"""Global count of queued/running rows, used to bound cross-task concurrency."""
stmt = select(func.count()).select_from(ScheduledTaskRunRow).where(ScheduledTaskRunRow.status.in_(ACTIVE_RUN_STATUSES))
async with self._sf() as session:
result = await session.execute(stmt)
return int(result.scalar() or 0)
async def update_status(
self,
run_record_id: str,
*,
status: str,
run_id: str | None = None,
error: str | None = None,
started_at: datetime | None = None,
finished_at: datetime | None = None,
protect_terminal: bool = False,
) -> None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRunRow, run_record_id)
if row is None:
return
if protect_terminal and row.status in TERMINAL_RUN_STATUSES:
# The launch-path "running" write lost the race against the
# completion hook; keep the terminal status/error and only
# backfill bookkeeping the completion write could not know.
if row.run_id is None and run_id is not None:
row.run_id = run_id
if row.started_at is None and started_at is not None:
row.started_at = started_at
await session.commit()
return
row.status = status
row.run_id = run_id
row.error = error
if started_at is not None:
row.started_at = started_at
if finished_at is not None:
row.finished_at = finished_at
await session.commit()
async def has_active_runs(self, task_id: str) -> bool:
stmt = (
select(ScheduledTaskRunRow.id)
.where(
ScheduledTaskRunRow.task_id == task_id,
ScheduledTaskRunRow.status.in_(ACTIVE_RUN_STATUSES),
)
.limit(1)
)
async with self._sf() as session:
result = await session.execute(stmt)
return result.scalars().first() is not None
async def mark_stale_active_runs(self, *, error: str) -> int:
"""Fail-fast bookkeeping for runs orphaned by a process crash.
Agent runs execute in-process, so any ``queued``/``running`` row found
at scheduler startup belongs to a run whose process is gone. Only valid
under the MVP's single-scheduler-instance assumption.
"""
stmt = select(ScheduledTaskRunRow).where(ScheduledTaskRunRow.status.in_(ACTIVE_RUN_STATUSES))
now = datetime.now(UTC)
async with self._sf() as session:
result = await session.execute(stmt)
rows = list(result.scalars())
for row in rows:
row.status = "interrupted"
row.error = error
row.finished_at = now
await session.commit()
return len(rows)

View file

@ -0,0 +1,4 @@
from .model import ScheduledTaskRow
from .sql import ScheduledTaskRepository
__all__ = ["ScheduledTaskRow", "ScheduledTaskRepository"]

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class ScheduledTaskRow(Base):
__tablename__ = "scheduled_tasks"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), index=True)
thread_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
context_mode: Mapped[str] = mapped_column(String(32), default="fresh_thread_per_run")
assistant_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
title: Mapped[str] = mapped_column(String(255))
prompt: Mapped[str] = mapped_column(Text)
schedule_type: Mapped[str] = mapped_column(String(16))
schedule_spec: Mapped[dict] = mapped_column(JSON, default=dict)
timezone: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16), default="enabled", index=True)
overlap_policy: Mapped[str] = mapped_column(String(16), default="skip")
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True, nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_run_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
lease_owner: Mapped[str | None] = mapped_column(String(128), nullable=True)
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
run_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)

View file

@ -0,0 +1,232 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow
from deerflow.utils.time import coerce_iso
TERMINAL_TASK_STATUSES: frozenset[str] = frozenset({"completed", "failed", "cancelled"})
class ScheduledTaskRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ScheduledTaskRow) -> dict[str, Any]:
data = row.to_dict()
for key in (
"created_at",
"updated_at",
"next_run_at",
"last_run_at",
"lease_expires_at",
):
if data.get(key) is not None:
data[key] = coerce_iso(data[key])
return data
async def create(
self,
*,
task_id: str,
user_id: str,
thread_id: str | None,
context_mode: str,
assistant_id: str | None,
title: str,
prompt: str,
schedule_type: str,
schedule_spec: dict[str, Any],
timezone: str,
next_run_at: datetime | None,
) -> dict[str, Any]:
now = datetime.now(UTC)
row = ScheduledTaskRow(
id=task_id,
user_id=user_id,
thread_id=thread_id,
context_mode=context_mode,
assistant_id=assistant_id,
title=title,
prompt=prompt,
schedule_type=schedule_type,
schedule_spec=schedule_spec,
timezone=timezone,
next_run_at=next_run_at,
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def get(self, task_id: str, *, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None or row.user_id != user_id:
return None
return self._row_to_dict(row)
async def list_by_user(self, user_id: str) -> list[dict[str, Any]]:
stmt = select(ScheduledTaskRow).where(ScheduledTaskRow.user_id == user_id).order_by(ScheduledTaskRow.created_at.desc(), ScheduledTaskRow.id.desc())
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(row) for row in result.scalars()]
async def update(
self,
task_id: str,
*,
user_id: str,
updates: dict[str, Any],
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None or row.user_id != user_id:
return None
for key, value in updates.items():
if hasattr(row, key):
setattr(row, key, value)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def delete(self, task_id: str, *, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
async def claim_due_tasks(
self,
*,
now: datetime,
lease_owner: str,
lease_seconds: int,
limit: int,
) -> list[dict[str, Any]]:
lease_expires_at = now + timedelta(seconds=lease_seconds)
stmt = (
select(ScheduledTaskRow)
.where(
ScheduledTaskRow.next_run_at.is_not(None),
ScheduledTaskRow.next_run_at <= now,
or_(
and_(
ScheduledTaskRow.status == "enabled",
or_(
ScheduledTaskRow.lease_expires_at.is_(None),
ScheduledTaskRow.lease_expires_at < now,
),
),
# A task stuck in "running" with an expired lease means the
# claiming process died between claim and dispatch; it must
# stay reclaimable or the task is dead forever.
and_(
ScheduledTaskRow.status == "running",
ScheduledTaskRow.lease_expires_at.is_not(None),
ScheduledTaskRow.lease_expires_at < now,
),
),
)
.order_by(ScheduledTaskRow.next_run_at.asc(), ScheduledTaskRow.id.asc())
.limit(limit)
.with_for_update(skip_locked=True)
)
async with self._sf() as session:
result = await session.execute(stmt)
rows = list(result.scalars())
for row in rows:
row.lease_owner = lease_owner
row.lease_expires_at = lease_expires_at
row.status = "running"
row.updated_at = datetime.now(UTC)
await session.commit()
return [self._row_to_dict(row) for row in rows]
async def update_after_launch(
self,
task_id: str,
*,
status: str,
next_run_at: datetime | None,
last_run_at: datetime | None,
last_run_id: str | None,
last_thread_id: str | None,
last_error: str | None,
increment_run_count: bool,
protect_terminal: bool = False,
) -> None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None:
return
if protect_terminal and row.status in TERMINAL_TASK_STATUSES:
# A fast-failing run can reach handle_run_completion (which
# finalizes a `once` task) before this launch-path write
# commits; keep the hook's status/error and only record the
# launch bookkeeping.
pass
else:
row.status = status
row.last_error = last_error
row.next_run_at = next_run_at
row.last_run_at = last_run_at
row.last_run_id = last_run_id
row.last_thread_id = last_thread_id
if increment_run_count:
row.run_count += 1
row.lease_owner = None
row.lease_expires_at = None
row.updated_at = datetime.now(UTC)
await session.commit()
async def list_by_user_and_thread(self, user_id: str, thread_id: str) -> list[dict[str, Any]]:
stmt = (
select(ScheduledTaskRow)
.where(
ScheduledTaskRow.user_id == user_id,
ScheduledTaskRow.thread_id == thread_id,
)
.order_by(ScheduledTaskRow.created_at.desc(), ScheduledTaskRow.id.desc())
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(row) for row in result.scalars()]
async def cancel_stuck_once_tasks(self, *, error: str) -> int:
"""Reconcile ``once`` tasks orphaned in ``running`` by a process crash.
A launched ``once`` task stays ``running`` until the in-process
completion hook moves it to a terminal status; its lease was cleared at
launch, so the claim query's expired-lease reclaim branch never sees
it. After a crash the hook is gone and the task would be stuck forever.
Tasks still holding a lease are left alone they were claimed but not
launched, and expired-lease reclaim recovers them safely.
"""
stmt = select(ScheduledTaskRow).where(
ScheduledTaskRow.schedule_type == "once",
ScheduledTaskRow.status == "running",
ScheduledTaskRow.lease_expires_at.is_(None),
)
async with self._sf() as session:
result = await session.execute(stmt)
rows = list(result.scalars())
now = datetime.now(UTC)
for row in rows:
row.status = "cancelled"
row.last_error = error
row.updated_at = now
await session.commit()
return len(rows)

View file

@ -106,6 +106,7 @@ class RunContext:
run_events_config: Any | None = field(default=None) run_events_config: Any | None = field(default=None)
thread_store: Any | None = field(default=None) thread_store: Any | None = field(default=None)
app_config: AppConfig | None = field(default=None) app_config: AppConfig | None = field(default=None)
on_run_completed: Any | None = field(default=None)
def _install_runtime_context(config: dict, runtime_context: dict[str, Any]) -> None: def _install_runtime_context(config: dict, runtime_context: dict[str, Any]) -> None:
@ -588,6 +589,11 @@ async def run_agent(
except Exception: except Exception:
logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id) logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id)
if ctx.on_run_completed is not None:
try:
await ctx.on_run_completed(record)
except Exception:
logger.warning("Run completion hook failed for %s (non-fatal)", run_id, exc_info=True)
if record.finalizing: if record.finalizing:
await run_manager.set_finalizing(run_id, False) await run_manager.set_finalizing(run_id, False)

View file

@ -0,0 +1,3 @@
from .schedules import next_run_at, normalize_cron_expression, validate_timezone
__all__ = ["next_run_at", "normalize_cron_expression", "validate_timezone"]

View file

@ -0,0 +1,55 @@
from __future__ import annotations
from datetime import UTC, datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from croniter import croniter
def validate_timezone(timezone_name: str) -> str:
try:
ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise ValueError(f"Unknown timezone: {timezone_name}") from exc
return timezone_name
def normalize_cron_expression(expr: str) -> str:
parts = [part for part in expr.split() if part]
if len(parts) != 5:
raise ValueError("Cron expression must contain exactly 5 fields")
return " ".join(parts)
def next_run_at(
schedule_type: str,
schedule_spec: dict[str, object],
timezone_name: str,
*,
now: datetime,
) -> datetime | None:
validate_timezone(timezone_name)
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
if schedule_type == "once":
run_at_raw = schedule_spec.get("run_at")
if not isinstance(run_at_raw, str):
raise ValueError("once schedule requires run_at")
run_at = datetime.fromisoformat(run_at_raw)
if run_at.tzinfo is None:
# A naive run_at means "wall-clock time in the task's declared
# timezone", matching how cron schedules interpret it.
run_at = run_at.replace(tzinfo=ZoneInfo(timezone_name))
return run_at if run_at > now else None
if schedule_type == "cron":
cron_expr = normalize_cron_expression(str(schedule_spec.get("cron", "")))
zone = ZoneInfo(timezone_name)
local_now = now.astimezone(zone)
next_local = croniter(cron_expr, local_now).get_next(datetime)
if next_local.tzinfo is None:
next_local = next_local.replace(tzinfo=zone)
return next_local.astimezone(UTC)
raise ValueError(f"Unsupported schedule_type: {schedule_type}")

View file

@ -6,6 +6,7 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"agent-client-protocol>=0.4.0", "agent-client-protocol>=0.4.0",
"agent-sandbox>=0.0.19", "agent-sandbox>=0.0.19",
"croniter>=6.0.0",
"dotenv>=0.9.9", "dotenv>=0.9.9",
"exa-py>=1.0.0", "exa-py>=1.0.0",
"httpx>=0.28.0", "httpx>=0.28.0",

View file

@ -416,6 +416,29 @@ def test_build_run_config_dual_write_matches_merge_run_context_overrides_shape()
assert via_assistant_id["context"]["agent_name"] == via_context["context"]["agent_name"] assert via_assistant_id["context"]["agent_name"] == via_context["context"]["agent_name"]
def test_non_interactive_context_override_is_internal_only():
"""Client-supplied ``non_interactive`` must be dropped: it strips the
``ask_clarification`` tool, so only the internal scheduler path may set it."""
from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", None, None)
merge_run_context_overrides(config, {"non_interactive": True})
assert "non_interactive" not in config["configurable"]
assert "non_interactive" not in config["context"]
def test_non_interactive_context_override_honored_for_internal_caller():
from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", None, None)
merge_run_context_overrides(config, {"non_interactive": True, "model_name": "gpt"}, internal=True)
assert config["configurable"]["non_interactive"] is True
assert config["context"]["non_interactive"] is True
assert config["configurable"]["model_name"] == "gpt"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -851,6 +874,41 @@ def test_start_run_uses_internal_owner_header_for_persistence(_stub_app_config):
assert task_context["user_id"] == "owner-1" assert task_context["user_id"] == "owner-1"
def test_launch_scheduled_thread_run_marks_context_non_interactive(_stub_app_config):
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
from app.gateway.services import launch_scheduled_thread_run
async def _scenario():
captured: dict[str, object] = {}
async def fake_start_run(body, thread_id, request):
captured["thread_id"] = thread_id
captured["context"] = body.context
captured["metadata"] = body.metadata
return SimpleNamespace(run_id="run-1", thread_id=thread_id)
with patch("app.gateway.services.start_run", side_effect=fake_start_run):
result = await launch_scheduled_thread_run(
thread_id="thread-scheduled",
assistant_id="lead_agent",
prompt="Run in background",
app=SimpleNamespace(state=SimpleNamespace()),
owner_user_id="user-1",
metadata={"scheduled_task_id": "task-1"},
)
return captured, result
captured, result = asyncio.run(_scenario())
assert captured["thread_id"] == "thread-scheduled"
assert captured["context"] == {"non_interactive": True, "user_id": "user-1"}
assert captured["metadata"] == {"scheduled_task_id": "task-1"}
assert result == {"run_id": "run-1", "thread_id": "thread-scheduled"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# build_run_config — context / configurable precedence (LangGraph >= 0.6.0) # build_run_config — context / configurable precedence (LangGraph >= 0.6.0)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -965,3 +1023,19 @@ def test_build_run_config_no_request_config():
config = build_run_config("thread-abc", None, None) config = build_run_config("thread-abc", None, None)
assert config["configurable"] == {"thread_id": "thread-abc"} assert config["configurable"] == {"thread_id": "thread-abc"}
assert "context" not in config assert "context" not in config
def test_strip_internal_context_keys_scrubs_config_smuggled_non_interactive():
"""A non-internal client must not force ``non_interactive`` via the free-form
``body.config`` either ``build_run_config`` copies ``config.context`` and
``config.configurable`` verbatim, so the assembled config gets scrubbed."""
from app.gateway.services import build_run_config, strip_internal_context_keys
via_context = build_run_config("thread-1", {"context": {"non_interactive": True, "model_name": "gpt"}}, None)
strip_internal_context_keys(via_context)
assert "non_interactive" not in via_context["context"]
assert via_context["context"]["model_name"] == "gpt"
via_configurable = build_run_config("thread-1", {"configurable": {"non_interactive": True}}, None)
strip_internal_context_keys(via_configurable)
assert "non_interactive" not in via_configurable["configurable"]

View file

@ -288,6 +288,40 @@ def test_make_lead_agent_reads_runtime_options_from_context(monkeypatch):
assert result["model"] is not None assert result["model"] is not None
def test_make_lead_agent_filters_clarification_tool_for_non_interactive_runs(monkeypatch):
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
import deerflow.tools as tools_module
def _named_tool(name: str):
tool = MagicMock()
tool.name = name
return tool
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(
tools_module,
"get_available_tools",
lambda **kwargs: [_named_tool("ask_clarification"), _named_tool("bash")],
)
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object())
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
result = lead_agent_module.make_lead_agent(
{
"context": {
"model_name": "safe-model",
"thinking_enabled": False,
"subagent_enabled": False,
"non_interactive": True,
}
}
)
assert [tool.name for tool in result["tools"]] == ["bash"]
def test_make_lead_agent_rejects_invalid_bootstrap_agent_name(monkeypatch): def test_make_lead_agent_rejects_invalid_bootstrap_agent_name(monkeypatch):
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)]) app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])

View file

@ -47,7 +47,7 @@ from deerflow.persistence.migrations._helpers import _normalize_default
asyncio_test = pytest.mark.asyncio asyncio_test = pytest.mark.asyncio
HEAD = "0002_runs_token_usage" HEAD = "0003_scheduled_tasks"
BASELINE = "0001_baseline" BASELINE = "0001_baseline"

View file

@ -28,7 +28,7 @@ from deerflow.persistence.bootstrap import bootstrap_schema
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
HEAD = "0002_runs_token_usage" HEAD = "0003_scheduled_tasks"
def _url(tmp_path: Path) -> str: def _url(tmp_path: Path) -> str:

View file

@ -76,7 +76,7 @@ async def test_legacy_database_recovers_token_usage_column(tmp_path: Path) -> No
cols = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()} cols = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()}
assert "token_usage_by_model" in cols assert "token_usage_by_model" in cols
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone() version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
assert version_row[0] == "0002_runs_token_usage" assert version_row[0] == "0003_scheduled_tasks"
# And the read path that originally 500'd must now succeed. # And the read path that originally 500'd must now succeed.
sf = get_session_factory() sf = get_session_factory()
@ -116,6 +116,6 @@ async def test_legacy_database_with_manual_alter_still_bootstraps(tmp_path: Path
# No duplicate column -- list, not set, to catch dupes. # No duplicate column -- list, not set, to catch dupes.
assert cols.count("token_usage_by_model") == 1 assert cols.count("token_usage_by_model") == 1
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone() version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
assert version_row[0] == "0002_runs_token_usage" assert version_row[0] == "0003_scheduled_tasks"
finally: finally:
await close_engine() await close_engine()

View file

@ -0,0 +1,152 @@
from datetime import UTC, datetime, timedelta
import pytest
from deerflow.config.database_config import DatabaseConfig
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
@pytest.mark.asyncio
async def test_claim_due_tasks_claims_only_due_rows(tmp_path):
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
due = datetime.now(UTC) - timedelta(minutes=1)
future = datetime.now(UTC) + timedelta(hours=1)
await repo.create(
task_id="due-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Due",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=due,
)
await repo.create(
task_id="future-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Future",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=future,
)
claimed = await repo.claim_due_tasks(
now=datetime.now(UTC),
lease_owner="worker-1",
lease_seconds=120,
limit=10,
)
assert [task["id"] for task in claimed] == ["due-1"]
await close_engine()
@pytest.mark.asyncio
async def test_claim_reclaims_task_stuck_in_running_with_expired_lease(tmp_path):
"""A task whose claiming process died mid-dispatch must stay reclaimable.
Regression for the lease dead-end bug: claim flips status to ``running``,
and the old claim query only selected ``status == 'enabled'``, so a crash
between claim and dispatch left the task permanently un-triggerable.
"""
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
now = datetime.now(UTC)
due = now - timedelta(minutes=5)
await repo.create(
task_id="stuck-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Stuck",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=due,
)
first_claim = await repo.claim_due_tasks(
now=now,
lease_owner="dead-worker",
lease_seconds=60,
limit=10,
)
assert first_claim[0]["id"] == "stuck-1"
assert first_claim[0]["status"] == "running"
# Simulate the claiming process dying: lease expires, status stays "running".
expired_now = now + timedelta(seconds=120)
reclaimed = await repo.claim_due_tasks(
now=expired_now,
lease_owner="new-worker",
lease_seconds=60,
limit=10,
)
assert [task["id"] for task in reclaimed] == ["stuck-1"]
assert reclaimed[0]["lease_owner"] == "new-worker"
await close_engine()
@pytest.mark.asyncio
async def test_claim_skips_task_with_active_lease(tmp_path):
"""A task whose lease has not expired must not be reclaimed."""
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
now = datetime.now(UTC)
due = now - timedelta(minutes=5)
await repo.create(
task_id="active-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Active",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=due,
)
await repo.claim_due_tasks(
now=now,
lease_owner="worker-1",
lease_seconds=300,
limit=10,
)
# Lease still valid — second claim within the same process must not re-grab it.
reclaimed = await repo.claim_due_tasks(
now=now + timedelta(seconds=10),
lease_owner="worker-2",
lease_seconds=300,
limit=10,
)
assert reclaimed == []
await close_engine()

View file

@ -0,0 +1,7 @@
from app.gateway.app import create_app
def test_gateway_app_includes_scheduled_task_router():
app = create_app()
paths = {route.path for route in app.routes}
assert "/api/scheduled-tasks" in paths

View file

@ -0,0 +1,19 @@
from deerflow.config.app_config import AppConfig
from deerflow.persistence.models import ScheduledTaskRow, ScheduledTaskRunRow
def test_app_config_exposes_scheduler_section():
config = AppConfig.model_validate(
{
"models": [],
"sandbox": {"use": "local"},
}
)
assert config.scheduler.enabled is False
assert config.scheduler.poll_interval_seconds == 5
assert config.scheduler.lease_seconds == 120
def test_scheduled_task_models_registered():
assert ScheduledTaskRow.__tablename__ == "scheduled_tasks"
assert ScheduledTaskRunRow.__tablename__ == "scheduled_task_runs"

View file

@ -0,0 +1,318 @@
from datetime import UTC, datetime
import pytest
from deerflow.config.database_config import DatabaseConfig
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.persistence.scheduled_task_runs import ScheduledTaskRunRepository
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
@pytest.mark.asyncio
async def test_scheduled_task_repository_create_and_list(tmp_path):
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
created = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize this thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="Asia/Shanghai",
next_run_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
)
assert created["id"] == "task-1"
listed = await repo.list_by_user("user-1")
assert [task["id"] for task in listed] == ["task-1"]
await close_engine()
@pytest.mark.asyncio
async def test_scheduled_task_run_repository_records_history(tmp_path):
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRunRepository(sf)
row = await repo.create(
run_record_id="task-run-1",
task_id="task-1",
thread_id="thread-1",
scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
trigger="manual",
status="queued",
)
assert row["id"] == "task-run-1"
history = await repo.list_by_task("task-1")
assert [entry["id"] for entry in history] == ["task-run-1"]
await close_engine()
@pytest.mark.asyncio
async def test_mark_stale_active_runs_fails_orphaned_runs(tmp_path):
"""Runs stuck in queued/running after a process crash are swept to interrupted."""
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRunRepository(sf)
await repo.create(
run_record_id="task-run-queued",
task_id="task-1",
thread_id="thread-1",
scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
trigger="scheduled",
status="queued",
)
await repo.create(
run_record_id="task-run-running",
task_id="task-1",
thread_id="thread-1",
scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
trigger="scheduled",
status="running",
)
await repo.create(
run_record_id="task-run-success",
task_id="task-1",
thread_id="thread-1",
scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
trigger="scheduled",
status="success",
)
swept = await repo.mark_stale_active_runs(error="interrupted: gateway restarted")
assert swept == 2
history = await repo.list_by_task("task-1")
by_id = {entry["id"]: entry for entry in history}
assert by_id["task-run-queued"]["status"] == "interrupted"
assert by_id["task-run-running"]["status"] == "interrupted"
assert by_id["task-run-success"]["status"] == "success"
await close_engine()
@pytest.mark.asyncio
async def test_update_status_protect_terminal_keeps_completion_result(tmp_path):
"""The launch-path "running" write must not clobber a terminal status
already committed by the completion hook (launch/completion race)."""
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRunRepository(sf)
await repo.create(
run_record_id="task-run-race",
task_id="task-1",
thread_id="thread-1",
scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
trigger="scheduled",
status="queued",
)
# Completion hook wins the race and commits the terminal state first.
await repo.update_status("task-run-race", status="failed", run_id="run-1", error="boom", finished_at=datetime(2026, 7, 2, 1, 1, tzinfo=UTC))
# Late launch-path write: keeps terminal status/error, backfills started_at.
await repo.update_status("task-run-race", status="running", run_id="run-1", started_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), protect_terminal=True)
entry = (await repo.list_by_task("task-1"))[0]
assert entry["status"] == "failed"
assert entry["error"] == "boom"
assert entry["started_at"] is not None
await close_engine()
@pytest.mark.asyncio
async def test_has_active_runs_sees_only_queued_and_running(tmp_path):
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRunRepository(sf)
assert await repo.has_active_runs("task-1") is False
await repo.create(
run_record_id="task-run-active",
task_id="task-1",
thread_id="thread-1",
scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
trigger="scheduled",
status="running",
)
assert await repo.has_active_runs("task-1") is True
await repo.update_status("task-run-active", status="success", run_id="run-1")
assert await repo.has_active_runs("task-1") is False
await close_engine()
@pytest.mark.asyncio
async def test_cancel_stuck_once_tasks_reconciles_orphaned_running(tmp_path):
"""Launched (lease cleared) once tasks stuck in running are cancelled at
startup; leased ones are left for expired-lease reclaim."""
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
for task_id, schedule_type, status in (
("task-once-stuck", "once", "running"),
("task-once-done", "once", "completed"),
("task-cron-running", "cron", "running"),
):
await repo.create(
task_id=task_id,
user_id="user-1",
thread_id=None,
context_mode="fresh_thread_per_run",
assistant_id="lead_agent",
title=task_id,
prompt="p",
schedule_type=schedule_type,
schedule_spec={"run_at": "2026-07-02T01:00:00+00:00"} if schedule_type == "once" else {"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
await repo.update(task_id, user_id="user-1", updates={"status": status})
# A claimed-but-not-launched once task still holds its lease: keep it.
await repo.create(
task_id="task-once-leased",
user_id="user-1",
thread_id=None,
context_mode="fresh_thread_per_run",
assistant_id="lead_agent",
title="task-once-leased",
prompt="p",
schedule_type="once",
schedule_spec={"run_at": "2026-07-02T01:00:00+00:00"},
timezone="UTC",
next_run_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
)
await repo.update("task-once-leased", user_id="user-1", updates={"status": "running", "lease_expires_at": datetime(2026, 7, 2, 1, 2, tzinfo=UTC)})
cancelled = await repo.cancel_stuck_once_tasks(error="interrupted: gateway restarted")
assert cancelled == 1
by_id = {t["id"]: t for t in await repo.list_by_user("user-1")}
assert by_id["task-once-stuck"]["status"] == "cancelled"
assert by_id["task-once-stuck"]["last_error"] == "interrupted: gateway restarted"
assert by_id["task-once-done"]["status"] == "completed"
assert by_id["task-cron-running"]["status"] == "running"
assert by_id["task-once-leased"]["status"] == "running"
await close_engine()
@pytest.mark.asyncio
async def test_update_after_launch_protect_terminal_keeps_hook_result(tmp_path):
"""The launch-path bookkeeping write must not clobber a terminal task
status committed first by the completion hook (fast-failing run)."""
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
await repo.create(
task_id="task-race",
user_id="user-1",
thread_id=None,
context_mode="fresh_thread_per_run",
assistant_id="lead_agent",
title="task-race",
prompt="p",
schedule_type="once",
schedule_spec={"run_at": "2026-07-02T01:00:00+00:00"},
timezone="UTC",
next_run_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
)
# Completion hook wins the race: task finalized as failed.
await repo.update("task-race", user_id="user-1", updates={"status": "failed", "last_error": "boom"})
# Late launch-path write with protection keeps the hook's outcome.
await repo.update_after_launch(
"task-race",
status="running",
next_run_at=None,
last_run_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
last_run_id="run-1",
last_thread_id="thread-1",
last_error=None,
increment_run_count=True,
protect_terminal=True,
)
task = await repo.get("task-race", user_id="user-1")
assert task is not None
assert task["status"] == "failed"
assert task["last_error"] == "boom"
# Launch bookkeeping still recorded.
assert task["last_run_id"] == "run-1"
assert task["run_count"] == 1
await close_engine()
@pytest.mark.asyncio
async def test_list_by_task_paginates(tmp_path):
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRunRepository(sf)
for i in range(5):
await repo.create(
run_record_id=f"task-run-{i}",
task_id="task-1",
thread_id="thread-1",
scheduled_for=datetime(2026, 7, 2, 1, i, tzinfo=UTC),
trigger="scheduled",
status="success",
)
assert await repo.count_active_runs() == 0
page1 = await repo.list_by_task("task-1", limit=2)
page2 = await repo.list_by_task("task-1", limit=2, offset=2)
assert len(page1) == 2
assert len(page2) == 2
assert {e["id"] for e in page1}.isdisjoint({e["id"] for e in page2})
await close_engine()
@pytest.mark.asyncio
async def test_list_by_user_and_thread_filters_in_sql(tmp_path):
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
for task_id, thread_id in (("task-a", "thread-1"), ("task-b", "thread-2"), ("task-c", "thread-1")):
await repo.create(
task_id=task_id,
user_id="user-1",
thread_id=thread_id,
context_mode="reuse_thread",
assistant_id="lead_agent",
title=task_id,
prompt="p",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
listed = await repo.list_by_user_and_thread("user-1", "thread-1")
assert sorted(t["id"] for t in listed) == ["task-a", "task-c"]
assert await repo.list_by_user_and_thread("user-2", "thread-1") == []
await close_engine()

View file

@ -0,0 +1,38 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import scheduled_tasks
def test_router_registers_list_endpoint():
app = FastAPI()
app.include_router(scheduled_tasks.router)
client = TestClient(app)
response = client.get("/api/scheduled-tasks")
assert response.status_code != 404
def test_router_registers_trigger_route():
app = FastAPI()
app.include_router(scheduled_tasks.router)
client = TestClient(app)
response = client.post("/api/scheduled-tasks/task-1/trigger")
assert response.status_code != 404
def test_router_registers_create_route():
app = FastAPI()
app.include_router(scheduled_tasks.router)
client = TestClient(app)
response = client.post(
"/api/scheduled-tasks",
json={
"thread_id": "thread-1",
"title": "Daily summary",
"prompt": "Summarize thread",
"schedule_type": "cron",
"schedule_spec": {"cron": "0 9 * * *"},
"timezone": "UTC",
},
)
assert response.status_code != 404

View file

@ -0,0 +1,652 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.gateway.routers import scheduled_tasks
class _Repo:
def __init__(self) -> None:
self.created = []
self.items = {}
async def list_by_user(self, user_id: str):
return [item for item in self.items.values() if item["user_id"] == user_id]
async def list_by_user_and_thread(self, user_id: str, thread_id: str):
return [item for item in self.items.values() if item["user_id"] == user_id and item["thread_id"] == thread_id]
async def create(self, **kwargs):
item = {
"id": kwargs["task_id"],
"user_id": kwargs["user_id"],
"thread_id": kwargs["thread_id"],
"context_mode": kwargs["context_mode"],
"title": kwargs["title"],
"prompt": kwargs["prompt"],
"schedule_type": kwargs["schedule_type"],
"schedule_spec": kwargs["schedule_spec"],
"timezone": kwargs["timezone"],
"status": "enabled",
"next_run_at": kwargs["next_run_at"],
}
self.items[item["id"]] = item
self.created.append(item)
return item
async def get(self, task_id: str, *, user_id: str):
item = self.items.get(task_id)
if item is None or item["user_id"] != user_id:
return None
return item
async def update(self, task_id: str, *, user_id: str, updates):
item = await self.get(task_id, user_id=user_id)
if item is None:
return None
item.update(updates)
return item
async def delete(self, task_id: str, *, user_id: str):
item = await self.get(task_id, user_id=user_id)
if item is None:
return False
self.items.pop(task_id, None)
return True
async def list_by_task(self, task_id: str):
return []
class _Service:
def __init__(self) -> None:
self.calls = []
self.result = {"outcome": "launched"}
async def dispatch_task(self, task, *, now, trigger):
self.calls.append((task, now, trigger))
return self.result
class _RunStore:
def __init__(self, runs):
self.runs = runs
async def get(self, run_id: str, *, user_id: str):
run = self.runs.get(run_id)
if run is None or run.get("user_id") != user_id:
return None
return run
class _Config:
def __init__(self, min_once_delay_seconds: int = 60) -> None:
self.scheduler = SimpleNamespace(min_once_delay_seconds=min_once_delay_seconds)
@pytest.mark.asyncio
async def test_create_scheduled_task_uses_repo():
repo = _Repo()
request = SimpleNamespace()
body = scheduled_tasks.ScheduledTaskCreateRequest(
thread_id="thread-1",
title="Daily summary",
prompt="Summarize thread",
schedule_type="once",
schedule_spec={"run_at": "2027-01-01T01:00:00+00:00"},
timezone="UTC",
)
user = SimpleNamespace(id="user-1")
thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True))
config = _Config()
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)
created = await scheduled_tasks.create_scheduled_task.__wrapped__(
request=request,
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
assert created["title"] == "Daily summary"
assert created["user_id"] == "user-1"
assert created["next_run_at"] == datetime(2027, 1, 1, 1, 0, tzinfo=UTC)
@pytest.mark.asyncio
async def test_create_fresh_thread_task_does_not_require_thread_id():
repo = _Repo()
request = SimpleNamespace()
body = scheduled_tasks.ScheduledTaskCreateRequest(
context_mode="fresh_thread_per_run",
thread_id=None,
title="Fresh task",
prompt="Run in fresh thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
)
user = SimpleNamespace(id="user-1")
thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True))
config = _Config()
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)
created = await scheduled_tasks.create_scheduled_task.__wrapped__(
request=request,
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
assert created["context_mode"] == "fresh_thread_per_run"
assert created["thread_id"] is None
@pytest.mark.asyncio
async def test_trigger_scheduled_task_dispatches_manual_run():
repo = _Repo()
service = _Service()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_service = scheduled_tasks.get_scheduled_task_service
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_scheduled_task_service = lambda _request: service
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.trigger_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_scheduled_task_service = old_service
scheduled_tasks.get_optional_user_from_request = old_user
assert result == {"id": "task-1", "triggered": True}
assert len(service.calls) == 1
assert service.calls[0][2] == "manual"
@pytest.mark.asyncio
async def test_trigger_scheduled_task_returns_conflict_when_dispatch_conflicts():
repo = _Repo()
service = _Service()
service.result = {"outcome": "conflict", "error": "Thread thread-1 already has an active run"}
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_service = scheduled_tasks.get_scheduled_task_service
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_scheduled_task_service = lambda _request: service
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
with pytest.raises(Exception) as exc_info:
await scheduled_tasks.trigger_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_scheduled_task_service = old_service
scheduled_tasks.get_optional_user_from_request = old_user
assert "already has an active run" in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_scheduled_task_writes_repo():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
config = _Config()
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)
result = await scheduled_tasks.update_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
body=scheduled_tasks.ScheduledTaskUpdateRequest(title="Updated title"),
)
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
assert result["title"] == "Updated title"
@pytest.mark.asyncio
async def test_delete_scheduled_task_deletes_repo_row():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.delete_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert result == {"id": "task-1", "deleted": True}
assert repo.items == {}
@pytest.mark.asyncio
async def test_pause_and_resume_scheduled_task_update_status():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
paused = await scheduled_tasks.pause_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
paused_status = paused["status"]
resumed = await scheduled_tasks.resume_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert paused_status == "paused"
assert resumed["status"] == "enabled"
@pytest.mark.asyncio
async def test_pause_rejects_running_task():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
task["status"] = "running"
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
with pytest.raises(Exception) as exc_info:
await scheduled_tasks.pause_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert "currently running" in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_rejects_running_task():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
task["status"] = "running"
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
config = _Config()
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)
with pytest.raises(Exception) as exc_info:
await scheduled_tasks.update_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
body=scheduled_tasks.ScheduledTaskUpdateRequest(title="Updated title"),
)
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
assert "currently running" in str(exc_info.value)
@pytest.mark.asyncio
async def test_list_thread_scheduled_tasks_filters_by_thread_id():
repo = _Repo()
await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Thread one task",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
await repo.create(
task_id="task-2",
user_id="user-1",
thread_id="thread-2",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Thread two task",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.list_thread_scheduled_tasks.__wrapped__(
thread_id="thread-1",
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert [task["id"] for task in result] == ["task-1"]
@pytest.mark.asyncio
async def test_list_scheduled_task_runs_returns_persisted_rows_without_side_effects():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Task",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
run_repo = SimpleNamespace(
list_by_task=AsyncMock(
return_value=[
{
"id": "task-run-1",
"task_id": "task-1",
"thread_id": "thread-1",
"run_id": "run-1",
"status": "running",
"error": None,
}
]
),
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_task_repo = scheduled_tasks.get_scheduled_task_repo
old_run_repo = scheduled_tasks.get_scheduled_task_run_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_scheduled_task_run_repo = lambda _request: run_repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.list_scheduled_task_runs.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_task_repo
scheduled_tasks.get_scheduled_task_run_repo = old_run_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert result[0]["status"] == "running"
@pytest.mark.asyncio
async def test_create_once_task_enforces_minimum_delay():
repo = _Repo()
request = SimpleNamespace()
body = scheduled_tasks.ScheduledTaskCreateRequest(
thread_id="thread-1",
title="Soon task",
prompt="Run soon",
schedule_type="once",
schedule_spec={"run_at": (datetime.now(UTC) + timedelta(seconds=30)).isoformat()},
timezone="UTC",
)
user = SimpleNamespace(id="user-1")
thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True))
config = _Config(min_once_delay_seconds=60)
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)
with pytest.raises(Exception) as exc_info:
await scheduled_tasks.create_scheduled_task.__wrapped__(
request=request,
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
assert "once schedule must be at least" in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_terminal_once_task_with_future_run_at_rearms_it():
"""PATCHing a fresh future run_at onto a completed/failed/cancelled once
task must reset status to enabled claim_due_tasks only admits enabled
rows, so keeping the terminal status returns a next_run_at that never fires."""
repo = _Repo()
task = await repo.create(
task_id="task-terminal",
user_id="user-1",
thread_id=None,
context_mode="fresh_thread_per_run",
assistant_id="lead_agent",
title="Once done",
prompt="p",
schedule_type="once",
schedule_spec={"run_at": "2026-07-01T00:00:00+00:00"},
timezone="UTC",
next_run_at=None,
)
task["status"] = "completed"
future_run_at = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
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=user)
result = await scheduled_tasks.update_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
body=scheduled_tasks.ScheduledTaskUpdateRequest(schedule_spec={"run_at": future_run_at}),
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
assert result["status"] == "enabled"
assert result["next_run_at"] is not None

View file

@ -0,0 +1,49 @@
from datetime import UTC, datetime
import pytest
from deerflow.scheduler.schedules import (
next_run_at,
normalize_cron_expression,
validate_timezone,
)
def test_validate_timezone_accepts_iana_name():
assert validate_timezone("Asia/Shanghai") == "Asia/Shanghai"
def test_validate_timezone_rejects_unknown_name():
with pytest.raises(ValueError):
validate_timezone("Mars/Base")
def test_normalize_cron_accepts_five_fields():
assert normalize_cron_expression("0 9 * * 1") == "0 9 * * 1"
def test_normalize_cron_rejects_seconds_field():
with pytest.raises(ValueError):
normalize_cron_expression("0 0 9 * * 1")
def test_next_run_at_for_once_returns_none_after_fire_time():
now = datetime(2026, 7, 2, 2, 0, tzinfo=UTC)
result = next_run_at(
"once",
{"run_at": "2026-07-02T01:00:00+00:00"},
"UTC",
now=now,
)
assert result is None
def test_next_run_at_for_cron_uses_timezone():
now = datetime(2026, 7, 1, 0, 30, tzinfo=UTC)
result = next_run_at(
"cron",
{"cron": "0 9 * * *"},
"Asia/Shanghai",
now=now,
)
assert result == datetime(2026, 7, 1, 1, 0, tzinfo=UTC)

View file

@ -0,0 +1,539 @@
from datetime import UTC, datetime, timedelta
import pytest
from app.scheduler.service import ScheduledTaskService
from deerflow.runtime import ConflictError, RunStatus
from deerflow.runtime.runs.manager import RunRecord
from deerflow.runtime.runs.schemas import DisconnectMode
class DummyTaskRepo:
def __init__(self, rows):
self.rows = rows
self.claimed = False
self.updated = None
self.cancelled_stuck_once = None
async def cancel_stuck_once_tasks(self, *, error):
self.cancelled_stuck_once = error
return 0
async def claim_due_tasks(self, **_kwargs):
if self.claimed:
return []
self.claimed = True
return self.rows
async def update_after_launch(self, *args, **kwargs):
self.updated = (args, kwargs)
async def get(self, task_id: str, *, user_id: str):
row = next((item for item in self.rows if item["id"] == task_id and item["user_id"] == user_id), None)
return dict(row) if row is not None else None
async def update(self, task_id: str, *, user_id: str, updates):
row = next((item for item in self.rows if item["id"] == task_id and item["user_id"] == user_id), None)
if row is None:
return None
row.update(updates)
return dict(row)
class DummyRunRepo:
def __init__(self, *, active=False, active_count=0):
self.created = None
self.updated = []
self.active = active
self.active_count = active_count
self.stale_marked = None
async def count_active_runs(self):
return self.active_count
async def create(self, **kwargs):
self.created = kwargs
return {"id": kwargs["run_record_id"]}
async def update_status(self, run_record_id, **kwargs):
self.updated.append((run_record_id, kwargs))
async def has_active_runs(self, task_id):
return self.active
async def mark_stale_active_runs(self, *, error):
self.stale_marked = error
return 0
@pytest.mark.asyncio
async def test_service_claims_and_dispatches_due_task():
async def fake_launch(**kwargs):
assert kwargs["owner_user_id"] == "user-1"
assert kwargs["metadata"]["scheduled_task_id"] == "task-1"
assert kwargs["metadata"]["scheduled_trigger"] == "scheduled"
return {"run_id": "run-1", "thread_id": kwargs["thread_id"]}
task_repo = DummyTaskRepo(
[
{
"id": "task-1",
"user_id": "user-1",
"thread_id": "thread-1",
"context_mode": "reuse_thread",
"assistant_id": "lead_agent",
"prompt": "Summarize thread",
"schedule_type": "once",
"schedule_spec": {"run_at": "2026-07-02T01:00:00+00:00"},
"timezone": "UTC",
}
]
)
run_repo = DummyRunRepo()
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=fake_launch,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
await service.run_once(now=datetime.now(UTC) + timedelta(days=1))
assert run_repo.created["task_id"] == "task-1"
assert run_repo.updated[0][1]["status"] == "running"
assert run_repo.updated[0][1]["protect_terminal"] is True
# `once` terminal status is owned by handle_run_completion, not the launch.
assert task_repo.updated[1]["status"] == "running"
@pytest.mark.asyncio
async def test_manual_trigger_keeps_paused_cron_task_paused():
async def fake_launch(**kwargs):
return {"run_id": "run-2", "thread_id": kwargs["thread_id"]}
task_repo = DummyTaskRepo(
[
{
"id": "task-2",
"user_id": "user-1",
"thread_id": "thread-1",
"context_mode": "reuse_thread",
"assistant_id": "lead_agent",
"prompt": "Summarize thread",
"schedule_type": "cron",
"schedule_spec": {"cron": "0 9 * * *"},
"timezone": "UTC",
"status": "paused",
}
]
)
run_repo = DummyRunRepo()
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=fake_launch,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
await service.dispatch_task(
task_repo.rows[0],
now=datetime.now(UTC),
trigger="manual",
)
assert task_repo.updated[1]["status"] == "paused"
@pytest.mark.asyncio
async def test_fresh_thread_per_run_creates_new_execution_thread():
async def fake_launch(**kwargs):
assert kwargs["thread_id"] != "thread-template"
return {"run_id": "run-3", "thread_id": kwargs["thread_id"]}
task_repo = DummyTaskRepo(
[
{
"id": "task-3",
"user_id": "user-1",
"thread_id": "thread-template",
"context_mode": "fresh_thread_per_run",
"assistant_id": "lead_agent",
"prompt": "Summarize thread",
"schedule_type": "cron",
"schedule_spec": {"cron": "0 9 * * *"},
"timezone": "UTC",
"status": "enabled",
}
]
)
run_repo = DummyRunRepo()
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=fake_launch,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
await service.dispatch_task(
task_repo.rows[0],
now=datetime.now(UTC),
trigger="scheduled",
)
assert run_repo.created["thread_id"] != "thread-template"
assert task_repo.updated[1]["last_thread_id"] == run_repo.created["thread_id"]
@pytest.mark.asyncio
async def test_scheduled_overlap_conflict_is_recorded_as_skip():
async def fake_launch(**_kwargs):
raise ConflictError("Thread thread-1 already has an active run")
task_repo = DummyTaskRepo(
[
{
"id": "task-4",
"user_id": "user-1",
"thread_id": "thread-1",
"context_mode": "reuse_thread",
"assistant_id": "lead_agent",
"prompt": "Summarize thread",
"schedule_type": "cron",
"schedule_spec": {"cron": "0 9 * * *"},
"timezone": "UTC",
"status": "running",
"overlap_policy": "skip",
"last_run_id": "run-old",
"last_thread_id": "thread-1",
"last_run_at": "2026-07-01T00:00:00+00:00",
}
]
)
run_repo = DummyRunRepo()
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=fake_launch,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
result = await service.dispatch_task(
task_repo.rows[0],
now=datetime.now(UTC),
trigger="scheduled",
)
assert result["outcome"] == "skipped"
assert run_repo.updated[-1][1]["status"] == "skipped"
assert task_repo.updated[1]["status"] == "enabled"
@pytest.mark.asyncio
async def test_manual_overlap_conflict_returns_conflict():
async def fake_launch(**_kwargs):
raise ConflictError("Thread thread-1 already has an active run")
task_repo = DummyTaskRepo(
[
{
"id": "task-5",
"user_id": "user-1",
"thread_id": "thread-1",
"context_mode": "reuse_thread",
"assistant_id": "lead_agent",
"prompt": "Summarize thread",
"schedule_type": "cron",
"schedule_spec": {"cron": "0 9 * * *"},
"timezone": "UTC",
"status": "enabled",
"overlap_policy": "skip",
}
]
)
run_repo = DummyRunRepo()
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=fake_launch,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
result = await service.dispatch_task(
task_repo.rows[0],
now=datetime.now(UTC),
trigger="manual",
)
assert result["outcome"] == "conflict"
assert run_repo.updated[-1][1]["status"] == "failed"
@pytest.mark.asyncio
async def test_handle_run_completion_persists_success():
task_repo = DummyTaskRepo(
[
{
"id": "task-6",
"user_id": "user-1",
"thread_id": None,
"context_mode": "fresh_thread_per_run",
"assistant_id": "lead_agent",
"prompt": "Summarize thread",
"schedule_type": "cron",
"schedule_spec": {"cron": "0 9 * * *"},
"timezone": "UTC",
"status": "enabled",
}
]
)
run_repo = DummyRunRepo()
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=lambda **_kwargs: None,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
record = RunRecord(
run_id="run-6",
thread_id="thread-6",
assistant_id="lead_agent",
status=RunStatus.success,
on_disconnect=DisconnectMode.continue_,
metadata={
"scheduled_task_id": "task-6",
"scheduled_task_run_id": "task-run-6",
},
user_id="user-1",
)
await service.handle_run_completion(record)
assert run_repo.updated[-1][0] == "task-run-6"
assert run_repo.updated[-1][1]["status"] == "success"
assert task_repo.rows[0]["last_error"] is None
def _make_service(task_repo, run_repo):
return ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=lambda **_kwargs: None,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
def _once_task_row(task_id="task-once", status="running"):
return {
"id": task_id,
"user_id": "user-1",
"thread_id": None,
"context_mode": "fresh_thread_per_run",
"assistant_id": "lead_agent",
"prompt": "Summarize thread",
"schedule_type": "once",
"schedule_spec": {"run_at": "2026-07-02T01:00:00+00:00"},
"timezone": "UTC",
"status": status,
}
def _completion_record(status, *, task_id="task-once", error=None):
return RunRecord(
run_id="run-x",
thread_id="thread-x",
assistant_id="lead_agent",
status=status,
on_disconnect=DisconnectMode.continue_,
metadata={
"scheduled_task_id": task_id,
"scheduled_task_run_id": "task-run-x",
},
user_id="user-1",
error=error,
)
@pytest.mark.asyncio
async def test_once_task_completes_only_via_completion_hook():
task_repo = DummyTaskRepo([_once_task_row()])
run_repo = DummyRunRepo()
service = _make_service(task_repo, run_repo)
await service.handle_run_completion(_completion_record(RunStatus.success))
assert run_repo.updated[-1][1]["status"] == "success"
assert task_repo.rows[0]["status"] == "completed"
@pytest.mark.asyncio
async def test_once_task_failed_run_marks_task_failed():
task_repo = DummyTaskRepo([_once_task_row()])
run_repo = DummyRunRepo()
service = _make_service(task_repo, run_repo)
await service.handle_run_completion(_completion_record(RunStatus.error, error="boom"))
assert run_repo.updated[-1][1]["status"] == "failed"
assert run_repo.updated[-1][1]["error"] == "boom"
assert task_repo.rows[0]["status"] == "failed"
assert task_repo.rows[0]["last_error"] == "boom"
@pytest.mark.asyncio
async def test_interrupted_run_is_distinct_and_cancels_once_task():
task_repo = DummyTaskRepo([_once_task_row()])
run_repo = DummyRunRepo()
service = _make_service(task_repo, run_repo)
await service.handle_run_completion(_completion_record(RunStatus.interrupted))
run_update = run_repo.updated[-1][1]
assert run_update["status"] == "interrupted"
assert run_update["error"] == "run was interrupted before completion"
assert task_repo.rows[0]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_interrupted_cron_run_keeps_task_enabled():
row = _once_task_row(task_id="task-cron")
row.update({"schedule_type": "cron", "schedule_spec": {"cron": "0 9 * * *"}, "status": "enabled"})
task_repo = DummyTaskRepo([row])
run_repo = DummyRunRepo()
service = _make_service(task_repo, run_repo)
await service.handle_run_completion(_completion_record(RunStatus.interrupted, task_id="task-cron"))
assert run_repo.updated[-1][1]["status"] == "interrupted"
assert task_repo.rows[0]["status"] == "enabled"
@pytest.mark.asyncio
async def test_skip_policy_applies_to_fresh_thread_runs():
launched = []
async def fake_launch(**kwargs):
launched.append(kwargs)
return {"run_id": "run-9", "thread_id": kwargs["thread_id"]}
row = _once_task_row(task_id="task-9")
row.update({"schedule_type": "cron", "schedule_spec": {"cron": "* * * * *"}, "status": "running", "overlap_policy": "skip"})
task_repo = DummyTaskRepo([row])
run_repo = DummyRunRepo(active=True)
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=fake_launch,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
result = await service.dispatch_task(row, now=datetime.now(UTC), trigger="scheduled")
assert result["outcome"] == "skipped"
assert launched == []
assert run_repo.created["status"] == "queued"
assert run_repo.updated[-1][1]["status"] == "skipped"
assert task_repo.updated[1]["status"] == "enabled"
assert task_repo.updated[1]["increment_run_count"] is False
@pytest.mark.asyncio
async def test_startup_sweep_reconciles_stale_runs_and_stuck_once_tasks():
task_repo = DummyTaskRepo([])
run_repo = DummyRunRepo()
service = _make_service(task_repo, run_repo)
await service.start()
await service.stop()
assert run_repo.stale_marked is not None
assert task_repo.cancelled_stuck_once == run_repo.stale_marked
@pytest.mark.asyncio
async def test_manual_trigger_with_active_run_returns_conflict_without_launching():
launched = []
async def fake_launch(**kwargs):
launched.append(kwargs)
return {"run_id": "run-x", "thread_id": kwargs["thread_id"]}
row = _once_task_row(task_id="task-manual-busy")
row.update({"schedule_type": "cron", "schedule_spec": {"cron": "* * * * *"}, "status": "enabled", "overlap_policy": "skip"})
task_repo = DummyTaskRepo([row])
run_repo = DummyRunRepo(active=True)
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=fake_launch,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
result = await service.dispatch_task(row, now=datetime.now(UTC), trigger="manual")
assert result["outcome"] == "conflict"
assert launched == []
# Nothing was scheduled to happen, so no run-history row is recorded.
assert run_repo.created is None
assert result["task_run_id"] is None
@pytest.mark.asyncio
async def test_run_once_claims_only_into_remaining_global_budget():
claim_limits = []
class BudgetTaskRepo(DummyTaskRepo):
async def claim_due_tasks(self, **kwargs):
claim_limits.append(kwargs["limit"])
return []
task_repo = BudgetTaskRepo([])
run_repo = DummyRunRepo(active_count=2)
service = _make_service(task_repo, run_repo)
await service.run_once(now=datetime.now(UTC))
assert claim_limits == [1]
run_repo.active_count = 3
await service.run_once(now=datetime.now(UTC))
# Budget exhausted: no claim at all this cycle.
assert claim_limits == [1]
@pytest.mark.asyncio
async def test_launch_bookkeeping_passes_protect_terminal():
async def fake_launch(**kwargs):
return {"run_id": "run-pt", "thread_id": kwargs["thread_id"]}
task_repo = DummyTaskRepo([_once_task_row(task_id="task-pt", status="enabled")])
run_repo = DummyRunRepo()
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=fake_launch,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
await service.dispatch_task(task_repo.rows[0], now=datetime.now(UTC), trigger="scheduled")
assert task_repo.updated[1]["protect_terminal"] is True

2
backend/uv.lock generated
View file

@ -851,6 +851,7 @@ dependencies = [
{ name = "agent-sandbox" }, { name = "agent-sandbox" },
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "alembic" }, { name = "alembic" },
{ name = "croniter" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "ddgs" }, { name = "ddgs" },
{ name = "dotenv" }, { name = "dotenv" },
@ -910,6 +911,7 @@ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.19" }, { name = "aiosqlite", specifier = ">=0.19" },
{ name = "alembic", specifier = ">=1.13" }, { name = "alembic", specifier = ">=1.13" },
{ name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29" },
{ name = "croniter", specifier = ">=6.0.0" },
{ name = "cryptography", specifier = ">=48.0.1" }, { name = "cryptography", specifier = ">=48.0.1" },
{ name = "ddgs", specifier = ">=9.10.0" }, { name = "ddgs", specifier = ">=9.10.0" },
{ name = "dotenv", specifier = ">=0.9.9" }, { name = "dotenv", specifier = ">=0.9.9" },

View file

@ -15,7 +15,7 @@
# ============================================================================ # ============================================================================
# Bump this number when the config schema changes. # Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml. # Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 17 config_version: 18
# ============================================================================ # ============================================================================
# Logging # Logging
@ -1404,6 +1404,30 @@ run_events:
max_trace_content: 10240 max_trace_content: 10240
track_token_usage: true track_token_usage: true
# ============================================================================
# Scheduled Tasks Configuration
# ============================================================================
# Background scheduler for one-time and recurring (cron) agent runs.
# All fields are restart-required (captured at Gateway lifespan startup).
#
# Multi-worker note: the scheduler runs once per uvicorn worker. SQLite silently
# ignores row-level locks, so multiple workers can double-fire the same task.
# For multi-worker deployments (GATEWAY_WORKERS > 1), use the Postgres database
# backend, where FOR UPDATE SKIP LOCKED serializes claims correctly.
#
# scheduler:
# enabled: false # Master switch for the background poller
# poll_interval_seconds: 5 # How often to scan for due tasks
# lease_seconds: 120 # Claim lease; a crashed process's task becomes reclaimable after this
# max_concurrent_runs: 3 # Global cap on active scheduled runs; each poll claims only into the remaining budget
# min_once_delay_seconds: 60 # Minimum future offset for one-time tasks at creation time
scheduler:
enabled: false
poll_interval_seconds: 5
lease_seconds: 120
max_concurrent_runs: 3
min_once_delay_seconds: 60
# ============================================================================ # ============================================================================
# Stream Bridge Configuration # Stream Bridge Configuration
# ============================================================================ # ============================================================================

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,596 @@
# DeerFlow Scheduled Tasks MVP Design
**Date**: 2026-07-01
**Status**: Approved for implementation
**Scope**: First-class scheduled-task management for DeerFlow web workspace
---
## Problem Statement
DeerFlow main does not ship a real scheduled-task product surface today. The repository already has internal timers, worker pools, and run persistence, but users cannot create, inspect, pause, resume, trigger, or delete durable background tasks from the product.
This creates three concrete problems:
1. Users cannot automate recurring DeerFlow work such as daily summaries, periodic follow-ups, or recurring repo triage from the normal workspace.
2. Existing cron-related PRs prove demand, but they either cut scope too broadly or start from the wrong interaction surface, which makes them hard to merge and harder to operate safely.
3. Without a management surface, any future chat-created schedule would be operationally unsafe because users would have no first-class place to inspect or stop unattended jobs.
The first implementation must solve the operational and product-control problem before natural-language schedule creation.
## Solution Summary
Build a scheduled-task MVP with these hard boundaries:
1. **Durable backend resource**: add a `scheduled_task` resource with DB-backed persistence and DB-backed task-run history.
2. **Shared execution path**: scheduled executions must launch through the existing DeerFlow run lifecycle, not a parallel agent path.
3. **Workspace management page**: add a first-class page at `/workspace/scheduled-tasks` for list/detail/create/edit/pause/resume/trigger/delete.
4. **Execution context mode is explicit**: every task chooses whether runs reuse an existing thread or create a fresh thread per occurrence.
5. **Minimal schedule surface**: MVP supports `once` and `cron`, but not `interval`.
6. **Opt-in runtime gate**: background scheduling remains disabled by default and requires explicit config enablement.
## Explicit Non-Goals
The MVP intentionally does **not** include:
1. Conversation-created schedules or a `schedule_task` tool.
2. Text-only notification jobs.
3. Channel, IM, or GitHub dispatch targets.
4. Goal-backed scheduled work.
5. Retry/dead-letter orchestration.
6. Distributed leader election beyond a single enabled scheduler instance with DB lease claims.
7. Intervals shorter than 60 seconds for user-created tasks.
These exclusions are not optional polish cuts. They are what keeps the first PR reviewable.
## Chosen Architecture
### Why this shape
This MVP combines the right parts of prior DeerFlow cron attempts without inheriting their problems:
1. Keep the **execution discipline** from the narrower backend MVPs: scheduler decides *when* to run, existing run services decide *how* to run.
2. Keep the **durable task identity + task history** shape from broader implementations.
3. Put **management UI before chat-created scheduling**, because users need a reliable control plane before background automation can be created from conversation.
### Resource Model
The MVP introduces two durable entities:
1. `scheduled_tasks`
2. `scheduled_task_runs`
`scheduled_tasks` is the durable trigger definition. `scheduled_task_runs` is the execution ledger per occurrence.
This keeps schedule identity separate from DeerFlow `runs`, which already model one concrete execution attempt.
## User Stories
1. As a DeerFlow user, I want to create a one-time task that can run in a fresh thread, so periodic automation does not silently accumulate old context.
2. As a DeerFlow user, I want to create a recurring cron task that can either reuse a thread or create a fresh thread per run, so I can choose between continuity and isolation explicitly.
3. As a DeerFlow user, I want to see next run time, last run result, and last error at a glance, so I know whether automation is healthy.
4. As a DeerFlow user, I want to pause and resume a task, so I can stop automation without deleting configuration.
5. As a DeerFlow user, I want to trigger a task manually, so I can test or re-run it on demand.
6. As a DeerFlow user, I want to inspect task run history, so I can audit what happened.
7. As a DeerFlow user, I want tasks to be owner-scoped, so no other user can list or mutate my automations.
8. As a maintainer, I want scheduler execution to reuse existing run-launch code, so scheduled runs do not become a second runtime stack.
## MVP Product Shape
### Supported Task Kinds
Only one execution kind is supported in MVP:
- `task_type = "agent"`
- `dispatch_type = "thread"`
That means every scheduled task is defined as:
- context mode
- optional target thread id
- title
- prompt override
- schedule definition
- runtime policy
When it fires, DeerFlow launches a normal run, but the execution thread is selected by `context_mode`.
These are fixed MVP semantics, not user-editable API fields and not persisted schema columns. The first PR must behave as if every task implicitly carries those values, without prematurely generalizing the contract.
### Supported Schedule Kinds
The user-facing MVP supports:
1. `once`
2. `cron`
The MVP does **not** support `interval` because:
1. it adds another schedule parser path,
2. it enlarges frontend validation,
3. it increases edge-case surface around cadence drift and minimum interval enforcement,
4. it is not required to prove the scheduler architecture.
If later added, `interval` can be layered on the same resource model.
### Execution Context Rule
The MVP supports two execution-context modes:
1. `fresh_thread_per_run` — default. Each scheduled occurrence creates a fresh DeerFlow thread.
2. `reuse_thread` — optional. Each scheduled occurrence reuses an existing thread.
This is deliberate:
1. recurring digests, summaries, and automation jobs should not silently accumulate context forever;
2. follow-up and reminder use cases still need an explicit reuse mode;
3. the scheduler definition stays separate from the execution thread used by each occurrence.
## Backend Design
### Persistence Layout
Add harness-owned persistence packages:
- `backend/packages/harness/deerflow/persistence/scheduled_tasks/`
- `backend/packages/harness/deerflow/persistence/scheduled_task_runs/`
Add ORM registration in:
- `backend/packages/harness/deerflow/persistence/models/__init__.py`
Add Alembic migration under:
- `backend/packages/harness/deerflow/persistence/migrations/versions/`
### `scheduled_tasks` schema
Fields:
- `id`: string primary key
- `user_id`: owner user id, indexed
- `thread_id`: nullable target thread id, indexed
- `context_mode`: `fresh_thread_per_run | reuse_thread`
- `assistant_id`: nullable assistant id snapshot
- `title`: user-visible task title
- `prompt`: explicit prompt to send when the task runs
- `schedule_type`: `once | cron`
- `schedule_spec`: JSON payload
- `timezone`: IANA timezone
- `status`: `enabled | paused | running | completed | failed | cancelled`
- `overlap_policy`: fixed to `skip` in MVP, still persisted explicitly
- `misfire_policy`: fixed to `run_once` in MVP, still persisted explicitly
- `next_run_at`: UTC timestamp, indexed
- `last_run_at`: nullable UTC timestamp
- `last_run_id`: nullable DeerFlow run id
- `last_thread_id`: nullable DeerFlow thread id from the latest execution
- `last_error`: nullable text
- `lease_owner`: nullable string
- `lease_expires_at`: nullable UTC timestamp
- `run_count`: integer
- `max_runs`: nullable integer
- `created_at`
- `updated_at`
Not included in MVP schema:
- `dispatch_type`
- `dispatch_target`
- `task_type`
- `sandbox_profile`
- `trust_policy`
- `credential_scope`
Reason: those are real future needs, but introducing dormant columns now weakens the first implementation and invites half-implemented policy behavior. The first PR should store only what it truly enforces.
### `scheduled_task_runs` schema
Fields:
- `id`: string primary key
- `task_id`: foreign-key-like indexed link to `scheduled_tasks.id`
- `thread_id`: indexed for efficient thread-level lookup
- `run_id`: nullable DeerFlow run id
- `scheduled_for`: UTC timestamp
- `trigger`: `scheduled | manual`
- `status`: `queued | running | success | failed | skipped`
- `error`: nullable text
- `started_at`: nullable UTC timestamp
- `finished_at`: nullable UTC timestamp
- `created_at`
This run ledger is distinct from DeerFlow `runs` because:
1. a scheduled occurrence may fail before a DeerFlow run is created,
2. an overlap skip still deserves audit visibility,
3. manual and scheduled triggers need explicit occurrence records.
### Repository APIs
Create two repositories:
1. `ScheduledTaskRepository`
2. `ScheduledTaskRunRepository`
Required repository behavior:
- create/get/list/update/delete tasks
- owner-scoped search
- claim due tasks atomically
- update lease / clear lease
- record status transitions
- insert run history rows
- list task run history
Atomic due-claim API must operate in one DB transaction:
- find due enabled tasks
- skip tasks with live unexpired lease
- set `lease_owner`, `lease_expires_at`, and temporary `status="running"`
- return claimed rows
The scheduler service must not implement claim logic in Python-only in-memory filters.
## Scheduler Runtime Design
### Location
Runtime service lives under:
- `backend/app/scheduler/`
Reason: it needs app-layer dependencies and shared run-launch services. Harness persistence remains app-agnostic.
### Lifecycle
The scheduler starts during FastAPI lifespan only when config enables it.
Suggested config section:
```yaml
scheduler:
enabled: false
poll_interval_seconds: 5
lease_seconds: 120
max_concurrent_runs: 3
min_interval_seconds: 60
```
There is no separate `leader` toggle in MVP. The DB lease is the operational guard. If deployments later require multi-instance topology, a leader dimension can be added in hardening.
### Execution flow
For each poll cycle:
1. fetch up to `max_concurrent_runs` due tasks via repository claim,
2. for each claimed task:
- create `scheduled_task_runs` row with `status=queued`,
- compute and persist the next schedule before or immediately after launch,
- dispatch a normal DeerFlow run through shared run-launch helper,
- persist `last_run_id`, `last_run_at`, `run_count`, task-run status, and error fields,
- release lease.
### Shared run-launch helper
MVP must extract or reuse a non-router helper based on existing logic in:
- [backend/app/gateway/services.py](/Users/nowcoder/Desktop/auto-code-work/deer-flow/.worktrees/scheduled-tasks-mvp/backend/app/gateway/services.py)
- [backend/app/gateway/routers/thread_runs.py](/Users/nowcoder/Desktop/auto-code-work/deer-flow/.worktrees/scheduled-tasks-mvp/backend/app/gateway/routers/thread_runs.py)
Required property:
- manual API trigger and background scheduler trigger both call the same launch helper.
The helper takes:
- target thread id
- target assistant id
- prompt content
- authenticated owner context
- origin metadata indicating `scheduled_task_id` and `scheduled_trigger`
### Overlap semantics
MVP uses one fixed overlap rule:
- if the target thread already has a pending/running run, record the occurrence as `skipped`, update `next_run_at`, and do not launch another run.
This is intentionally narrower than exposing multiple overlap policies in the first PR.
### Misfire semantics
MVP uses one fixed misfire rule:
- `run_once`
If the scheduler was down and multiple occurrences were missed, only the latest eligible missed occurrence runs when the scheduler comes back.
Reason:
1. avoids backlog explosion,
2. avoids unreviewed catch-up storms,
3. keeps first implementation deterministic.
### One-time task completion
For `once` tasks:
- successful dispatch marks task `completed`
- dispatch failure marks task `failed`
- task remains visible and queryable from UI/history after completion or failure
MVP uses soft retention, not destructive deletion.
### Cron semantics
Cron rules:
1. accept exactly 5 fields
2. reject 6-field cron with seconds
3. store explicit IANA timezone
4. compute `next_run_at` in UTC
5. normalize weekday semantics consistently and test them explicitly
The implementation must not silently depend on an ambiguous day-of-week interpretation.
## API Design
Add REST routes under `/api/scheduled-tasks`.
### Routes
- `GET /api/scheduled-tasks`
- `POST /api/scheduled-tasks`
- `GET /api/scheduled-tasks/{task_id}`
- `PATCH /api/scheduled-tasks/{task_id}`
- `POST /api/scheduled-tasks/{task_id}/pause`
- `POST /api/scheduled-tasks/{task_id}/resume`
- `POST /api/scheduled-tasks/{task_id}/trigger`
- `DELETE /api/scheduled-tasks/{task_id}`
- `GET /api/scheduled-tasks/{task_id}/runs`
- `GET /api/threads/{thread_id}/scheduled-tasks`
There is intentionally **no** dispatch-target discovery endpoint in MVP because the only target is an owned thread.
### Request validation
Create:
- title required
- prompt required
- thread id required and must be owner-accessible
- `schedule_type` required
- `once` requires run timestamp
- `cron` requires valid 5-field cron
- timezone required
Update:
- allow title/prompt/schedule/timezone changes
- disallow owner/thread reassignment across users
- disallow mutation while task is in temporary `running` state if that would invalidate schedule semantics
### Authorization
Owner checks are mandatory for:
- list
- detail
- run history
- patch
- pause
- resume
- trigger
- delete
- thread-scoped list
This should reuse existing auth patterns from thread/runs routers rather than inventing a new access scheme.
## Frontend Design
### Navigation
Add new workspace nav item:
- `/workspace/scheduled-tasks`
This belongs beside existing high-level workspace surfaces in `WorkspaceNavChatList`, not hidden under settings.
### Main page
Add page:
- `frontend/src/app/workspace/scheduled-tasks/page.tsx`
The page includes:
1. list table/cards
2. filter bar
3. create-task button
4. detail drawer or side panel
### List columns
- title
- thread title
- schedule summary
- status
- next run
- last run
- last result
- actions
### Filters
MVP filters:
- status
- schedule type
- thread
No owner filter is needed in MVP because tasks are already owner-scoped.
### Create/edit form
Fields:
- title
- thread selector
- prompt textarea
- schedule type: `once | cron`
- once datetime picker
- cron input
- timezone selector
Validation:
- prompt non-empty
- title non-empty
- once datetime must be in the future
- cron must be valid before submit
### Detail view
Displays:
- full prompt
- thread link
- raw schedule config
- last error
- run history list
- actions: pause/resume/trigger/delete/edit
### Thread-level entry point
Thread chat pages gain a visible entry point to view schedules for the current thread.
MVP behavior:
- small button/link in thread page header opens filtered scheduled-task page for current thread
It does not need a full embedded task manager in-thread. Reusing the main page keeps the first PR smaller.
## State and Data Fetching
Frontend adds a small `scheduled-tasks` API layer under:
- `frontend/src/core/scheduled-tasks/`
Recommended pieces:
- typed request/response models
- list/detail/run-history fetchers
- mutations for create/update/pause/resume/trigger/delete
- React Query hooks
This should follow the same shape the repo already uses for threads and feedback, not ad-hoc local fetch calls sprinkled through components.
## Error Handling
### Backend
Explicit failures that must surface cleanly:
1. missing or deleted thread
2. unauthorized owner access
3. invalid cron
4. invalid timezone
5. task already paused/resumed
6. trigger rejected due to active in-flight thread run
7. scheduler launch failure before run creation
Failure must never cause infinite immediate retry loops.
### Frontend
Users should see:
1. inline form validation errors
2. mutation toasts for pause/resume/trigger/delete
3. visible failed state in task row
4. visible `last_error` in details
The UI must not show a healthy-looking task row when the last scheduler attempt failed.
## Testing Strategy
### Backend unit tests
1. valid and invalid cron expressions
2. valid and invalid timezone handling
3. weekday normalization semantics
4. next-run computation across timezone boundaries and DST-sensitive cases
5. one-time schedule status transitions
6. due-task claim logic and lease expiry
7. overlap skip behavior
8. misfire `run_once` behavior
### Backend integration tests
1. CRUD API with owner isolation
2. thread-scoped task list route
3. pause/resume/trigger/delete flows
4. manual trigger creates a normal DeerFlow run through shared launch helper
5. scheduler loop claims each due task once
6. dispatch failure writes task and task-run errors correctly
7. deleted thread does not hot-loop retries
### Frontend unit tests
1. scheduled-task nav item renders and routes
2. list renders status/next run/last result
3. create dialog validates form state
4. action buttons settle correctly after API response
5. detail drawer renders history and last error
### Frontend E2E
Playwright with mocked APIs:
1. list page loads
2. create task from UI
3. pause/resume/trigger/delete flows
4. thread header link navigates to filtered scheduled-task view
### Real-path validation
Required before claiming feature complete:
1. start backend and frontend
2. create a one-time task due soon from the real UI
3. observe row move through live status updates
4. confirm linked DeerFlow run exists
5. confirm completed/failure state is visible in the management page
## Documentation Updates Required
If code lands, update:
1. `README.md` with feature overview and enablement note
2. `AGENTS.md` and `backend/AGENTS.md` with scheduler/runtime ownership and commands if architecture changes
3. config docs for new `scheduler` section
## Code Review Checklist
1. Scheduled runs reuse the existing run lifecycle.
2. Harness persistence does not import `app.*`.
3. Due-task claim logic is atomic.
4. No hot loop after dispatch failure.
5. Day-of-week semantics are explicit and tested.
6. Owner checks cover list/detail/history/mutate/trigger/delete.
7. UI shows failing state honestly.
8. Background scheduler remains opt-in.
9. Thread-level entry point does not introduce duplicate management UI logic.
10. Chat-created scheduling remains absent from MVP.
## Implementation Order
1. Backend persistence and repository layer
2. Schedule parser / next-run computation
3. Shared run-launch helper
4. Scheduler service and API
5. Frontend API layer and page
6. Thread header entry point
7. E2E and real-path validation
This order is mandatory because the frontend cannot be implemented against an unstable backend contract.

View file

@ -0,0 +1,78 @@
# Scheduled-tasks page: full i18n + recipe templates — design
Follow-up to the preset-driven schedule form (commit `1ca27a73`). Scope kept
front-end only and backend-contract-free so it stays reviewable inside PR #3898.
## Background
The preset form i18n'd the schedule section but left the rest of the
scheduled-tasks page in hard-coded English (filters, detail pane, actions, edit
form, run list). A `zh-CN` user saw a half-English page right next to the
i18n'd schedule input. Separately, the page still required users to hand-write
prompts for the headline use cases (GitHub Trending, news digest, issue triage,
weekly report).
## Goals
1. Every visible string on `/workspace/scheduled-tasks` goes through
`t.scheduledTasks.*` (en + zh).
2. One-click "quick create" via four built-in recipe templates that pre-fill
title + prompt + schedule.
3. No backend change. No new dependency.
## Design
### Full i18n
Added to the `scheduledTasks` i18n section (types + en-US + zh-CN):
- `create`, `context`, `filters`, `detail`, `actions`, `edit` — UI labels.
- `status`, `runTrigger`, `runStatus` — enum maps so list/detail/run rows render
localized values instead of raw `enabled` / `manual` / `success`.
- `recipes` — recipe labels (below).
The page maps raw enum values through small lookup helpers
(`statusLabel`, `scheduleTypeLabel`, `contextModeLabel`, `runTriggerLabel`,
`runStatusLabel`) that fall back to the raw value if unknown. E2E selectors
switched from exact English strings to case-insensitive regex so they survive
the i18n capitalization change (`cron · enabled``Cron · Enabled`).
### Recipe templates
New `frontend/src/core/scheduled-tasks/recipes.ts` exports `RECIPES: Recipe[]`
with `{ id, icon, titleKey, prompt, schedule }`. Four starters:
| id | icon | schedule | prompt gist |
|---|---|---|---|
| `trending` | 🔥 | daily 09:00 | web_search GitHub Trending, summarize top 10 |
| `news` | 📰 | daily 09:00 | web_search top tech news, 5-item digest |
| `issues` | 🏷️ | daily 09:00 | triage open issues in `{{repo}}` (user fills placeholder) |
| `weekly` | 📅 | weekly Mon 09:00 | weekly report |
`schedule.timezone` is intentionally `""` so the `ScheduledTaskScheduleInput`
falls back to the browser timezone when applied. Recipe titles/descriptions live
in i18n (`recipes.{id}.{title,desc}`); the file only stores the prompt + schedule.
### Wiring into the create form
- A `createNonce` counter state keys the create-form `ScheduledTaskScheduleInput`
(`key={createNonce}`). Bumping the nonce forces the component to remount so the
recipe's schedule is re-initialized into its `useState`, not just emitted.
- `applyRecipe(recipe)` sets title + prompt + createSchedule + contextMode +
bumps the nonce.
- A chip row (`data-testid="schedule-recipes"`) renders above the form fields.
## Verification
- `pnpm check` 0 errors; `pnpm test` all green (403 unit tests).
- Real browser (chrome-devtools, zh-CN): 4 recipe chips render, clicking
「每周周报」pre-fills title + prompt + preset (weekly) + preview
`每周 周一 09:00 (Asia/Shanghai)`.
- Playwright `scheduled-tasks` suite re-verified with regex selectors.
## Non-goals (next session)
- Backend dispatch targets / `task_type` (RFC phase 2) — kept out because it
changes schema/API/executor and would muddy this PR's review.
- Backend-stored / user-authored recipes — front-end built-ins are enough for
the first cut (YAGNI).

View file

@ -11,6 +11,7 @@ export default defineConfig({
use: { use: {
baseURL: "http://localhost:3000", baseURL: "http://localhost:3000",
locale: "en-US",
trace: "on-first-retry", trace: "on-first-retry",
}, },
@ -22,7 +23,7 @@ export default defineConfig({
], ],
webServer: { webServer: {
command: "pnpm build && pnpm start", command: "./node_modules/.bin/next build && ./node_modules/.bin/next start",
url: "http://localhost:3000", url: "http://localhost:3000",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 120_000,

View file

@ -19,6 +19,7 @@ import {
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
} from "@/components/workspace/messages"; } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
@ -194,6 +195,7 @@ export default function ChatPage() {
<ThreadTitle threadId={threadId} thread={thread} /> <ThreadTitle threadId={threadId} thread={thread} />
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{!isNewThread && <ThreadScheduledTasksLink threadId={threadId} />}
<TokenUsageIndicator <TokenUsageIndicator
threadId={isNewThread ? undefined : threadId} threadId={isNewThread ? undefined : threadId}
backendUsage={backendTokenUsage} backendUsage={backendTokenUsage}

View file

@ -0,0 +1,612 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
ScheduledTaskScheduleInput,
type ScheduleValue,
} from "@/components/workspace/scheduled-task-schedule-input";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import {
useCreateScheduledTask,
useUpdateScheduledTask,
useDeleteScheduledTask,
usePauseScheduledTask,
useResumeScheduledTask,
useScheduledTaskRuns,
useScheduledTasks,
useTriggerScheduledTask,
useThreadScheduledTasks,
} from "@/core/scheduled-tasks/hooks";
import { RECIPES, type Recipe } from "@/core/scheduled-tasks/recipes";
import type {
ScheduledTask,
ScheduledTaskRun,
} from "@/core/scheduled-tasks/types";
import { cn } from "@/lib/utils";
const NONE = "—";
function formatTimestamp(value: string | null, locale: string): string {
if (!value) {
return NONE;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
// Use a locale-aware short format like "2026-07-03 09:00". Future timestamps
// (next_run_at) render as an absolute time, not a relative "ago" string.
const intlLocale = locale === "zh-CN" ? "zh-CN" : "en-US";
return new Intl.DateTimeFormat(intlLocale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
export default function ScheduledTasksPage() {
const { t, locale } = useI18n();
const st = t.scheduledTasks;
const searchParams = useSearchParams();
const threadId = searchParams.get("thread_id");
const allTasksQuery = useScheduledTasks();
const threadTasksQuery = useThreadScheduledTasks(threadId);
const data = threadId ? threadTasksQuery.data : allTasksQuery.data;
const queryError = threadId ? threadTasksQuery.error : allTasksQuery.error;
const [deleteOpen, setDeleteOpen] = useState(false);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [contextMode, setContextMode] = useState<
"fresh_thread_per_run" | "reuse_thread"
>(threadId ? "reuse_thread" : "fresh_thread_per_run");
const [targetThreadId, setTargetThreadId] = useState(threadId ?? "");
const [title, setTitle] = useState("");
const [prompt, setPrompt] = useState("");
const [createSchedule, setCreateSchedule] = useState<ScheduleValue>({
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "",
});
const [statusFilter, setStatusFilter] = useState<
"all" | "enabled" | "paused" | "running" | "completed" | "failed"
>("all");
const [typeFilter, setTypeFilter] = useState<"all" | "once" | "cron">("all");
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editPrompt, setEditPrompt] = useState("");
const [editSchedule, setEditSchedule] = useState<ScheduleValue>({
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
});
const [createNonce, setCreateNonce] = useState(0);
const filteredData = (data ?? []).filter((task) => {
const statusPass = statusFilter === "all" || task.status === statusFilter;
const typePass = typeFilter === "all" || task.schedule_type === typeFilter;
return statusPass && typePass;
});
const selectedTask =
filteredData.find((task) => task.id === selectedTaskId) ?? filteredData[0];
const taskRunsQuery = useScheduledTaskRuns(selectedTask?.id);
const createTask = useCreateScheduledTask();
const updateTask = useUpdateScheduledTask(selectedTask?.id ?? "");
const pauseTask = usePauseScheduledTask();
const resumeTask = useResumeScheduledTask();
const triggerTask = useTriggerScheduledTask();
const deleteTask = useDeleteScheduledTask();
const scheduleTypeLabel = (v: string) =>
v === "cron"
? st.scheduleType.cron
: v === "once"
? st.scheduleType.once
: v;
const statusLabel = (v: string) =>
(st.status as Record<string, string>)[v] ?? v;
const contextModeLabel = (v: string) =>
v === "fresh_thread_per_run"
? st.context.fresh
: v === "reuse_thread"
? st.context.reuse
: v;
const runTriggerLabel = (v: string) =>
(st.runTrigger as Record<string, string>)[v] ?? v;
const runStatusLabel = (v: string) =>
(st.runStatus as Record<string, string>)[v] ?? v;
const taskSummary = (task: ScheduledTask) =>
`${scheduleTypeLabel(task.schedule_type)} · ${statusLabel(task.status)}`;
const runSummary = (run: ScheduledTaskRun) =>
`${runTriggerLabel(run.trigger)} · ${runStatusLabel(run.status)}`;
const applyRecipe = (recipe: Recipe) => {
const labels = st.recipes[recipe.titleKey];
setTitle(labels.title);
setPrompt(recipe.prompt);
setCreateSchedule(recipe.schedule);
setContextMode("fresh_thread_per_run");
setCreateNonce((n) => n + 1);
};
useEffect(() => {
document.title = `${t.sidebar.scheduledTasks} - ${t.pages.appName}`;
}, [t.pages.appName, t.sidebar.scheduledTasks]);
useEffect(() => {
if (!selectedTaskId) {
return;
}
const stillVisible = filteredData.some(
(task) => task.id === selectedTaskId,
);
if (!stillVisible) {
setSelectedTaskId(filteredData[0]?.id ?? null);
setEditing(false);
}
}, [filteredData, selectedTaskId]);
useEffect(() => {
if (!selectedTask) {
setEditing(false);
return;
}
setEditTitle(selectedTask.title);
setEditPrompt(selectedTask.prompt);
const spec = selectedTask.schedule_spec as {
cron?: string;
run_at?: string;
};
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,
},
timezone: selectedTask.timezone || "UTC",
});
// Depend on id only so a background refetch (same task, new object reference)
// does not wipe edits in progress.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTask?.id]);
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="mx-auto flex w-full max-w-(--container-width-md) flex-col gap-4 p-6">
<h1 className="text-2xl font-semibold">{t.sidebar.scheduledTasks}</h1>
<div
className="grid gap-2 rounded-lg border p-4"
data-testid="scheduled-task-create-form"
>
<div className="font-medium">{st.create.title}</div>
<div
className="flex flex-wrap items-center gap-1"
data-testid="schedule-recipes"
>
<span className="text-muted-foreground text-sm">
{st.recipes.label}:
</span>
{RECIPES.map((recipe) => (
<Button
key={recipe.id}
variant="outline"
size="sm"
onClick={() => applyRecipe(recipe)}
>
<span aria-hidden>{recipe.icon}</span>
{st.recipes[recipe.titleKey].title}
</Button>
))}
</div>
<div className="flex gap-2">
<Button
variant={
contextMode === "fresh_thread_per_run" ? "default" : "outline"
}
size="sm"
onClick={() => setContextMode("fresh_thread_per_run")}
>
{st.context.fresh}
</Button>
<Button
variant={contextMode === "reuse_thread" ? "default" : "outline"}
size="sm"
onClick={() => setContextMode("reuse_thread")}
>
{st.context.reuse}
</Button>
</div>
{contextMode === "reuse_thread" && (
<Input
value={targetThreadId}
onChange={(event) => setTargetThreadId(event.target.value)}
placeholder={st.context.threadIdPlaceholder}
/>
)}
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={st.create.taskTitle}
/>
<Textarea
rows={4}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={st.create.prompt}
/>
<ScheduledTaskScheduleInput
key={createNonce}
initial={createSchedule}
onChange={setCreateSchedule}
/>
{formError && (
<div className="text-destructive text-sm">{formError}</div>
)}
<Button
onClick={() => {
const hasSchedule =
Boolean(createSchedule.schedule_spec.cron) ||
Boolean(createSchedule.schedule_spec.run_at);
if (
!title ||
!prompt ||
!hasSchedule ||
(contextMode === "reuse_thread" && !targetThreadId)
) {
setFormError(st.create.fillRequired);
return;
}
setFormError(null);
createTask.mutate(
{
context_mode: contextMode,
thread_id:
contextMode === "reuse_thread" ? targetThreadId : null,
title,
prompt,
schedule_type: createSchedule.schedule_type,
schedule_spec: createSchedule.schedule_spec,
timezone: createSchedule.timezone || "UTC",
},
{
onSuccess: () => {
// Clear the form so a follow-up task starts fresh.
setTitle("");
setPrompt("");
setTargetThreadId("");
setContextMode("fresh_thread_per_run");
setCreateSchedule({
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "",
});
setCreateNonce((n) => n + 1);
},
},
);
}}
disabled={
!title ||
!prompt ||
(!createSchedule.schedule_spec.cron &&
!createSchedule.schedule_spec.run_at) ||
(contextMode === "reuse_thread" && !targetThreadId) ||
createTask.isPending
}
>
{st.create.submit}
</Button>
</div>
{threadId && (
<div className="text-muted-foreground text-sm">
{st.detail.filteredByThread.replace("{id}", threadId)}
</div>
)}
{queryError ? (
<div
className="text-destructive text-sm"
data-testid="scheduled-task-load-error"
>
{st.detail.loadFailed}: {queryError.message}
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("all")}
>
{st.filters.allStatuses}
</Button>
<Button
variant={statusFilter === "enabled" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("enabled")}
>
{st.filters.enabled}
</Button>
<Button
variant={statusFilter === "paused" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("paused")}
>
{st.filters.paused}
</Button>
<Button
variant={statusFilter === "completed" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("completed")}
>
{st.filters.completed}
</Button>
<Button
variant={statusFilter === "failed" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("failed")}
>
{st.filters.failed}
</Button>
<Button
variant={typeFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setTypeFilter("all")}
>
{st.filters.allTypes}
</Button>
<Button
variant={typeFilter === "cron" ? "default" : "outline"}
size="sm"
onClick={() => setTypeFilter("cron")}
>
{st.filters.cron}
</Button>
<Button
variant={typeFilter === "once" ? "default" : "outline"}
size="sm"
onClick={() => setTypeFilter("once")}
>
{st.filters.once}
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div
data-testid="scheduled-task-list"
className="flex flex-col gap-3"
>
{filteredData.map((task) => {
const isSelected = selectedTask?.id === task.id;
return (
<button
type="button"
key={task.id}
onClick={() => setSelectedTaskId(task.id)}
data-testid={`scheduled-task-item-${task.id}`}
className={cn(
"rounded-lg border p-4 text-left",
isSelected ? "border-foreground" : "border-border",
)}
>
<div className="font-medium">{task.title}</div>
<div className="text-muted-foreground text-sm">
{taskSummary(task)}
</div>
</button>
);
})}
</div>
<div
className="rounded-lg border p-4"
data-testid="scheduled-task-detail"
>
{selectedTask ? (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="text-lg font-semibold">
{selectedTask.title}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setEditing((value) => !value)}
>
{editing ? st.actions.cancelEdit : st.actions.edit}
</Button>
</div>
<div className="text-muted-foreground text-sm">
{st.detail.contextMode}:{" "}
{contextModeLabel(selectedTask.context_mode)}
</div>
<div className="text-muted-foreground text-sm">
{selectedTask.context_mode === "reuse_thread"
? `${st.detail.thread}: ${selectedTask.thread_id ?? NONE}`
: `${st.detail.lastThread}: ${selectedTask.last_thread_id ?? NONE}`}
</div>
<div className="text-muted-foreground text-sm">
{st.detail.schedule}:{" "}
{scheduleTypeLabel(selectedTask.schedule_type)}
</div>
<div className="text-muted-foreground text-sm">
{st.detail.nextRun}:{" "}
{formatTimestamp(selectedTask.next_run_at, locale)}
</div>
<div className="text-muted-foreground text-sm">
{st.detail.lastRun}:{" "}
{formatTimestamp(selectedTask.last_run_at, locale)}
</div>
<div className="text-muted-foreground text-sm">
{st.detail.lastRunId}: {selectedTask.last_run_id ?? NONE}
</div>
<div className="text-muted-foreground text-sm">
{st.detail.lastError}: {selectedTask.last_error ?? NONE}
</div>
{editing ? (
<div className="flex flex-col gap-2 rounded-lg border p-3">
<Input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
placeholder={st.edit.titlePlaceholder}
/>
<Textarea
rows={4}
value={editPrompt}
onChange={(event) => setEditPrompt(event.target.value)}
placeholder={st.edit.promptPlaceholder}
/>
<ScheduledTaskScheduleInput
key={selectedTask.id}
initial={editSchedule}
onChange={setEditSchedule}
scheduleTypeLocked
/>
<Button
size="sm"
onClick={() =>
updateTask.mutate({
title: editTitle,
prompt: editPrompt,
schedule_spec: editSchedule.schedule_spec,
timezone: editSchedule.timezone || "UTC",
})
}
disabled={updateTask.isPending}
>
{st.edit.submit}
</Button>
</div>
) : (
<div className="text-sm">{selectedTask.prompt}</div>
)}
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
selectedTask.status === "paused"
? resumeTask.mutate(selectedTask.id)
: pauseTask.mutate(selectedTask.id)
}
>
{selectedTask.status === "paused"
? st.actions.resume
: st.actions.pause}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => triggerTask.mutate(selectedTask.id)}
>
{st.actions.trigger}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
>
{st.actions.delete}
</Button>
</div>
<div data-testid="scheduled-task-runs">
{(taskRunsQuery.data ?? []).length === 1
? st.detail.runsCountOne.replace(
"{count}",
String((taskRunsQuery.data ?? []).length),
)
: st.detail.runsCount.replace(
"{count}",
String((taskRunsQuery.data ?? []).length),
)}
</div>
<div
className="flex flex-col gap-2"
data-testid="scheduled-task-run-list"
>
{(taskRunsQuery.data ?? []).length > 0 ? (
(taskRunsQuery.data ?? []).map((run) => (
<div
key={run.id}
className="rounded-md border p-3 text-sm"
>
<div className="font-medium">{runSummary(run)}</div>
<div className="text-muted-foreground text-xs">
{run.run_id ?? NONE}
</div>
<div className="text-muted-foreground text-xs">
{formatTimestamp(run.scheduled_for, locale)}
</div>
{run.error && (
<div className="text-destructive text-xs">
{run.error}
</div>
)}
</div>
))
) : (
<div className="text-muted-foreground text-sm">
{st.detail.noRuns}
</div>
)}
</div>
</div>
) : (
<div className="text-muted-foreground text-sm">
{st.detail.noSelection}
</div>
)}
</div>
</div>
</div>
</WorkspaceBody>
{/* Delete confirm — follows the agent-card confirm pattern. */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{st.actions.delete}</DialogTitle>
<DialogDescription>{st.deleteConfirm}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteTask.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => {
if (selectedTask) {
deleteTask.mutate(selectedTask.id, {
onSuccess: () => setDeleteOpen(false),
});
}
}}
disabled={deleteTask.isPending}
>
{deleteTask.isPending ? t.common.loading : st.actions.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkspaceContainer>
);
}

View file

@ -0,0 +1,333 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useI18n } from "@/core/i18n/hooks";
import {
describeSchedule,
pad2,
parseCron,
serializeCron,
utcToZonedLocalInput,
WEEKDAYS,
zonedLocalToUtcIso,
type CronParts,
type CronPreset,
type ScheduleLocale,
type Weekday,
} from "@/core/scheduled-tasks/cron";
export type ScheduleValue = {
schedule_type: "once" | "cron";
schedule_spec: { cron?: string; run_at?: string };
timezone: string;
};
const PRESETS: CronPreset[] = [
"hourly",
"daily",
"weekly",
"monthly",
"custom",
];
const FALLBACK_TIMEZONES = [
"UTC",
"Asia/Shanghai",
"Asia/Tokyo",
"Asia/Singapore",
"Europe/London",
"Europe/Berlin",
"America/New_York",
"America/Chicago",
"America/Los_Angeles",
];
function detectBrowserTimezone(): string {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (typeof tz === "string" && tz.length > 0) {
return tz;
}
} catch {
// resolvedOptions unavailable
}
return "UTC";
}
function timezoneOptions(): string[] {
const supported = (
Intl as unknown as {
supportedValuesOf?: (key: string) => string[] | undefined;
}
).supportedValuesOf?.("timeZone");
if (Array.isArray(supported) && supported.length > 0) {
return supported;
}
return FALLBACK_TIMEZONES;
}
const TIMEZONE_OPTIONS = timezoneOptions();
export function ScheduledTaskScheduleInput({
initial,
onChange,
scheduleTypeLocked = false,
}: {
initial: ScheduleValue;
onChange: (value: ScheduleValue) => void;
scheduleTypeLocked?: boolean;
}) {
const { t, locale } = useI18n();
const schedLocale: ScheduleLocale = locale.startsWith("zh") ? "zh" : "en";
const labels = t.scheduledTasks;
const [scheduleType, setScheduleType] = useState<"once" | "cron">(
initial.schedule_type,
);
const [preset, setPreset] = useState<CronPreset>(
() => parseCron(initial.schedule_spec.cron ?? "0 9 * * *").preset,
);
const [parts, setParts] = useState<CronParts>(
() => parseCron(initial.schedule_spec.cron ?? "0 9 * * *").parts,
);
const [runAtLocal, setRunAtLocal] = useState<string>(
initial.schedule_type === "once" && initial.schedule_spec.run_at
? utcToZonedLocalInput(
initial.schedule_spec.run_at,
initial.timezone || "UTC",
)
: "",
);
const [timezone, setTimezone] = useState<string>(
initial.timezone || detectBrowserTimezone(),
);
// 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
// onChange (new reference each render), depending on it directly would
// re-fire the effect every render and call onChange again, looping.
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Emit on every change including mount. On mount this syncs the parent with
// the browser-detected timezone and the canonicalized cron, so the submitted
// value always matches what the user sees in the preview.
useEffect(() => {
if (scheduleType === "once") {
const runAt = runAtLocal ? zonedLocalToUtcIso(runAtLocal, timezone) : "";
onChangeRef.current({
schedule_type: "once",
schedule_spec: runAt ? { run_at: runAt } : {},
timezone,
});
return;
}
const cron =
preset === "custom" ? (parts.raw ?? "") : serializeCron(preset, parts);
onChangeRef.current({
schedule_type: "cron",
schedule_spec: cron ? { cron } : {},
timezone,
});
}, [scheduleType, preset, parts, runAtLocal, timezone]);
function updateParts(patch: Partial<CronParts>) {
setParts((prev) => ({ ...prev, ...patch }));
}
function changePreset(next: CronPreset) {
setParts((prev) => {
const merged = { ...prev };
if (next === "weekly" && (merged.weekdays ?? []).length === 0) {
merged.weekdays = ["mon"];
}
if (next === "monthly" && merged.dayOfMonth == null) {
merged.dayOfMonth = 1;
}
if (next === "custom" && !merged.raw) {
merged.raw = serializeCron("daily", prev);
}
return merged;
});
setPreset(next);
}
function toggleWeekday(w: Weekday) {
setParts((prev) => {
const set = new Set(prev.weekdays ?? []);
if (set.has(w)) {
if (set.size <= 1) {
return prev;
}
set.delete(w);
} else {
set.add(w);
}
return { ...prev, weekdays: WEEKDAYS.filter((d) => set.has(d)) };
});
}
const preview = describeSchedule(
{ scheduleType, preset, parts, runAtLocal, timezone },
schedLocale,
);
return (
<div className="flex flex-col gap-2" data-testid="schedule-input">
{!scheduleTypeLocked && (
<div className="flex flex-wrap gap-2">
<Button
variant={scheduleType === "cron" ? "default" : "outline"}
size="sm"
onClick={() => setScheduleType("cron")}
>
{labels.scheduleType.cron}
</Button>
<Button
variant={scheduleType === "once" ? "default" : "outline"}
size="sm"
onClick={() => setScheduleType("once")}
>
{labels.scheduleType.once}
</Button>
</div>
)}
{scheduleType === "cron" ? (
<>
<Select
value={preset}
onValueChange={(v) => changePreset(v as CronPreset)}
>
<SelectTrigger className="w-full" data-testid="schedule-preset">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRESETS.map((p) => (
<SelectItem key={p} value={p}>
{labels.preset[p]}
</SelectItem>
))}
</SelectContent>
</Select>
{preset === "hourly" && (
<Input
type="number"
min={0}
max={59}
value={parts.minute ?? 0}
onChange={(e) => updateParts({ minute: Number(e.target.value) })}
aria-label={labels.fields.minute}
/>
)}
{(preset === "daily" ||
preset === "weekly" ||
preset === "monthly") && (
<Input
type="time"
value={`${pad2(parts.hour ?? 9)}:${pad2(parts.minute ?? 0)}`}
onChange={(e) => {
const [h, m] = e.target.value.split(":").map(Number);
updateParts({ hour: h, minute: m });
}}
aria-label={labels.fields.time}
/>
)}
{preset === "weekly" && (
<div className="flex flex-wrap gap-1">
<span className="text-muted-foreground w-full text-sm">
{labels.fields.weekday}
</span>
{WEEKDAYS.map((w) => {
const active = (parts.weekdays ?? []).includes(w);
return (
<Button
key={w}
variant={active ? "default" : "outline"}
size="sm"
onClick={() => toggleWeekday(w)}
aria-pressed={active}
>
{labels.weekdays[w]}
</Button>
);
})}
</div>
)}
{preset === "monthly" && (
<Input
type="number"
min={1}
max={31}
value={parts.dayOfMonth ?? 1}
onChange={(e) =>
updateParts({ dayOfMonth: Number(e.target.value) })
}
aria-label={labels.fields.dayOfMonth}
/>
)}
{preset === "custom" && (
<div className="flex flex-col gap-1">
<Input
value={parts.raw ?? ""}
onChange={(e) => updateParts({ raw: e.target.value })}
placeholder={labels.fields.cronPlaceholder}
aria-label={labels.fields.cron}
/>
<a
href="https://crontab.guru/"
target="_blank"
rel="noreferrer"
className="text-muted-foreground text-xs hover:underline"
>
{labels.cronHelp}
</a>
</div>
)}
</>
) : (
<Input
type="datetime-local"
value={runAtLocal}
onChange={(e) => setRunAtLocal(e.target.value)}
aria-label={labels.fields.runAt}
/>
)}
<Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger className="w-full" data-testid="schedule-timezone">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEZONE_OPTIONS.map((tzOption) => (
<SelectItem key={tzOption} value={tzOption}>
{tzOption}
</SelectItem>
))}
</SelectContent>
</Select>
<div
className="text-muted-foreground text-sm"
data-testid="schedule-preview"
>
{preview}
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
export function ThreadScheduledTasksLink({ threadId }: { threadId: string }) {
const { t } = useI18n();
return (
<Button variant="outline" size="sm" asChild>
<Link
href={`/workspace/scheduled-tasks?thread_id=${encodeURIComponent(threadId)}`}
>
{t.sidebar.scheduledTasks}
</Link>
</Button>
);
}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { BotIcon, MessagesSquare } from "lucide-react"; import { BotIcon, CalendarClock, MessagesSquare } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@ -75,6 +75,20 @@ export function WorkspaceNavChatList() {
</Tooltip> </Tooltip>
)} )}
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/scheduled-tasks")}
asChild
>
<Link
className="text-muted-foreground"
href="/workspace/scheduled-tasks"
>
<CalendarClock />
<span>{t.sidebar.scheduledTasks}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
); );

View file

@ -0,0 +1,17 @@
/**
* Throw an Error from a failed Gateway REST response.
*
* Parses the FastAPI error envelope (`{ detail: string }`) and falls back to
* the caller-provided message when the body is missing or not that shape.
* Shared by the domain API modules (channels, scheduled tasks) so the envelope
* format is interpreted in exactly one place.
*/
export async function throwGatewayApiError(
response: Response,
fallback: string,
): Promise<never> {
const body = (await response.json().catch(() => ({}))) as {
detail?: unknown;
};
throw new Error(typeof body.detail === "string" ? body.detail : fallback);
}

View file

@ -1,3 +1,4 @@
import { throwGatewayApiError } from "@/core/api/errors";
import { fetch } from "@/core/api/fetcher"; import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
@ -15,20 +16,10 @@ function channelsUrl(path: string): string {
return `${getBackendBaseURL()}/api/channels${path}`; return `${getBackendBaseURL()}/api/channels${path}`;
} }
async function throwChannelApiError(
response: Response,
fallback: string,
): Promise<never> {
const body = (await response.json().catch(() => ({}))) as {
detail?: unknown;
};
throw new Error(typeof body.detail === "string" ? body.detail : fallback);
}
export async function listChannelProviders(): Promise<ChannelProvidersResponse> { export async function listChannelProviders(): Promise<ChannelProvidersResponse> {
const response = await fetch(channelsUrl("/providers")); const response = await fetch(channelsUrl("/providers"));
if (!response.ok) { if (!response.ok) {
await throwChannelApiError( await throwGatewayApiError(
response, response,
`Failed to load channel providers: ${response.statusText}`, `Failed to load channel providers: ${response.statusText}`,
); );
@ -39,7 +30,7 @@ export async function listChannelProviders(): Promise<ChannelProvidersResponse>
export async function listChannelConnections(): Promise<ChannelConnection[]> { export async function listChannelConnections(): Promise<ChannelConnection[]> {
const response = await fetch(channelsUrl("/connections")); const response = await fetch(channelsUrl("/connections"));
if (!response.ok) { if (!response.ok) {
await throwChannelApiError( await throwGatewayApiError(
response, response,
`Failed to load channel connections: ${response.statusText}`, `Failed to load channel connections: ${response.statusText}`,
); );
@ -56,7 +47,7 @@ export async function connectChannelProvider(
{ method: "POST" }, { method: "POST" },
); );
if (!response.ok) { if (!response.ok) {
await throwChannelApiError( await throwGatewayApiError(
response, response,
`Failed to connect ${provider}: ${response.statusText}`, `Failed to connect ${provider}: ${response.statusText}`,
); );
@ -77,7 +68,7 @@ export async function configureChannelProvider(
}, },
); );
if (!response.ok) { if (!response.ok) {
await throwChannelApiError( await throwGatewayApiError(
response, response,
`Failed to configure ${provider}: ${response.statusText}`, `Failed to configure ${provider}: ${response.statusText}`,
); );
@ -93,7 +84,7 @@ export async function disconnectChannelConnection(
{ method: "DELETE" }, { method: "DELETE" },
); );
if (!response.ok) { if (!response.ok) {
await throwChannelApiError( await throwGatewayApiError(
response, response,
`Failed to disconnect channel: ${response.statusText}`, `Failed to disconnect channel: ${response.statusText}`,
); );
@ -108,7 +99,7 @@ export async function disconnectChannelProvider(
{ method: "DELETE" }, { method: "DELETE" },
); );
if (!response.ok) { if (!response.ok) {
await throwChannelApiError( await throwGatewayApiError(
response, response,
`Failed to disconnect ${provider}: ${response.statusText}`, `Failed to disconnect ${provider}: ${response.statusText}`,
); );

View file

@ -197,9 +197,144 @@ export const enUS: Translations = {
recentChats: "Recent chats", recentChats: "Recent chats",
demoChats: "Demo chats", demoChats: "Demo chats",
agents: "Agents", agents: "Agents",
scheduledTasks: "Scheduled tasks",
agentsDisabledTooltip: "Feature not enabled", agentsDisabledTooltip: "Feature not enabled",
}, },
// Scheduled tasks
scheduledTasks: {
scheduleType: {
cron: "Recurring",
once: "One-time",
},
preset: {
label: "Repeat",
hourly: "Hourly",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
custom: "Custom cron",
},
fields: {
minute: "Minute",
time: "Time",
weekday: "On",
dayOfMonth: "Day of month",
cron: "Cron expression",
cronPlaceholder: "0 9 * * *",
runAt: "Run at",
timezone: "Timezone",
},
weekdays: {
mon: "Mon",
tue: "Tue",
wed: "Wed",
thu: "Thu",
fri: "Fri",
sat: "Sat",
sun: "Sun",
},
preview: "Preview",
cronHelp: "Open crontab.guru",
create: {
title: "Create scheduled task",
taskTitle: "Task title",
prompt: "Prompt",
submit: "Create",
fillRequired: "Fill all required fields",
},
context: {
fresh: "Fresh thread",
reuse: "Reuse thread",
threadIdPlaceholder: "Thread ID",
},
filters: {
allStatuses: "All statuses",
enabled: "Enabled",
paused: "Paused",
completed: "Completed",
failed: "Failed",
allTypes: "All types",
cron: "Cron",
once: "Once",
},
detail: {
contextMode: "Context mode",
thread: "Thread",
lastThread: "Last thread",
schedule: "Schedule",
nextRun: "Next run",
lastRun: "Last run",
lastRunId: "Last run id",
lastError: "Last error",
runsCount: "{count} runs",
runsCountOne: "{count} run",
noRuns: "No runs yet",
noSelection: "No scheduled task selected",
filteredByThread: "Filtered by thread: {id}",
loadFailed: "Failed to load scheduled tasks",
},
actions: {
edit: "Edit",
cancelEdit: "Cancel edit",
pause: "Pause",
resume: "Resume",
trigger: "Trigger now",
delete: "Delete",
},
deleteConfirm:
"Are you sure you want to delete this scheduled task? This action cannot be undone.",
errors: {
create: "Failed to create scheduled task",
update: "Failed to update scheduled task",
pause: "Failed to pause scheduled task",
resume: "Failed to resume scheduled task",
trigger: "Failed to trigger scheduled task",
delete: "Failed to delete scheduled task",
},
edit: {
titlePlaceholder: "Edit title",
promptPlaceholder: "Edit prompt",
submit: "Save edit",
},
status: {
enabled: "Enabled",
paused: "Paused",
running: "Running",
completed: "Completed",
failed: "Failed",
cancelled: "Cancelled",
},
runTrigger: { scheduled: "scheduled", manual: "manual" },
runStatus: {
queued: "Queued",
running: "Running",
success: "Success",
failed: "Failed",
skipped: "Skipped",
interrupted: "Interrupted",
},
recipes: {
label: "Quick create",
trending: {
title: "GitHub Trending daily",
desc: "Summarize today's top 10 trending repos",
},
news: {
title: "Daily tech news digest",
desc: "Collect and summarize the day's top tech news",
},
issues: {
title: "GitHub Issue triage",
desc: "Triage a repo's open issues (fill in {{repo}})",
},
weekly: {
title: "Weekly report",
desc: "Compile a weekly summary, every Monday",
},
},
},
// Agents // Agents
agents: { agents: {
title: "Agents", title: "Agents",

View file

@ -137,10 +137,129 @@ export interface Translations {
chats: string; chats: string;
demoChats: string; demoChats: string;
agents: string; agents: string;
scheduledTasks: string;
agentsDisabledTooltip: string; agentsDisabledTooltip: string;
channels: string; channels: string;
}; };
// Scheduled tasks
scheduledTasks: {
scheduleType: { cron: string; once: string };
preset: {
label: string;
hourly: string;
daily: string;
weekly: string;
monthly: string;
custom: string;
};
fields: {
minute: string;
time: string;
weekday: string;
dayOfMonth: string;
cron: string;
cronPlaceholder: string;
runAt: string;
timezone: string;
};
weekdays: {
mon: string;
tue: string;
wed: string;
thu: string;
fri: string;
sat: string;
sun: string;
};
preview: string;
cronHelp: string;
create: {
title: string;
taskTitle: string;
prompt: string;
submit: string;
fillRequired: string;
};
context: {
fresh: string;
reuse: string;
threadIdPlaceholder: string;
};
filters: {
allStatuses: string;
enabled: string;
paused: string;
completed: string;
failed: string;
allTypes: string;
cron: string;
once: string;
};
detail: {
contextMode: string;
thread: string;
lastThread: string;
schedule: string;
nextRun: string;
lastRun: string;
lastRunId: string;
lastError: string;
runsCount: string;
runsCountOne: string;
noRuns: string;
noSelection: string;
filteredByThread: string;
loadFailed: string;
};
actions: {
edit: string;
cancelEdit: string;
pause: string;
resume: string;
trigger: string;
delete: string;
};
deleteConfirm: string;
errors: {
create: string;
update: string;
pause: string;
resume: string;
trigger: string;
delete: string;
};
edit: {
titlePlaceholder: string;
promptPlaceholder: string;
submit: string;
};
status: {
enabled: string;
paused: string;
running: string;
completed: string;
failed: string;
cancelled: string;
};
runTrigger: { scheduled: string; manual: string };
runStatus: {
queued: string;
running: string;
success: string;
failed: string;
skipped: string;
interrupted: string;
};
recipes: {
label: string;
trending: { title: string; desc: string };
news: { title: string; desc: string };
issues: { title: string; desc: string };
weekly: { title: string; desc: string };
};
};
// Agents // Agents
agents: { agents: {
title: string; title: string;

View file

@ -189,9 +189,143 @@ export const zhCN: Translations = {
recentChats: "最近的对话", recentChats: "最近的对话",
demoChats: "演示对话", demoChats: "演示对话",
agents: "智能体", agents: "智能体",
scheduledTasks: "定时任务",
agentsDisabledTooltip: "功能未启用", agentsDisabledTooltip: "功能未启用",
}, },
// 定时任务
scheduledTasks: {
scheduleType: {
cron: "重复",
once: "单次",
},
preset: {
label: "重复方式",
hourly: "每小时",
daily: "每天",
weekly: "每周",
monthly: "每月",
custom: "自定义 cron",
},
fields: {
minute: "分钟",
time: "时间",
weekday: "在",
dayOfMonth: "几号",
cron: "cron 表达式",
cronPlaceholder: "0 9 * * *",
runAt: "运行时间",
timezone: "时区",
},
weekdays: {
mon: "周一",
tue: "周二",
wed: "周三",
thu: "周四",
fri: "周五",
sat: "周六",
sun: "周日",
},
preview: "预览",
cronHelp: "打开 crontab.guru",
create: {
title: "创建定时任务",
taskTitle: "任务标题",
prompt: "提示词",
submit: "创建",
fillRequired: "请填写所有必填项",
},
context: {
fresh: "新线程",
reuse: "复用线程",
threadIdPlaceholder: "线程 ID",
},
filters: {
allStatuses: "全部状态",
enabled: "已启用",
paused: "已暂停",
completed: "已完成",
failed: "已失败",
allTypes: "全部类型",
cron: "定时",
once: "单次",
},
detail: {
contextMode: "上下文模式",
thread: "线程",
lastThread: "上个线程",
schedule: "调度",
nextRun: "下次运行",
lastRun: "上次运行",
lastRunId: "上次运行 ID",
lastError: "上次错误",
runsCount: "{count} 次运行",
runsCountOne: "{count} 次运行",
noRuns: "暂无运行",
noSelection: "未选择定时任务",
filteredByThread: "按线程筛选:{id}",
loadFailed: "加载定时任务失败",
},
actions: {
edit: "编辑",
cancelEdit: "取消编辑",
pause: "暂停",
resume: "恢复",
trigger: "立即触发",
delete: "删除",
},
deleteConfirm: "确定要删除该定时任务吗?此操作不可撤销。",
errors: {
create: "创建定时任务失败",
update: "更新定时任务失败",
pause: "暂停定时任务失败",
resume: "恢复定时任务失败",
trigger: "触发定时任务失败",
delete: "删除定时任务失败",
},
edit: {
titlePlaceholder: "编辑标题",
promptPlaceholder: "编辑提示词",
submit: "保存编辑",
},
status: {
enabled: "已启用",
paused: "已暂停",
running: "运行中",
completed: "已完成",
failed: "已失败",
cancelled: "已取消",
},
runTrigger: { scheduled: "定时", manual: "手动" },
runStatus: {
queued: "排队中",
running: "运行中",
success: "成功",
failed: "失败",
skipped: "跳过",
interrupted: "已中断",
},
recipes: {
label: "快速创建",
trending: {
title: "GitHub Trending 日榜",
desc: "总结今日 Trending 前十仓库",
},
news: {
title: "每日科技新闻摘要",
desc: "收集并总结当日科技要闻",
},
issues: {
title: "GitHub Issue 分诊",
desc: "分诊某仓库的 open issues填入 {{repo}}",
},
weekly: {
title: "每周周报",
desc: "每周一汇总一周工作",
},
},
},
// Agents // Agents
agents: { agents: {
title: "智能体", title: "智能体",

View file

@ -0,0 +1,164 @@
import { throwGatewayApiError } from "@/core/api/errors";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type { ScheduledTask, ScheduledTaskRun } from "./types";
function scheduledTasksUrl(path: string): string {
return `${getBackendBaseURL()}/api/scheduled-tasks${path}`;
}
export async function fetchScheduledTasks(): Promise<ScheduledTask[]> {
const response = await fetch(scheduledTasksUrl(""));
if (!response.ok) {
await throwGatewayApiError(
response,
`Failed to load scheduled tasks: ${response.statusText}`,
);
}
return response.json();
}
export async function fetchThreadScheduledTasks(
threadId: string,
): Promise<ScheduledTask[]> {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/scheduled-tasks`,
);
if (!response.ok) {
await throwGatewayApiError(
response,
`Failed to load thread scheduled tasks: ${response.statusText}`,
);
}
return response.json();
}
export async function fetchScheduledTaskRuns(
taskId: string,
): Promise<ScheduledTaskRun[]> {
const response = await fetch(
scheduledTasksUrl(`/${encodeURIComponent(taskId)}/runs`),
);
if (!response.ok) {
await throwGatewayApiError(
response,
`Failed to load scheduled task runs: ${response.statusText}`,
);
}
return response.json();
}
export type ScheduledTaskPayload = {
context_mode: "fresh_thread_per_run" | "reuse_thread";
thread_id?: string | null;
title: string;
prompt: string;
schedule_type: "once" | "cron";
schedule_spec: Record<string, unknown>;
timezone: string;
};
export async function createScheduledTask(
payload: ScheduledTaskPayload,
): Promise<ScheduledTask> {
const response = await fetch(scheduledTasksUrl(""), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
await throwGatewayApiError(
response,
`Failed to create scheduled task: ${response.statusText}`,
);
}
return response.json();
}
export async function updateScheduledTask(
taskId: string,
payload: Partial<Omit<ScheduledTaskPayload, "thread_id" | "schedule_type">>,
): Promise<ScheduledTask> {
const response = await fetch(
scheduledTasksUrl(`/${encodeURIComponent(taskId)}`),
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
await throwGatewayApiError(
response,
`Failed to update scheduled task: ${response.statusText}`,
);
}
return response.json();
}
export async function pauseScheduledTask(
taskId: string,
): Promise<ScheduledTask> {
const response = await fetch(
scheduledTasksUrl(`/${encodeURIComponent(taskId)}/pause`),
{ method: "POST" },
);
if (!response.ok) {
await throwGatewayApiError(
response,
`Failed to pause scheduled task: ${response.statusText}`,
);
}
return response.json();
}
export async function resumeScheduledTask(
taskId: string,
): Promise<ScheduledTask> {
const response = await fetch(
scheduledTasksUrl(`/${encodeURIComponent(taskId)}/resume`),
{ method: "POST" },
);
if (!response.ok) {
await throwGatewayApiError(
response,
`Failed to resume scheduled task: ${response.statusText}`,
);
}
return response.json();
}
export async function triggerScheduledTask(
taskId: string,
): Promise<{ id: string; triggered: boolean }> {
const response = await fetch(
scheduledTasksUrl(`/${encodeURIComponent(taskId)}/trigger`),
{ method: "POST" },
);
if (!response.ok) {
await throwGatewayApiError(
response,
`Failed to trigger scheduled task: ${response.statusText}`,
);
}
return response.json();
}
export async function deleteScheduledTask(
taskId: string,
): Promise<{ id: string; deleted: boolean }> {
const response = await fetch(
scheduledTasksUrl(`/${encodeURIComponent(taskId)}`),
{
method: "DELETE",
},
);
if (!response.ok) {
await throwGatewayApiError(
response,
`Failed to delete scheduled task: ${response.statusText}`,
);
}
return response.json();
}

View file

@ -0,0 +1,338 @@
/**
* Cron preset helpers for the scheduled-task form.
*
* Pure functions, no external deps. Cron expressions produced by presets are
* generated by us, so we never need to parse arbitrary cron for the happy path;
* `parseCron` only round-trips the canonical shapes we emit and falls back to
* `custom` for anything else.
*/
export type CronPreset = "hourly" | "daily" | "weekly" | "monthly" | "custom";
export type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
export type CronParts = {
minute?: number;
hour?: number;
weekdays?: Weekday[];
dayOfMonth?: number;
raw?: string;
};
export type ScheduleFormState = {
scheduleType: "once" | "cron";
preset?: CronPreset;
parts?: CronParts;
/** datetime-local wall value "YYYY-MM-DDTHH:mm", interpreted in `timezone`. */
runAtLocal?: string;
timezone: string;
};
export type ScheduleLocale = "en" | "zh";
export const WEEKDAYS: Weekday[] = [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
];
const WEEKDAY_TO_CRON: Record<Weekday, string> = {
mon: "1",
tue: "2",
wed: "3",
thu: "4",
fri: "5",
sat: "6",
sun: "0",
};
const CRON_TO_WEEKDAY: Record<string, Weekday> = {
"0": "sun",
"1": "mon",
"2": "tue",
"3": "wed",
"4": "thu",
"5": "fri",
"6": "sat",
"7": "sun",
};
const EN_WEEKDAY: Record<Weekday, string> = {
mon: "Mon",
tue: "Tue",
wed: "Wed",
thu: "Thu",
fri: "Fri",
sat: "Sat",
sun: "Sun",
};
const ZH_WEEKDAY: Record<Weekday, string> = {
mon: "周一",
tue: "周二",
wed: "周三",
thu: "周四",
fri: "周五",
sat: "周六",
sun: "周日",
};
function clamp(
value: number | undefined,
min: number,
max: number,
fallback: number,
): number {
const n =
typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(min, Math.min(max, Math.trunc(n)));
}
export function pad2(n: number): string {
return String(Math.trunc(Number.isFinite(n) ? n : 0)).padStart(2, "0");
}
function orderedWeekdays(days: Weekday[] | undefined): Weekday[] {
const set = new Set(days ?? []);
return WEEKDAYS.filter((w) => set.has(w));
}
export function serializeCron(preset: CronPreset, parts: CronParts): string {
const m = clamp(parts.minute, 0, 59, 0);
const h = clamp(parts.hour, 0, 23, 9);
switch (preset) {
case "hourly":
return `${m} * * * *`;
case "daily":
return `${m} ${h} * * *`;
case "weekly": {
const ordered = orderedWeekdays(parts.weekdays);
if (ordered.length === 0) {
return `${m} ${h} * * *`;
}
const dow = ordered.map((w) => WEEKDAY_TO_CRON[w]).join(",");
return `${m} ${h} * * ${dow}`;
}
case "monthly": {
const dom = clamp(parts.dayOfMonth, 1, 31, 1);
return `${m} ${h} ${dom} * *`;
}
case "custom":
return (parts.raw ?? "").trim() || "0 9 * * *";
}
// Unreachable — the switch above is exhaustive over CronPreset.
return (parts.raw ?? "").trim() || "0 9 * * *";
}
function isStar(field: string): boolean {
return field === "*";
}
function isSimpleDowList(field: string): boolean {
return field.split(",").every((tok) => /^[0-7]$/.test(tok));
}
export function parseCron(cron: string): {
preset: CronPreset;
parts: CronParts;
} {
const expr = cron.trim();
const fields = expr.split(/\s+/);
if (fields.length !== 5) {
return { preset: "custom", parts: { raw: expr } };
}
// fields.length === 5 is verified above, so every index is defined.
const mF = fields[0]!;
const hF = fields[1]!;
const domF = fields[2]!;
const monF = fields[3]!;
const dowF = fields[4]!;
const numMinute = /^\d+$/.test(mF);
const numHour = /^\d+$/.test(hF);
const numDom = /^\d+$/.test(domF);
const stars = isStar(domF) && isStar(monF);
// hourly: "M * * * *"
if (numMinute && isStar(hF) && stars && isStar(dowF)) {
return { preset: "hourly", parts: { minute: Number(mF) } };
}
// daily: "M H * * *"
if (numMinute && numHour && stars && isStar(dowF)) {
return {
preset: "daily",
parts: { minute: Number(mF), hour: Number(hF) },
};
}
// weekly: "M H * * DOW" (simple comma list of single digits only)
if (
numMinute &&
numHour &&
isStar(domF) &&
isStar(monF) &&
!isStar(dowF) &&
isSimpleDowList(dowF)
) {
const parsed = dowF
.split(",")
.map((tok) => CRON_TO_WEEKDAY[tok])
.filter((w): w is Weekday => Boolean(w));
const ordered = orderedWeekdays(parsed);
if (ordered.length > 0) {
return {
preset: "weekly",
parts: { minute: Number(mF), hour: Number(hF), weekdays: ordered },
};
}
}
// monthly: "M H DOM * *"
if (numMinute && numHour && numDom && isStar(monF) && isStar(dowF)) {
return {
preset: "monthly",
parts: { minute: Number(mF), hour: Number(hF), dayOfMonth: Number(domF) },
};
}
return { preset: "custom", parts: { raw: expr } };
}
export function describeSchedule(
state: ScheduleFormState,
locale: ScheduleLocale,
): string {
const tz = state.timezone;
const zh = locale === "zh";
if (state.scheduleType === "once") {
const runAt = (state.runAtLocal ?? "").replace("T", " ");
return zh ? `单次 ${runAt} (${tz})` : `Once at ${runAt} (${tz})`;
}
const parts = state.parts ?? {};
const hhmm = `${pad2(parts.hour ?? 0)}:${pad2(parts.minute ?? 0)}`;
switch (state.preset) {
case "hourly": {
const minute = parts.minute ?? 0;
return zh
? `每小时第 ${minute} 分钟 (${tz})`
: `Every hour at :${pad2(minute)} (${tz})`;
}
case "daily":
return zh ? `每天 ${hhmm} (${tz})` : `Every day at ${hhmm} (${tz})`;
case "weekly": {
const ordered = orderedWeekdays(parts.weekdays);
if (ordered.length === 0) {
return zh ? `每天 ${hhmm} (${tz})` : `Every day at ${hhmm} (${tz})`;
}
if (zh) {
const names = ordered.map((w) => ZH_WEEKDAY[w]).join("、");
return `每周 ${names} ${hhmm} (${tz})`;
}
const names = ordered.map((w) => EN_WEEKDAY[w]).join(", ");
return `Every ${names} at ${hhmm} (${tz})`;
}
case "monthly": {
const dom = parts.dayOfMonth ?? 1;
return zh
? `每月 ${dom}${hhmm} (${tz})`
: `On day ${dom} of every month at ${hhmm} (${tz})`;
}
case "custom":
return zh
? `自定义: ${parts.raw ?? ""} (${tz})`
: `Custom: ${parts.raw ?? ""} (${tz})`;
}
// Unreachable — switch is exhaustive over CronPreset.
return zh ? `自定义 (${tz})` : `Custom (${tz})`;
}
/**
* Convert a datetime-local wall value (interpreted in `timezone`) to a UTC ISO
* string. The backend's `datetime.fromisoformat` treats naive strings as UTC,
* so we must submit an explicit offset. DST-safe via Intl.
*/
export function zonedLocalToUtcIso(
localValue: string,
timezone: string,
): string {
const [date, time] = localValue.split("T");
const [y, mo, d] = (date ?? "").split("-").map(Number);
const [h, mi] = (time ?? "00:00").split(":").map(Number);
const refMs = Date.UTC(y ?? 1970, (mo ?? 1) - 1, d ?? 1, h ?? 0, mi ?? 0);
// The offset at the wall-time-as-UTC instant is stale when a DST transition
// sits between that instant and the real one (e.g. "03:30" the morning of
// spring-forward resolves with the pre-transition offset, firing an hour
// late and breaking the round-trip with `utcToZonedLocalInput`). Resolve
// once, then re-derive the offset at the candidate instant; one correction
// converges for real timezones.
let offsetMs = tzOffsetMs(timezone, new Date(refMs));
let utcMs = refMs - offsetMs;
const correctedOffsetMs = tzOffsetMs(timezone, new Date(utcMs));
if (correctedOffsetMs !== offsetMs) {
offsetMs = correctedOffsetMs;
utcMs = refMs - offsetMs;
}
const utc = new Date(utcMs);
return `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(
utc.getUTCDate(),
)}T${pad2(utc.getUTCHours())}:${pad2(utc.getUTCMinutes())}:${pad2(
utc.getUTCSeconds(),
)}+00:00`;
}
/**
* Inverse of `zonedLocalToUtcIso`: render a stored UTC ISO as the datetime-local
* wall value ("YYYY-MM-DDTHH:mm") in `timezone`. Used when editing a one-time task.
*/
export function utcToZonedLocalInput(iso: string, timezone: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return "";
}
const offsetMs = tzOffsetMs(timezone, d);
const local = new Date(d.getTime() + offsetMs);
return `${local.getUTCFullYear()}-${pad2(local.getUTCMonth() + 1)}-${pad2(
local.getUTCDate(),
)}T${pad2(local.getUTCHours())}:${pad2(local.getUTCMinutes())}`;
}
function tzOffsetMs(timezone: string, date: Date): number {
const tzParts = formatParts(timezone, date);
const utcParts = formatParts("UTC", date);
const tzMs = toUtcMs(tzParts);
const utcMs = toUtcMs(utcParts);
// >0 means the timezone wall clock is ahead of UTC (east of UTC).
return tzMs - utcMs;
}
function formatParts(timezone: string, date: Date): Record<string, number> {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(date);
const out: Record<string, number> = {};
for (const p of parts) {
out[p.type] = Number(p.value);
}
return out;
}
function toUtcMs(p: Record<string, number>): number {
return Date.UTC(
p.year ?? 1970,
(p.month ?? 1) - 1,
p.day ?? 1,
p.hour ?? 0,
p.minute ?? 0,
p.second ?? 0,
);
}

View file

@ -0,0 +1,150 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useI18n } from "@/core/i18n/hooks";
import {
createScheduledTask,
deleteScheduledTask,
fetchScheduledTaskRuns,
fetchScheduledTasks,
fetchThreadScheduledTasks,
pauseScheduledTask,
resumeScheduledTask,
triggerScheduledTask,
updateScheduledTask,
type ScheduledTaskPayload,
} from "./api";
export function useScheduledTasks() {
return useQuery({
queryKey: ["scheduled-tasks"],
queryFn: fetchScheduledTasks,
refetchInterval: 15000,
refetchIntervalInBackground: false,
});
}
export function useThreadScheduledTasks(threadId: string | null | undefined) {
return useQuery({
queryKey: ["scheduled-tasks", "thread", threadId],
queryFn: () => fetchThreadScheduledTasks(threadId ?? ""),
enabled: Boolean(threadId),
});
}
export function useScheduledTaskRuns(taskId: string | null | undefined) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", taskId],
queryFn: () => fetchScheduledTaskRuns(taskId ?? ""),
enabled: Boolean(taskId),
refetchInterval: 15000,
refetchIntervalInBackground: false,
});
}
export function useCreateScheduledTask() {
const queryClient = useQueryClient();
const { t } = useI18n();
return useMutation({
mutationFn: (payload: ScheduledTaskPayload) => createScheduledTask(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
},
onError: (error: Error) => {
toast.error(`${t.scheduledTasks.errors.create}: ${error.message}`);
},
});
}
export function useUpdateScheduledTask(taskId: string) {
const queryClient = useQueryClient();
const { t } = useI18n();
return useMutation({
mutationFn: (
payload: Partial<
Omit<ScheduledTaskPayload, "thread_id" | "schedule_type">
>,
) => updateScheduledTask(taskId, payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
void queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "thread"],
});
},
onError: (error: Error) => {
toast.error(`${t.scheduledTasks.errors.update}: ${error.message}`);
},
});
}
export function usePauseScheduledTask() {
const queryClient = useQueryClient();
const { t } = useI18n();
return useMutation({
mutationFn: (taskId: string) => pauseScheduledTask(taskId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
void queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "thread"],
});
},
onError: (error: Error) => {
toast.error(`${t.scheduledTasks.errors.pause}: ${error.message}`);
},
});
}
export function useResumeScheduledTask() {
const queryClient = useQueryClient();
const { t } = useI18n();
return useMutation({
mutationFn: (taskId: string) => resumeScheduledTask(taskId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
void queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "thread"],
});
},
onError: (error: Error) => {
toast.error(`${t.scheduledTasks.errors.resume}: ${error.message}`);
},
});
}
export function useTriggerScheduledTask() {
const queryClient = useQueryClient();
const { t } = useI18n();
return useMutation({
mutationFn: (taskId: string) => triggerScheduledTask(taskId),
onSuccess: (_result, taskId) => {
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
void queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "thread"],
});
void queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "runs", taskId],
});
},
onError: (error: Error) => {
toast.error(`${t.scheduledTasks.errors.trigger}: ${error.message}`);
},
});
}
export function useDeleteScheduledTask() {
const queryClient = useQueryClient();
const { t } = useI18n();
return useMutation({
mutationFn: (taskId: string) => deleteScheduledTask(taskId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
void queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "thread"],
});
},
onError: (error: Error) => {
toast.error(`${t.scheduledTasks.errors.delete}: ${error.message}`);
},
});
}

View file

@ -0,0 +1,66 @@
import type { ScheduleValue } from "@/components/workspace/scheduled-task-schedule-input";
export type RecipeTitleKey = "trending" | "news" | "issues" | "weekly";
export type Recipe = {
id: string;
icon: string;
titleKey: RecipeTitleKey;
prompt: string;
schedule: ScheduleValue;
};
// Front-end-only starter recipes. The schedule's timezone is left empty so the
// ScheduleInput falls back to the browser-detected timezone when applied.
// `{{repo}}` style placeholders are intentional — the user fills them in the
// prompt field after applying the recipe.
export const RECIPES: Recipe[] = [
{
id: "trending",
icon: "🔥",
titleKey: "trending",
prompt:
"Use web_search to open today's GitHub Trending page, then summarize the top 10 repositories. For each: name, primary language, today's star delta, and a one-line description of what it is and why it's trending. Output as a markdown list.",
schedule: {
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "",
},
},
{
id: "news",
icon: "📰",
titleKey: "news",
prompt:
"Use web_search to collect today's top tech news across AI, developer tools, infrastructure, and security. Summarize the 5 most important items: headline, source, and a one-line takeaway each. Output as a markdown list.",
schedule: {
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "",
},
},
{
id: "issues",
icon: "🏷️",
titleKey: "issues",
prompt:
"Triage the open issues in {{repo}}: list the 10 most recent, label each as bug / feature / question, flag any that look stale or high-priority, and suggest 2 that are good first issues. Replace {{repo}} with the target repository (owner/name). Output as a markdown table.",
schedule: {
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "",
},
},
{
id: "weekly",
icon: "📅",
titleKey: "weekly",
prompt:
"Compile a weekly report: what was accomplished this week, what is currently blocked, and the top 3 priorities for next week. Keep it concise and skimmable.",
schedule: {
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * 1" },
timezone: "",
},
},
];

View file

@ -0,0 +1,45 @@
export type ScheduledTask = {
id: string;
thread_id: string | null;
context_mode: "fresh_thread_per_run" | "reuse_thread";
title: string;
prompt: string;
schedule_type: "once" | "cron";
schedule_spec: Record<string, unknown>;
timezone: string;
status:
| "enabled"
| "paused"
| "running"
| "completed"
| "failed"
| "cancelled";
next_run_at: string | null;
last_run_at: string | null;
last_run_id: string | null;
last_thread_id: string | null;
last_error: string | null;
run_count: number;
created_at: string;
updated_at: string;
};
export type ScheduledTaskRun = {
id: string;
task_id: string;
thread_id: string;
run_id: string | null;
scheduled_for: string;
trigger: "scheduled" | "manual";
status:
| "queued"
| "running"
| "success"
| "failed"
| "skipped"
| "interrupted";
error: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
};

View file

@ -0,0 +1,224 @@
import { expect, test } from "@playwright/test";
import { MOCK_THREAD_ID, mockLangGraphAPI } from "./utils/mock-api";
test.describe.configure({ mode: "serial" });
test("scheduled tasks page is reachable from sidebar", async ({ page }) => {
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [
{
id: "task-1",
thread_id: "thread-1",
title: "Daily summary",
prompt: "Summarize thread",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2026-07-02T01:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
},
],
});
await page.goto("/workspace/chats/new");
await page.getByRole("link", { name: /scheduled tasks/i }).click();
await page.waitForURL("**/workspace/scheduled-tasks");
await expect(page).toHaveURL(/workspace\/scheduled-tasks/);
await expect(
page.getByRole("button", { name: /Daily summary/i }),
).toBeVisible();
await expect(page.getByTestId("scheduled-task-runs")).toContainText("0 runs");
});
test("thread page links to filtered scheduled tasks", async ({ page }) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Thread with schedules",
updated_at: "2025-06-01T12:00:00Z",
},
],
scheduledTasks: [
{
id: "task-1",
thread_id: MOCK_THREAD_ID,
title: "Thread task",
prompt: "Summarize thread",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2026-07-02T01:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page
.locator("header")
.getByRole("link", { name: /scheduled tasks/i })
.click();
await page.waitForURL(new RegExp(`thread_id=${MOCK_THREAD_ID}`));
});
test("user can create a scheduled task from the page", async ({ page }) => {
mockLangGraphAPI(page, { threads: [], scheduledTasks: [] });
await page.goto("/workspace/scheduled-tasks");
const createForm = page.getByTestId("scheduled-task-create-form");
await createForm.getByRole("button", { name: "One-time" }).click();
await createForm.getByLabel("Run at").fill("2026-07-02T09:00");
await createForm.getByPlaceholder("Task title").fill("Created from UI");
await createForm.getByPlaceholder("Prompt").fill("Summarize thread");
await createForm.getByRole("button", { name: "Create" }).click();
await expect(
page.getByRole("button", { name: /Created from UI/i }),
).toBeVisible();
await expect(
page.getByTestId("scheduled-task-detail").getByText("Summarize thread"),
).toBeVisible();
});
test("user can pause a scheduled task from the detail pane", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [
{
id: "task-1",
thread_id: "thread-1",
title: "Pausable task",
prompt: "Summarize thread",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2026-07-02T01:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
},
],
});
await page.goto("/workspace/scheduled-tasks");
const detail = page.getByTestId("scheduled-task-detail");
await detail.getByRole("button", { name: "Pause" }).click();
await expect(page.getByTestId("scheduled-task-item-task-1")).toBeVisible();
await expect(
page.getByTestId("scheduled-task-item-task-1").getByText(/paused/i),
).toBeVisible();
});
test("trigger shows a run entry in the detail pane", async ({ page }) => {
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [
{
id: "task-1",
thread_id: "thread-1",
title: "Triggerable task",
prompt: "Summarize thread",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2026-07-02T01:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
},
],
});
await page.goto("/workspace/scheduled-tasks");
await page.getByRole("button", { name: "Trigger now" }).click();
await expect(page.getByTestId("scheduled-task-runs")).toContainText("1 run");
await expect(
page.getByTestId("scheduled-task-run-list").getByText(/Manual · Success/i),
).toBeVisible();
});
test("detail pane falls back to a visible task after filters hide the selected task", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [
{
id: "task-enabled",
thread_id: "thread-1",
title: "Enabled task",
prompt: "Enabled prompt",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2026-07-02T01:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
},
{
id: "task-paused",
thread_id: "thread-2",
title: "Paused task",
prompt: "Paused prompt",
schedule_type: "cron",
schedule_spec: { cron: "0 10 * * *" },
timezone: "UTC",
status: "paused",
next_run_at: "2026-07-02T02:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
},
],
});
await page.goto("/workspace/scheduled-tasks");
await page.getByTestId("scheduled-task-item-task-paused").click();
await expect(
page.getByTestId("scheduled-task-detail").getByText("Paused task"),
).toBeVisible();
await page.getByRole("button", { name: "Enabled", exact: true }).click();
await expect(
page.getByTestId("scheduled-task-detail").getByText("Enabled task"),
).toBeVisible();
await expect(
page.getByTestId("scheduled-task-item-task-enabled"),
).toBeVisible();
await expect(page.getByTestId("scheduled-task-item-task-paused")).toHaveCount(
0,
);
});

View file

@ -56,6 +56,31 @@ export type MockAPIOptions = {
threads?: MockThread[]; threads?: MockThread[];
agents?: MockAgent[]; agents?: MockAgent[];
skills?: MockSkill[]; skills?: MockSkill[];
scheduledTasks?: Array<{
id: string;
thread_id: string | null;
context_mode?: "fresh_thread_per_run" | "reuse_thread";
last_thread_id?: string | null;
title: string;
prompt: string;
schedule_type: "once" | "cron";
schedule_spec: Record<string, unknown>;
timezone: string;
status:
| "enabled"
| "paused"
| "running"
| "completed"
| "failed"
| "cancelled";
next_run_at: string | null;
last_run_at: string | null;
last_run_id: string | null;
last_error: string | null;
run_count: number;
created_at: string;
updated_at: string;
}>;
uploadLimits?: { uploadLimits?: {
max_files: number; max_files: number;
max_file_size: number; max_file_size: number;
@ -112,6 +137,24 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
let threads = [...(options?.threads ?? [])]; let threads = [...(options?.threads ?? [])];
const agents = options?.agents ?? []; const agents = options?.agents ?? [];
const skills = options?.skills ?? DEFAULT_SKILLS; const skills = options?.skills ?? DEFAULT_SKILLS;
const scheduledTasks = options?.scheduledTasks ?? [];
let mutableScheduledTasks = [...scheduledTasks];
const mutableTaskRuns: Record<
string,
Array<{
id: string;
task_id: string;
thread_id: string | null;
run_id: string | null;
scheduled_for: string;
trigger: "scheduled" | "manual";
status: "queued" | "running" | "success" | "failed" | "skipped";
error: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
}>
> = {};
const uploadLimits = options?.uploadLimits ?? { const uploadLimits = options?.uploadLimits ?? {
max_files: 10, max_files: 10,
max_file_size: 50 * 1024 * 1024, max_file_size: 50 * 1024 * 1024,
@ -189,6 +232,245 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback(); return route.fallback();
}); });
void page.route("**/api/suggestions/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ enabled: false }),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
mutableScheduledTasks.map((task) => ({
context_mode: "fresh_thread_per_run",
last_thread_id: null,
...task,
thread_id: task.thread_id ?? null,
})),
),
});
}
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON() as Record<string, unknown>;
const threadId =
typeof payload.thread_id === "string" ? payload.thread_id : "";
const title = typeof payload.title === "string" ? payload.title : "";
const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
const timezone =
typeof payload.timezone === "string" ? payload.timezone : "UTC";
const created = {
id: "task-created",
thread_id: threadId || null,
context_mode:
(payload.context_mode as "fresh_thread_per_run" | "reuse_thread") ??
"fresh_thread_per_run",
last_thread_id: null,
title,
prompt,
schedule_type: payload.schedule_type as "once" | "cron",
schedule_spec: (payload.schedule_spec as Record<string, unknown>) ?? {},
timezone,
status: "enabled" as const,
next_run_at: null,
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
};
mutableScheduledTasks = [created, ...mutableScheduledTasks];
mutableTaskRuns[created.id] = [];
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(created),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*/pause", (route) => {
if (route.request().method() === "POST") {
const taskId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
mutableScheduledTasks = mutableScheduledTasks.map((task) =>
task.id === taskId ? { ...task, status: "paused" as const } : task,
);
const task = mutableScheduledTasks.find((item) => item.id === taskId);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(task),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*/resume", (route) => {
if (route.request().method() === "POST") {
const taskId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
mutableScheduledTasks = mutableScheduledTasks.map((task) =>
task.id === taskId ? { ...task, status: "enabled" as const } : task,
);
const task = mutableScheduledTasks.find((item) => item.id === taskId);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(task),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*/trigger", (route) => {
if (route.request().method() === "POST") {
const taskId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
const task = mutableScheduledTasks.find((item) => item.id === taskId);
if (task) {
const runId = `run-${taskId}`;
mutableTaskRuns[taskId] = [
{
id: `task-run-${taskId}`,
task_id: taskId,
thread_id: task.thread_id,
run_id: runId,
scheduled_for: "2026-07-01T00:00:00+00:00",
trigger: "manual",
status: "success",
error: null,
started_at: "2026-07-01T00:00:00+00:00",
finished_at: "2026-07-01T00:00:00+00:00",
created_at: "2026-07-01T00:00:00+00:00",
},
...(mutableTaskRuns[taskId] ?? []),
];
mutableScheduledTasks = mutableScheduledTasks.map((item) =>
item.id === taskId
? {
...item,
last_run_id: runId,
last_run_at: "2026-07-01T00:00:00+00:00",
run_count: item.run_count + 1,
}
: item,
);
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: taskId, triggered: true }),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*", (route) => {
const request = route.request();
if (request.method() === "PATCH") {
const taskId = decodeURIComponent(
new URL(request.url()).pathname.split("/").at(-1) ?? "",
);
const payload = request.postDataJSON() as Record<string, unknown>;
let updated: (typeof mutableScheduledTasks)[number] | undefined;
mutableScheduledTasks = mutableScheduledTasks.map((task) => {
if (task.id !== taskId) {
return task;
}
updated = {
...task,
...(typeof payload.title === "string"
? { title: payload.title }
: {}),
...(typeof payload.prompt === "string"
? { prompt: payload.prompt }
: {}),
...(payload.schedule_spec
? {
schedule_spec: payload.schedule_spec as Record<string, unknown>,
}
: {}),
...(typeof payload.timezone === "string"
? { timezone: payload.timezone }
: {}),
updated_at: "2026-07-01T00:00:00+00:00",
};
return updated;
});
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(updated ?? {}),
});
}
if (request.method() === "DELETE") {
const taskId = decodeURIComponent(
new URL(request.url()).pathname.split("/").at(-1) ?? "",
);
mutableScheduledTasks = mutableScheduledTasks.filter(
(task) => task.id !== taskId,
);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: taskId, deleted: true }),
});
}
return route.fallback();
});
void page.route("**/api/threads/*/scheduled-tasks", (route) => {
if (route.request().method() === "GET") {
const url = new URL(route.request().url());
const parts = url.pathname.split("/");
const threadId = decodeURIComponent(
parts[parts.indexOf("threads") + 1] ?? "",
);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
mutableScheduledTasks
.filter((task) => task.thread_id === threadId)
.map((task) => ({
context_mode: "fresh_thread_per_run",
last_thread_id: null,
...task,
thread_id: task.thread_id ?? null,
})),
),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*/runs", (route) => {
if (route.request().method() === "GET") {
const taskId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mutableTaskRuns[taskId] ?? []),
});
}
return route.fallback();
});
// Thread search — sidebar thread list & chats list page // Thread search — sidebar thread list & chats list page
void page.route("**/api/langgraph/threads/search", async (route) => { void page.route("**/api/langgraph/threads/search", async (route) => {
const body = threads.map(threadSearchResult); const body = threads.map(threadSearchResult);

View file

@ -0,0 +1,353 @@
import { describe, expect, test } from "@rstest/core";
import {
describeSchedule,
parseCron,
serializeCron,
utcToZonedLocalInput,
zonedLocalToUtcIso,
type CronParts,
} from "@/core/scheduled-tasks/cron";
describe("serializeCron", () => {
test("hourly emits minute + star fields", () => {
expect(serializeCron("hourly", { minute: 30 } as CronParts)).toBe(
"30 * * * *",
);
});
test("daily emits minute + hour", () => {
expect(serializeCron("daily", { minute: 0, hour: 9 } as CronParts)).toBe(
"0 9 * * *",
);
});
test("weekly emits comma-joined weekday numbers in cron order (0=sun)", () => {
expect(
serializeCron("weekly", {
minute: 0,
hour: 9,
weekdays: ["mon", "wed"],
} as CronParts),
).toBe("0 9 * * 1,3");
});
test("weekly sorts + dedupes out-of-order / duplicate weekdays", () => {
expect(
serializeCron("weekly", {
minute: 0,
hour: 9,
weekdays: ["wed", "mon", "wed"],
} as CronParts),
).toBe("0 9 * * 1,3");
});
test("weekly maps sunday to 0", () => {
expect(
serializeCron("weekly", {
minute: 0,
hour: 9,
weekdays: ["sun"],
} as CronParts),
).toBe("0 9 * * 0");
});
test("monthly emits day-of-month", () => {
expect(
serializeCron("monthly", {
minute: 0,
hour: 9,
dayOfMonth: 1,
} as CronParts),
).toBe("0 9 1 * *");
});
test("custom returns raw expression", () => {
expect(serializeCron("custom", { raw: "*/5 * * * *" } as CronParts)).toBe(
"*/5 * * * *",
);
});
test("clamps out-of-range minute / hour / day-of-month", () => {
expect(serializeCron("daily", { minute: 99, hour: 24 } as CronParts)).toBe(
"59 23 * * *",
);
expect(
serializeCron("monthly", {
minute: 0,
hour: 9,
dayOfMonth: 32,
} as CronParts),
).toBe("0 9 31 * *");
expect(
serializeCron("monthly", {
minute: 0,
hour: 9,
dayOfMonth: 0,
} as CronParts),
).toBe("0 9 1 * *");
});
});
describe("parseCron", () => {
test("hourly: M * * * *", () => {
expect(parseCron("30 * * * *").preset).toBe("hourly");
expect(parseCron("30 * * * *").parts.minute).toBe(30);
});
test("daily: M H * * *", () => {
const r = parseCron("0 9 * * *");
expect(r.preset).toBe("daily");
expect(r.parts).toMatchObject({ minute: 0, hour: 9 });
});
test("weekly: M H * * DOW", () => {
const r = parseCron("0 9 * * 1,3");
expect(r.preset).toBe("weekly");
expect(r.parts.weekdays).toEqual(["mon", "wed"]);
});
test("weekly maps 0 and 7 to sunday", () => {
expect(parseCron("0 9 * * 0").parts.weekdays).toEqual(["sun"]);
expect(parseCron("0 9 * * 7").parts.weekdays).toEqual(["sun"]);
});
test("monthly: M H DOM * *", () => {
const r = parseCron("0 9 1 * *");
expect(r.preset).toBe("monthly");
expect(r.parts.dayOfMonth).toBe(1);
});
test("non-canonical forms fall back to custom", () => {
expect(parseCron("*/5 * * * *").preset).toBe("custom");
expect(parseCron("0 9,10 * * *").preset).toBe("custom");
expect(parseCron("0 9 * * 1-5").preset).toBe("custom");
expect(parseCron("garbage").preset).toBe("custom");
expect(parseCron("garbage").parts.raw).toBe("garbage");
});
});
describe("describeSchedule", () => {
const baseCron = {
minute: 0,
hour: 9,
weekdays: [],
dayOfMonth: 1,
} as CronParts;
test("once renders wall time + timezone (en)", () => {
expect(
describeSchedule(
{
scheduleType: "once",
runAtLocal: "2026-07-02T09:00",
timezone: "Asia/Shanghai",
},
"en",
),
).toBe("Once at 2026-07-02 09:00 (Asia/Shanghai)");
});
test("daily en", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "daily",
parts: baseCron,
timezone: "UTC",
},
"en",
),
).toBe("Every day at 09:00 (UTC)");
});
test("daily zh", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "daily",
parts: baseCron,
timezone: "UTC",
},
"zh",
),
).toBe("每天 09:00 (UTC)");
});
test("weekly en lists weekday abbreviations", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "weekly",
parts: { ...baseCron, weekdays: ["mon", "wed"] },
timezone: "UTC",
},
"en",
),
).toBe("Every Mon, Wed at 09:00 (UTC)");
});
test("weekly zh lists 周X", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "weekly",
parts: { ...baseCron, weekdays: ["mon", "wed", "fri"] },
timezone: "UTC",
},
"zh",
),
).toBe("每周 周一、周三、周五 09:00 (UTC)");
});
test("weekly with no weekdays falls back to daily wording", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "weekly",
parts: { ...baseCron, weekdays: [] },
timezone: "UTC",
},
"en",
),
).toBe("Every day at 09:00 (UTC)");
});
test("hourly en", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "hourly",
parts: { minute: 30 },
timezone: "UTC",
},
"en",
),
).toBe("Every hour at :30 (UTC)");
});
test("monthly en", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "monthly",
parts: { minute: 0, hour: 9, dayOfMonth: 1 },
timezone: "UTC",
},
"en",
),
).toBe("On day 1 of every month at 09:00 (UTC)");
});
test("custom en echoes the expression", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "custom",
parts: { raw: "*/5 * * * *" },
timezone: "UTC",
},
"en",
),
).toBe("Custom: */5 * * * * (UTC)");
});
});
describe("zonedLocalToUtcIso", () => {
test("Asia/Shanghai is UTC-8 (wall 09:00 -> 01:00Z)", () => {
expect(zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Shanghai")).toBe(
"2026-07-02T01:00:00+00:00",
);
});
test("UTC passes through", () => {
expect(zonedLocalToUtcIso("2026-07-02T09:00", "UTC")).toBe(
"2026-07-02T09:00:00+00:00",
);
});
test("America/New_York July is EDT (-04:00)", () => {
expect(zonedLocalToUtcIso("2026-07-02T09:00", "America/New_York")).toBe(
"2026-07-02T13:00:00+00:00",
);
});
test("America/New_York January is EST (-05:00) — DST season flip", () => {
expect(zonedLocalToUtcIso("2026-01-15T09:00", "America/New_York")).toBe(
"2026-01-15T14:00:00+00:00",
);
});
test("Asia/Kolkata half-hour offset UTC+5:30", () => {
expect(zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Kolkata")).toBe(
"2026-07-02T03:30:00+00:00",
);
});
});
describe("utcToZonedLocalInput", () => {
test("Shanghai +8: 01:00Z -> 09:00 wall", () => {
expect(
utcToZonedLocalInput("2026-07-02T01:00:00+00:00", "Asia/Shanghai"),
).toBe("2026-07-02T09:00");
});
test("New_York EDT: 13:00Z -> 09:00 wall", () => {
expect(
utcToZonedLocalInput("2026-07-02T13:00:00+00:00", "America/New_York"),
).toBe("2026-07-02T09:00");
});
test("invalid -> empty string", () => {
expect(utcToZonedLocalInput("not-a-date", "UTC")).toBe("");
});
test("round-trips with zonedLocalToUtcIso", () => {
const iso = zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Shanghai");
expect(utcToZonedLocalInput(iso, "Asia/Shanghai")).toBe("2026-07-02T09:00");
});
});
describe("zonedLocalToUtcIso DST transitions", () => {
// US spring-forward 2026: clocks jump 02:00 -> 03:00 EST->EDT on 2026-03-08.
test("New_York wall time after spring-forward uses the post-transition offset", () => {
// 03:30 EDT (-4) is 07:30Z; the stale pre-transition offset (-5) would say 08:30Z.
expect(zonedLocalToUtcIso("2026-03-08T03:30", "America/New_York")).toBe(
"2026-03-08T07:30:00+00:00",
);
});
test("New_York wall time before spring-forward keeps the EST offset", () => {
expect(zonedLocalToUtcIso("2026-03-08T01:30", "America/New_York")).toBe(
"2026-03-08T06:30:00+00:00",
);
});
// US fall-back 2026: clocks repeat 01:00-02:00 EDT->EST on 2026-11-01.
test("New_York ambiguous fall-back wall time resolves deterministically", () => {
expect(zonedLocalToUtcIso("2026-11-01T01:30", "America/New_York")).toBe(
"2026-11-01T05:30:00+00:00",
);
});
test("create -> edit round-trip survives spring-forward", () => {
const iso = zonedLocalToUtcIso("2026-03-08T03:30", "America/New_York");
expect(utcToZonedLocalInput(iso, "America/New_York")).toBe(
"2026-03-08T03:30",
);
});
test("no-DST timezone is unaffected", () => {
expect(zonedLocalToUtcIso("2026-03-08T03:30", "Asia/Shanghai")).toBe(
"2026-03-07T19:30:00+00:00",
);
});
});

View file

@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, rs } from "@rstest/core";
rs.mock("@/core/api/fetcher", () => ({
fetch: rs.fn(),
}));
import { fetch } from "@/core/api/fetcher";
import {
createScheduledTask,
fetchScheduledTasks,
type ScheduledTaskPayload,
} from "@/core/scheduled-tasks/api";
const mockedFetch = rs.mocked(fetch);
const SAMPLE_TASK = {
id: "task-1",
thread_id: null as string | null,
context_mode: "fresh_thread_per_run" as const,
last_thread_id: null as string | null,
title: "Daily summary",
prompt: "Summarize thread",
schedule_type: "cron" as const,
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled" as const,
next_run_at: "2026-07-02T01:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
};
function jsonResponse(body: unknown, ok = true): Response {
return {
ok,
status: 200,
statusText: "OK",
json: async () => body,
} as Response;
}
function errorResponse(
detail: string,
status = 400,
statusText = "Bad Request",
): Response {
return {
ok: false,
status,
statusText,
json: async () => ({ detail }),
} as Response;
}
describe("scheduled tasks api", () => {
beforeEach(() => {
mockedFetch.mockReset();
});
it("fetchScheduledTasks hits GET /api/scheduled-tasks", async () => {
mockedFetch.mockResolvedValue(jsonResponse([SAMPLE_TASK]));
const result = await fetchScheduledTasks();
expect(mockedFetch).toHaveBeenCalledTimes(1);
const call = mockedFetch.mock.calls[0];
expect(call).toBeDefined();
const url = String(call?.[0] as string);
expect(url).toContain("/api/scheduled-tasks");
expect(call?.[1]?.method).toBeUndefined();
expect(result).toEqual([SAMPLE_TASK]);
});
it("createScheduledTask hits POST /api/scheduled-tasks with payload", async () => {
mockedFetch.mockResolvedValue(jsonResponse(SAMPLE_TASK));
const payload: ScheduledTaskPayload = {
context_mode: "fresh_thread_per_run",
thread_id: null,
title: "Daily summary",
prompt: "Summarize thread",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
};
const result = await createScheduledTask(payload);
expect(mockedFetch).toHaveBeenCalledTimes(1);
const call = mockedFetch.mock.calls[0];
expect(call).toBeDefined();
const url = String(call?.[0] as string);
expect(url).toContain("/api/scheduled-tasks");
expect(call?.[1]?.method).toBe("POST");
const body = call?.[1]?.body as string;
expect(JSON.parse(body)).toEqual(payload);
expect(result).toEqual(SAMPLE_TASK);
});
it("throws an Error carrying backend detail on failure", async () => {
mockedFetch.mockResolvedValue(
errorResponse("Cron expression is invalid", 422, "Unprocessable Entity"),
);
await expect(fetchScheduledTasks()).rejects.toThrow(
"Cron expression is invalid",
);
});
it("falls back to a generic message when detail is missing", async () => {
mockedFetch.mockResolvedValue({
ok: false,
status: 502,
statusText: "Bad Gateway",
// body is not valid JSON → body.detail is undefined → fallback used
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response);
await expect(fetchScheduledTasks()).rejects.toThrow(/Failed to load/);
});
});