from __future__ import annotations import asyncio import shutil from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException from app.codex_runs import CodexRunStore from app.errors import normalize_error from app.indexing_jobs import IndexJobStore from app.mcp_client import McpStdioClient from app.rooms_store import RoomsStore from app.routes.admin import router as admin_router from app.routes.base import router as base_router from app.routes.chat import router as chat_router from app.routes.codex import router as codex_router from app.routes.indexing import router as indexing_router from app.routes.mcp import router as mcp_router from app.routes.rooms import router as rooms_router from app.routes.rag import router as rag_router from app.routes.terminal import router as terminal_router from app.routes.user import router as user_router from app.routes.vault import router as vault_router _ROOT = Path(__file__).resolve().parent.parent def _ensure_supabase_asset() -> None: target = _ROOT / "static" / "vendor" / "supabase-js.min.js" if target.exists(): return pkg = _ROOT / "node_modules" / "@supabase" / "supabase-js" candidates = [ pkg / "dist" / "umd" / "supabase.min.js", pkg / "dist" / "umd" / "supabase.js", pkg / "dist" / "supabase.min.js", pkg / "dist" / "supabase.js", ] source = next((p for p in candidates if p.exists()), None) if not source: return target.parent.mkdir(parents=True, exist_ok=True) try: shutil.copyfile(source, target) except Exception: pass @asynccontextmanager async def lifespan(app: FastAPI): app.state.codex_mcp_client = McpStdioClient(["codex", "mcp-server"]) app.state.codex_run_store = CodexRunStore() app.state.index_job_store = IndexJobStore() app.state.rooms_store = RoomsStore() app.state.rooms_connections = {} app.state.rooms_lock = asyncio.Lock() app.state.device_login_attempts = {} app.state.device_login_lock = asyncio.Lock() stop = asyncio.Event() async def _cleanup_device_logins(): # Best-effort pruning to keep memory bounded. while not stop.is_set(): await asyncio.sleep(60) try: now = asyncio.get_running_loop().time() attempts = getattr(app.state, "device_login_attempts", {}) for key, attempt in list(attempts.items()): created = getattr(attempt, "created_at", 0.0) or 0.0 age = now - created done = bool(getattr(attempt, "done", False)) # Keep active attempts for up to 30 minutes; completed for 5 minutes. if age > 1800 or (done and age > 300): attempts.pop(key, None) except Exception: continue async def _cleanup_codex_runs(): while not stop.is_set(): await asyncio.sleep(60) try: await app.state.codex_run_store.prune() except Exception: continue cleanup_task = asyncio.create_task(_cleanup_device_logins()) cleanup_runs_task = asyncio.create_task(_cleanup_codex_runs()) try: yield finally: stop.set() cleanup_task.cancel() cleanup_runs_task.cancel() try: await app.state.codex_mcp_client.close() except Exception: pass def create_app() -> FastAPI: _ensure_supabase_asset() app = FastAPI( lifespan=lifespan, docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", ) @app.exception_handler(StarletteHTTPException) async def _http_exception_handler(_request, exc: StarletteHTTPException): err = normalize_error(exc.detail, status_code=exc.status_code) # Keep `detail` for backward compatibility with existing UI; add structured `error`. return JSONResponse(status_code=exc.status_code, content={"detail": err["message"], "error": err}) @app.exception_handler(RequestValidationError) async def _validation_exception_handler(_request, exc: RequestValidationError): err = normalize_error(exc.errors(), status_code=422, default_code="validation_error") return JSONResponse(status_code=422, content={"detail": err["message"], "error": err}) @app.exception_handler(Exception) async def _unhandled_exception_handler(_request, exc: Exception): err = normalize_error(str(exc), status_code=500, default_code="internal_error") return JSONResponse(status_code=500, content={"detail": err["message"], "error": err}) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) static_dir = _ROOT / "static" app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") app.include_router(base_router) app.include_router(chat_router) app.include_router(codex_router) app.include_router(indexing_router) app.include_router(mcp_router) app.include_router(terminal_router) app.include_router(rooms_router) app.include_router(user_router) app.include_router(admin_router) app.include_router(rag_router) app.include_router(vault_router) return app