135ea21a61
Fixes the v0.3.4 fresh-box failures: silent CLI under bare-word PATH invocation (SEA argv detection), bootstrap dying on venv creation without an actionable hint (poisoned-partial-venv recovery + apt hint + doctor venv-capability probe + preflight), installer falsely 'verifying' a silent binary, and localhost-only server binding (network mode: 0.0.0.0 + wildcard CORS + hostname-derived API URL — remote browsing zero-config). 38 CLI tests, 3 web tests, 409 ai-service tests green; build/typecheck/lint clean. ---ci--- phase: hotfix milestone: v0.4 status: complete type: hotfix requirements: covered: [REQ-4-001, REQ-4-002, REQ-4-003, REQ-4-004, REQ-4-005] partial: [] ---/ci---
210 lines
9.4 KiB
Python
210 lines
9.4 KiB
Python
"""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()
|