"""FastAPI app factory — lifespan, CORS, health, routers.""" import asyncio import contextlib import logging from contextlib import asynccontextmanager import httpx from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from .agents.registry import AgentRegistry, register_builtin_agents from .agents.session import InMemorySessionStore from .api import ( assessment_router, chat_router, lab_router, mentor_router, proctor_router, sandboxes_router, ) from .config import Settings from .llm import create_provider from .sandbox import SandboxManager, UnshareBackend logger = logging.getLogger(__name__) #: Interval between wall-clock/G-2 reaper passes (the manager owns the pass; #: the lifespan owns the loop). 60s against a 900s default timeout → ≤6.7% lag. REAPER_INTERVAL_S = 60.0 def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or Settings() @asynccontextmanager async def lifespan(app: FastAPI): # Shared HTTP client pool (D-017): 10s connect / 300s read for cloud TTFT timeout = httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=10.0) app.state.http_client = httpx.AsyncClient(timeout=timeout) app.state.settings = settings app.state.provider = create_provider(settings, app.state.http_client) app.state.session_store = InMemorySessionStore() app.state.agent_registry = AgentRegistry() register_builtin_agents(app.state.agent_registry) # v0.3 sandbox fabric (REQ-3-001): singleton manager, DI'd via # app.state. Tests may pre-set app.state.sandbox_manager (dependency # override by state injection) to swap the backend; the lifespan then # adopts it instead of constructing the real UnshareBackend one. manager = getattr(app.state, "sandbox_manager", None) if manager is None: manager = SandboxManager(backend=UnshareBackend(), settings=settings) app.state.sandbox_manager = manager await manager.start() # a-1: reap on-disk orphans from a previous process async def _reaper_loop() -> None: # Wall-clock timeout + G-2 workdir-size sweep, one pass per tick. while True: await asyncio.sleep(REAPER_INTERVAL_S) try: await manager.reap_expired() except Exception: logger.exception("sandbox reaper pass failed; retrying next tick") reaper = asyncio.create_task(_reaper_loop()) try: yield finally: reaper.cancel() with contextlib.suppress(asyncio.CancelledError): await reaper # No orphans outlive the process (a-1, shutdown half): destroy # everything live; workdirs stay on disk for snapshot restore. await manager.destroy_all() await app.state.http_client.aclose() app = FastAPI(title="Nextcraft AI Service", version="0.3.0", lifespan=lifespan) # A-008: localhost-only CORS, no credentials app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["Content-Type"], allow_credentials=False, ) @app.get("/health") async def health() -> dict: return { "status": "ok", "provider": settings.provider, "model": settings.model, } app.include_router(chat_router) app.include_router(lab_router) app.include_router(assessment_router) app.include_router(mentor_router) app.include_router(proctor_router) app.include_router(sandboxes_router) return app app = create_app()