mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-18 18:46:17 +00:00
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.
79 lines
3.6 KiB
Python
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 ""}
|