gadget/gadget-code/frontend/src/lib/socket.ts
Rob Colbert e155a7ffcf Phase 2: SubProcess observability in DroneManager and DroneInspector
Adds real-time subprocess monitoring to the IDE via the callback-chain and
subscribe/broadcast socket patterns:

Shared types (packages/api):
- New subprocess.ts message types: SubProcessStat, RequestProcessStatsMessage,
  SubscribeDroneMessage, UnsubscribeDroneMessage
- New socket events in socket.ts: requestProcessStats, subscribeDrone,
  unsubscribeDrone (ClientToServer); drone:log, drone:status (ServerToClient)

Drone (gadget-drone):
- onRequestProcessStats handler calls SubProcessService.ps() + summarize(),
  returns typed SubProcessStat[] with status detection (exitCode/killed)

Backend routing (gadget-code):
- SocketService: getDroneSessionByRegistrationId(), droneMonitorIndex map,
  addDroneMonitor()/removeDroneMonitor()/broadcastToMonitors()
- CodeSession: requestProcessStats proxy, subscribeDrone/unsubscribeDrone
- DroneSession: broadcast drone:log + drone:status to monitor subscribers
  in addition to existing chat-session routing

Frontend socket layer:
- SocketClient: requestProcessStats(), subscribeDrone(), unsubscribeDrone()
- drone:log + drone:status events forwarded to event bus

Frontend components:
- LogRenderer — reusable log rendering core extracted from LogPanel
- SubProcessTable — btop-style process table with status dots
- DroneMonitorGauge — canvas oscilloscope waveform (studio-equipment aesthetic)
- DroneMonitor — 3-gauge container (CPU/red, NETWORK/cyan, FILE I/O/green)

Frontend pages:
- DroneManager: live log streaming, SubProcessTable, DroneMonitor gauges,
  1-second polling, auto-scroll log with pause-on-scroll-up
- Home/DroneInspector: process count summary, mini LogRenderer, navigate-to-manager link

Documentation:
- Updated gadget-drone/docs/subprocess.md Phase 2 section
- Updated gadget-code/docs/ui-design-guide.md Phase 2 section
- Updated docs/socket-protocol.md with new events, sequences, and patterns
2026-05-14 13:52:59 -04:00

673 lines
19 KiB
TypeScript

