diff --git a/stackunderflow/routes/cost.py b/stackunderflow/routes/cost.py index fb55b34..e0431c8 100644 --- a/stackunderflow/routes/cost.py +++ b/stackunderflow/routes/cost.py @@ -16,9 +16,9 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Annotated, Any -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query import stackunderflow.deps as deps from stackunderflow.infra.currency import active_currency_payload @@ -153,7 +153,7 @@ async def get_cost_data(log_path: str | None = None, timezone_offset: int = 0): @router.get("/api/cost-data/by-provider") async def get_cost_by_provider( period: str = "month", - provider: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, ): """Return total cost / message count / session count grouped by provider. diff --git a/stackunderflow/routes/data.py b/stackunderflow/routes/data.py index 911f4fd..eea6ced 100644 --- a/stackunderflow/routes/data.py +++ b/stackunderflow/routes/data.py @@ -5,8 +5,9 @@ import threading import time from pathlib import Path +from typing import Annotated -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import JSONResponse import stackunderflow.deps as deps @@ -113,8 +114,8 @@ async def get_stats(timezone_offset: int = 0): @router.get("/api/dashboard-data") async def get_dashboard_data( timezone_offset: int = 0, - provider: list[str] | None = None, - model: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, + model: Annotated[list[str] | None, Query()] = None, ): """Get optimized data for initial dashboard load. @@ -278,8 +279,8 @@ def _apply_currency_to_stats(stats: dict) -> dict: async def get_messages( limit: int | None = None, timezone_offset: int = 0, - provider: list[str] | None = None, - model: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, + model: Annotated[list[str] | None, Query()] = None, ): """Get messages for the current project. diff --git a/stackunderflow/routes/optimize.py b/stackunderflow/routes/optimize.py index bd5d058..8db917a 100644 --- a/stackunderflow/routes/optimize.py +++ b/stackunderflow/routes/optimize.py @@ -14,7 +14,9 @@ from __future__ import annotations -from fastapi import APIRouter, HTTPException +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query import stackunderflow.deps as deps from stackunderflow.reports.optimize import find_patterns, find_waste @@ -30,8 +32,8 @@ @router.get("/api/optimize") async def get_optimize_report( period: str = "30days", - project: list[str] | None = None, - exclude: list[str] | None = None, + project: Annotated[list[str] | None, Query()] = None, + exclude: Annotated[list[str] | None, Query()] = None, ): """Run waste + structural-pattern detection over *period*. diff --git a/stackunderflow/routes/projects.py b/stackunderflow/routes/projects.py index 9457941..12c6329 100644 --- a/stackunderflow/routes/projects.py +++ b/stackunderflow/routes/projects.py @@ -3,8 +3,9 @@ import os from collections import defaultdict from pathlib import Path +from typing import Annotated -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import JSONResponse import stackunderflow.deps as deps @@ -155,7 +156,7 @@ async def get_projects( sort_by: str = "last_modified", limit: int | None = None, offset: int = 0, - provider: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, ): """ Get all available Claude projects with metadata. diff --git a/stackunderflow/routes/sessions.py b/stackunderflow/routes/sessions.py index dd4e69d..d0f47e1 100644 --- a/stackunderflow/routes/sessions.py +++ b/stackunderflow/routes/sessions.py @@ -3,8 +3,9 @@ import json from datetime import datetime from pathlib import Path +from typing import Annotated -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import JSONResponse import stackunderflow.deps as deps @@ -38,7 +39,7 @@ def _duration_minutes(first: str | None, last: str | None) -> float | None: @router.get("/api/jsonl-files") async def get_jsonl_files( project: str | None = None, - provider: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, ): """Get list of JSONL files for a project with metadata. diff --git a/stackunderflow/server.py b/stackunderflow/server.py index b80dc9e..44f28d9 100644 --- a/stackunderflow/server.py +++ b/stackunderflow/server.py @@ -89,20 +89,40 @@ async def _lifespan(_app: FastAPI): if failed: logger.warning(f"Failed services: {', '.join(failed)}") - # Initialise the session store and run one ingest pass. + # Initialise the session store schema synchronously (cheap), then + # run the ingest in a background thread so HTTP starts serving + # immediately. Without this, the lifespan blocks the bind for the + # full duration of the reindex (~90s on 7 small projects, 30+min + # on a cold 188-project store) — and the "live at..." line that + # already printed from the CLI wrapper is misleading because the + # HTTP server hasn't actually started yet. + import threading from stackunderflow.adapters import registered from stackunderflow.ingest import run_ingest from stackunderflow.store import db, schema try: - store_conn = db.connect(deps.store_path) - schema.apply(store_conn) - counts = run_ingest(store_conn, registered()) - logger.info("Ingest complete: %s", counts) - store_conn.close() - _maybe_clean_cold_cache() + _schema_conn = db.connect(deps.store_path) + schema.apply(_schema_conn) + _schema_conn.close() except Exception as e: - logger.error("Ingest failed at startup: %s", e) + logger.error("Schema apply failed at startup: %s", e) + + def _background_ingest() -> None: + try: + conn = db.connect(deps.store_path) + counts = run_ingest(conn, registered()) + logger.info("Ingest complete: %s", counts) + conn.close() + _maybe_clean_cold_cache() + except Exception as exc: # noqa: BLE001 — top of background thread + logger.error("Background ingest failed: %s", exc) + + threading.Thread( + target=_background_ingest, + name="stackunderflow-ingest", + daemon=True, + ).start() yield