"""FastAPI entry point for nvi. Endpoints: GET /healthz — liveness probe POST /ask — submit a question, returns run_id GET /runs/{run_id}/stream — SSE stream of run events GET /runs/{run_id} — final state snapshot GET /runs/{run_id}/log — JSONL transcript (every published event) """ from __future__ import annotations import asyncio import logging import uuid from contextlib import asynccontextmanager from datetime import datetime, timezone from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel from api.runtime import events from api.runtime.runner import run as run_graph logger = logging.getLogger("nvi") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") # In-memory run store. Each entry holds final-state + lifecycle metadata; the # event queue is owned by api.runtime.events. runs: dict[str, dict] = {} RUN_TTL_SECONDS = 3600 RUN_CLEANUP_INTERVAL = 300 async def _cleanup_runs(): while True: await asyncio.sleep(RUN_CLEANUP_INTERVAL) now = datetime.now(timezone.utc).timestamp() expired = [ rid for rid, r in runs.items() if r["status"] in ("completed", "error") and now - r.get("created_at", now) > RUN_TTL_SECONDS ] for rid in expired: del runs[rid] @asynccontextmanager async def lifespan(_app: FastAPI): task = asyncio.create_task(_cleanup_runs()) yield task.cancel() app = FastAPI(title="nvi", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.get("/healthz") async def healthz(): return {"status": "ok", "runs_in_memory": len(runs)} class AskRequest(BaseModel): question: str @app.post("/ask") async def ask(req: AskRequest): run_id = uuid.uuid4().hex[:8] now = datetime.now(timezone.utc) runs[run_id] = { "status": "running", "question": req.question, "created_at": now.timestamp(), } events.open_run(run_id) async def _drive(): try: final = await run_graph(run_id, req.question) runs[run_id] = { **runs[run_id], "status": "error" if final.get("error") else "completed", "plan_rationale": final.get("plan_rationale"), "plan_steps": final.get("plan_steps", []), "findings": final.get("findings", []), "answer": final.get("answer", ""), "error": final.get("error"), } except Exception as e: logger.exception("run %s failed at top level", run_id) runs[run_id] = {**runs[run_id], "status": "error", "error": str(e)} asyncio.create_task(_drive()) logger.info("ask_received run_id=%s q=%r", run_id, req.question) return {"run_id": run_id, "status": "running"} @app.get("/runs/{run_id}") async def get_run(run_id: str): if run_id not in runs: raise HTTPException(404, detail=f"Run {run_id} not found") return runs[run_id] @app.get("/runs/{run_id}/stream") async def stream_run(run_id: str): if run_id not in runs: raise HTTPException(404, detail=f"Run {run_id} not found") return StreamingResponse(events.stream(run_id), media_type="text/event-stream") @app.get("/runs/{run_id}/log") async def get_run_log(run_id: str): """Serve the JSONL transcript written by `events.publish`. Available even for in-flight runs (the file is flushed after every write).""" path = events.log_path(run_id) if not path.exists(): raise HTTPException(404, detail=f"No log file for run {run_id}") return FileResponse(path, media_type="application/x-ndjson", filename=path.name)