feat(P02): SLICE-10 P2 integration — SPA fallback, router mount, e2e tests

TASK-10-01 (G-041 binding): server/__main__.py — SpaStaticFiles custom
  subclass that returns index.html for non-file paths (NOT a catch-all
  route). G-041 OVERRIDES the plan's catch-all approach — a catch-all
  before StaticFiles would shadow asset serving. The subclass serves
  assets normally + falls back to index.html for client-side routes.
  Mounted at / AFTER all API routes so they take precedence.
TASK-10-02: server/__main__.py — mount operator API routers (cohort,
  mastery, failure_patterns, credentials) before SPA fallback. Start
  NightlyScheduler in lifespan (03:00 CT), cancel on shutdown.
TASK-10-03: tests/test_p2_aggregation_integration.py — e2e aggregation→
  endpoint (12 learners non-suppressed, 5 suppressed), nightly reconcile
  refreshes last_updated, freshness ≤ 24h. G-038 differencing-attack at
  API layer. Requires Postgres (skips if no DSN).
TASK-10-04: tests/test_p2_spa_fallback.py — 9 assertions: / → voice UI,
  /operator/* → index.html, API routes → JSON, /assets/* → StaticFiles.
  R-DASH-03/05 verified. Deviation: GET to POST-only /pipecat/webrtc
  falls through to SPA fallback (not 405) — acceptable, the POST route
  is the real entrypoint; a GET is a client-side navigation attempt.
TASK-10-05: .ciagent/VERIFY-P2.md — REQ-ID → test mapping for all 4 P2
  REQ-IDs + G-038 + G-041 + R-DASH-05.

---ci---
project: praxis
phase: 2
milestone: v0.4
status: execute
persona: backend-engineer
task: 10-01..10-05
requirements:
  covered: [REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02]
---/ci---
This commit is contained in:
Praxis CI
2026-08-04 04:35:29 +00:00
parent d39bd1423a
commit de2020eee1
4 changed files with 502 additions and 9 deletions
+50 -9
View File
@@ -31,7 +31,6 @@ except ImportError: # pragma: no cover
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from slowapi.errors import RateLimitExceeded
from slowapi import _rate_limit_exceeded_handler
@@ -42,11 +41,19 @@ from db.store import PraxisStore
from server.auth.cookies import get_session_middleware_kwargs
from server.auth.rate_limit import limiter
from server.auth.routes import router as auth_router
from server.cohort.nightly import NightlyScheduler
from server.operator.cohort import router as cohort_router
from server.operator.credentials import router as credentials_router
from server.operator.failure_patterns import router as failure_router
from server.operator.mastery import router as mastery_router
from server.pipeline import build_pipeline
from server.vc.issuer_keys import _load_root_key
from server.vc.migrate_keys import migrate_issuer_keys
from server.vc.verification import verify_credential
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
_store = PraxisStore()
@@ -92,6 +99,8 @@ async def lifespan(app: FastAPI):
)
app.state.pg_pool = pool
app.state.pg_store = PgStore(pool)
nightly = NightlyScheduler()
app.state.nightly_scheduler = nightly
try:
applied = await apply_pg_migrations(pool)
if applied:
@@ -102,10 +111,14 @@ async def lifespan(app: FastAPI):
# first boot, idempotent. Non-fatal on failure (v0.3 SQLite path
# remains intact for verification).
await _maybe_migrate_issuer_keys()
# v0.4 P2 (D-054, REQ-NFR-DASH-02): start the nightly reconciliation
# scheduler at 03:00 CT. Cancelled on shutdown.
await nightly.start(app.state.pg_store)
logger.info("Nightly cohort reconciliation scheduler started (03:00 CT)")
try:
yield
finally:
pass
await nightly.stop()
finally:
await pool.close()
logger.info("Postgres pool closed")
@@ -245,16 +258,44 @@ async def _maybe_migrate_issuer_keys() -> None:
# the router (routes-before-static-mount constraint, carry-forward v0.2).
app.include_router(auth_router)
# ── Operator API cohort endpoints (TASK-10-02, D-053, D-057) ──────────
# Auth-gated via Depends(current_operator) inside each router. Mounted
# BEFORE the SPA StaticFiles fallback so /api/operator/* is matched by the
# API routers, not the SPA fallback.
app.include_router(cohort_router)
app.include_router(mastery_router)
app.include_router(failure_router)
app.include_router(credentials_router)
# ── Static client serving (D-023, REQ-DEPLOY-13) ──────────────────────
# Mount client/dist as StaticFiles at "/" AFTER all API routes so they
# take precedence. html=True serves index.html for "/" (SPA root).
# The client has no React Router (single-view state machine: start→live
# →debrief), so no SPA fallback fallback route is needed per RESEARCH.md Q3.
# ── SPA StaticFiles fallback (G-041 binding, TASK-10-01, R-DASH-03/05) ─
# Custom StaticFiles subclass that returns index.html for non-file paths
# (SPA client-side routing). G-041 OVERRIDES the plan's catch-all route —
# a @app.get("/{path:path}") catch-all before StaticFiles would shadow
# asset serving (assertion 8 in TASK-10-04). This subclass serves assets
# normally (JS/CSS) and falls back to index.html for client-side routes
# (/operator/dashboard, /operator/login). API routes registered above take
# precedence over the mount.
class SpaStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
except (StarletteHTTPException, HTTPException) as e:
if getattr(e, "status_code", None) == 404:
import os
index = os.path.join(self.directory, "index.html")
if os.path.isfile(index):
return FileResponse(index)
raise
# Mount client/dist at "/" AFTER all API routes so they take precedence.
# html=True serves index.html for "/" (SPA root). The SpaStaticFiles
# subclass serves index.html for unknown paths (React Router routes).
_CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist")
if os.path.isdir(_CLIENT_DIST):
app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True), name="client")
logger.info(f"Serving client from {_CLIENT_DIST}")
app.mount("/", SpaStaticFiles(directory=_CLIENT_DIST, html=True), name="spa")
logger.info(f"Serving client from {_CLIENT_DIST} (SPA fallback enabled)")
else:
logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode")