Skip to content

Commit d7227c2

Browse files
authored
feat: React UI v2.0 — Phase 0 backend foundation + Phase 1 scaffold (#17)
Phase 0 (10 tasks): /api/v1/* router move, 5 new endpoints (sessions/{id}/full bootstrap, config/ui-hints, apps/{app}/ui-views, sessions/recent/events SSE, StaticFiles + SPA fallback), env-driven CORS, sweep-update of existing tests. Phase 1 (2 of 10): Vite+React 19+TS scaffold, Tailwind v4 + design tokens. 1334 tests / 88.76% coverage. Spec + plan preserved in agent artifacts. Closes #17.
1 parent 2355202 commit d7227c2

40 files changed

Lines changed: 9474 additions & 189 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,9 @@ scripts/integration_scenarios.py
8787
# Coverage / CI artefacts
8888
coverage.xml
8989
junit.xml
90+
91+
# React UI build artifacts (Phase 1+)
92+
web/node_modules
93+
web/dist
94+
web/playwright-report
95+
web/test-results

dist/app.py

Lines changed: 539 additions & 33 deletions
Large diffs are not rendered by default.

dist/apps/code-review.py

Lines changed: 539 additions & 33 deletions
Large diffs are not rendered by default.

dist/apps/incident-management.py

Lines changed: 539 additions & 33 deletions
Large diffs are not rendered by default.

scripts/build_single_file.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,29 @@
187187
# api.py. Bundled after api.py so register_dedup_routes can be
188188
# invoked against the FastAPI app at the bottom of the bundle.
189189
(RUNTIME_ROOT, "api_dedup.py"),
190+
# Bootstrap bundle endpoint — single round-trip the React UI hits
191+
# on session open. Side-car module mounted on api_v1 inside
192+
# ``api.build_app``; bundled after api.py for the same reason as
193+
# api_dedup.py.
194+
(RUNTIME_ROOT, "api_session_full.py"),
195+
# UI hints endpoint — read once at React boot for the topbar brand
196+
# block, env switcher list, and approval-rationale dropdown. Same
197+
# side-car pattern as api_session_full.py.
198+
(RUNTIME_ROOT, "api_ui_hints.py"),
199+
# App-overlay UI views endpoint — Approach C extensibility surface
200+
# the framework UI's Selected-detail panel queries to render
201+
# "App-specific views →" links. Same side-car pattern.
202+
(RUNTIME_ROOT, "api_apps_overlay.py"),
203+
# Cross-session SSE endpoint — drives the React UI's "Other
204+
# Sessions" monitor with session-level lifecycle events
205+
# (session.created / session.status_changed / session.agent_running)
206+
# across ALL sessions, ordered by global seq. Same side-car pattern.
207+
(RUNTIME_ROOT, "api_recent_events.py"),
208+
# React SPA static-file mount + SPA fallback. Mounts onto the
209+
# FastAPI root app (not the api_v1 router) via ``api_static.mount``,
210+
# which build_app invokes as the LAST route-registration step.
211+
# Bundled after api.py for the same reason as the side-cars above.
212+
(RUNTIME_ROOT, "api_static.py"),
190213
]
191214

192215
# Example app modules — flattened *after* the runtime modules in the

src/runtime/api.py

Lines changed: 127 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,17 @@
2828
from pathlib import Path
2929
from 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
3232
from fastapi.middleware.cors import CORSMiddleware
33-
from fastapi.responses import JSONResponse, StreamingResponse
33+
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
3434
from pydantic import BaseModel, Field
3535
from 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
3742
from 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

src/runtime/api_apps_overlay.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""App-overlay UI views discovery — Approach C extensibility.
2+
3+
GET /api/v1/apps/{app}/ui-views returns the app-registered overlay views.
4+
The framework UI's Selected-detail panel renders matching views as
5+
"App-specific views →" links. v2.0 ships with one app per deployment;
6+
multi-app per-app filtering is v2.1 scope (the path's app_name is
7+
currently informational).
8+
9+
Registered only via :func:`runtime.api.build_app` (requires
10+
``app.state.cfg``); not suitable for lightweight test fixtures that
11+
construct a bare ``FastAPI()`` app — use ``build_app(cfg)`` for tests.
12+
"""
13+
from __future__ import annotations
14+
from fastapi import APIRouter, Request
15+
16+
17+
def add_apps_overlay_routes(api_v1: APIRouter) -> None:
18+
"""Mount the /apps/{app}/ui-views handler on the api_v1 router.
19+
20+
Module-qualified name so the bundler can flatten alongside sibling
21+
``api_*`` side-cars without ``add_routes`` collisions. See
22+
``runtime.api_session_full.add_session_full_routes``.
23+
"""
24+
25+
@api_v1.get("/apps/{app_name}/ui-views")
26+
async def list_app_views(app_name: str, request: Request) -> list[dict]:
27+
# app_name is informational for now; v2.0 has one app per deploy.
28+
cfg = request.app.state.cfg
29+
return [v.model_dump() for v in cfg.ui.app_views]

0 commit comments

Comments
 (0)