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
+36
View File
@@ -8,6 +8,7 @@ import { api } from "@/lib/api";
import { CommandPalette } from "@/components/CommandPalette";
import { NAV_ITEMS } from "@/lib/nav";
import { APP_VERSION_LABEL } from "@/lib/version";
import { useRealtime } from "@/lib/realtime";
import { useSession } from "@/lib/session";
/**
@@ -38,6 +39,38 @@ function VersionBadge() {
);
}
/** A tiny dot that tells the user whether the live feed is connected. */
function LiveIndicator() {
const { status } = useRealtime();
const tone =
status === "live"
? "success"
: status === "connecting" || status === "reconnecting"
? "warning"
: "neutral";
const label =
status === "live"
? "زنده"
: status === "connecting"
? "در حال اتصال…"
: status === "reconnecting"
? "در حال اتصال مجدد…"
: "آفلاین";
return (
<span className="flex items-center gap-1.5" title={label}>
<span
className={cx(
"inline-block h-1.5 w-1.5 rounded-full",
tone === "success" && "bg-emerald-400",
tone === "warning" && "bg-amber-400 animate-pulse",
tone === "neutral" && "bg-slate-500",
)}
/>
<span className="text-[10px] text-slate-500">{label}</span>
</span>
);
}
/** Credit badge — reads the organization's real balance, never a placeholder. */
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
const [balance, setBalance] = React.useState<number | null>(null);
@@ -195,6 +228,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<span className="hidden sm:inline-flex">
<CreditBadge organizationId={activeOrganization?.id} />
</span>
<span className="hidden sm:inline-flex">
<LiveIndicator />
</span>
<CommandPalette isPlatformOwner={user?.is_platform_owner} />
</div>
+48
View File
@@ -35,6 +35,7 @@ import {
Stat,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime";
const STATUS_TONES = {
ready: "success",
@@ -316,6 +317,53 @@ function SessionPanel({
const [snapshotName, setSnapshotName] = React.useState("");
const [snapshotBusy, setSnapshotBusy] = React.useState(false);
// Live feed: command output and session lifecycle events push live.
useLiveChannel(
[`session:${session.id}`],
(event) => {
switch (event.type) {
case "sandbox.command": {
const data = event.data as {
run_id: string;
command: string;
status: string;
exit_code: number | null;
stdout: string;
stderr: string;
duration_ms: number;
truncated: boolean;
};
setRuns((current) => {
if (current.some((r) => r.id === data.run_id)) return current;
return [
...current,
{
id: data.run_id,
command: data.command,
exit_code: data.exit_code,
stdout: data.stdout,
stderr: data.stderr,
duration_ms: data.duration_ms,
truncated: data.truncated,
created_at: new Date().toISOString(),
},
];
});
break;
}
case "sandbox.session": {
const data = event.data as { session_id: string; status: string };
if (data.session_id === session.id) {
onSessionChange({ ...session, status: data.status });
}
break;
}
default:
break;
}
},
);
const [previewPort, setPreviewPort] = React.useState("8080");
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);