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

69 lines
2.9 KiB
Python

"""Boundary mapping -- the schedule_spec HTTP body field.
The primary adapter's own translation between the request/response shape and
`ScheduleSpec`. Its counterpart is `app/adapters/schedule/spec_column.py`, which
does the same job for the stored JSON column; see that module for why the two
are kept apart rather than shared.
The split is deliberate: **structural** checks (is the key present? is it a
str?) belong here, **value** rules (5-field cron, resolvable timezone, run_at
present) belong to `ScheduleSpec.__post_init__`. That is why this module is
thin -- most of what could go wrong is caught one layer in, and reported with
the same domain error, so the router maps one family onto 422.
"""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from deerflow.domain.schedule.model import InvalidScheduleError, ScheduleSpec, ScheduleType
def wire_to_spec(schedule_type: str, spec: Mapping[str, Any] | None, timezone: str) -> ScheduleSpec:
"""Parse the submitted triple into the value object.
Raises:
InvalidScheduleError: unknown schedule type, or the type's required key
is missing or not a string. Raising a *domain* error from a primary
adapter is intentional -- domain errors are the vocabulary the outer
ring uses to say "this violates a domain rule", and the router maps
that one family uniformly onto 422.
"""
try:
kind = ScheduleType(schedule_type)
except ValueError as exc:
raise InvalidScheduleError(f"Unsupported schedule_type: {schedule_type}") from exc
fields = spec or {}
if kind is ScheduleType.CRON:
raw_cron = fields.get("cron")
if not isinstance(raw_cron, str):
raise InvalidScheduleError("cron schedule requires schedule_spec.cron")
return ScheduleSpec.cron_schedule(raw_cron, timezone)
raw_run_at = fields.get("run_at")
if not isinstance(raw_run_at, str):
raise InvalidScheduleError("once schedule requires run_at")
try:
run_at = datetime.fromisoformat(raw_run_at)
except ValueError as exc:
raise InvalidScheduleError(f"once schedule has an unparseable run_at: {raw_run_at!r}") from exc
return ScheduleSpec.once_at(run_at, timezone)
def spec_to_wire(spec: ScheduleSpec) -> dict[str, str]:
"""Rebuild the response body shape.
Note this emits the normalized value rather than echoing the caller's
bytes: the frontend submits an already-UTC-aware ISO value
(`zonedLocalToUtcIso`), so a trailing-Z input comes back as "+00:00". Both
forms parse on either side, so the normalization is deliberate --
preferable to carrying the raw dict on the value object just to preserve
the exact input spelling.
"""
if spec.schedule_type is ScheduleType.CRON:
return {"cron": spec.cron or ""}
return {"run_at": spec.run_at.isoformat() if spec.run_at else ""}