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

79 lines
3.6 KiB
Python

"""Boundary mapping (not a port implementation) -- the schedule_spec JSON column.
Unlike its siblings in this package, this module implements no port: it is the
translation `scheduled_task_repository` needs between the stored column and the
value object, so the domain never grows a `Mapping[str, Any]` in its
signatures.
Its counterpart on the other side of the application is
`app/gateway/routers/schedule/spec_wire.py`, which does the same job for the
HTTP request/response body. The two are near-identical today and are still kept
apart on purpose: a primary adapter must not import a secondary one, and the
two shapes are only equal by coincidence -- the day the API grows a field the
column does not have, they diverge without either side having to be untangled
first. The function names differ (`column_to_spec` here, `wire_to_spec` there)
so an import from the wrong side is visible rather than silently working.
The duplication is bounded because the split inside each is deliberate:
**structural** checks (is the key present? is it a str?) belong to the
boundary, **value** rules (5-field cron, resolvable timezone, run_at present)
belong to `ScheduleSpec.__post_init__`. Only the structural half is repeated,
and `tests/test_schedule_spec_parity.py` feeds both the same malformed inputs
so a drift between them fails a test rather than reaching production.
"""
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 column_to_spec(schedule_type: str, spec: Mapping[str, Any] | None, timezone: str) -> ScheduleSpec:
"""Parse the stored/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 an
adapter is intentional -- domain errors are the vocabulary the
outer ring uses to say "this violates a domain rule", and the
router maps this one family uniformly.
"""
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_column(spec: ScheduleSpec) -> dict[str, str]:
"""Rebuild the persisted/wire JSON shape.
Note this normalizes the stored string rather than echoing the caller's
bytes: the frontend submits an already-UTC-aware ISO value
(`zonedLocalToUtcIso`), so a trailing-Z input round-trips out 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 ""}