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
@@ -25,6 +25,7 @@ import {
} from "@agy/ui";
import type { TableColumn } from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime";
/**
* Platform-owner error console.
@@ -101,6 +102,63 @@ export default function AdminErrorsPage() {
const [busy, setBusy] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
// Live feed: new errors appear in the console as they happen.
useLiveChannel(["admin"], (event) => {
if (event.type === "error.recorded") {
const data = event.data as {
error_id: string | null;
severity: ErrorSeverity;
source: ErrorSource;
kind: string;
message: string;
organization_id: string | null;
};
const id = data.error_id ?? event.id;
setEvents((current) => {
if (current.some((e) => e.id === id)) return current;
return [
{
id,
kind: data.kind,
message: data.message,
stack: "",
severity: data.severity,
source: data.source,
error_type: data.kind,
fingerprint: id,
status_code: null,
method: "",
path: "",
organization_id: data.organization_id,
user_id: null,
run_id: null,
request_id: "",
ip: "",
user_agent: "",
metadata_json: {},
resolved_at: null,
resolved_note: "",
created_at: new Date().toISOString(),
},
...current,
];
});
setTotal((current) => current + 1);
setSummary((current) =>
current
? {
...current,
total_open: current.total_open + 1,
open_by_severity: {
...current.open_by_severity,
[data.severity]: (current.open_by_severity[data.severity] ?? 0) + 1,
},
}
: current,
);
}
});
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
+39
View File
@@ -23,6 +23,7 @@ import {
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
const UNIT_LABELS: Record<string, string> = {
@@ -69,6 +70,44 @@ export default function BillingPage() {
const [actionError, setActionError] = React.useState<string | null>(null);
const [checkoutUrl, setCheckoutUrl] = React.useState<string | null>(null);
// Live feed: wallet balance and usage events update as they happen.
useLiveChannel(
orgId ? [`org:${orgId}`] : [],
(event) => {
switch (event.type) {
case "billing.wallet": {
const data = event.data as { organization_id: string; balance: number };
setSummary((current) =>
current ? { ...current, balance: data.balance, low_balance: Boolean(data.balance <= current.low_balance_threshold) } : current,
);
break;
}
case "usage.recorded": {
const data = event.data as {
run_id: string | null;
unit: string;
quantity: number;
};
setEvents((current) => [
{
id: event.id,
run_id: data.run_id,
unit: data.unit as UsageEvent["unit"],
quantity: data.quantity,
unit_price: 0,
total_price: 0,
created_at: new Date().toISOString(),
},
...current,
]);
break;
}
default:
break;
}
},
);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
+27
View File
@@ -15,6 +15,7 @@ import {
UsageBar,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
export default function DashboardPage() {
@@ -24,6 +25,32 @@ export default function DashboardPage() {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
// Live feed: audit events stream into the timeline; usage updates the bar.
useLiveChannel(
activeOrganization ? [`org:${activeOrganization.id}`] : [],
(event) => {
switch (event.type) {
case "audit.recorded": {
const data = event.data as { action: string; target_type: string; target_id: string };
setAudit((current) => [
{
id: event.id,
action: data.action,
actor_user_id: null,
target_type: data.target_type,
target_id: data.target_id,
created_at: new Date().toISOString(),
},
...current,
]);
break;
}
default:
break;
}
},
);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
+59 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import * as React from "react";
import type { Employee, Message, Task, Thread } from "@agy/contracts";
import type { Employee, Message, Task, TaskStatus, Thread } from "@agy/contracts";
import {
Badge,
Button,
@@ -17,6 +17,7 @@ import {
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
/**
@@ -48,6 +49,63 @@ export default function ManagerPage() {
const bottomRef = React.useRef<HTMLDivElement | null>(null);
// Live feed: new messages in the active thread, and tasks created from chat.
const liveChannels = React.useMemo(() => {
const base: string[] = [];
if (orgId) base.push(`org:${orgId}`);
if (activeThreadId) base.push(`thread:${activeThreadId}`);
return base;
}, [orgId, activeThreadId]);
useLiveChannel(liveChannels, (event) => {
switch (event.type) {
case "message.created": {
const data = event.data as {
thread_id: string;
message: { id: string; role: string; content: string; employee_id?: string | null };
};
if (data.thread_id === activeThreadId) {
setMessages((current) => [
...current,
{
id: data.message.id,
thread_id: data.thread_id,
role: data.message.role as Message["role"],
content: data.message.content,
employee_id: data.message.employee_id ?? null,
created_at: new Date().toISOString(),
},
]);
}
break;
}
case "task.created": {
const data = event.data as { task_id: string; title: string; status: string };
setTasks((current) => {
if (current.some((t) => t.id === data.task_id)) return current;
return [
{
id: data.task_id,
organization_id: orgId ?? "",
project_id: null,
thread_id: activeThreadId,
title: data.title,
description: "",
status: data.status as TaskStatus,
assigned_employee_id: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
...current,
];
});
break;
}
default:
break;
}
});
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
+108 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import * as React from "react";
import type { Employee, RunDetail, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts";
import type { Employee, RunDetail, RunStatus, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts";
import {
Badge,
Button,
@@ -17,6 +17,7 @@ import {
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
const STATUS_LABELS: Record<TaskStatus, string> = {
@@ -64,6 +65,112 @@ export default function TaskDetailPage({
const [running, setRunning] = React.useState(false);
const [actionError, setActionError] = React.useState<string | null>(null);
// Live feed: task status, run steps, approvals, artifacts — all push.
const channels = React.useMemo(() => {
const base: string[] = [];
if (orgId) base.push(`org:${orgId}`);
if (taskId) base.push(`task:${taskId}`);
if (selectedRun?.id) base.push(`run:${selectedRun.id}`);
return base;
}, [orgId, taskId, selectedRun?.id]);
useLiveChannel(channels, (event) => {
switch (event.type) {
case "task.status": {
const data = event.data as { task_id: string; status: string };
if (data.task_id === taskId) {
setTask((current) => (current ? { ...current, status: data.status as TaskStatus } : current));
}
break;
}
case "run.step": {
const data = event.data as {
run_id: string;
task_id: string;
step: { id: string; index: number; kind: string; title: string; payload: Record<string, unknown> };
};
setSelectedRun((current) => {
if (!current || current.id !== data.run_id) return current;
return {
...current,
steps: [
...current.steps,
{
id: data.step.id,
index: data.step.index,
kind: data.step.kind,
title: data.step.title,
payload_json: data.step.payload,
created_at: new Date().toISOString(),
},
],
};
});
break;
}
case "run.status": {
const data = event.data as { run_id: string; task_id: string; status: string };
setSelectedRun((current) =>
current && current.id === data.run_id
? { ...current, status: data.status as RunDetail["status"] }
: current,
);
setTask((current) => {
if (!current) return current;
return {
...current,
runs: current.runs.map((r) =>
r.id === data.run_id ? { ...r, status: data.status as RunStatus } : r,
),
};
});
break;
}
case "approval.pending": {
const data = event.data as { tool_call_id: string; tool_key: string; run_id: string };
setApprovals((current) => {
if (current.some((a) => a.id === data.tool_call_id)) return current;
return [
...current,
{
id: data.tool_call_id,
tool_key: data.tool_key,
status: "awaiting_approval",
requires_approval: true,
arguments_json: {},
result_json: {},
created_at: new Date().toISOString(),
},
];
});
break;
}
case "artifact.created": {
const data = event.data as { artifact_id: string; run_id: string; name: string };
setSelectedRun((current) => {
if (!current || current.id !== data.run_id) return current;
return {
...current,
artifacts: [
...current.artifacts,
{
id: data.artifact_id,
name: data.name,
kind: "text",
size_bytes: 0,
content_ref: "",
created_at: new Date().toISOString(),
},
],
};
});
break;
}
default:
break;
}
});
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
+67 -1
View File
@@ -18,7 +18,7 @@
import Link from "next/link";
import * as React from "react";
import type { AuditEvent, Run, RunDetail } from "@agy/contracts";
import type { AuditEvent, Run, RunDetail, RunStatus } from "@agy/contracts";
import {
Badge,
Button,
@@ -32,6 +32,7 @@ import {
Timeline,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
/** How many tasks to fan out into. Bounds the request count. */
@@ -108,6 +109,71 @@ export default function TracesPage() {
const [error, setError] = React.useState<string | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// Live feed: run steps and status changes update both the list and the detail.
useLiveChannel(
orgId ? [`org:${orgId}`] : [],
(event) => {
switch (event.type) {
case "run.step": {
const data = event.data as {
run_id: string;
task_id: string;
step: { id: string; index: number; kind: string; title: string; payload: Record<string, unknown> };
};
setSelected((current) => {
if (!current || current.id !== data.run_id) return current;
return {
...current,
steps: [
...current.steps,
{
id: data.step.id,
index: data.step.index,
kind: data.step.kind,
title: data.step.title,
payload_json: data.step.payload,
created_at: new Date().toISOString(),
},
],
};
});
break;
}
case "run.status": {
const data = event.data as { run_id: string; task_id: string; status: string };
setRows((current) =>
current.map((row) =>
row.run.id === data.run_id ? { ...row, run: { ...row.run, status: data.status as RunStatus } } : row,
),
);
setSelected((current) =>
current && current.id === data.run_id
? { ...current, status: data.status as RunDetail["status"] }
: current,
);
break;
}
case "audit.recorded": {
const data = event.data as { action: string; target_type: string; target_id: string };
setAudit((current) => [
{
id: event.id,
action: data.action,
actor_user_id: null,
target_type: data.target_type,
target_id: data.target_id,
created_at: new Date().toISOString(),
},
...current,
]);
break;
}
default:
break;
}
},
);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
+5 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { RealtimeProvider } from "@/lib/realtime";
import { SessionProvider } from "@/lib/session";
export const metadata: Metadata = {
@@ -11,7 +12,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="fa" dir="rtl" className="dark">
<body>
<SessionProvider>{children}</SessionProvider>
<SessionProvider>
{/* One socket for the whole app: every live feed rides on it. */}
<RealtimeProvider>{children}</RealtimeProvider>
</SessionProvider>
</body>
</html>
);