"""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, defense_router, lab_router, mentor_router, proctor_router, sandboxes_router, telemetry_router, variants_router, ) from .config import Settings from .grading.engine import GradingEngine from .grading.store import SQLiteGradeStore from .llm import create_provider from .sandbox import SandboxManager, UnshareBackend from .telemetry.ingest import TraceIntegrityMap from .telemetry.store import SQLiteTraceStore from .variants.generator import VariantGenerator from .variants.store import SQLiteVariantStore from .voice.defense_store import SQLiteDefenseStore from .voice.factory import voice_provider_from_settings 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 # State-injection override (same pattern as the stores): tests may # pre-set app.state.provider with a scripted mock; only construct the # configured provider when none is present. if getattr(app.state, "provider", None) is None: 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 # Telemetry persistence (REQ-3-003, D-027): TraceStore wired through # app.state. Tests may pre-set app.state.trace_store (state-injection # override, same pattern as sandbox_manager) — the lifespan adopts it. trace_store = getattr(app.state, "trace_store", None) if trace_store is None: settings.db_path.parent.mkdir(parents=True, exist_ok=True) trace_store = SQLiteTraceStore(db_path=settings.db_path) app.state.trace_store = trace_store # Trace-integrity flags (G-3 INCOMPLETE_FLOODED): process-local map is # intentional (D-019 registry precedent); the lifespan owns it so the # grader and the ingest endpoint share one instance. if getattr(app.state, "trace_integrity", None) is None: app.state.trace_integrity = TraceIntegrityMap() # Variant generation (REQ-3-005): VariantStore from the same # SQLite file as traces/grades (D-027), one VariantGenerator singleton # wired through app.state — the generator receives store + provider # via constructor DI and knows nothing of FastAPI (api/ composes it, # same pattern as GradingEngine). Tests may pre-set # app.state.variant_store / app.state.variant_generator (the same # state-injection override); the lifespan adopts a pre-set store but # NEVER rebuilds a pre-set generator (its provider binding is part # of the test fixture). # ORDER NOTE: built BEFORE the grading engine — the engine takes the # variant store (Phase 4 MH#4: variant anchors ship to the grader # prompt; variant_seed stamped on graded records). variant_store = getattr(app.state, "variant_store", None) if variant_store is None: settings.db_path.parent.mkdir(parents=True, exist_ok=True) variant_store = SQLiteVariantStore(db_path=settings.db_path) app.state.variant_store = variant_store if getattr(app.state, "variant_generator", None) is None: app.state.variant_generator = VariantGenerator( variant_store, app.state.provider, model=settings.model, ) # Oral defense (REQ-3-006): DefenseStore (same SQLite file) + the # mock-first voice provider (D-030) + the seventh Examiner agent. # Tests may pre-set app.state.defense_store / voice_provider / # examiner_agent (state-injection override; never rebuilt if pre-set). defense_store = getattr(app.state, "defense_store", None) if defense_store is None: defense_store = SQLiteDefenseStore(db_path=settings.db_path) app.state.defense_store = defense_store if getattr(app.state, "voice_provider", None) is None: app.state.voice_provider = voice_provider_from_settings(settings) if getattr(app.state, "examiner_agent", None) is None: from .agents.examiner import ExaminerAgent app.state.examiner_agent = ExaminerAgent(app.state.provider, settings) # Grading persistence + engine (REQ-3-004): GradeStore from the same # SQLite file as traces (D-027), one GradingEngine singleton wired # through app.state — the engine receives its stores via constructor # DI and knows nothing of FastAPI (api/ owns composition). Tests may # pre-set app.state.grade_store / app.state.grading_engine (the same # state-injection override as sandbox_manager/trace_store) to swap # either; the lifespan adopts a pre-set store but NEVER rebuilds a # pre-set engine (its provider binding is part of the test fixture). grade_store = getattr(app.state, "grade_store", None) if grade_store is None: grade_store = SQLiteGradeStore(db_path=settings.db_path) app.state.grade_store = grade_store if getattr(app.state, "grading_engine", None) is None: app.state.grading_engine = GradingEngine( trace_store, grade_store, app.state.trace_integrity, app.state.provider, model=settings.model, variant_store=variant_store, # MH#4: anchors + seed (D-029) ) 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() trace_store.close() grade_store.close() variant_store.close() defense_store.close() await app.state.http_client.aclose() app = FastAPI(title="Nextcraft AI Service", version="0.3.0", lifespan=lifespan) # A-008 + D-038: no-credentials CORS. Default '*' admits remote-browser # origins in network mode (safe only because allow_credentials stays # False — never enable credentials with a wildcard). AI_CORS_ORIGINS # restricts to an explicit list. PUT is CONTRACT, not trivia: the learner # build surface writes workspace files with PUT (engine-client writeFile) # — v0.3 initially shipped without it and every cross-origin Save failed # preflight (caught in P7 review; tests/api/test_cors.py pins the policy). app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origin_list, allow_methods=["GET", "POST", "PUT", "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) app.include_router(telemetry_router) app.include_router(variants_router) app.include_router(defense_router) return app app = create_app()