mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-18 18:58:42 +00:00
The outer ring for the domain added in #4597: SQL repositories, the run launcher, the thread lookup, and the run-completion listener implementing the ports it declared, plus the HTTP router and the poller that drive them. All of it is instantiated in one composition root, so no route or lifespan hook builds an adapter of its own. With the ports filled, the pre-hexagonal implementation is deleted rather than left alongside: `app/scheduler/service.py` and its router mixed policy, persistence, and HTTP into one class, which is why its rules were only reachable through a live database. Keeping both would leave two implementations of the same rules writing to the same table. Three of the domain's contracts needed real work on this side rather than a straight port of the pre-#4597 adapters: - The launcher now distinguishes certain failure from doubt. Only a 4xx is certain enough to raise LaunchFailedError, which releases the task's single active slot; a 5xx, an arbitrary exception, or a reply whose identity will not decode all raise LaunchIndeterminateError and keep the slot held. Guessing "failed" after the launch request was sent is what re-opens #4452's duplicate execution. - The task repository implements the optimistic token. `save` is a conditional UPDATE on `version` rather than read-check-write, because the latter lets two savers observe the same version and both commit; every other committed write increments it. This needs a column, so it ships with migration 0011 -- the only schema change in the slice, and the reason the alembic head pins move. - The router builds commands with plain `None` for "not supplied", and maps ConcurrentUpdateError onto a retryable 409. The concurrency invariants are pinned by contract suites that run each port against both the in-memory double and real sqlite -- including a new TestOptimisticConcurrency covering what invalidates an earlier read -- plus the dispatch-race tests against a real database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
8.5 KiB
Python
201 lines
8.5 KiB
Python
"""Primary adapter -- the scheduled-task HTTP API.
|
|
|
|
Everything here is protocol translation. Parse the body into domain values,
|
|
call one service method, render the result, and map domain errors onto status
|
|
codes. What is deliberately *absent* is the giveaway: no cron normalisation,
|
|
no `next_run_at` arithmetic, no re-arm rule, no ownership checks written out
|
|
by hand. Those moved into the aggregate, where they are covered by domain
|
|
tests that never construct an HTTP request.
|
|
|
|
The error mapping is one table rather than a `raise HTTPException` per branch,
|
|
so a new domain error surfaces as a 500 that has to be classified, instead of
|
|
being silently swallowed by whichever `except` happened to be nearest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from functools import wraps
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
|
|
from app.gateway.authz import require_permission
|
|
from app.gateway.deps import ScheduleServiceDep, get_optional_user_from_request
|
|
from app.gateway.routers.schedule.models import (
|
|
CreateScheduledTaskRequest,
|
|
DeleteResponse,
|
|
ScheduledRunResponse,
|
|
ScheduledTaskResponse,
|
|
TriggerResponse,
|
|
UpdateScheduledTaskRequest,
|
|
)
|
|
from deerflow.domain.schedule.commands import DeleteTask, PauseTask, ResumeTask, TriggerTask
|
|
from deerflow.domain.schedule.exceptions import (
|
|
ConcurrentUpdateError,
|
|
InvalidContextModeError,
|
|
InvalidScheduleError,
|
|
ScheduleError,
|
|
TaskNotFoundError,
|
|
TaskNotMutableError,
|
|
ThreadNotFoundError,
|
|
)
|
|
from deerflow.domain.schedule.model import DispatchOutcome
|
|
|
|
router = APIRouter(prefix="/api", tags=["scheduled-tasks"])
|
|
|
|
# One family of domain errors, one place they become a protocol.
|
|
_STATUS_BY_ERROR: dict[type[ScheduleError], int] = {
|
|
# "Not yours" and "does not exist" are both 404 by design -- the service
|
|
# already refuses to distinguish them, and so must the status code.
|
|
TaskNotFoundError: 404,
|
|
ThreadNotFoundError: 404,
|
|
InvalidScheduleError: 422,
|
|
InvalidContextModeError: 422,
|
|
TaskNotMutableError: 409,
|
|
# The service already retried a bounded number of rounds; reaching here
|
|
# means the row kept moving under it. 409 says "retry", which is exactly
|
|
# what a client can usefully do.
|
|
ConcurrentUpdateError: 409,
|
|
}
|
|
|
|
|
|
def _map_domain_errors(handler):
|
|
"""Translate domain errors raised by the service into HTTP responses.
|
|
|
|
An unclassified `ScheduleError` becomes a 500 on purpose: a new domain
|
|
error is a new protocol decision, and defaulting it to 4xx would let it
|
|
ship as a client error nobody chose.
|
|
"""
|
|
|
|
@wraps(handler)
|
|
async def wrapper(*args, **kwargs):
|
|
try:
|
|
return await handler(*args, **kwargs)
|
|
except ScheduleError as exc:
|
|
status = _STATUS_BY_ERROR.get(type(exc))
|
|
if status is None:
|
|
raise
|
|
raise HTTPException(status_code=status, detail=str(exc)) from exc
|
|
|
|
return wrapper
|
|
|
|
|
|
async def _require_user_id(request: Request) -> str:
|
|
user = await get_optional_user_from_request(request)
|
|
if user is None:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
return str(user.id)
|
|
|
|
|
|
@router.get("/scheduled-tasks", response_model=list[ScheduledTaskResponse])
|
|
@require_permission("threads", "read")
|
|
@_map_domain_errors
|
|
async def list_scheduled_tasks(request: Request, service: ScheduleServiceDep):
|
|
user = await get_optional_user_from_request(request)
|
|
if user is None:
|
|
# Not 401: an unauthenticated listing has always been an empty one.
|
|
return []
|
|
return [ScheduledTaskResponse.from_domain(task) for task in await service.list_tasks(str(user.id))]
|
|
|
|
|
|
@router.post("/scheduled-tasks", response_model=ScheduledTaskResponse)
|
|
@require_permission("threads", "write")
|
|
@_map_domain_errors
|
|
async def create_scheduled_task(request: Request, body: CreateScheduledTaskRequest, service: ScheduleServiceDep):
|
|
user_id = await _require_user_id(request)
|
|
task = await service.create_scheduled_task(body.to_command(user_id), now=datetime.now(UTC))
|
|
return ScheduledTaskResponse.from_domain(task)
|
|
|
|
|
|
@router.get("/scheduled-tasks/{task_id}", response_model=ScheduledTaskResponse)
|
|
@require_permission("threads", "read")
|
|
@_map_domain_errors
|
|
async def get_scheduled_task(task_id: str, request: Request, service: ScheduleServiceDep):
|
|
user_id = await _require_user_id(request)
|
|
return ScheduledTaskResponse.from_domain(await service.get_task(task_id, user_id=user_id))
|
|
|
|
|
|
@router.patch("/scheduled-tasks/{task_id}", response_model=ScheduledTaskResponse)
|
|
@require_permission("threads", "write")
|
|
@_map_domain_errors
|
|
async def update_scheduled_task(
|
|
task_id: str,
|
|
request: Request,
|
|
body: UpdateScheduledTaskRequest,
|
|
service: ScheduleServiceDep,
|
|
):
|
|
user_id = await _require_user_id(request)
|
|
# A schedule or context change needs the parts the client omitted, so the
|
|
# current task is read once and handed to `to_command`. That one extra
|
|
# fetch is what lets the command carry whole value objects instead of a
|
|
# patch of loose fields.
|
|
current = await service.get_task(task_id, user_id=user_id) if body.changes_schedule() or body.changes_context() else None
|
|
task = await service.update_scheduled_task(body.to_command(task_id, user_id, current), now=datetime.now(UTC))
|
|
return ScheduledTaskResponse.from_domain(task)
|
|
|
|
|
|
@router.post("/scheduled-tasks/{task_id}/pause", response_model=ScheduledTaskResponse)
|
|
@require_permission("threads", "write")
|
|
@_map_domain_errors
|
|
async def pause_scheduled_task(task_id: str, request: Request, service: ScheduleServiceDep):
|
|
user_id = await _require_user_id(request)
|
|
# No body, so no request model: the command is built right here (spec §2.1 ①).
|
|
return ScheduledTaskResponse.from_domain(await service.pause_task(PauseTask(task_id=task_id, user_id=user_id)))
|
|
|
|
|
|
@router.post("/scheduled-tasks/{task_id}/resume", response_model=ScheduledTaskResponse)
|
|
@require_permission("threads", "write")
|
|
@_map_domain_errors
|
|
async def resume_scheduled_task(task_id: str, request: Request, service: ScheduleServiceDep):
|
|
user_id = await _require_user_id(request)
|
|
return ScheduledTaskResponse.from_domain(await service.resume_task(ResumeTask(task_id=task_id, user_id=user_id)))
|
|
|
|
|
|
@router.post("/scheduled-tasks/{task_id}/trigger", response_model=TriggerResponse)
|
|
@require_permission("threads", "write")
|
|
@_map_domain_errors
|
|
async def trigger_scheduled_task(task_id: str, request: Request, service: ScheduleServiceDep):
|
|
user_id = await _require_user_id(request)
|
|
result = await service.trigger_task(TriggerTask(task_id=task_id, user_id=user_id), now=datetime.now(UTC))
|
|
if result.outcome is DispatchOutcome.CONFLICT:
|
|
raise HTTPException(status_code=409, detail=result.error or "Scheduled task trigger conflicted with an active run")
|
|
if result.outcome is DispatchOutcome.FAILED:
|
|
# 502, not 500: the failure is downstream of this API -- the run could
|
|
# not be started -- and the task itself is intact.
|
|
raise HTTPException(status_code=502, detail=result.error or "Scheduled task trigger failed")
|
|
return TriggerResponse(id=task_id, triggered=True)
|
|
|
|
|
|
@router.delete("/scheduled-tasks/{task_id}", response_model=DeleteResponse)
|
|
@require_permission("threads", "write")
|
|
@_map_domain_errors
|
|
async def delete_scheduled_task(task_id: str, request: Request, service: ScheduleServiceDep):
|
|
user_id = await _require_user_id(request)
|
|
await service.delete_task(DeleteTask(task_id=task_id, user_id=user_id))
|
|
return DeleteResponse(id=task_id, deleted=True)
|
|
|
|
|
|
@router.get("/scheduled-tasks/{task_id}/runs", response_model=list[ScheduledRunResponse])
|
|
@require_permission("threads", "read")
|
|
@_map_domain_errors
|
|
async def list_scheduled_task_runs(
|
|
task_id: str,
|
|
request: Request,
|
|
service: ScheduleServiceDep,
|
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
offset: Annotated[int, Query(ge=0)] = 0,
|
|
):
|
|
user_id = await _require_user_id(request)
|
|
runs = await service.list_task_runs(task_id, user_id=user_id, limit=limit, offset=offset)
|
|
return [ScheduledRunResponse.from_domain(run) for run in runs]
|
|
|
|
|
|
@router.get("/threads/{thread_id}/scheduled-tasks", response_model=list[ScheduledTaskResponse])
|
|
@require_permission("threads", "read", owner_check=True)
|
|
@_map_domain_errors
|
|
async def list_thread_scheduled_tasks(thread_id: str, request: Request, service: ScheduleServiceDep):
|
|
user_id = await _require_user_id(request)
|
|
tasks = await service.list_tasks_by_thread(user_id, thread_id)
|
|
return [ScheduledTaskResponse.from_domain(task) for task in tasks]
|