deer-flow/backend/app/composition.py
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

137 lines
5.8 KiB
Python

"""Composition root -- the one place adapters are instantiated and wired.
Every application service is assembled here and nowhere else. Ports are
declared by the domain, implemented under ``app/adapters/``, and the two are
introduced to each other in this file; no router, middleware, or lifespan hook
constructs an adapter of its own.
**Why this is a pure function rather than part of the lifespan.** Wiring used
to live inside ``deps.py::langgraph_runtime``, tangled with engine startup,
orphan recovery, and graceful shutdown -- so the one rule that actually
governs it ("no SQL backend means no service, and the routes answer 503")
could not be tested without driving a full application startup, and was held
up by a single comment. ``build_domain_services`` takes what it needs and
returns what it built, so that rule is an assertion in
``tests/test_composition.py`` instead.
**What "no SQL backend" means.** ``session_factory is None`` is how a
``database.backend: memory`` deployment presents itself. Both contexts own
tables, so neither can run on it; each service is ``None`` and the dependency
providers translate that into 503. This is deliberately not a silent
degradation to an in-memory implementation -- scheduled work that vanishes on
restart is worse than scheduled work that is refused.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from deerflow.domain.feedback.service import FeedbackService
from deerflow.domain.schedule.model import SchedulePolicy
from deerflow.domain.schedule.service import ScheduleService
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.config.scheduler_config import SchedulerConfig
from deerflow.persistence.thread_meta.base import ThreadMetaStore
from deerflow.runtime.runs.store import RunStore
@dataclass(frozen=True)
class DomainServices:
"""Every application service the Gateway serves, or ``None`` where the
configured backend cannot support one."""
feedback: FeedbackService | None
schedule: ScheduleService | None
def build_domain_services(
*,
session_factory: async_sessionmaker[AsyncSession] | None,
run_store: RunStore,
thread_store: ThreadMetaStore,
launch_run: Callable[..., Awaitable[Mapping[str, Any]]],
scheduler_config: SchedulerConfig,
) -> DomainServices:
"""Wire the domain services from already-built infrastructure.
Takes infrastructure rather than building it: engines, stores and the run
launcher have lifecycles (startup, recovery, shutdown) that belong to the
lifespan, while deciding *what is assembled from them* is this function's
only job. That split is what makes it callable from a test.
"""
if session_factory is None:
return DomainServices(feedback=None, schedule=None)
# Imported here, not at module scope: these pull in SQLAlchemy and the
# Gateway run path, and the composition root is imported by tests that
# only want the memory-backend branch.
from app.adapters.feedback.feedback_repository import SqlFeedbackRepository
from app.adapters.feedback.run_lookup import RunStoreRunLookup
from app.adapters.schedule.run_launcher import GatewayRunLauncher
from app.adapters.schedule.scheduled_run_repository import SqlScheduledRunRepository
from app.adapters.schedule.scheduled_task_repository import SqlScheduledTaskRepository
from app.adapters.schedule.thread_lookup import ThreadStoreThreadLookup
return DomainServices(
feedback=FeedbackService(
repository=SqlFeedbackRepository(session_factory),
runs=RunStoreRunLookup(run_store),
),
schedule=ScheduleService(
tasks=SqlScheduledTaskRepository(session_factory),
runs=SqlScheduledRunRepository(session_factory),
launcher=GatewayRunLauncher(launch_run),
threads=ThreadStoreThreadLookup(thread_store),
policy=build_schedule_policy(scheduler_config),
),
)
def build_run_completion_hook(
schedule_service: ScheduleService | None,
) -> Callable[[Any], Awaitable[None]] | None:
"""Install the schedule context on the run runtime's completion callback.
This is the inbound half of the wiring: the run runtime hands every
finished run to one callback, and `run_outcome_from_record` decides which
of them the schedule context has any business with. Runs that are not
scheduled executions produce no outcome and the service is never called,
which is why it carries no guard clauses of its own.
Returns ``None`` when there is no service, so the runtime installs no hook
at all rather than one that always declines.
"""
if schedule_service is None:
return None
from app.adapters.schedule.run_outcome_mapping import run_outcome_from_record
async def on_run_completed(record: Any) -> None:
outcome = run_outcome_from_record(record)
if outcome is not None:
await schedule_service.handle_run_completion(outcome, now=datetime.now(UTC))
return on_run_completed
def build_schedule_policy(scheduler_config: SchedulerConfig) -> SchedulePolicy:
"""Project the operator's scheduler config onto the domain's policy.
Separate from the wiring above because it is the whole of the
configuration-to-domain translation: the domain declares which thresholds
it needs, and this names where each one comes from. `poll_interval_seconds`
is deliberately absent -- how often to look is the poller's business, not a
rule any task is subject to.
"""
return SchedulePolicy(
min_once_delay_seconds=scheduler_config.min_once_delay_seconds,
max_concurrent_runs=scheduler_config.max_concurrent_runs,
lease_seconds=scheduler_config.lease_seconds,
)