mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-07 05:18:53 +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.
69 lines
2.9 KiB
Python
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 ""}
|