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
@@ -18,6 +18,10 @@ import contextlib
from datetime import datetime
from app.application.ports import CommandResult, SandboxProviderPort, UsageRecord
from app.application.ports.realtime import (
emit_sandbox_command,
emit_sandbox_session,
)
from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError, ProviderUnavailableError, ValidationError
from app.domain.ai_control.models import RateUnit
@@ -210,6 +214,12 @@ class SandboxService:
)
)
await self.workspaces.set_status(workspace.id, WorkspaceStatus.READY)
await emit_sandbox_session(
session_id=session.id,
organization_id=organization_id,
status=SessionStatus.RUNNING.value,
workspace_id=workspace.id,
)
return session
async def get_session_or_404(
@@ -229,6 +239,11 @@ class SandboxService:
await self.provider.destroy(session_external_id=session.external_session_id)
await self.sessions.set_status(session.id, SessionStatus.STOPPED, stopped=True)
refreshed = await self.sessions.get(session.id)
await emit_sandbox_session(
session_id=session.id,
organization_id=organization_id,
status=SessionStatus.STOPPED.value,
)
return refreshed or session
# -- commands ------------------------------------------------------------
@@ -285,6 +300,20 @@ class SandboxService:
)
await self.sessions.set_status(session.id, SessionStatus.RUNNING)
# Live push: the terminal panel on the project page streams the command
# and its output as the row is written, not on the next poll.
await emit_sandbox_command(
session_id=session.id,
organization_id=organization_id,
command_run_id=run.id,
command=cleaned,
exit_code=result.exit_code,
stdout=run.stdout_ref,
stderr=run.stderr_ref,
duration_ms=result.duration_ms,
truncated=result.truncated,
)
# CPU seconds are billed. 1 second of wall time ~= 1 CPU second at MVP.
cpu_seconds = max(0.0, result.duration_ms / 1000.0)
if cpu_seconds > 0: