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

151 lines
4.9 KiB
Python

"""The HTTP shapes of the scheduled-task API.
The primary adapter's own model of what a client sends and receives -- the
counterpart to the domain aggregates, not a view of them. Two things follow
from that:
**Responses are an allowlist, not a dump.** The pre-migration router returned
the ORM row's ``to_dict()``, which leaked ``user_id``, ``lease_owner``,
``lease_expires_at``, ``overlap_policy`` and ``assistant_id`` -- lease fields
are scheduler-internal bookkeeping, and the other three are server-owned. None
appear in the frontend's ``ScheduledTask`` type or anywhere in its code, so
naming the fields explicitly here closes the leak without a client change. A
field added to the aggregate from now on stays invisible until it is
deliberately published.
**Timestamps keep the legacy spelling.** The legacy path emitted
``coerce_iso`` -> ``astimezone(UTC).isoformat()``, i.e.
``2026-08-01T09:00:00+00:00``. Pydantic v2 would serialize the same instant as
``...T09:00:00Z``, which is a silent wire change for every client parsing
these, so ``UtcTimestamp`` pins ``isoformat()`` explicitly. Both spellings are
valid ISO 8601 and JS ``Date`` accepts either -- the point is that changing it
is a decision, not a side effect of adopting a model.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Annotated, Any
from pydantic import BaseModel, Field, PlainSerializer
from app.gateway.routers.schedule.spec_wire import spec_to_wire
from deerflow.domain.schedule.model import ScheduledRun, ScheduledTask
UtcTimestamp = Annotated[datetime, PlainSerializer(lambda value: value.isoformat(), return_type=str)]
def _utc(value: datetime | None) -> datetime | None:
"""Match the legacy `coerce_iso` normalisation exactly."""
if value is None:
return None
return value.astimezone(UTC) if value.tzinfo is not None else value.replace(tzinfo=UTC)
class ScheduledTaskCreateRequest(BaseModel):
thread_id: str | None = None
context_mode: str = "fresh_thread_per_run"
title: str = Field(min_length=1)
prompt: str = Field(min_length=1)
schedule_type: str
schedule_spec: dict[str, Any]
timezone: str
class ScheduledTaskUpdateRequest(BaseModel):
context_mode: str | None = None
thread_id: str | None = None
title: str | None = Field(default=None, min_length=1)
prompt: str | None = Field(default=None, min_length=1)
schedule_spec: dict[str, Any] | None = None
timezone: str | None = None
class ScheduledTaskResponse(BaseModel):
"""One scheduled task as the client sees it.
Mirrors the frontend's `ScheduledTask` type field for field.
"""
id: str
thread_id: str | None
context_mode: str
title: str
prompt: str
schedule_type: str
schedule_spec: dict[str, str]
timezone: str
status: str
next_run_at: UtcTimestamp | None
last_run_at: UtcTimestamp | None
last_run_id: str | None
last_thread_id: str | None
last_error: str | None
run_count: int
created_at: UtcTimestamp
updated_at: UtcTimestamp
@classmethod
def from_domain(cls, task: ScheduledTask) -> ScheduledTaskResponse:
return cls(
id=task.task_id,
thread_id=task.thread_id,
context_mode=str(task.context_mode),
title=task.title,
prompt=task.prompt,
schedule_type=str(task.schedule.schedule_type),
schedule_spec=spec_to_wire(task.schedule),
timezone=task.schedule.timezone,
status=str(task.status),
next_run_at=_utc(task.next_run_at),
last_run_at=_utc(task.last_run_at),
last_run_id=task.last_run_id,
last_thread_id=task.last_thread_id,
last_error=task.last_error,
run_count=task.run_count,
created_at=_utc(task.created_at),
updated_at=_utc(task.updated_at),
)
class ScheduledRunResponse(BaseModel):
"""One execution record. Mirrors the frontend's `ScheduledTaskRun` type."""
id: str
task_id: str
thread_id: str
run_id: str | None
scheduled_for: UtcTimestamp
trigger: str
status: str
error: str | None
started_at: UtcTimestamp | None
finished_at: UtcTimestamp | None
created_at: UtcTimestamp
@classmethod
def from_domain(cls, run: ScheduledRun) -> ScheduledRunResponse:
return cls(
id=run.record_id,
task_id=run.task_id,
thread_id=run.thread_id,
run_id=run.run_id,
scheduled_for=_utc(run.scheduled_for),
trigger=str(run.trigger),
status=str(run.status),
error=run.error,
started_at=_utc(run.started_at),
finished_at=_utc(run.finished_at),
created_at=_utc(run.created_at),
)
class TriggerResponse(BaseModel):
id: str
triggered: bool
class DeleteResponse(BaseModel):
id: str
deleted: bool