diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 2bc2332f5..80de83bcc 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -145,9 +145,14 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan handler.""" - # Load config and check necessary environment variables at startup + # Load config and check necessary environment variables at startup. + # Phase 2: explicit-passing primitive. app.state.config is the single source + # of truth for FastAPI request handlers via Depends(get_config). AppConfig.init() + # is still invoked for backward compatibility with legacy AppConfig.current() + # callers that haven't been migrated yet. try: - AppConfig.current() + app.state.config = AppConfig.from_file() + AppConfig.init(app.state.config) logger.info("Configuration loaded successfully") except Exception as e: error_msg = f"Failed to load configuration during gateway startup: {e}" diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index 54b92dc3a..ad6f950c5 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING from fastapi import FastAPI, HTTPException, Request +from deerflow.config.app_config import AppConfig from deerflow.runtime import RunContext, RunManager if TYPE_CHECKING: @@ -22,6 +23,21 @@ if TYPE_CHECKING: from deerflow.persistence.thread_meta.base import ThreadMetaStore +def get_config(request: Request) -> AppConfig: + """FastAPI dependency returning the app-scoped ``AppConfig``. + + Prefer this over ``AppConfig.current()`` in new code. Reads from + ``request.app.state.config`` which is set at startup (``app.py`` + lifespan) and swapped on config reload (``routers/mcp.py``, + ``routers/skills.py``). Phase 2 of the config refactor migrates all + router-level ``AppConfig.current()`` callers to this dependency. + """ + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="Configuration not available") + return cfg + + @asynccontextmanager async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: """Bootstrap and tear down all LangGraph runtime singletons. diff --git a/backend/tests/test_gateway_deps_config.py b/backend/tests/test_gateway_deps_config.py new file mode 100644 index 000000000..a3a1d1007 --- /dev/null +++ b/backend/tests/test_gateway_deps_config.py @@ -0,0 +1,56 @@ +"""Tests for the FastAPI get_config dependency. + +Phase 2 step 1: introduces the new explicit-config primitive that +resolves ``AppConfig`` from ``request.app.state.config``. This coexists +with the existing ``AppConfig.current()`` process-global during the +migration; it becomes the sole mechanism after Phase 2 task P2-10. +""" + +from __future__ import annotations + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from app.gateway.deps import get_config +from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig + + +def test_get_config_returns_app_state_config(): + """get_config returns the AppConfig stored on app.state.config.""" + app = FastAPI() + cfg = AppConfig(sandbox=SandboxConfig(use="test")) + app.state.config = cfg + + @app.get("/probe") + def probe(c: AppConfig = Depends(get_config)): + # Identity check: FastAPI must hand us the exact object from app.state + return {"same_identity": c is cfg, "log_level": c.log_level} + + client = TestClient(app) + response = client.get("/probe") + + assert response.status_code == 200 + body = response.json() + assert body["same_identity"] is True + assert body["log_level"] == "info" + + +def test_get_config_reads_updated_app_state(): + """When app.state.config is swapped (config reload), get_config sees the new value.""" + app = FastAPI() + original = AppConfig(sandbox=SandboxConfig(use="test"), log_level="info") + replacement = original.model_copy(update={"log_level": "debug"}) + + app.state.config = original + + @app.get("/log-level") + def log_level(c: AppConfig = Depends(get_config)): + return {"level": c.log_level} + + client = TestClient(app) + assert client.get("/log-level").json() == {"level": "info"} + + # Simulate config reload (PUT /mcp/config, etc.) + app.state.config = replacement + assert client.get("/log-level").json() == {"level": "debug"}