stream live run detail output via websocket
This commit is contained in:
@@ -56,7 +56,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||||
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState } from "@paperclipai/shared";
|
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
|
||||||
import { agentRouteRef } from "../lib/utils";
|
import { agentRouteRef } from "../lib/utils";
|
||||||
|
|
||||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||||
@@ -1761,6 +1761,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
const [logError, setLogError] = useState<string | null>(null);
|
const [logError, setLogError] = useState<string | null>(null);
|
||||||
const [logOffset, setLogOffset] = useState(0);
|
const [logOffset, setLogOffset] = useState(0);
|
||||||
const [isFollowing, setIsFollowing] = useState(false);
|
const [isFollowing, setIsFollowing] = useState(false);
|
||||||
|
const [isStreamingConnected, setIsStreamingConnected] = useState(false);
|
||||||
const logEndRef = useRef<HTMLDivElement>(null);
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingLogLineRef = useRef("");
|
const pendingLogLineRef = useRef("");
|
||||||
const scrollContainerRef = useRef<ScrollContainer | null>(null);
|
const scrollContainerRef = useRef<ScrollContainer | null>(null);
|
||||||
@@ -1957,7 +1958,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
|
|
||||||
// Poll for live updates
|
// Poll for live updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return;
|
if (!isLive || isStreamingConnected) return;
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
|
const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
|
||||||
try {
|
try {
|
||||||
@@ -1970,11 +1971,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [run.id, isLive, events]);
|
}, [run.id, isLive, isStreamingConnected, events]);
|
||||||
|
|
||||||
// Poll shell log for running runs
|
// Poll shell log for running runs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return;
|
if (!isLive || isStreamingConnected) return;
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await heartbeatsApi.log(run.id, logOffset, 256_000);
|
const result = await heartbeatsApi.log(run.id, logOffset, 256_000);
|
||||||
@@ -1992,7 +1993,119 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [run.id, isLive, logOffset]);
|
}, [run.id, isLive, isStreamingConnected, logOffset]);
|
||||||
|
|
||||||
|
// Stream live updates from websocket (primary path for running runs).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) return;
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`;
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
setIsStreamingConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (message) => {
|
||||||
|
const rawMessage = typeof message.data === "string" ? message.data : "";
|
||||||
|
if (!rawMessage) return;
|
||||||
|
|
||||||
|
let event: LiveEvent;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(rawMessage) as LiveEvent;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.companyId !== run.companyId) return;
|
||||||
|
const payload = asRecord(event.payload);
|
||||||
|
const eventRunId = asNonEmptyString(payload?.runId);
|
||||||
|
if (!payload || eventRunId !== run.id) return;
|
||||||
|
|
||||||
|
if (event.type === "heartbeat.run.log") {
|
||||||
|
const chunk = typeof payload.chunk === "string" ? payload.chunk : "";
|
||||||
|
if (!chunk) return;
|
||||||
|
const streamRaw = asNonEmptyString(payload.stream);
|
||||||
|
const stream = streamRaw === "stderr" || streamRaw === "system" ? streamRaw : "stdout";
|
||||||
|
const ts = asNonEmptyString((payload as Record<string, unknown>).ts) ?? event.createdAt;
|
||||||
|
setLogLines((prev) => [...prev, { ts, stream, chunk }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type !== "heartbeat.run.event") return;
|
||||||
|
|
||||||
|
const seq = typeof payload.seq === "number" ? payload.seq : null;
|
||||||
|
if (seq === null || !Number.isFinite(seq)) return;
|
||||||
|
|
||||||
|
const streamRaw = asNonEmptyString(payload.stream);
|
||||||
|
const stream =
|
||||||
|
streamRaw === "stdout" || streamRaw === "stderr" || streamRaw === "system"
|
||||||
|
? streamRaw
|
||||||
|
: null;
|
||||||
|
const levelRaw = asNonEmptyString(payload.level);
|
||||||
|
const level =
|
||||||
|
levelRaw === "info" || levelRaw === "warn" || levelRaw === "error"
|
||||||
|
? levelRaw
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const liveEvent: HeartbeatRunEvent = {
|
||||||
|
id: seq,
|
||||||
|
companyId: run.companyId,
|
||||||
|
runId: run.id,
|
||||||
|
agentId: run.agentId,
|
||||||
|
seq,
|
||||||
|
eventType: asNonEmptyString(payload.eventType) ?? "event",
|
||||||
|
stream,
|
||||||
|
level,
|
||||||
|
color: asNonEmptyString(payload.color),
|
||||||
|
message: asNonEmptyString(payload.message),
|
||||||
|
payload: asRecord(payload.payload),
|
||||||
|
createdAt: new Date(event.createdAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
setEvents((prev) => {
|
||||||
|
if (prev.some((existing) => existing.seq === seq)) return prev;
|
||||||
|
return [...prev, liveEvent];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
setIsStreamingConnected(false);
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
setIsStreamingConnected(false);
|
||||||
|
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
||||||
|
if (socket) {
|
||||||
|
socket.onopen = null;
|
||||||
|
socket.onmessage = null;
|
||||||
|
socket.onerror = null;
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.close(1000, "run_detail_unmount");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isLive, run.companyId, run.id, run.agentId]);
|
||||||
|
|
||||||
const adapterInvokePayload = useMemo(() => {
|
const adapterInvokePayload = useMemo(() => {
|
||||||
const evt = events.find((e) => e.eventType === "adapter.invoke");
|
const evt = events.find((e) => e.eventType === "adapter.invoke");
|
||||||
|
|||||||
Reference in New Issue
Block a user