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.
137 lines
5.8 KiB
Python
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,
|
|
)
|