2828from pathlib import Path
2929from typing import AsyncIterator , Literal
3030
31- from fastapi import FastAPI , HTTPException , Request , Response , WebSocket , WebSocketDisconnect
31+ from fastapi import APIRouter , FastAPI , HTTPException , Request , Response , WebSocket , WebSocketDisconnect
3232from fastapi .middleware .cors import CORSMiddleware
33- from fastapi .responses import JSONResponse , StreamingResponse
33+ from fastapi .responses import JSONResponse , RedirectResponse , StreamingResponse
3434from pydantic import BaseModel , Field
3535from starlette .exceptions import HTTPException as StarletteHTTPException
3636
37+ from runtime .api_apps_overlay import add_apps_overlay_routes
38+ from runtime .api_recent_events import add_recent_events_routes
39+ from runtime .api_session_full import add_session_full_routes
40+ from runtime .api_static import mount_static_assets
41+ from runtime .api_ui_hints import add_ui_hints_routes
3742from runtime .config import AppConfig , load_config
3843
3944_log = logging .getLogger ("runtime.api" )
@@ -258,6 +263,9 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
258263 orch = svc .submit_and_wait (svc ._ensure_orchestrator (), timeout = 30.0 )
259264 app .state .service = svc
260265 app .state .orchestrator = orch
266+ # Surface the validated AppConfig so app.state.cfg-readers
267+ # (e.g. /api/v1/config/ui-hints) don't have to re-load YAML.
268+ app .state .cfg = cfg
261269 # Environments roster is app-specific (incident-management has
262270 # production/staging/dev/local; code-review doesn't expose one).
263271 # Read it from the YAML's top-level ``environments:`` block;
@@ -345,16 +353,28 @@ def build_app(cfg: AppConfig) -> FastAPI:
345353 title = "ASR — Agent Orchestrator" ,
346354 lifespan = _make_lifespan (cfg ),
347355 )
348-
349- # CORS: configure once with the AppConfig-supplied origins so the
350- # React dev server (Vite at :5173, CRA/Next at :3000 by default) can
351- # call every endpoint, SSE included. Production deployments lock
352- # the origin list down via YAML — same shape, narrower allow-list.
356+ # All framework routes (except /health) live under /api/v1 so the
357+ # React client can stably target a versioned surface; /health stays
358+ # at root for monitor / load-balancer health-check conventions.
359+ api_v1 = APIRouter (prefix = "/api/v1" )
360+
361+ # CORS: env-driven so the React dev server (Vite at :5173) can call
362+ # every endpoint, SSE included. Override via ``ASR_CORS_ORIGINS``
363+ # (comma-separated) — production deployments lock the origin list
364+ # down by setting the env var to the narrower allow-list.
365+ # ``allow_credentials=False`` matches the bearer-token auth pattern
366+ # (no cookies); methods are explicit so OPTIONS preflights are
367+ # handled the same way for every route.
368+ _cors_origins_raw = os .environ .get (
369+ "ASR_CORS_ORIGINS" ,
370+ "http://localhost:5173,http://127.0.0.1:5173" , # Vite dev defaults
371+ )
372+ _cors_origins = [o .strip () for o in _cors_origins_raw .split ("," ) if o .strip ()]
353373 fastapi_app .add_middleware (
354374 CORSMiddleware ,
355- allow_origins = cfg . api . cors_origins ,
356- allow_credentials = cfg . api . cors_allow_credentials ,
357- allow_methods = ["* " ],
375+ allow_origins = _cors_origins ,
376+ allow_credentials = False ,
377+ allow_methods = ["GET" , "POST" , "PUT" , "DELETE" , "OPTIONS " ],
358378 allow_headers = ["*" ],
359379 )
360380
@@ -389,27 +409,27 @@ async def _http_exception_handler(
389409 async def health ():
390410 return {"status" : "ok" }
391411
392- @fastapi_app .get ("/agents" )
412+ @api_v1 .get ("/agents" )
393413 async def agents ():
394414 return fastapi_app .state .orchestrator .list_agents ()
395415
396- @fastapi_app .get ("/tools" )
416+ @api_v1 .get ("/tools" )
397417 async def tools ():
398418 return fastapi_app .state .orchestrator .list_tools ()
399419
400- @fastapi_app .get ("/incidents" )
420+ @api_v1 .get ("/incidents" )
401421 async def incidents (limit : int = 20 ):
402422 return fastapi_app .state .orchestrator .list_recent_incidents (limit = limit )
403423
404- @fastapi_app .get ("/incidents/{incident_id}" )
424+ @api_v1 .get ("/incidents/{incident_id}" )
405425 async def incident (incident_id : str ):
406426 return fastapi_app .state .orchestrator .get_incident (incident_id )
407427
408- @fastapi_app .delete ("/incidents/{incident_id}" )
428+ @api_v1 .delete ("/incidents/{incident_id}" )
409429 async def delete_incident (incident_id : str ):
410430 return fastapi_app .state .orchestrator .delete_incident (incident_id )
411431
412- @fastapi_app .post ("/investigate" )
432+ @api_v1 .post ("/investigate" )
413433 async def investigate (req : InvestigateRequest , request : Request ) -> InvestigateResponse :
414434 """Legacy alias for ``POST /sessions`` — kept for back-compat.
415435
@@ -443,11 +463,11 @@ async def investigate(req: InvestigateRequest, request: Request) -> InvestigateR
443463 raise
444464 return InvestigateResponse (incident_id = sid )
445465
446- @fastapi_app .get ("/environments" )
466+ @api_v1 .get ("/environments" )
447467 async def environments ():
448468 return fastapi_app .state .environments
449469
450- @fastapi_app .post ("/investigate/stream" )
470+ @api_v1 .post ("/investigate/stream" )
451471 async def investigate_stream (req : InvestigateRequest ) -> StreamingResponse :
452472 orch = fastapi_app .state .orchestrator
453473
@@ -460,7 +480,7 @@ async def _events():
460480
461481 return StreamingResponse (_events (), media_type = _SSE_MEDIA_TYPE )
462482
463- @fastapi_app .post ("/incidents/{incident_id}/resume" )
483+ @api_v1 .post ("/incidents/{incident_id}/resume" )
464484 async def resume_incident (incident_id : str , req : ResumeRequest ) -> StreamingResponse :
465485 orch = fastapi_app .state .orchestrator
466486 decision : dict = {"action" : req .decision }
@@ -492,7 +512,7 @@ async def _events():
492512 # Multi-session endpoints
493513 # ------------------------------------------------------------------
494514
495- @fastapi_app .post (
515+ @api_v1 .post (
496516 "/sessions" ,
497517 status_code = 201 ,
498518 )
@@ -523,7 +543,7 @@ class is matched by name so this handler does not depend on a
523543 raise
524544 return SessionStartResponse (session_id = sid )
525545
526- @fastapi_app .get ("/sessions" )
546+ @api_v1 .get ("/sessions" )
527547 async def list_sessions_endpoint (request : Request ) -> list [SessionStatus ]:
528548 """Snapshot of in-flight sessions (running / awaiting_input / error)."""
529549 svc = request .app .state .service
@@ -533,7 +553,7 @@ async def list_sessions_endpoint(request: Request) -> list[SessionStatus]:
533553 # HITL approval endpoints (risk-rated tool gateway)
534554 # ------------------------------------------------------------------
535555
536- @fastapi_app .get ("/sessions/{session_id}/approvals" )
556+ @api_v1 .get ("/sessions/{session_id}/approvals" )
537557 async def list_pending_approvals (
538558 session_id : str , request : Request
539559 ) -> list [PendingApproval ]:
@@ -575,7 +595,7 @@ async def list_pending_approvals(
575595 ))
576596 return out
577597
578- @fastapi_app .post (
598+ @api_v1 .post (
579599 "/sessions/{session_id}/approvals/{tool_call_id}" ,
580600 status_code = 200 ,
581601 )
@@ -661,7 +681,7 @@ async def _resume() -> None:
661681 "rationale" : body .rationale ,
662682 }
663683
664- @fastapi_app .delete ("/sessions/{session_id}" , status_code = 204 )
684+ @api_v1 .delete ("/sessions/{session_id}" , status_code = 204 )
665685 async def stop_session_endpoint (
666686 session_id : str , request : Request
667687 ) -> Response :
@@ -692,7 +712,7 @@ async def stop_session_endpoint(
692712 # T2: generic /sessions/* endpoints (React-ready, non-legacy).
693713 # ==================================================================
694714
695- @fastapi_app .get ("/sessions/recent" )
715+ @api_v1 .get ("/sessions/recent" )
696716 async def recent_sessions (request : Request , limit : int = 20 ) -> list [dict ]:
697717 """List recent sessions of ANY status — closed + active.
698718
@@ -702,7 +722,7 @@ async def recent_sessions(request: Request, limit: int = 20) -> list[dict]:
702722 orch = request .app .state .orchestrator
703723 return orch .list_recent_sessions (limit = limit )
704724
705- @fastapi_app .get ("/sessions/{session_id}" )
725+ @api_v1 .get ("/sessions/{session_id}" )
706726 async def get_session_detail (session_id : str , request : Request ) -> dict :
707727 """Full session detail. Generic equivalent of the legacy
708728 domain-flavoured detail route. 404 when the id is unknown."""
@@ -714,7 +734,7 @@ async def get_session_detail(session_id: str, request: Request) -> dict:
714734 status_code = 404 , detail = _SESSION_NOT_FOUND_DETAIL ,
715735 ) from e
716736
717- @fastapi_app .post ("/sessions/{session_id}/resume" )
737+ @api_v1 .post ("/sessions/{session_id}/resume" )
718738 async def resume_session_sse (
719739 session_id : str , req : ResumeRequest , request : Request ,
720740 ) -> StreamingResponse :
@@ -748,7 +768,7 @@ async def _events():
748768
749769 return StreamingResponse (_events (), media_type = _SSE_MEDIA_TYPE )
750770
751- @fastapi_app .post ("/sessions/{session_id}/retry" )
771+ @api_v1 .post ("/sessions/{session_id}/retry" )
752772 async def retry_session_sse (
753773 session_id : str , request : Request ,
754774 ) -> StreamingResponse :
@@ -771,7 +791,7 @@ async def _events():
771791
772792 return StreamingResponse (_events (), media_type = _SSE_MEDIA_TYPE )
773793
774- @fastapi_app .get ("/sessions/{session_id}/retry/preview" )
794+ @api_v1 .get ("/sessions/{session_id}/retry/preview" )
775795 async def preview_retry (
776796 session_id : str , request : Request ,
777797 ) -> RetryDecisionPreview :
@@ -790,7 +810,7 @@ async def preview_retry(
790810 reason = str (decision .reason ),
791811 )
792812
793- @fastapi_app .get ("/sessions/{session_id}/lessons" )
813+ @api_v1 .get ("/sessions/{session_id}/lessons" )
794814 async def list_session_lessons (
795815 session_id : str , request : Request ,
796816 ) -> list [LessonResponse ]:
@@ -835,7 +855,7 @@ async def list_session_lessons(
835855 # T3: SSE event stream + T4: WebSocket fallback.
836856 # ==================================================================
837857
838- @fastapi_app .get ("/sessions/{session_id}/events" )
858+ @api_v1 .get ("/sessions/{session_id}/events" )
839859 async def sse_events (
840860 session_id : str , request : Request , since : int = 0 ,
841861 ) -> StreamingResponse :
@@ -889,7 +909,7 @@ async def _stream():
889909
890910 return StreamingResponse (_stream (), media_type = _SSE_MEDIA_TYPE )
891911
892- @fastapi_app .websocket ("/ws/sessions/{session_id}/events" )
912+ @api_v1 .websocket ("/ws/sessions/{session_id}/events" )
893913 async def ws_events (websocket : WebSocket , session_id : str ) -> None :
894914 """WebSocket fallback for the SSE event stream. Same payload
895915 shape (:class:`EventEnvelope`); clients that prefer WS over
@@ -936,6 +956,81 @@ async def ws_events(websocket: WebSocket, session_id: str) -> None:
936956 except Exception : # noqa: BLE001
937957 pass
938958
959+ # ==================================================================
960+ # Bootstrap bundle: GET /api/v1/sessions/{id}/full
961+ # Single round-trip the React UI calls on session open. Module
962+ # lives next door so this file stays focused on routing wiring.
963+ # ==================================================================
964+ add_session_full_routes (api_v1 )
965+
966+ # ==================================================================
967+ # UI hints: GET /api/v1/config/ui-hints
968+ # Drives the React shell's brand block, environment switcher list,
969+ # and approval-rationale dropdown. Read once at app boot.
970+ # ==================================================================
971+ add_ui_hints_routes (api_v1 )
972+
973+ # ==================================================================
974+ # App-overlay UI views: GET /api/v1/apps/{app}/ui-views
975+ # Approach C extensibility — apps register bespoke deep-dive pages
976+ # (e.g. "Deploy diff") that the framework UI's Selected-detail
977+ # panel lists as "App-specific views →" links.
978+ # ==================================================================
979+ add_apps_overlay_routes (api_v1 )
980+
981+ # ==================================================================
982+ # Cross-session SSE: GET /api/v1/sessions/recent/events
983+ # Drives the React UI's "Other Sessions" monitor — session.created /
984+ # session.status_changed / session.agent_running events across ALL
985+ # sessions, ordered by global seq.
986+ # ==================================================================
987+ add_recent_events_routes (api_v1 )
988+
989+ # Legacy /incidents/* and /investigate redirects to /api/v1/* equivalents.
990+ # 308 preserves method + body so legacy POSTs (e.g. /incidents/{id}/resume)
991+ # keep working transparently. Removed in v2.1.
992+ @fastapi_app .api_route (
993+ "/incidents" , methods = ["GET" , "POST" ], include_in_schema = False ,
994+ )
995+ async def _legacy_incidents_collection () -> RedirectResponse :
996+ return RedirectResponse (url = "/api/v1/sessions" , status_code = 308 )
997+
998+ @fastapi_app .api_route (
999+ "/incidents/{path:path}" ,
1000+ methods = ["GET" , "POST" , "DELETE" , "PUT" ],
1001+ include_in_schema = False ,
1002+ )
1003+ async def _legacy_incidents_detail (path : str ) -> RedirectResponse :
1004+ return RedirectResponse (url = f"/api/v1/sessions/{ path } " , status_code = 308 )
1005+
1006+ @fastapi_app .api_route (
1007+ "/investigate" , methods = ["POST" ], include_in_schema = False ,
1008+ )
1009+ async def _legacy_investigate () -> RedirectResponse :
1010+ return RedirectResponse (url = "/api/v1/investigate" , status_code = 308 )
1011+
1012+ @fastapi_app .api_route (
1013+ "/investigate/{path:path}" ,
1014+ methods = ["POST" ],
1015+ include_in_schema = False ,
1016+ )
1017+ async def _legacy_investigate_subpath (path : str ) -> RedirectResponse :
1018+ return RedirectResponse (
1019+ url = f"/api/v1/investigate/{ path } " , status_code = 308 ,
1020+ )
1021+
1022+ # Mount the versioned router. /health stays at root (registered
1023+ # directly on ``fastapi_app`` above); everything else lives under
1024+ # /api/v1.
1025+ fastapi_app .include_router (api_v1 )
1026+ # ==================================================================
1027+ # React UI bundle: StaticFiles mount at / + SPA fallback.
1028+ # MUST be the last route-registration step in build_app — the
1029+ # catch-all ``GET /{full_path:path}`` would otherwise shadow every
1030+ # API route and legacy redirect. The fallback excludes /api/, /health,
1031+ # and /docs so unknown API paths still return structured JSON 404s.
1032+ # ==================================================================
1033+ mount_static_assets (fastapi_app )
9391034 return fastapi_app
9401035
9411036
0 commit comments