import { createContext } from "react";
import { io, Socket } from "socket.io-client";
import type { ChatSession } from "./api";
/**
* Web Worker for heartbeat timing — avoids browser tab throttling.
* Chrome throttles setInterval in background tabs to ~1/min, which
* would cause heartbeat timeouts against the drone's 120s timer.
*/
let HeartbeatWorker: typeof Worker | null = null;
try {
// Vite supports `new Worker` with `?worker` import syntax
HeartbeatWorker = Worker;
} catch {
// Web Workers not available (extremely rare in modern browsers)
}
const SOCKET_URL = "";
export interface ServerToClientEvents {
thinking: (content: string) => void;
response: (content: string) => void;
toolCall: (
callId: string,
name: string,
params: string,
response: string,
) => void;
workOrderComplete: (
turnId: string,
success: boolean,
message?: string,
) => void;
log: (
timestamp: string,
component: { name: string; slug: string },
level: string,
message: string,
metadata?: unknown,
) => void;
}
export interface ClientToServerEvents {
submitPrompt: (content: string) => void;
abortWorkOrder: (cb: (success: boolean, message?: string) => void) => void;
requestSessionLock: (
registration: any,
project: any,
chatSession: any,
cb: (success: boolean, chatSessionId: string) => void,
) => void;
requestWorkspaceMode: (
registration: any,
project: any,
chatSession: any,
mode: string,
cb: (success: boolean, mode: string) => void,
) => void;
releaseSessionLock: (
registration: any,
project: any,
chatSession: any,
cb: (success: boolean) => void,
) => void;
sessionHeartbeat: (cb: (ack: boolean) => void) => void;
fileTreeRequest: (
args: { path?: string; recursive?: boolean; maxDepth?: number },
cb: (success: boolean, data: { entries?: any[]; error?: string }) => void,
) => void;
fileReadRequest: (
args: { path: string },
cb: (success: boolean, data: { content?: string; error?: string }) => void,
) => void;
fileWriteRequest: (
args: { path: string; content: string },
cb: (success: boolean, data: { success?: boolean; error?: string }) => void,
) => void;
}
export interface SocketEvents {
thinking: (content: string) => void;
response: (content: string) => void;
toolCall: (
callId: string,
name: string,
params: string,
response: string,
) => void;
workOrderComplete: (
turnId: string,
success: boolean,
message?: string,
) => void;
"agent:thinking": (data: { agentId: string; thinking: string }) => void;
"agent:response": (data: { agentId: string; chunk: string }) => void;
"agent:tool-call": (data: {
agentId: string;
tool: string;
args: unknown;
}) => void;
"agent:tool-result": (data: {
agentId: string;
tool: string;
result: unknown;
}) => void;
"agent:complete": (data: {
agentId: string;
response?: string;
subagent?: Record<string, unknown>;
stats?: {
toolCallCount: number;
inputTokens: number;
thinkingTokenCount: number;
responseTokens: number;
durationMs: number;
durationLabel: string;
};
}) => void;
"log:entry": (data: {
level: string;
component: string;
message: string;
metadata?: unknown;
timestamp: number;
}) => void;
"chat:message": (data: {
agentId: string;
message: string;
role: "user" | "assistant" | "system";
}) => void;
workspaceModeChanged: (mode: string) => void;
sessionUpdated: (updates: Partial<ChatSession>) => void;
tabLockDenied: (data: { message: string }) => void;
fileTreeResponse: (path: string, entries: any[], error?: string) => void;
fileReadResponse: (path: string, content: string | null, error?: string) => void;
fileWriteResponse: (path: string, success: boolean, error?: string) => void;
status: (content: string) => void;
"drone:log": (data: {
timestamp: string;
level: string;
component: string;
message: string;
metadata?: unknown;
}) => void;
"drone:status": (data: {
timestamp: string;
message: string;
}) => void;
reconnect_attempt: (attempt: number) => void;
reconnect_failed: () => void;
reconnect: (attempt: number) => void;
connect: () => void;
disconnect: (reason: string) => void;
error: (error: Error) => void;
}
class SocketClient {
private _socket: Socket | null = null;
private eventListeners: Map<string, Set<(...args: unknown[]) => void>> =
new Map();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private jwt: string | null = null;
/**
* Web Worker for heartbeat timing. Runs the 19-second interval in a
* worker thread to avoid browser tab throttling, which would cause
* heartbeat timeouts against the drone's 120-second timer.
*/
private heartbeatWorker: Worker | null = null;
/**
* Fallback setInterval for environments where Web Workers are unavailable.
* This is subject to browser tab throttling but is better than nothing.
*/
private heartbeatFallbackInterval: ReturnType<typeof setInterval> | null = null;
/**
* Bound visibility handler so we can remove it on disconnect.
*/
private boundVisibilityHandler: (() => void) | null = null;
get connected(): boolean {
return this._socket?.connected ?? false;
}
get socket(): Socket | null {
return this._socket;
}
connect(token: string): void {
if (this._socket?.connected) {
return;
}
this.jwt = token;
this._socket = io(SOCKET_URL, {
auth: {
token: this.jwt,
},
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: this.maxReconnectAttempts,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
if (!this.socket) {
return;
}
// Forward server events to our event listeners
this.socket.on("thinking", (content: string) => {
this.emit("thinking", content);
});
this.socket.on("response", (content: string) => {
this.emit("response", content);
});
this.socket.on(
"toolCall",
(callId: string, name: string, params: string, response: string) => {
this.emit("toolCall", callId, name, params, response);
},
);
this.socket.on(
"workOrderComplete",
(turnId: string, success: boolean, message?: string) => {
this.emit("workOrderComplete", turnId, success, message);
},
);
this.socket.on("workspaceModeChanged", (mode: string) => {
this.emit("workspaceModeChanged", mode);
});
this.socket.on("sessionUpdated", (updates: unknown) => {
this.emit("sessionUpdated", updates as Partial<ChatSession>);
});
this.socket.on("fileTreeResponse", (path: string, entries: unknown[], error?: string) => {
this.emit("fileTreeResponse", path, entries as any[], error);
});
this.socket.on("fileReadResponse", (path: string, content: string | null, error?: string) => {
this.emit("fileReadResponse", path, content, error);
});
this.socket.on("fileWriteResponse", (path: string, success: boolean, error?: string) => {
this.emit("fileWriteResponse", path, success, error);
});
this.socket.on(
"log",
(
timestamp: string,
component: { name: string; slug: string },
level: string,
message: string,
metadata?: unknown,
) => {
this.emit("log:entry", {
level,
component: component.name,
message,
metadata,
timestamp: new Date(timestamp).getTime(),
});
},
);
this.socket.on("agent:thinking", (data: unknown) => {
this.emit("agent:thinking", data as { agentId: string; thinking: string });
});
this.socket.on("agent:response", (data: unknown) => {
this.emit("agent:response", data as { agentId: string; chunk: string });
});
this.socket.on("agent:tool-call", (data: unknown) => {
this.emit("agent:tool-call", data as { agentId: string; tool: string; args: unknown });
});
this.socket.on("agent:tool-result", (data: unknown) => {
this.emit("agent:tool-result", data as { agentId: string; tool: string; result: unknown });
});
this.socket.on("agent:complete", (data: unknown) => {
this.emit("agent:complete", data as SocketEvents["agent:complete"] extends (data: infer T) => void ? T : never);
});
this._socket.on("connect", () => {
this.reconnectAttempts = 0;
this.emit("connect");
});
this._socket.on("disconnect", (reason) => {
this.emit("disconnect", reason);
});
this._socket.on("connect_error", (error) => {
this.reconnectAttempts++;
this.emit("error", error);
});
this._socket.on("tabLockDenied", (data: { message: string }) => {
this.emit("tabLockDenied", data);
});
this.socket.on("status", (content: string) => {
this.emit("status", content);
});
this.socket.on("drone:log", (data: unknown) => {
this.emit("drone:log", data as SocketEvents["drone:log"] extends (data: infer T) => void ? T : never);
});
this.socket.on("drone:status", (data: unknown) => {
this.emit("drone:status", data as SocketEvents["drone:status"] extends (data: infer T) => void ? T : never);
});
this._socket.on("reconnect_attempt", (attempt: number) => {
this.emit("reconnect_attempt", attempt);
});
this._socket.on("reconnect_failed", () => {
this.emit("reconnect_failed");
});
this._socket.on("reconnect", (attempt: number) => {
this.emit("reconnect", attempt);
});
}
disconnect(): void {
this.stopSessionHeartbeat();
this.removeVisibilityHandler();
if (this._socket) {
this._socket.disconnect();
this._socket = null;
this.jwt = null;
}
}
on<K extends keyof SocketEvents>(event: K, callback: SocketEvents[K]): void {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set());
}
this.eventListeners
.get(event)!
.add(callback as (...args: unknown[]) => void);
}
off<K extends keyof SocketEvents>(event: K, callback: SocketEvents[K]): void {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.delete(callback as (...args: unknown[]) => void);
}
}
emit<K extends keyof SocketEvents>(
event: K,
...args: Parameters<SocketEvents[K]>
): void {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.forEach((callback) => callback(...args));
}
}
send<K extends keyof SocketEvents>(
event: K,
...args: Parameters<SocketEvents[K]>
): void {
if (this._socket?.connected) {
this._socket.emit(event, ...args);
}
}
emitServer<K extends keyof ClientToServerEvents>(
event: K,
...args: Parameters<ClientToServerEvents[K]>
): void {
if (this._socket?.connected) {
this._socket.emit(event, ...args);
}
}
submitPrompt(
prompt: string,
cb: (success: boolean, data: { turnId?: string; message?: string }) => void,
): void {
if (this._socket?.connected) {
this._socket.emit('submitPrompt', prompt, cb);
} else {
cb(false, { message: 'Socket not connected' });
}
}
abortWorkOrder(cb?: (success: boolean, message?: string) => void): void {
if (this._socket?.connected) {
this._socket.emit('abortWorkOrder', (success: boolean, message?: string) => {
cb?.(success, message);
});
} else {
cb?.(false, 'Socket not connected');
}
}
requestSessionLock(
registration: any,
project: any,
chatSession: any,
): Promise<boolean> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit(
"requestSessionLock",
registration,
project,
chatSession,
resolve,
);
} else {
resolve(false);
}
});
}
requestWorkspaceMode(
registration: any,
project: any,
chatSession: any,
mode: string,
): Promise<{ success: boolean; mode: string; reason?: string }> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit(
"requestWorkspaceMode",
registration,
project,
chatSession,
mode,
(success: boolean, mode: string, reason?: string) =>
resolve({ success, mode, reason }),
);
} else {
resolve({ success: false, mode: "" });
}
});
}
requestFileTree(
args: { path?: string; recursive?: boolean; maxDepth?: number } = {},
): Promise<{ success: boolean; entries?: any[]; error?: string }> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit(
"fileTreeRequest",
args,
(success: boolean, data: { entries?: any[]; error?: string }) =>
resolve({ success, ...data }),
);
} else {
resolve({ success: false, error: "Socket not connected" });
}
});
}
requestFileRead(
args: { path: string },
): Promise<{ success: boolean; content?: string; error?: string }> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit(
"fileReadRequest",
args,
(success: boolean, data: { content?: string; error?: string }) =>
resolve({ success, ...data }),
);
} else {
resolve({ success: false, error: "Socket not connected" });
}
});
}
requestFileWrite(
args: { path: string; content: string },
): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit(
"fileWriteRequest",
args,
(success: boolean, data: { success?: boolean; error?: string }) =>
resolve({ success: success, ...data }),
);
} else {
resolve({ success: false, error: "Socket not connected" });
}
});
}
releaseSessionLock(
registration: any,
project: any,
chatSession: any,
): Promise<boolean> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit(
"releaseSessionLock",
registration,
project,
chatSession,
resolve,
);
} else {
resolve(false);
}
});
}
requestProcessStats(
registrationId: string,
): Promise<{
success: boolean;
processes?: { pid: number; command: string; args: string[]; projectId: string; projectSlug: string; projectName: string; status: string; createdAt: string; updatedAt: string; stdoutFileName: string; stderrFileName: string }[];
message?: string;
}> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit(
"requestProcessStats",
registrationId,
(success: boolean, data?: { processes?: any[]; message?: string }) => {
resolve({ success, processes: data?.processes, message: data?.message });
},
);
} else {
resolve({ success: false, message: "Socket not connected" });
}
});
}
subscribeDrone(registrationId: string): Promise<boolean> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit("subscribeDrone", registrationId, resolve);
} else {
resolve(false);
}
});
}
unsubscribeDrone(registrationId: string): Promise<boolean> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit("unsubscribeDrone", registrationId, resolve);
} else {
resolve(false);
}
});
}
/**
* Sends a single sessionHeartbeat event to the server (which relays
* it to the drone). The drone resets its 120-second timeout timer
* on each heartbeat.
*/
private sendHeartbeat(): void {
if (this._socket?.connected) {
this._socket.emit("sessionHeartbeat", (ack: boolean) => {
if (!ack) {
console.warn("sessionHeartbeat: drone did not acknowledge");
}
});
}
}
startSessionHeartbeat(): void {
// Already running?
if (this.heartbeatWorker || this.heartbeatFallbackInterval) return;
if (HeartbeatWorker) {
// Use a Web Worker for the heartbeat interval — not subject to
// browser tab throttling, which can delay setInterval to 1/min
// in background tabs and cause drone heartbeat timeouts.
this.heartbeatWorker = new HeartbeatWorker(
new URL("../workers/heartbeat.worker.ts", import.meta.url),
{ type: "module" },
);
this.heartbeatWorker.onmessage = (event: MessageEvent) => {
if (event.data?.type === "tick") {
this.sendHeartbeat();
}
};
this.heartbeatWorker.onerror = (err) => {
console.error("heartbeat worker error, falling back to setInterval", err);
this.stopSessionHeartbeat();
this.startFallbackHeartbeat();
};
this.heartbeatWorker.postMessage({ type: "start" });
} else {
// Fallback: use setInterval (subject to tab throttling)
this.startFallbackHeartbeat();
}
// Install visibility handler: send an immediate heartbeat when
// the tab becomes visible again, to reset the drone's timer
// after any throttling that may have occurred.
this.installVisibilityHandler();
}
private startFallbackHeartbeat(): void {
if (this.heartbeatFallbackInterval) return;
this.heartbeatFallbackInterval = setInterval(() => {
this.sendHeartbeat();
}, 19000);
}
stopSessionHeartbeat(): void {
if (this.heartbeatWorker) {
this.heartbeatWorker.postMessage({ type: "stop" });
this.heartbeatWorker.terminate();
this.heartbeatWorker = null;
}
if (this.heartbeatFallbackInterval) {
clearInterval(this.heartbeatFallbackInterval);
this.heartbeatFallbackInterval = null;
}
}
private installVisibilityHandler(): void {
if (this.boundVisibilityHandler) return; // already installed
this.boundVisibilityHandler = () => {
if (document.visibilityState === "visible") {
// Tab is now visible — send an immediate heartbeat to reset
// the drone's timeout timer, which may have been starved
// while the tab was backgrounded and timers were throttled.
this.sendHeartbeat();
}
};
document.addEventListener("visibilitychange", this.boundVisibilityHandler);
}
private removeVisibilityHandler(): void {
if (this.boundVisibilityHandler) {
document.removeEventListener("visibilitychange", this.boundVisibilityHandler);
this.boundVisibilityHandler = null;
}
}
/**
* Update the stored JWT used for socket authentication.
* Called after a successful token renewal so that future socket
* reconnections use the new token instead of the expired one.
*/
updateJwt(newToken: string): void {
this.jwt = newToken;
if (this._socket) {
this._socket.auth = { ...this._socket.auth, token: newToken };
}
}
}
export const socketClient = new SocketClient();
export const SocketContext = createContext<Socket | null>(null);