mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
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:
parent
6060d95ee0
commit
4fc08b4f15
65 changed files with 9392 additions and 29 deletions
|
|
@ -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):
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
||||||
310
backend/app/gateway/routers/scheduled_tasks.py
Normal file
310
backend/app/gateway/routers/scheduled_tasks.py
Normal 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)
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
3
backend/app/scheduler/__init__.py
Normal file
3
backend/app/scheduler/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .service import ScheduledTaskService
|
||||||
|
|
||||||
|
__all__ = ["ScheduledTaskService"]
|
||||||
346
backend/app/scheduler/service.py
Normal file
346
backend/app/scheduler/service.py
Normal 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .model import ScheduledTaskRunRow
|
||||||
|
from .sql import ScheduledTaskRunRepository
|
||||||
|
|
||||||
|
__all__ = ["ScheduledTaskRunRow", "ScheduledTaskRunRepository"]
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .model import ScheduledTaskRow
|
||||||
|
from .sql import ScheduledTaskRepository
|
||||||
|
|
||||||
|
__all__ = ["ScheduledTaskRow", "ScheduledTaskRepository"]
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
3
backend/packages/harness/deerflow/scheduler/__init__.py
Normal file
3
backend/packages/harness/deerflow/scheduler/__init__.py
Normal 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"]
|
||||||
55
backend/packages/harness/deerflow/scheduler/schedules.py
Normal file
55
backend/packages/harness/deerflow/scheduler/schedules.py
Normal 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}")
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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)])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
152
backend/tests/test_scheduled_task_claims.py
Normal file
152
backend/tests/test_scheduled_task_claims.py
Normal 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()
|
||||||
7
backend/tests/test_scheduled_task_lifecycle.py
Normal file
7
backend/tests/test_scheduled_task_lifecycle.py
Normal 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
|
||||||
19
backend/tests/test_scheduled_task_models.py
Normal file
19
backend/tests/test_scheduled_task_models.py
Normal 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"
|
||||||
318
backend/tests/test_scheduled_task_repository.py
Normal file
318
backend/tests/test_scheduled_task_repository.py
Normal 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()
|
||||||
38
backend/tests/test_scheduled_task_router.py
Normal file
38
backend/tests/test_scheduled_task_router.py
Normal 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
|
||||||
652
backend/tests/test_scheduled_task_router_behavior.py
Normal file
652
backend/tests/test_scheduled_task_router_behavior.py
Normal 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
|
||||||
49
backend/tests/test_scheduled_task_schedules.py
Normal file
49
backend/tests/test_scheduled_task_schedules.py
Normal 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)
|
||||||
539
backend/tests/test_scheduled_task_service.py
Normal file
539
backend/tests/test_scheduled_task_service.py
Normal 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
2
backend/uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
2136
docs/superpowers/plans/2026-07-01-scheduled-tasks-mvp.md
Normal file
2136
docs/superpowers/plans/2026-07-01-scheduled-tasks-mvp.md
Normal file
File diff suppressed because it is too large
Load diff
596
docs/superpowers/specs/2026-07-01-scheduled-tasks-mvp-design.md
Normal file
596
docs/superpowers/specs/2026-07-01-scheduled-tasks-mvp-design.md
Normal 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.
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
612
frontend/src/app/workspace/scheduled-tasks/page.tsx
Normal file
612
frontend/src/app/workspace/scheduled-tasks/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
17
frontend/src/core/api/errors.ts
Normal file
17
frontend/src/core/api/errors.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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}`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: "智能体",
|
||||||
|
|
|
||||||
164
frontend/src/core/scheduled-tasks/api.ts
Normal file
164
frontend/src/core/scheduled-tasks/api.ts
Normal 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();
|
||||||
|
}
|
||||||
338
frontend/src/core/scheduled-tasks/cron.ts
Normal file
338
frontend/src/core/scheduled-tasks/cron.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/core/scheduled-tasks/hooks.ts
Normal file
150
frontend/src/core/scheduled-tasks/hooks.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
66
frontend/src/core/scheduled-tasks/recipes.ts
Normal file
66
frontend/src/core/scheduled-tasks/recipes.ts
Normal 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: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
45
frontend/src/core/scheduled-tasks/types.ts
Normal file
45
frontend/src/core/scheduled-tasks/types.ts
Normal 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;
|
||||||
|
};
|
||||||
224
frontend/tests/e2e/scheduled-tasks.spec.ts
Normal file
224
frontend/tests/e2e/scheduled-tasks.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
353
frontend/tests/unit/core/scheduled-tasks/cron.test.ts
Normal file
353
frontend/tests/unit/core/scheduled-tasks/cron.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
125
frontend/tests/unit/core/scheduled-tasks/hooks.test.ts
Normal file
125
frontend/tests/unit/core/scheduled-tasks/hooks.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue