rayhpeng eb37df0779 refactor(schedule): wire the slice through a composition root
Switches production onto the hexagonal path. The legacy modules still
compile and still have tests, but nothing assembles them any more; deleting
them is the next commit, kept separate so it stays reviewable.

Composition root
----------------
`app/composition.py::build_domain_services()` is now the only place an
adapter is instantiated. It was extracted from `deps.py::langgraph_runtime`
rather than added to it: wiring there was tangled with engine startup,
orphan recovery and shutdown, so the one rule that governs it -- no SQL
backend means no service and the routes answer 503 -- could not be tested
without booting the whole application, and was held up by a single comment.
It is a pure function of already-built infrastructure, so that rule is now
an assertion. Feedback moved with it; doing this while adding schedule's
five objects costs one change instead of two.

Primary adapter
---------------
The router is protocol translation only. What is gone is the giveaway: cron
normalisation, `next_run_at` arithmetic, the re-arm rule and hand-written
ownership checks all now live in the aggregate. Domain errors map to status
codes through one table, so a new error surfaces as a 500 to be classified
rather than being swallowed by whichever `except` was nearest.

`spec_mapping` split in two (AWS's own layout puts the wire model under the
entrypoint that owns it, and a primary adapter must not import a secondary
one): `adapters/schedule/spec_column.py` for the JSON column,
`routers/schedule/spec_wire.py` for the HTTP body. The two shapes are equal
only by coincidence, so `test_schedule_spec_parity.py` runs every case
against both and compares their outputs and messages directly. Function
names differ per side so an import from the wrong one is visible.

Explicit responses
------------------
Routes returned the ORM row's `to_dict()`, leaking `user_id`,
`assistant_id`, `overlap_policy` and the two lease columns. The response
models publish exactly the field set the frontend declares -- asserted in
both directions, since an extra field is a leak and a missing one breaks a
client.

One wire detail was nearly changed by accident: Pydantic v2 serializes a UTC
datetime as `...Z`, while the legacy `coerce_iso` path emitted `+00:00`.
`UtcTimestamp` pins `isoformat()` so adopting a model does not silently
alter the wire format for every client parsing these.

Tests
-----
73 new cases: router behaviour driven through a real `ScheduleService` over
in-memory fakes (a mocked service would let the error mapping pass without
a domain error ever being raised), response shape, and the composition root.
Router mappings verified by mutation -- a wrong status code or a dropped
timezone fallback turns 9, 2 and 1 cases red respectively.

Two lifespan tests carried a `SimpleNamespace` config that predates this
change; `langgraph_runtime` now reads `config.scheduler`, so they were given
one. Tolerating the gap with `getattr` was rejected: `AppConfig.scheduler`
always exists, so the fallback would be unreachable in production and exist
purely to excuse an incomplete test double.

Full suite is back to its 24 pre-existing failures.
2026-07-28 19:09:22 +08:00

238 lines
9.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 (
DeleteResponse,
ScheduledRunResponse,
ScheduledTaskCreateRequest,
ScheduledTaskResponse,
ScheduledTaskUpdateRequest,
TriggerResponse,
)
from app.gateway.routers.schedule.spec_wire import spec_to_wire, wire_to_spec
from deerflow.domain.schedule.model import (
DispatchOutcome,
InvalidContextModeError,
InvalidScheduleError,
ScheduleError,
TaskNotFoundError,
TaskNotMutableError,
ThreadNotFoundError,
)
from deerflow.domain.schedule.service import ContextChange
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,
}
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: ScheduledTaskCreateRequest, service: ScheduleServiceDep):
user_id = await _require_user_id(request)
task = await service.create_task(
user_id=user_id,
title=body.title,
prompt=body.prompt,
schedule=wire_to_spec(body.schedule_type, body.schedule_spec, body.timezone),
context_mode=body.context_mode,
thread_id=body.thread_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: ScheduledTaskUpdateRequest,
service: ScheduleServiceDep,
):
user_id = await _require_user_id(request)
# `exclude_none` rather than `exclude_unset`, matching the pre-migration
# router: an explicit `null` has always meant "not supplied" on this
# endpoint, and unbinding a thread is expressed by switching
# `context_mode`, not by nulling `thread_id`.
supplied = body.model_dump(exclude_none=True)
changes_schedule = "schedule_spec" in supplied or "timezone" in supplied
changes_context = "context_mode" in supplied or "thread_id" in supplied
# Both a schedule and a context change need the parts the client omitted,
# so the current task is read once and supplies the defaults. That one
# extra fetch is what lets the service receive whole value objects instead
# of a patch of loose fields.
current = await service.get_task(task_id, user_id=user_id) if changes_schedule or changes_context else None
schedule = None
if current is not None and changes_schedule:
schedule = wire_to_spec(
# The schedule *type* is not patchable; only its spec and zone are.
str(current.schedule.schedule_type),
supplied.get("schedule_spec", spec_to_wire(current.schedule)),
supplied.get("timezone", current.schedule.timezone),
)
context = None
if current is not None and changes_context:
context = ContextChange(
context_mode=supplied.get("context_mode", str(current.context_mode)),
thread_id=supplied.get("thread_id", current.thread_id),
)
task = await service.update_task(
task_id,
user_id=user_id,
now=datetime.now(UTC),
title=supplied.get("title"),
prompt=supplied.get("prompt"),
schedule=schedule,
context=context,
)
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)
return ScheduledTaskResponse.from_domain(await service.pause_task(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(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(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(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]