mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-21 20:16:18 +00:00
* 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>
2137 lines
62 KiB
Markdown
2137 lines
62 KiB
Markdown
# Scheduled Tasks MVP Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a first-class scheduled-task MVP for DeerFlow with durable backend scheduling, a workspace management page, run history, and real-path validation, limited to thread-attached agent runs with `once` and `cron` schedules.
|
|
|
|
**Architecture:** Add harness persistence models and repositories for scheduled tasks and task-run history, then add an app-layer scheduler service and REST API that reuse the existing run lifecycle. Build a dedicated frontend workspace page and thread-level entry point backed by typed React Query hooks. Validate through backend tests, frontend tests, Playwright, and a real browser smoke path.
|
|
|
|
**Tech Stack:** Python 3.12, FastAPI, SQLAlchemy, Alembic, pytest, Next.js 16, React 19, TypeScript 5.8, TanStack Query, Playwright, pnpm, uv.
|
|
|
|
## Global Constraints
|
|
|
|
- The MVP supports only thread-attached agent runs; there is no text-only task type, no channel dispatch, and no GitHub dispatch.
|
|
- The MVP supports only `once` and `cron`; it must not add `interval`.
|
|
- Scheduled executions must reuse the normal DeerFlow run lifecycle rather than introducing a parallel agent execution path.
|
|
- Harness persistence code must not import `app.*`.
|
|
- Background scheduling remains opt-in through config and must default to disabled.
|
|
- Owner isolation must cover task list, task detail, task history, mutate, trigger, and delete.
|
|
- Completion claims require fresh verification evidence: backend tests, frontend tests, Playwright, and one real-path browser validation.
|
|
|
|
---
|
|
|
|
### Task 1: Add scheduler config and persistence skeleton
|
|
|
|
**Files:**
|
|
- Create: `backend/packages/harness/deerflow/config/scheduler_config.py`
|
|
- Modify: `backend/packages/harness/deerflow/config/app_config.py`
|
|
- Modify: `backend/packages/harness/deerflow/config/reload_boundary.py`
|
|
- Modify: `backend/packages/harness/deerflow/persistence/models/__init__.py`
|
|
- Create: `backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py`
|
|
- Create: `backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py`
|
|
- Create: `backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py`
|
|
- Create: `backend/packages/harness/deerflow/persistence/scheduled_task_runs/model.py`
|
|
- Test: `backend/tests/test_scheduled_task_models.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing `AppConfig`, `Base`, and ORM registration pattern.
|
|
- Produces:
|
|
- `SchedulerConfig` with fields:
|
|
- `enabled: bool`
|
|
- `poll_interval_seconds: int`
|
|
- `lease_seconds: int`
|
|
- `max_concurrent_runs: int`
|
|
- `min_once_delay_seconds: int`
|
|
- `ScheduledTaskRow`
|
|
- `ScheduledTaskRunRow`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `backend/tests/test_scheduled_task_models.py`:
|
|
|
|
```python
|
|
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"
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_models.py -v
|
|
```
|
|
|
|
Expected:
|
|
- fail because `AppConfig` has no `scheduler`
|
|
- fail because `ScheduledTaskRow` / `ScheduledTaskRunRow` do not exist
|
|
|
|
- [ ] **Step 3: Implement minimal config and model skeleton**
|
|
|
|
Create `backend/packages/harness/deerflow/config/scheduler_config.py`:
|
|
|
|
```python
|
|
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)
|
|
```
|
|
|
|
Update `backend/packages/harness/deerflow/config/app_config.py` imports and fields:
|
|
|
|
```python
|
|
from deerflow.config.scheduler_config import SchedulerConfig
|
|
```
|
|
|
|
Add field inside `AppConfig`:
|
|
|
|
```python
|
|
scheduler: SchedulerConfig = Field(
|
|
default_factory=SchedulerConfig,
|
|
description="Scheduled task runtime configuration",
|
|
)
|
|
```
|
|
|
|
Create `backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py`:
|
|
|
|
```python
|
|
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] = mapped_column(String(64), index=True)
|
|
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")
|
|
misfire_policy: Mapped[str] = mapped_column(String(16), default="run_once")
|
|
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_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)
|
|
max_runs: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
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))
|
|
```
|
|
|
|
Create `backend/packages/harness/deerflow/persistence/scheduled_task_runs/model.py`:
|
|
|
|
```python
|
|
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))
|
|
```
|
|
|
|
Create `__init__.py` files:
|
|
|
|
```python
|
|
from .model import ScheduledTaskRow
|
|
|
|
__all__ = ["ScheduledTaskRow"]
|
|
```
|
|
|
|
```python
|
|
from .model import ScheduledTaskRunRow
|
|
|
|
__all__ = ["ScheduledTaskRunRow"]
|
|
```
|
|
|
|
Update `backend/packages/harness/deerflow/persistence/models/__init__.py`:
|
|
|
|
```python
|
|
from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow
|
|
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow
|
|
```
|
|
|
|
Append to `__all__`:
|
|
|
|
```python
|
|
"ScheduledTaskRow",
|
|
"ScheduledTaskRunRow",
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_models.py -v
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/packages/harness/deerflow/config/scheduler_config.py \
|
|
backend/packages/harness/deerflow/config/app_config.py \
|
|
backend/packages/harness/deerflow/persistence/models/__init__.py \
|
|
backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py \
|
|
backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py \
|
|
backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py \
|
|
backend/packages/harness/deerflow/persistence/scheduled_task_runs/model.py \
|
|
backend/tests/test_scheduled_task_models.py
|
|
git commit -m "feat(scheduler): add scheduler config and scheduled task models"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Add Alembic migration and repository CRUD
|
|
|
|
**Files:**
|
|
- Create: `backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py`
|
|
- Create: `backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py`
|
|
- Create: `backend/packages/harness/deerflow/persistence/migrations/versions/0002_scheduled_tasks.py`
|
|
- Modify: `backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py`
|
|
- Modify: `backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py`
|
|
- Test: `backend/tests/test_scheduled_task_repository.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `ScheduledTaskRow`, `ScheduledTaskRunRow`, async session factory.
|
|
- Produces:
|
|
- `ScheduledTaskRepository`
|
|
- `ScheduledTaskRunRepository`
|
|
- task CRUD API and owner-scoped listing
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `backend/tests/test_scheduled_task_repository.py`:
|
|
|
|
```python
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
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({"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",
|
|
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({"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()
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_repository.py -v
|
|
```
|
|
|
|
Expected:
|
|
- fail because repositories do not exist and migration/table path is absent
|
|
|
|
- [ ] **Step 3: Implement repositories and migration**
|
|
|
|
Create `backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow
|
|
from deerflow.utils.time import coerce_iso
|
|
|
|
|
|
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,
|
|
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]:
|
|
row = ScheduledTaskRow(
|
|
id=task_id,
|
|
user_id=user_id,
|
|
thread_id=thread_id,
|
|
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=datetime.now(UTC),
|
|
updated_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 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()]
|
|
```
|
|
|
|
Create `backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import 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
|
|
|
|
|
|
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) -> list[dict[str, Any]]:
|
|
stmt = (
|
|
select(ScheduledTaskRunRow)
|
|
.where(ScheduledTaskRunRow.task_id == task_id)
|
|
.order_by(ScheduledTaskRunRow.created_at.desc(), ScheduledTaskRunRow.id.desc())
|
|
)
|
|
async with self._sf() as session:
|
|
result = await session.execute(stmt)
|
|
return [self._row_to_dict(row) for row in result.scalars()]
|
|
```
|
|
|
|
Update package exports:
|
|
|
|
```python
|
|
from .model import ScheduledTaskRow
|
|
from .sql import ScheduledTaskRepository
|
|
|
|
__all__ = ["ScheduledTaskRow", "ScheduledTaskRepository"]
|
|
```
|
|
|
|
```python
|
|
from .model import ScheduledTaskRunRow
|
|
from .sql import ScheduledTaskRunRepository
|
|
|
|
__all__ = ["ScheduledTaskRunRow", "ScheduledTaskRunRepository"]
|
|
```
|
|
|
|
Create `backend/packages/harness/deerflow/persistence/migrations/versions/0002_scheduled_tasks.py` with `upgrade()` / `downgrade()` creating:
|
|
|
|
```python
|
|
op.create_table(
|
|
"scheduled_tasks",
|
|
...
|
|
)
|
|
op.create_index("ix_scheduled_tasks_user_id", "scheduled_tasks", ["user_id"])
|
|
op.create_index("ix_scheduled_tasks_thread_id", "scheduled_tasks", ["thread_id"])
|
|
op.create_index("ix_scheduled_tasks_status", "scheduled_tasks", ["status"])
|
|
op.create_index("ix_scheduled_tasks_next_run_at", "scheduled_tasks", ["next_run_at"])
|
|
|
|
op.create_table(
|
|
"scheduled_task_runs",
|
|
...
|
|
)
|
|
op.create_index("ix_scheduled_task_runs_task_id", "scheduled_task_runs", ["task_id"])
|
|
op.create_index("ix_scheduled_task_runs_thread_id", "scheduled_task_runs", ["thread_id"])
|
|
op.create_index("ix_scheduled_task_runs_status", "scheduled_task_runs", ["status"])
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_repository.py tests/test_scheduled_task_models.py -v
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py \
|
|
backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py \
|
|
backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py \
|
|
backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py \
|
|
backend/packages/harness/deerflow/persistence/migrations/versions/0002_scheduled_tasks.py \
|
|
backend/tests/test_scheduled_task_repository.py
|
|
git commit -m "feat(scheduler): add scheduled task repositories and migration"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Implement schedule parsing and next-run computation
|
|
|
|
**Files:**
|
|
- Create: `backend/packages/harness/deerflow/scheduler/__init__.py`
|
|
- Create: `backend/packages/harness/deerflow/scheduler/clock.py`
|
|
- Create: `backend/packages/harness/deerflow/scheduler/schedules.py`
|
|
- Test: `backend/tests/test_scheduled_task_schedules.py`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `validate_timezone(timezone: str) -> str`
|
|
- `normalize_cron_expression(expr: str) -> str`
|
|
- `next_run_at(schedule_type: str, schedule_spec: dict[str, object], timezone_name: str, *, now: datetime) -> datetime | None`
|
|
- `validate_once_time(...)`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `backend/tests/test_scheduled_task_schedules.py`:
|
|
|
|
```python
|
|
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)
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_schedules.py -v
|
|
```
|
|
|
|
Expected: fail because `deerflow.scheduler.schedules` does not exist.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `backend/packages/harness/deerflow/scheduler/schedules.py`:
|
|
|
|
```python
|
|
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:
|
|
run_at = run_at.replace(tzinfo=UTC)
|
|
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}")
|
|
```
|
|
|
|
Create `backend/packages/harness/deerflow/scheduler/__init__.py`:
|
|
|
|
```python
|
|
from .schedules import next_run_at, normalize_cron_expression, validate_timezone
|
|
|
|
__all__ = ["next_run_at", "normalize_cron_expression", "validate_timezone"]
|
|
```
|
|
|
|
Create `backend/packages/harness/deerflow/scheduler/clock.py`:
|
|
|
|
```python
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(UTC)
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_schedules.py -v
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/packages/harness/deerflow/scheduler/__init__.py \
|
|
backend/packages/harness/deerflow/scheduler/clock.py \
|
|
backend/packages/harness/deerflow/scheduler/schedules.py \
|
|
backend/tests/test_scheduled_task_schedules.py
|
|
git commit -m "feat(scheduler): add schedule parsing and next-run computation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Add due-claim, lease management, and task-run status updates
|
|
|
|
**Files:**
|
|
- Modify: `backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py`
|
|
- Modify: `backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py`
|
|
- Test: `backend/tests/test_scheduled_task_claims.py`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `claim_due_tasks(...)`
|
|
- `release_lease(...)`
|
|
- `mark_execution_result(...)`
|
|
- `update_after_launch(...)`
|
|
- `ScheduledTaskRunRepository.update_status(...)`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `backend/tests/test_scheduled_task_claims.py`:
|
|
|
|
```python
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
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_owned_rows(tmp_path):
|
|
await init_engine_from_config({"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",
|
|
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",
|
|
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()
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_claims.py -v
|
|
```
|
|
|
|
Expected: fail because `claim_due_tasks` does not exist.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Update `backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py` with:
|
|
|
|
```python
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy import and_, or_
|
|
```
|
|
|
|
Add methods:
|
|
|
|
```python
|
|
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.status == "enabled",
|
|
ScheduledTaskRow.next_run_at.is_not(None),
|
|
ScheduledTaskRow.next_run_at <= now,
|
|
or_(
|
|
ScheduledTaskRow.lease_expires_at.is_(None),
|
|
ScheduledTaskRow.lease_expires_at < now,
|
|
),
|
|
)
|
|
.order_by(ScheduledTaskRow.next_run_at.asc(), ScheduledTaskRow.id.asc())
|
|
.limit(limit)
|
|
.with_for_update()
|
|
)
|
|
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 release_lease(self, task_id: str, *, user_id: str | None = None) -> None:
|
|
async with self._sf() as session:
|
|
row = await session.get(ScheduledTaskRow, task_id)
|
|
if row is None:
|
|
return
|
|
if user_id is not None and row.user_id != user_id:
|
|
return
|
|
row.lease_owner = None
|
|
row.lease_expires_at = None
|
|
row.updated_at = datetime.now(UTC)
|
|
await session.commit()
|
|
|
|
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_error: str | None,
|
|
increment_run_count: bool,
|
|
) -> None:
|
|
async with self._sf() as session:
|
|
row = await session.get(ScheduledTaskRow, task_id)
|
|
if row is None:
|
|
return
|
|
row.status = status
|
|
row.next_run_at = next_run_at
|
|
row.last_run_at = last_run_at
|
|
row.last_run_id = last_run_id
|
|
row.last_error = last_error
|
|
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()
|
|
```
|
|
|
|
Update `backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py`:
|
|
|
|
```python
|
|
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,
|
|
) -> None:
|
|
async with self._sf() as session:
|
|
row = await session.get(ScheduledTaskRunRow, run_record_id)
|
|
if row is None:
|
|
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()
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_claims.py tests/test_scheduled_task_repository.py -v
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py \
|
|
backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py \
|
|
backend/tests/test_scheduled_task_claims.py
|
|
git commit -m "feat(scheduler): add due-task claim and lease management"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add scheduler service and shared run-launch helper
|
|
|
|
**Files:**
|
|
- Create: `backend/app/scheduler/__init__.py`
|
|
- Create: `backend/app/scheduler/service.py`
|
|
- Modify: `backend/app/gateway/services.py`
|
|
- Modify: `backend/app/gateway/deps.py`
|
|
- Test: `backend/tests/test_scheduled_task_service.py`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `launch_scheduled_thread_run(...)`
|
|
- `ScheduledTaskService`
|
|
- `get_scheduled_task_service`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `backend/tests/test_scheduled_task_service.py`:
|
|
|
|
```python
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from app.scheduler.service import ScheduledTaskService
|
|
|
|
|
|
class DummyTaskRepo:
|
|
def __init__(self, rows):
|
|
self.rows = rows
|
|
self.claimed = False
|
|
|
|
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)
|
|
|
|
|
|
class DummyRunRepo:
|
|
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 = (run_record_id, kwargs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_claims_and_dispatches_due_task():
|
|
async def fake_launch(**kwargs):
|
|
return {"run_id": "run-1"}
|
|
|
|
task_repo = DummyTaskRepo(
|
|
[
|
|
{
|
|
"id": "task-1",
|
|
"thread_id": "thread-1",
|
|
"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[1]["status"] == "success"
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_service.py -v
|
|
```
|
|
|
|
Expected: fail because service module does not exist.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Add helper to `backend/app/gateway/services.py`:
|
|
|
|
```python
|
|
async def launch_scheduled_thread_run(
|
|
*,
|
|
thread_id: str,
|
|
assistant_id: str | None,
|
|
prompt: str,
|
|
request: Request,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
body = SimpleNamespace(
|
|
assistant_id=assistant_id,
|
|
input={"messages": [{"role": "user", "content": prompt}]},
|
|
command=None,
|
|
metadata=metadata or {},
|
|
config=None,
|
|
context=None,
|
|
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(
|
|
request,
|
|
thread_id,
|
|
body,
|
|
require_existing=True,
|
|
)
|
|
return {"run_id": record.run_id, "thread_id": record.thread_id}
|
|
```
|
|
|
|
Create `backend/app/scheduler/service.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import socket
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Awaitable, Callable
|
|
|
|
from deerflow.scheduler.schedules import next_run_at
|
|
|
|
|
|
class ScheduledTaskService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
task_repo,
|
|
task_run_repo,
|
|
launch_run: Callable[..., Awaitable[dict[str, Any]]],
|
|
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:
|
|
claimed = await self._task_repo.claim_due_tasks(
|
|
now=now,
|
|
lease_owner=self._lease_owner,
|
|
lease_seconds=self._lease_seconds,
|
|
limit=self._max_concurrent_runs,
|
|
)
|
|
for task in claimed:
|
|
await self._dispatch_task(task, now=now)
|
|
|
|
async def _dispatch_task(self, task: dict[str, Any], *, now: datetime) -> None:
|
|
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=task["thread_id"],
|
|
scheduled_for=now,
|
|
trigger="scheduled",
|
|
status="queued",
|
|
)
|
|
try:
|
|
result = await self._launch_run(
|
|
thread_id=task["thread_id"],
|
|
assistant_id=task.get("assistant_id"),
|
|
prompt=task["prompt"],
|
|
)
|
|
next_at = next_run_at(
|
|
task["schedule_type"],
|
|
task["schedule_spec"],
|
|
task["timezone"],
|
|
now=now,
|
|
)
|
|
status = "completed" if task["schedule_type"] == "once" else "enabled"
|
|
await self._task_run_repo.update_status(
|
|
task_run_id,
|
|
status="success",
|
|
run_id=result["run_id"],
|
|
started_at=now,
|
|
finished_at=now,
|
|
)
|
|
await self._task_repo.update_after_launch(
|
|
task["id"],
|
|
status=status,
|
|
next_run_at=next_at,
|
|
last_run_at=now,
|
|
last_run_id=result["run_id"],
|
|
last_error=None,
|
|
increment_run_count=True,
|
|
)
|
|
except Exception as exc:
|
|
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="failed" if task["schedule_type"] == "once" else "enabled",
|
|
next_run_at=next_run_at(task["schedule_type"], task["schedule_spec"], task["timezone"], now=now),
|
|
last_run_at=now,
|
|
last_run_id=None,
|
|
last_error=str(exc),
|
|
increment_run_count=False,
|
|
)
|
|
|
|
async def start(self) -> None:
|
|
if self._task is not None:
|
|
return
|
|
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():
|
|
await self.run_once(now=datetime.now(UTC))
|
|
try:
|
|
await asyncio.wait_for(self._stop.wait(), timeout=self._poll_interval_seconds)
|
|
except TimeoutError:
|
|
continue
|
|
```
|
|
|
|
Create `backend/app/scheduler/__init__.py`:
|
|
|
|
```python
|
|
from .service import ScheduledTaskService
|
|
|
|
__all__ = ["ScheduledTaskService"]
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_service.py -v
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/gateway/services.py \
|
|
backend/app/scheduler/__init__.py \
|
|
backend/app/scheduler/service.py \
|
|
backend/tests/test_scheduled_task_service.py
|
|
git commit -m "feat(scheduler): add scheduled task service and shared run launcher"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Add scheduled-task API routes and app wiring
|
|
|
|
**Files:**
|
|
- Create: `backend/app/gateway/routers/scheduled_tasks.py`
|
|
- Modify: `backend/app/gateway/routers/__init__.py`
|
|
- Modify: `backend/app/gateway/app.py`
|
|
- Modify: `backend/app/gateway/deps.py`
|
|
- Test: `backend/tests/test_scheduled_task_router.py`
|
|
|
|
**Interfaces:**
|
|
- Produces REST endpoints:
|
|
- list/create/detail/update/pause/resume/trigger/delete/history/thread-list
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `backend/tests/test_scheduled_task_router.py` with a minimal FastAPI app mounting the new router and stubbing repo dependencies:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
|
|
```
|
|
|
|
Expected: fail because router module does not exist.
|
|
|
|
- [ ] **Step 3: Implement minimal router and wiring**
|
|
|
|
Create `backend/app/gateway/routers/scheduled_tasks.py` with:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
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
|
|
|
|
router = APIRouter(prefix="/api", tags=["scheduled-tasks"])
|
|
|
|
|
|
class ScheduledTaskCreateRequest(BaseModel):
|
|
thread_id: str
|
|
title: str = Field(min_length=1)
|
|
prompt: str = Field(min_length=1)
|
|
schedule_type: str
|
|
schedule_spec: dict[str, Any]
|
|
timezone: str
|
|
|
|
|
|
@router.get("/scheduled-tasks")
|
|
@require_permission("threads", "read")
|
|
async def list_scheduled_tasks(request: Request):
|
|
return []
|
|
```
|
|
|
|
Update router exports in `backend/app/gateway/routers/__init__.py`:
|
|
|
|
```python
|
|
from . import scheduled_tasks
|
|
```
|
|
|
|
Mount router in `backend/app/gateway/app.py` import list and `create_app()` include list.
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/gateway/routers/scheduled_tasks.py \
|
|
backend/app/gateway/routers/__init__.py \
|
|
backend/app/gateway/app.py \
|
|
backend/tests/test_scheduled_task_router.py
|
|
git commit -m "feat(scheduler): add scheduled task router skeleton"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Flesh out router behavior, owner isolation, and manual trigger
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/gateway/routers/scheduled_tasks.py`
|
|
- Modify: `backend/app/gateway/deps.py`
|
|
- Test: `backend/tests/test_scheduled_task_router.py`
|
|
|
|
**Interfaces:**
|
|
- Produces working route handlers and dependency accessors:
|
|
- `get_scheduled_task_repo`
|
|
- `get_scheduled_task_run_repo`
|
|
- `get_scheduled_task_service`
|
|
|
|
- [ ] **Step 1: Extend the failing tests**
|
|
|
|
Append to `backend/tests/test_scheduled_task_router.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail correctly**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
|
|
```
|
|
|
|
Expected: fail because trigger route not implemented.
|
|
|
|
- [ ] **Step 3: Implement routes**
|
|
|
|
Add dependencies to `backend/app/gateway/deps.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
Expand `backend/app/gateway/routers/scheduled_tasks.py` with route stubs:
|
|
|
|
```python
|
|
@router.post("/scheduled-tasks")
|
|
@require_permission("threads", "write")
|
|
async def create_scheduled_task(request: Request, body: ScheduledTaskCreateRequest):
|
|
return body.model_dump()
|
|
|
|
|
|
@router.get("/scheduled-tasks/{task_id}")
|
|
@require_permission("threads", "read")
|
|
async def get_scheduled_task(task_id: str):
|
|
return {"id": task_id}
|
|
|
|
|
|
@router.patch("/scheduled-tasks/{task_id}")
|
|
@require_permission("threads", "write")
|
|
async def update_scheduled_task(task_id: str, request: Request, body: dict[str, Any]):
|
|
return {"id": task_id, **body}
|
|
|
|
|
|
@router.post("/scheduled-tasks/{task_id}/pause")
|
|
@require_permission("threads", "write")
|
|
async def pause_scheduled_task(task_id: str):
|
|
return {"id": task_id, "status": "paused"}
|
|
|
|
|
|
@router.post("/scheduled-tasks/{task_id}/resume")
|
|
@require_permission("threads", "write")
|
|
async def resume_scheduled_task(task_id: str):
|
|
return {"id": task_id, "status": "enabled"}
|
|
|
|
|
|
@router.post("/scheduled-tasks/{task_id}/trigger")
|
|
@require_permission("threads", "write")
|
|
async def trigger_scheduled_task(task_id: str):
|
|
return {"id": task_id, "triggered": True}
|
|
|
|
|
|
@router.delete("/scheduled-tasks/{task_id}")
|
|
@require_permission("threads", "write")
|
|
async def delete_scheduled_task(task_id: str):
|
|
return {"id": task_id, "deleted": True}
|
|
|
|
|
|
@router.get("/scheduled-tasks/{task_id}/runs")
|
|
@require_permission("threads", "read")
|
|
async def list_scheduled_task_runs(task_id: str):
|
|
return []
|
|
|
|
|
|
@router.get("/threads/{thread_id}/scheduled-tasks")
|
|
@require_permission("threads", "read", owner_check=True)
|
|
async def list_thread_scheduled_tasks(thread_id: str):
|
|
return []
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/gateway/deps.py \
|
|
backend/app/gateway/routers/scheduled_tasks.py \
|
|
backend/tests/test_scheduled_task_router.py
|
|
git commit -m "feat(scheduler): add scheduled task route surface"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Wire app state repositories and lifecycle start/stop
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/gateway/deps.py`
|
|
- Modify: `backend/app/gateway/app.py`
|
|
- Test: `backend/tests/test_scheduled_task_lifecycle.py`
|
|
|
|
**Interfaces:**
|
|
- Produces app state members:
|
|
- `scheduled_task_repo`
|
|
- `scheduled_task_run_repo`
|
|
- `scheduled_task_service`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `backend/tests/test_scheduled_task_lifecycle.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_lifecycle.py -v
|
|
```
|
|
|
|
Expected: fail if route inclusion or lifecycle wiring is incomplete.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
In `backend/app/gateway/deps.py::langgraph_runtime`, after `thread_store` creation:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
In `backend/app/gateway/app.py::lifespan`, after channel service startup:
|
|
|
|
```python
|
|
scheduled_task_service = None
|
|
if getattr(startup_config.scheduler, "enabled", False):
|
|
from app.scheduler import ScheduledTaskService
|
|
from app.gateway.services import launch_scheduled_thread_run
|
|
|
|
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=launch_scheduled_thread_run,
|
|
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
|
|
await scheduled_task_service.start()
|
|
```
|
|
|
|
Before exiting lifespan shutdown:
|
|
|
|
```python
|
|
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")
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_lifecycle.py tests/test_scheduled_task_router.py -v
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/gateway/deps.py backend/app/gateway/app.py backend/tests/test_scheduled_task_lifecycle.py
|
|
git commit -m "feat(scheduler): wire scheduled task repos and lifecycle"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Build frontend scheduled-task API layer and page shell
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/core/scheduled-tasks/types.ts`
|
|
- Create: `frontend/src/core/scheduled-tasks/api.ts`
|
|
- Create: `frontend/src/core/scheduled-tasks/hooks.ts`
|
|
- Create: `frontend/src/app/workspace/scheduled-tasks/page.tsx`
|
|
- Modify: `frontend/src/components/workspace/workspace-nav-chat-list.tsx`
|
|
- Modify: `frontend/src/core/i18n/locales/types.ts`
|
|
- Modify: `frontend/src/core/i18n/locales/en-US.ts`
|
|
- Modify: `frontend/src/core/i18n/locales/zh-CN.ts`
|
|
- Test: `frontend/tests/unit/core/scheduled-tasks/hooks.test.ts`
|
|
- Test: `frontend/tests/e2e/scheduled-tasks.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `ScheduledTask`
|
|
- `ScheduledTaskRun`
|
|
- `useScheduledTasks`
|
|
- `/workspace/scheduled-tasks`
|
|
|
|
- [ ] **Step 1: Write the failing frontend tests**
|
|
|
|
Create `frontend/tests/e2e/scheduled-tasks.spec.ts`:
|
|
|
|
```typescript
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
test("scheduled tasks page is reachable from sidebar", async ({ page }) => {
|
|
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/);
|
|
});
|
|
```
|
|
|
|
Create `frontend/tests/unit/core/scheduled-tasks/hooks.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import type { ScheduledTask } from "@/core/scheduled-tasks/types";
|
|
|
|
describe("scheduled task types", () => {
|
|
test("scheduled task shape supports status and next run", () => {
|
|
const task: ScheduledTask = {
|
|
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",
|
|
};
|
|
|
|
expect(task.status).toBe("enabled");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd frontend && pnpm test
|
|
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
|
|
```
|
|
|
|
Expected:
|
|
- fail because scheduled-task modules and route do not exist
|
|
|
|
- [ ] **Step 3: Implement page shell and hooks**
|
|
|
|
Create `frontend/src/core/scheduled-tasks/types.ts`:
|
|
|
|
```typescript
|
|
export type ScheduledTask = {
|
|
id: string;
|
|
thread_id: string;
|
|
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;
|
|
};
|
|
|
|
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";
|
|
error: string | null;
|
|
started_at: string | null;
|
|
finished_at: string | null;
|
|
created_at: string;
|
|
};
|
|
```
|
|
|
|
Create `frontend/src/core/scheduled-tasks/api.ts`:
|
|
|
|
```typescript
|
|
import { fetch } from "@/core/api/fetcher";
|
|
import { getBackendBaseURL } from "@/core/config";
|
|
|
|
import type { ScheduledTask, ScheduledTaskRun } from "./types";
|
|
|
|
export async function fetchScheduledTasks(): Promise<ScheduledTask[]> {
|
|
const response = await fetch(`${getBackendBaseURL()}/api/scheduled-tasks`);
|
|
return response.json();
|
|
}
|
|
|
|
export async function fetchThreadScheduledTasks(
|
|
threadId: string,
|
|
): Promise<ScheduledTask[]> {
|
|
const response = await fetch(
|
|
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/scheduled-tasks`,
|
|
);
|
|
return response.json();
|
|
}
|
|
|
|
export async function fetchScheduledTaskRuns(
|
|
taskId: string,
|
|
): Promise<ScheduledTaskRun[]> {
|
|
const response = await fetch(
|
|
`${getBackendBaseURL()}/api/scheduled-tasks/${encodeURIComponent(taskId)}/runs`,
|
|
);
|
|
return response.json();
|
|
}
|
|
```
|
|
|
|
Create `frontend/src/core/scheduled-tasks/hooks.ts`:
|
|
|
|
```typescript
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
import {
|
|
fetchScheduledTaskRuns,
|
|
fetchScheduledTasks,
|
|
fetchThreadScheduledTasks,
|
|
} from "./api";
|
|
|
|
export function useScheduledTasks() {
|
|
return useQuery({
|
|
queryKey: ["scheduled-tasks"],
|
|
queryFn: fetchScheduledTasks,
|
|
});
|
|
}
|
|
|
|
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),
|
|
});
|
|
}
|
|
```
|
|
|
|
Create `frontend/src/app/workspace/scheduled-tasks/page.tsx`:
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
|
|
import { WorkspaceBody, WorkspaceContainer, WorkspaceHeader } from "@/components/workspace/workspace-container";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import { useScheduledTasks } from "@/core/scheduled-tasks/hooks";
|
|
|
|
export default function ScheduledTasksPage() {
|
|
const { t } = useI18n();
|
|
const { data } = useScheduledTasks();
|
|
|
|
useEffect(() => {
|
|
document.title = `${t.sidebar.scheduledTasks} - ${t.pages.appName}`;
|
|
}, [t.pages.appName, t.sidebar.scheduledTasks]);
|
|
|
|
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 data-testid="scheduled-task-list">
|
|
{(data ?? []).map((task) => (
|
|
<div key={task.id}>{task.title}</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</WorkspaceBody>
|
|
</WorkspaceContainer>
|
|
);
|
|
}
|
|
```
|
|
|
|
Update sidebar types and translations with `scheduledTasks`.
|
|
|
|
Update `frontend/src/components/workspace/workspace-nav-chat-list.tsx` to add a link:
|
|
|
|
```typescript
|
|
import { CalendarClock, BotIcon, MessagesSquare } from "lucide-react";
|
|
```
|
|
|
|
Add menu item:
|
|
|
|
```typescript
|
|
<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>
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd frontend && pnpm test
|
|
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/core/scheduled-tasks/types.ts \
|
|
frontend/src/core/scheduled-tasks/api.ts \
|
|
frontend/src/core/scheduled-tasks/hooks.ts \
|
|
frontend/src/app/workspace/scheduled-tasks/page.tsx \
|
|
frontend/src/components/workspace/workspace-nav-chat-list.tsx \
|
|
frontend/src/core/i18n/locales/types.ts \
|
|
frontend/src/core/i18n/locales/en-US.ts \
|
|
frontend/src/core/i18n/locales/zh-CN.ts \
|
|
frontend/tests/unit/core/scheduled-tasks/hooks.test.ts \
|
|
frontend/tests/e2e/scheduled-tasks.spec.ts
|
|
git commit -m "feat(scheduler): add scheduled tasks workspace page shell"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Add thread-level scheduled-task entry point
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/app/workspace/chats/[thread_id]/page.tsx`
|
|
- Create: `frontend/src/components/workspace/thread-scheduled-tasks-link.tsx`
|
|
- Test: `frontend/tests/e2e/scheduled-tasks.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Produces a thread-scoped link to `/workspace/scheduled-tasks?thread_id=<id>`
|
|
|
|
- [ ] **Step 1: Extend the failing E2E test**
|
|
|
|
Append to `frontend/tests/e2e/scheduled-tasks.spec.ts`:
|
|
|
|
```typescript
|
|
test("thread page links to filtered scheduled tasks", async ({ page }) => {
|
|
await page.goto("/workspace/chats/new");
|
|
await page.goto("/workspace/chats/thread-1");
|
|
await page.getByRole("link", { name: /scheduled tasks/i }).click();
|
|
await page.waitForURL(/thread_id=thread-1/);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
|
|
```
|
|
|
|
Expected: fail because thread page has no such link.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `frontend/src/components/workspace/thread-scheduled-tasks-link.tsx`:
|
|
|
|
```typescript
|
|
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>
|
|
);
|
|
}
|
|
```
|
|
|
|
Import and render it in `frontend/src/app/workspace/chats/[thread_id]/page.tsx` near the header controls using the current `threadId`.
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/workspace/thread-scheduled-tasks-link.tsx \
|
|
frontend/src/app/workspace/chats/[thread_id]/page.tsx \
|
|
frontend/tests/e2e/scheduled-tasks.spec.ts
|
|
git commit -m "feat(scheduler): add thread-level scheduled task entry point"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Flesh out frontend page interactions and backend contract
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/gateway/routers/scheduled_tasks.py`
|
|
- Modify: `frontend/src/core/scheduled-tasks/api.ts`
|
|
- Modify: `frontend/src/core/scheduled-tasks/hooks.ts`
|
|
- Modify: `frontend/src/app/workspace/scheduled-tasks/page.tsx`
|
|
- Test: `backend/tests/test_scheduled_task_router.py`
|
|
- Test: `frontend/tests/unit/core/scheduled-tasks/hooks.test.ts`
|
|
- Test: `frontend/tests/e2e/scheduled-tasks.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Produces working list/detail/create/update/pause/resume/trigger/delete flows
|
|
|
|
- [ ] **Step 1: Extend failing tests for CRUD and actions**
|
|
|
|
Add backend tests that assert response shapes and route presence for:
|
|
- create
|
|
- history
|
|
- pause/resume
|
|
- delete
|
|
|
|
Add Playwright interactions for:
|
|
- create modal/form submit
|
|
- pause/resume action buttons
|
|
- trigger action
|
|
- delete action
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
|
|
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
|
|
```
|
|
|
|
Expected: fail because current page shell and route stubs do not implement these behaviors.
|
|
|
|
- [ ] **Step 3: Implement minimal full flow**
|
|
|
|
Backend:
|
|
- validate payloads
|
|
- read/write through repositories
|
|
- manual trigger delegates to scheduled task service or shared launch helper
|
|
- list history from `ScheduledTaskRunRepository`
|
|
|
|
Frontend:
|
|
- add create form
|
|
- add selected-task detail panel
|
|
- add action buttons and mutation hooks
|
|
- refresh list/history on success
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py tests/test_scheduled_task_service.py tests/test_scheduled_task_repository.py tests/test_scheduled_task_claims.py tests/test_scheduled_task_schedules.py -v
|
|
cd frontend && pnpm test
|
|
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/gateway/routers/scheduled_tasks.py \
|
|
frontend/src/core/scheduled-tasks/api.ts \
|
|
frontend/src/core/scheduled-tasks/hooks.ts \
|
|
frontend/src/app/workspace/scheduled-tasks/page.tsx \
|
|
backend/tests/test_scheduled_task_router.py \
|
|
frontend/tests/unit/core/scheduled-tasks/hooks.test.ts \
|
|
frontend/tests/e2e/scheduled-tasks.spec.ts
|
|
git commit -m "feat(scheduler): implement scheduled task CRUD and UI actions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Real-path validation, docs sync, and final verification
|
|
|
|
**Files:**
|
|
- Modify: `README.md`
|
|
- Modify: `AGENTS.md`
|
|
- Modify: `backend/AGENTS.md`
|
|
- Modify: `backend/docs/CONFIGURATION.md`
|
|
- Test: no new file required; use existing verification commands
|
|
|
|
**Interfaces:**
|
|
- Produces updated docs and final evidence package
|
|
|
|
- [ ] **Step 1: Update docs**
|
|
|
|
Add concise documentation for:
|
|
- what scheduled tasks MVP supports
|
|
- how to enable scheduler in config
|
|
- where users manage tasks in the workspace
|
|
- what is intentionally unsupported in MVP
|
|
|
|
- [ ] **Step 2: Run backend verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest \
|
|
tests/test_scheduled_task_models.py \
|
|
tests/test_scheduled_task_repository.py \
|
|
tests/test_scheduled_task_schedules.py \
|
|
tests/test_scheduled_task_claims.py \
|
|
tests/test_scheduled_task_service.py \
|
|
tests/test_scheduled_task_router.py \
|
|
tests/test_scheduled_task_lifecycle.py -v
|
|
```
|
|
|
|
Expected: all PASS
|
|
|
|
- [ ] **Step 3: Run frontend verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd frontend && pnpm test
|
|
cd frontend && pnpm check
|
|
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
|
|
```
|
|
|
|
Expected: all PASS
|
|
|
|
- [ ] **Step 4: Run real-path browser validation**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
make dev
|
|
```
|
|
|
|
Then verify in a real browser:
|
|
- create a one-time scheduled task due soon
|
|
- observe list row and task detail
|
|
- observe task trigger and resulting DeerFlow run
|
|
- verify final status/result is reflected in the management page
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add README.md AGENTS.md backend/AGENTS.md backend/docs/CONFIGURATION.md
|
|
git commit -m "docs(scheduler): document scheduled tasks MVP"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Independent verifier pass before PR
|
|
|
|
**Files:**
|
|
- No code changes required; verifier may request fixes in touched files.
|
|
|
|
**Interfaces:**
|
|
- Produces fail-loud verification report covering:
|
|
- skipped paths
|
|
- warnings
|
|
- not-yet-verified areas
|
|
|
|
- [ ] **Step 1: Dispatch a fresh verifier**
|
|
|
|
Give the verifier only:
|
|
- goal contract
|
|
- changed files
|
|
- exact verification commands
|
|
|
|
- [ ] **Step 2: Address verifier findings**
|
|
|
|
If findings exist:
|
|
- write failing test
|
|
- confirm fail
|
|
- implement fix
|
|
- rerun relevant verification
|
|
|
|
- [ ] **Step 3: Run final full verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest
|
|
cd frontend && pnpm check
|
|
cd frontend && pnpm test
|
|
cd frontend && pnpm test:e2e
|
|
```
|
|
|
|
Expected: pass or clearly documented existing unrelated failures
|
|
|
|
- [ ] **Step 4: Prepare PR**
|
|
|
|
PR body must include:
|
|
- goal contract
|
|
- test evidence
|
|
- real browser validation evidence
|
|
- verifier fail-loud report
|
|
- explicit skipped/not-verified items if any
|