feat: realtime WebSocket layer for live events and streaming logs

- Backend: ConnectionManager, Redis-backed bus with in-process fallback,
  channel authorization, and typed emit helpers wired into workers
  (ai_runtime, sandbox_gateway), workforce/sandbox/billing/audit/error services.
- Frontend: single shared WS client with backoff + heartbeat, RealtimeProvider,
  useLiveChannel/useLiveEvent hooks, live indicators on task detail, manager
  chat, dashboard, traces, sandbox terminal, admin errors, and billing.
- Contracts: WsEvent/WsServerFrame/WsClientFrame types mirroring the port.

All 255 backend tests pass; web typecheck and build pass.
This commit is contained in:
mjalmoudy94
2026-10-13 12:56:36 +03:30
parent afdef4b444
commit f1e5df29ce
31 changed files with 2981 additions and 28 deletions
+17
View File
@@ -27,6 +27,10 @@ from app.interfaces.http.routers import (
sandbox,
workforce,
)
from app.interfaces.realtime.bus import install as install_realtime_bus
from app.interfaces.realtime.bus import start_relay, stop_relay
from app.interfaces.realtime.router import realtime_manager
from app.interfaces.realtime.router import router as realtime_router
logging.basicConfig(
level=logging.INFO,
@@ -62,8 +66,19 @@ async def lifespan(_: FastAPI):
# loudly — a half-started API must not accept traffic.
await _record_startup_failure(exc)
raise
# --- realtime wiring ---------------------------------------------------
# This process is the publisher for API-driven events and the relay for
# anything the standalone workers publish. Both are best-effort: if Redis
# is not reachable the in-process fan-out keeps every HTTP-driven feed live.
bus = install_realtime_bus()
bus.attach(realtime_manager().dispatch)
await start_relay()
yield
await stop_relay()
async def _record_startup_failure(exc: BaseException) -> None:
"""Persist a boot failure. Never raises; the caller re-raises regardless."""
@@ -123,6 +138,8 @@ def create_app() -> FastAPI:
application.include_router(sandbox.router, prefix="/api")
application.include_router(admin.router, prefix="/api")
application.include_router(agy.router, prefix="/api")
# The live feed: one WebSocket per browser, every push on it.
application.include_router(realtime_router, prefix="/api")
@application.get("/", tags=["meta"])
async def root() -> dict: