refactor(gateway): restructure gateway with new dependency injection and services

Reorganize app/gateway/ with:
- common/ - lifespan management
- dependencies/ - FastAPI dependency injection (db, checkpointer, repositories, stream_bridge)
- services/runs/ - run execution services (facade_factory, input adapters, store operations)
- registrar.py - router registration
- router.py - main router setup

Simplify app.py to use the new modular structure.
Remove deprecated utils.py.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rayhpeng
2026-04-22 11:27:08 +08:00
parent 274255b1a5
commit 39a575617b
24 changed files with 1120 additions and 371 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Gateway service layer."""
"""Compatibility package for app service submodules."""
__all__: list[str] = []
@@ -0,0 +1,29 @@
"""Runs app layer services."""
from app.infra.storage import StorageRunObserver
from .input import (
AdaptedRunRequest,
RunSpecBuilder,
UnsupportedRunFeatureError,
adapt_create_run_request,
adapt_create_stream_request,
adapt_create_wait_request,
adapt_join_stream_request,
adapt_join_wait_request,
)
from .store import AppRunCreateStore, AppRunDeleteStore, AppRunQueryStore
__all__ = [
"AdaptedRunRequest",
"AppRunCreateStore",
"AppRunDeleteStore",
"AppRunQueryStore",
"RunSpecBuilder",
"StorageRunObserver",
"UnsupportedRunFeatureError",
"adapt_create_run_request",
"adapt_create_stream_request",
"adapt_create_wait_request",
"adapt_join_stream_request",
"adapt_join_wait_request",
]
@@ -0,0 +1,150 @@
"""Facade factory - assembles RunsFacade with dependencies."""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
from fastapi import HTTPException, Request
from app.gateway.dependencies import get_checkpointer, get_stream_bridge
from deerflow.runtime.runs.facade import RunsFacade
from deerflow.runtime.runs.facade import RunsRuntime
from deerflow.runtime.runs.internal.execution.supervisor import RunSupervisor
from deerflow.runtime.runs.internal.planner import ExecutionPlanner
from deerflow.runtime.runs.internal.registry import RunRegistry
from deerflow.runtime.runs.internal.streams import RunStreamService
from deerflow.runtime.runs.internal.wait import RunWaitService
from app.infra.storage import StorageRunObserver, ThreadMetaStorage
from app.infra.storage.runs import RunDeleteRepository, RunReadRepository, RunWriteRepository
from .store import AppRunCreateStore, AppRunDeleteStore, AppRunQueryStore
if TYPE_CHECKING:
from deerflow.runtime.stream_bridge import StreamBridge
type AgentFactory = Callable[..., object]
# Module-level singleton registry (shared across requests)
_registry: RunRegistry | None = None
_supervisor: RunSupervisor | None = None
def _get_state(request: Request, attr: str, label: str):
value = getattr(request.app.state, attr, None)
if value is None:
raise HTTPException(status_code=503, detail=f"{label} not available")
return value
def get_registry() -> RunRegistry:
"""Get or create singleton registry."""
global _registry
if _registry is None:
_registry = RunRegistry()
return _registry
def get_supervisor() -> RunSupervisor:
"""Get or create singleton run supervisor."""
global _supervisor
if _supervisor is None:
_supervisor = RunSupervisor()
return _supervisor
def resolve_agent_factory(assistant_id: str | None) -> AgentFactory:
"""Resolve the agent factory callable from config."""
from deerflow.agents.lead_agent.agent import make_lead_agent
return make_lead_agent
def build_runs_facade(
*,
stream_bridge: "StreamBridge",
checkpointer: object,
store: object | None = None,
run_read_repo: RunReadRepository | None = None,
run_write_repo: RunWriteRepository | None = None,
run_delete_repo: RunDeleteRepository | None = None,
thread_meta_storage: ThreadMetaStorage | None = None,
run_event_store: object | None = None,
) -> RunsFacade:
"""
Build RunsFacade with all dependencies.
Args:
stream_bridge: StreamBridge instance
checkpointer: LangGraph checkpointer
store: Optional LangGraph runtime store
run_read_repo: Optional run repository for durable reads
run_write_repo: Optional run repository for durable writes
run_delete_repo: Optional run repository for durable deletes
thread_meta_storage: Optional thread metadata storage adapter
Returns:
Configured RunsFacade instance
"""
registry = get_registry()
planner = ExecutionPlanner()
supervisor = get_supervisor()
stream_service = RunStreamService(stream_bridge)
wait_service = RunWaitService(stream_service)
query_store = AppRunQueryStore(run_read_repo) if run_read_repo else None
create_store = (
AppRunCreateStore(run_write_repo, thread_meta_storage=thread_meta_storage)
if run_write_repo
else None
)
delete_store = AppRunDeleteStore(run_delete_repo) if run_delete_repo else None
# Build storage observer if repositories provided
storage_observer = None
if run_write_repo or thread_meta_storage:
storage_observer = StorageRunObserver(
run_write_repo=run_write_repo,
thread_meta_storage=thread_meta_storage,
)
return RunsFacade(
registry=registry,
planner=planner,
supervisor=supervisor,
stream_service=stream_service,
wait_service=wait_service,
runtime=RunsRuntime(
bridge=stream_bridge,
checkpointer=checkpointer,
store=store,
event_store=run_event_store,
agent_factory_resolver=resolve_agent_factory,
),
observer=storage_observer,
query_store=query_store,
create_store=create_store,
delete_store=delete_store,
)
def build_runs_facade_from_request(request: "Request") -> RunsFacade:
"""
Build RunsFacade from FastAPI request context.
Extracts dependencies from request.app.state.
"""
app_state = request.app.state
return build_runs_facade(
stream_bridge=get_stream_bridge(request),
checkpointer=get_checkpointer(request),
store=getattr(request.app.state, "store", None),
run_read_repo=getattr(app_state, "run_read_repo", None),
run_write_repo=getattr(app_state, "run_write_repo", None),
run_delete_repo=getattr(app_state, "run_delete_repo", None),
thread_meta_storage=getattr(app_state, "thread_meta_storage", None),
run_event_store=getattr(app_state, "run_event_store", None),
)
@@ -0,0 +1,22 @@
"""Input adapters for app-owned runs entrypoints."""
from .request_adapter import (
AdaptedRunRequest,
adapt_create_run_request,
adapt_create_stream_request,
adapt_create_wait_request,
adapt_join_stream_request,
adapt_join_wait_request,
)
from .spec_builder import RunSpecBuilder, UnsupportedRunFeatureError
__all__ = [
"AdaptedRunRequest",
"RunSpecBuilder",
"UnsupportedRunFeatureError",
"adapt_create_run_request",
"adapt_create_stream_request",
"adapt_create_wait_request",
"adapt_join_stream_request",
"adapt_join_wait_request",
]
@@ -0,0 +1,127 @@
"""App-owned request adapter for runs entrypoints."""
from __future__ import annotations
from dataclasses import dataclass
from deerflow.runtime.stream_bridge import JSONValue
from deerflow.runtime.runs.types import RunIntent
type RequestBody = dict[str, JSONValue]
type RequestQuery = dict[str, str]
@dataclass(frozen=True)
class AdaptedRunRequest:
"""
统一的内部请求 DTO.
路由层只负责提取 path/query/body,适配器负责转成稳定内部结构。
"""
intent: RunIntent
thread_id: str | None
run_id: str | None
body: RequestBody
headers: dict[str, str]
query: RequestQuery
@property
def last_event_id(self) -> str | None:
"""Extract Last-Event-ID from headers."""
return self.headers.get("last-event-id") or self.headers.get("Last-Event-ID")
@property
def is_stateless(self) -> bool:
"""Check if this is a stateless request."""
return self.thread_id is None
def adapt_create_run_request(
*,
thread_id: str | None,
body: RequestBody,
headers: dict[str, str] | None = None,
query: RequestQuery | None = None,
) -> AdaptedRunRequest:
"""Adapt POST /threads/{thread_id}/runs or POST /runs."""
return AdaptedRunRequest(
intent="create_background",
thread_id=thread_id,
run_id=None,
body=body,
headers=headers or {},
query=query or {},
)
def adapt_create_stream_request(
*,
thread_id: str | None,
body: RequestBody,
headers: dict[str, str] | None = None,
query: RequestQuery | None = None,
) -> AdaptedRunRequest:
"""Adapt POST /threads/{thread_id}/runs/stream or POST /runs/stream."""
return AdaptedRunRequest(
intent="create_and_stream",
thread_id=thread_id,
run_id=None,
body=body,
headers=headers or {},
query=query or {},
)
def adapt_create_wait_request(
*,
thread_id: str | None,
body: RequestBody,
headers: dict[str, str] | None = None,
query: RequestQuery | None = None,
) -> AdaptedRunRequest:
"""Adapt POST /threads/{thread_id}/runs/wait or POST /runs/wait."""
return AdaptedRunRequest(
intent="create_and_wait",
thread_id=thread_id,
run_id=None,
body=body,
headers=headers or {},
query=query or {},
)
def adapt_join_stream_request(
*,
thread_id: str,
run_id: str,
headers: dict[str, str] | None = None,
query: RequestQuery | None = None,
) -> AdaptedRunRequest:
"""Adapt GET /threads/{thread_id}/runs/{run_id}/stream."""
return AdaptedRunRequest(
intent="join_stream",
thread_id=thread_id,
run_id=run_id,
body={},
headers=headers or {},
query=query or {},
)
def adapt_join_wait_request(
*,
thread_id: str,
run_id: str,
headers: dict[str, str] | None = None,
query: RequestQuery | None = None,
) -> AdaptedRunRequest:
"""Adapt GET /threads/{thread_id}/runs/{run_id}/join."""
return AdaptedRunRequest(
intent="join_wait",
thread_id=thread_id,
run_id=run_id,
body={},
headers=headers or {},
query=query or {},
)
@@ -0,0 +1,254 @@
"""App-owned RunSpec builder."""
from __future__ import annotations
import re
import uuid
from langchain_core.messages import HumanMessage
from deerflow.runtime.runs.types import CheckpointRequest, RunScope, RunSpec
from deerflow.runtime.stream_bridge import JSONValue
from .request_adapter import AdaptedRunRequest
type JSONMapping = dict[str, JSONValue]
type GraphInput = dict[str, object]
type RunnableConfigDict = dict[str, object]
class UnsupportedRunFeatureError(ValueError):
"""Raised when a phase1-unsupported feature is requested."""
pass
class RunSpecBuilder:
"""
Build RunSpec from AdaptedRunRequest.
Phase 1 rules:
1. messages-tuple normalized to messages
2. enqueue not supported
3. rollback not supported
4. after_seconds not supported
5. stream_resumable accepted
6. stateless auto-generates temporary thread
"""
# Phase 1 unsupported features
UNSUPPORTED_MULTITASK_STRATEGIES = {"enqueue"}
UNSUPPORTED_ACTIONS = {"rollback"}
# Default stream modes
DEFAULT_STREAM_MODES = ["values", "messages"]
CONTEXT_CONFIGURABLE_KEYS = frozenset({
"model_name",
"mode",
"thinking_enabled",
"reasoning_effort",
"is_plan_mode",
"subagent_enabled",
"max_concurrent_subagents",
})
DEFAULT_ASSISTANT_ID = "lead_agent"
@staticmethod
def _as_json_mapping(value: JSONValue | None) -> JSONMapping | None:
return value if isinstance(value, dict) else None
@staticmethod
def _as_string_list(value: JSONValue | None) -> list[str] | None:
if not isinstance(value, list):
return None
return [item for item in value if isinstance(item, str)]
def build(self, request: AdaptedRunRequest) -> RunSpec:
"""Build RunSpec from adapted request."""
body = request.body
# Validate phase1 constraints
self._validate_constraints(body)
# Build scope
scope = self._build_scope(request)
# Normalize stream modes
stream_modes = self._normalize_stream_modes(body.get("stream_mode"))
# Build checkpoint request
checkpoint_request = self._build_checkpoint_request(body)
config = self._build_runnable_config(
thread_id=scope.thread_id,
request_config=self._as_json_mapping(body.get("config")),
metadata=self._as_json_mapping(body.get("metadata")),
assistant_id=body.get("assistant_id"),
context=self._as_json_mapping(body.get("context")),
)
return RunSpec(
intent=request.intent,
scope=scope,
assistant_id=body.get("assistant_id") if isinstance(body.get("assistant_id"), str) else None,
input=self._normalize_input(self._as_json_mapping(body.get("input"))),
command=self._as_json_mapping(body.get("command")),
runnable_config=config,
context=self._as_json_mapping(body.get("context")),
metadata=self._as_json_mapping(body.get("metadata")) or {},
stream_modes=stream_modes,
stream_subgraphs=bool(body.get("stream_subgraphs", False)),
stream_resumable=bool(body.get("stream_resumable", False)),
on_disconnect=body.get("on_disconnect", "cancel") if body.get("on_disconnect") in {"cancel", "continue"} else "cancel",
on_completion=body.get("on_completion", "keep") if body.get("on_completion") in {"delete", "keep"} else "keep",
multitask_strategy=body.get("multitask_strategy", "reject") if body.get("multitask_strategy") in {"reject", "interrupt"} else "reject",
interrupt_before="*" if body.get("interrupt_before") == "*" else self._as_string_list(body.get("interrupt_before")),
interrupt_after="*" if body.get("interrupt_after") == "*" else self._as_string_list(body.get("interrupt_after")),
checkpoint_request=checkpoint_request,
follow_up_to_run_id=body.get("follow_up_to_run_id") if isinstance(body.get("follow_up_to_run_id"), str) else None,
webhook=body.get("webhook") if isinstance(body.get("webhook"), str) else None,
feedback_keys=self._as_string_list(body.get("feedback_keys")),
)
def _validate_constraints(self, body: JSONMapping) -> None:
"""Validate phase1 constraints, raise UnsupportedRunFeatureError if violated."""
# Check multitask_strategy
strategy = body.get("multitask_strategy", "reject")
if strategy in self.UNSUPPORTED_MULTITASK_STRATEGIES:
raise UnsupportedRunFeatureError(
f"multitask_strategy '{strategy}' is not supported in phase1. "
f"Supported: reject, interrupt"
)
# Check for rollback action
command = self._as_json_mapping(body.get("command")) or {}
if command.get("action") in self.UNSUPPORTED_ACTIONS:
raise UnsupportedRunFeatureError(
f"action '{command.get('action')}' is not supported in phase1"
)
# Check for after_seconds
if body.get("after_seconds") is not None:
raise UnsupportedRunFeatureError("after_seconds is not supported in phase1")
def _build_scope(self, request: AdaptedRunRequest) -> RunScope:
"""Build RunScope from request."""
if request.is_stateless:
# Stateless: generate temporary thread
return RunScope(
kind="stateless",
thread_id=str(uuid.uuid4()),
temporary=True,
)
else:
assert request.thread_id is not None
return RunScope(
kind="stateful",
thread_id=request.thread_id,
temporary=False,
)
def _normalize_stream_modes(self, stream_mode: JSONValue | None) -> list[str]:
"""Normalize stream_mode to list, convert messages-tuple to messages."""
if stream_mode is None:
return self.DEFAULT_STREAM_MODES.copy()
if isinstance(stream_mode, str):
modes = [stream_mode]
elif isinstance(stream_mode, list):
modes = [mode for mode in stream_mode if isinstance(mode, str)]
else:
return self.DEFAULT_STREAM_MODES.copy()
return ["messages" if m == "messages-tuple" else m for m in modes]
def _build_checkpoint_request(self, body: JSONMapping) -> CheckpointRequest | None:
"""Build CheckpointRequest if checkpoint data is provided."""
checkpoint_id = body.get("checkpoint_id")
checkpoint = self._as_json_mapping(body.get("checkpoint"))
if not isinstance(checkpoint_id, str) and checkpoint is None:
return None
return CheckpointRequest(
checkpoint_id=checkpoint_id if isinstance(checkpoint_id, str) else None,
checkpoint=checkpoint,
)
def _normalize_input(self, raw_input: JSONMapping | None) -> GraphInput | None:
"""Convert HTTP-friendly message dicts into LangChain message objects."""
if raw_input is None:
return None
messages = raw_input.get("messages")
if not messages or not isinstance(messages, list):
return raw_input
converted: list[object] = []
for msg in messages:
if isinstance(msg, dict):
role = msg.get("role", msg.get("type", "user"))
content = msg.get("content", "")
if role in ("user", "human"):
converted.append(HumanMessage(content=content))
else:
converted.append(HumanMessage(content=content))
else:
converted.append(msg)
return {**raw_input, "messages": converted}
def _build_runnable_config(
self,
*,
thread_id: str,
request_config: JSONMapping | None,
metadata: JSONMapping | None,
assistant_id: str | None,
context: JSONMapping | None,
) -> RunnableConfigDict:
"""Build RunnableConfig from request payload and app-side rules."""
config: RunnableConfigDict = {"recursion_limit": 100}
if request_config:
if "context" in request_config:
config["context"] = request_config["context"]
else:
configurable = {"thread_id": thread_id}
raw_configurable = request_config.get("configurable")
if isinstance(raw_configurable, dict):
configurable.update(raw_configurable)
config["configurable"] = configurable
for key, value in request_config.items():
if key not in ("configurable", "context"):
config[key] = value
else:
config["configurable"] = {"thread_id": thread_id}
configurable = config.get("configurable")
if (
assistant_id
and assistant_id != self.DEFAULT_ASSISTANT_ID
and isinstance(configurable, dict)
and "agent_name" not in configurable
):
normalized = assistant_id.strip().lower().replace("_", "-")
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
raise ValueError(
f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization."
)
configurable["agent_name"] = normalized
if metadata:
existing_metadata = config.get("metadata")
if isinstance(existing_metadata, dict):
existing_metadata.update(metadata)
else:
config["metadata"] = dict(metadata)
if context and isinstance(configurable, dict):
for key in self.CONTEXT_CONFIGURABLE_KEYS:
if key in context:
configurable.setdefault(key, context[key])
return config
@@ -0,0 +1,5 @@
"""Compatibility wrapper for the app-owned storage observer."""
from app.infra.storage.runs import StorageRunObserver
__all__ = ["StorageRunObserver"]
@@ -0,0 +1,11 @@
"""App-owned runs store adapters."""
from .create_store import AppRunCreateStore
from .delete_store import AppRunDeleteStore
from .query_store import AppRunQueryStore
__all__ = [
"AppRunCreateStore",
"AppRunDeleteStore",
"AppRunQueryStore",
]
@@ -0,0 +1,38 @@
"""App-owned durable run creation adapter."""
from __future__ import annotations
from deerflow.runtime.runs.store import RunCreateStore
from deerflow.runtime.runs.types import RunRecord
from app.infra.storage import ThreadMetaStorage
from app.infra.storage.runs import RunWriteRepository
class AppRunCreateStore(RunCreateStore):
"""Write the initial durable row for a newly created run."""
def __init__(self, repo: RunWriteRepository, thread_meta_storage: ThreadMetaStorage | None = None) -> None:
self._repo = repo
self._thread_meta_storage = thread_meta_storage
async def create_run(self, record: RunRecord) -> None:
await self._repo.create(
run_id=record.run_id,
thread_id=record.thread_id,
assistant_id=record.assistant_id,
status=str(record.status),
metadata=record.metadata,
follow_up_to_run_id=record.follow_up_to_run_id,
created_at=record.created_at,
)
if self._thread_meta_storage is not None and record.assistant_id:
thread = await self._thread_meta_storage.ensure_thread(
thread_id=record.thread_id,
assistant_id=record.assistant_id,
)
if thread.assistant_id != record.assistant_id:
await self._thread_meta_storage.sync_thread_assistant_id(
thread_id=record.thread_id,
assistant_id=record.assistant_id,
)
@@ -0,0 +1,17 @@
"""App-owned durable run deletion adapter."""
from __future__ import annotations
from deerflow.runtime.runs.store import RunDeleteStore
from app.infra.storage.runs import RunDeleteRepository
class AppRunDeleteStore(RunDeleteStore):
"""Delete durable run rows via the app storage adapter."""
def __init__(self, repo: RunDeleteRepository) -> None:
self._repo = repo
async def delete_run(self, run_id: str) -> bool:
return await self._repo.delete(run_id)
@@ -0,0 +1,47 @@
"""App-owned durable run query adapter."""
from __future__ import annotations
from deerflow.runtime.runs.store import RunQueryStore
from deerflow.runtime.runs.types import RunRecord, RunStatus
from app.infra.storage.runs import RunReadRepository, RunRow
class AppRunQueryStore(RunQueryStore):
"""Map app-side durable run rows into harness RunRecord DTOs."""
def __init__(self, repo: RunReadRepository) -> None:
self._repo = repo
async def get_run(self, run_id: str) -> RunRecord | None:
row = await self._repo.get(run_id)
if row is None:
return None
return self._to_run_record(row)
async def list_runs(
self,
thread_id: str,
*,
limit: int = 100,
) -> list[RunRecord]:
rows = await self._repo.list_by_thread(thread_id, limit=limit)
return [self._to_run_record(row) for row in rows]
def _to_run_record(self, row: RunRow) -> RunRecord:
return RunRecord(
run_id=row["run_id"],
thread_id=row["thread_id"],
assistant_id=row.get("assistant_id"),
status=RunStatus(row.get("status", "pending")),
temporary=False,
multitask_strategy=row.get("multitask_strategy", "reject"),
metadata=row.get("metadata", {}),
follow_up_to_run_id=row.get("follow_up_to_run_id"),
created_at=row.get("created_at", ""),
updated_at=row.get("updated_at", ""),
started_at=row.get("started_at"),
ended_at=row.get("ended_at"),
error=row.get("error"),
)