"""Read-only workbench runtime aggregation.""" import logging from contextlib import asynccontextmanager from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from psycopg import AsyncConnection from pydantic import ValidationError from app.auth.project_dependencies import ( ProjectContext, get_project_context, get_project_pg_connection, ) from app.domain.schemas.workbench_runtime import ( WorkbenchAnalysisHistoryPage, WorkbenchPersistedAlert, WorkbenchRuntimeLatestAnalysis, WorkbenchRuntimeSnapshot, ) from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState from app.infra.db.postgresql.analysis import AnalysisRepository from app.infra.db.postgresql.workbench_operations import ( WorkbenchOperationsRepository, ) from app.infra.db.postgresql.task_alerts import TaskAlertRepository from app.services.task_alerts import TaskAlertService from app.domain.domain_errors import DomainError from app.services.workbench_runtime import ( build_workbench_runtime_snapshot, project_analysis_history_item, project_latest_analysis, project_sensor_location_map_payload, sensor_location_ids_for_map_projection, ) router = APIRouter() logger = logging.getLogger(__name__) @asynccontextmanager async def _projection_transaction(conn: AsyncConnection): """Isolate an optional projection so one SQL failure cannot poison others.""" transaction = getattr(conn, "transaction", None) if transaction is None: # Lightweight endpoint test doubles. yield return async with transaction(): yield @router.get( "/workbench/analysis-runs", response_model=WorkbenchAnalysisHistoryPage, ) async def workbench_analysis_runs( limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), conn: AsyncConnection = Depends(get_project_pg_connection), ): """Return a bounded, sanitized analysis history page for the workbench.""" rows = await AnalysisRepository.list_run_summaries( conn, limit=limit + 1, offset=offset, ) has_more = len(rows) > limit items = rows[:limit] return { "items": [project_analysis_history_item(run) for run in items], "limit": limit, "offset": offset, "next_offset": offset + limit if has_more else None, } @router.get( "/workbench/analysis-runs/{run_id}", response_model=WorkbenchRuntimeLatestAnalysis, ) async def workbench_analysis_run( run_id: UUID, conn: AsyncConnection = Depends(get_project_pg_connection), ): """Return one authorized project's analysis run as a typed workbench artifact.""" run = await AnalysisRepository.get_run(conn, run_id) if run is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Analysis run not found", ) results = await AnalysisRepository.list_recent_results(conn, run_id, limit=100) try: referenced_map_payload = await _sensor_location_map_payload(conn, results) except Exception: referenced_map_payload = None logger.warning( "Workbench analysis spatial references are unavailable", exc_info=True, ) return project_latest_analysis( run, results, referenced_map_payload=referenced_map_payload, ) @router.post("/workbench/task-alert-events", response_model=TaskAlertState) async def push_task_alert_event( command: TaskAlertCommand, ctx: ProjectContext = Depends(get_project_context), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: event = TaskAlertEvent( **command.model_dump(), producer_id=f"principal:{ctx.user_id}", ) return await TaskAlertService( TaskAlertRepository(conn, ctx.project_id), ctx ).push(event) except DomainError as exc: raise HTTPException(status_code=exc.status, detail=exc.message) from exc @router.get("/workbench/runtime", response_model=WorkbenchRuntimeSnapshot) async def workbench_runtime( ctx: ProjectContext = Depends(get_project_context), conn: AsyncConnection = Depends(get_project_pg_connection), ): analysis_runs: list[dict] | None = None latest_results: list[dict] | None = None latest_analysis_map_payload: dict | None = None analysis_availability = "not_configured" alert_storage_configured = False alerts: list[dict] | None = None schedule_availability = "not_configured" alert_availability = "not_configured" try: async with _projection_transaction(conn): analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100) if analysis_runs: latest_results = await AnalysisRepository.list_recent_results( conn, analysis_runs[0]["run_id"], limit=100 ) analysis_availability = "ready" if analysis_runs else "not_configured" except Exception: logger.warning("Workbench analysis projection is unavailable", exc_info=True) analysis_runs = None latest_results = None analysis_availability = "degraded" if analysis_runs and latest_results: try: async with _projection_transaction(conn): latest_analysis_map_payload = await _sensor_location_map_payload( conn, latest_results ) except Exception: logger.warning( "Workbench analysis spatial references are unavailable", exc_info=True, ) try: async with _projection_transaction(conn): operations_availability = await WorkbenchOperationsRepository.availability(conn) alert_storage_configured = operations_availability.alerts except Exception: logger.warning("Workbench operations storage probe is unavailable", exc_info=True) schedule_availability = "degraded" alert_availability = "degraded" if alert_storage_configured: try: async with _projection_transaction(conn): alert_documents = await WorkbenchOperationsRepository.list_alerts( conn, str(ctx.project_id) ) alerts, invalid_alerts = _validate_documents( alert_documents, WorkbenchPersistedAlert, "alert" ) alert_availability = "degraded" if invalid_alerts else "ready" except Exception: alerts = None alert_availability = "degraded" logger.warning("Workbench alert projection is unavailable", exc_info=True) return await build_workbench_runtime_snapshot( analysis_runs=analysis_runs, latest_analysis_results=latest_results, latest_analysis_map_payload=latest_analysis_map_payload, alert_storage_configured=alert_storage_configured, persisted_alerts=alerts, scenario_availability=analysis_availability, artifact_availability=analysis_availability, schedule_availability=schedule_availability, alert_availability=alert_availability, projection_degraded=False, ) async def _sensor_location_map_payload( conn: AsyncConnection, results: list[dict] ) -> dict | None: node_ids = sensor_location_ids_for_map_projection(results) if not node_ids: return None map_points = await AnalysisRepository.list_junction_map_points(conn, node_ids) return project_sensor_location_map_payload(node_ids, map_points) def _validate_documents(documents, model, label: str) -> tuple[list[dict], int]: valid: list[dict] = [] rejected = 0 for document in documents: try: valid.append(model.model_validate(document).model_dump()) except ValidationError: rejected += 1 logger.warning("Workbench %s document was rejected", label) return valid, rejected