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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user