fix(dashboard): resolver bugs críticos de tiempo real en HITL — race conditions, In-Band Auth y multi-stream buffer
- AppShell: corregir condición de carrera REST/WS que perdía tokens de agent_stream_chunk
- init_state atómico + eliminación de doble fuente REST/WS para actualización en tiempo real
- conversation_ended e idempotencia de eventos en máquina de estados por conversación
- Seguridad: migrar JWT de query param a In-Band Auth (primer mensaje {action:auth}) con timeout 5s y cierre 1008
- Multi-stream buffer: reemplazar buffer plano por TTL LRU (200 entradas, 60s TTL) para evitar pisado de tokens entre agentes
- agent_stream_completed ya no borra buffer incondicionalmente — delega purge a la política LRU
- Timer: corregir display de 00:00 en estado PENDING con visualización inmediata + cleanup en stop()
- Tests: 8 tests multi-stream, tests In-Band Auth, tests idempotencia y máquina de estados, tests Timer
- Resultado: 86/86 tests pasan | TypeScript 0 errores
This commit is contained in:
+107
-8
@@ -1,10 +1,11 @@
|
||||
import type { WSEnvelope } from '@/types/wsProtocol';
|
||||
import { auth } from '@/services/auth';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Configuration
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_WS_URL = 'ws://localhost:3000/ws/dashboard';
|
||||
const DEFAULT_WS_URL = 'ws://localhost:5503/ws/dashboard';
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || DEFAULT_WS_URL;
|
||||
|
||||
@@ -18,12 +19,21 @@ const BACKOFF_FACTOR = 2;
|
||||
|
||||
export type WsConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
export type WsAuthState = 'pending' | 'authenticated' | 'failed';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Event callback types
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type MessageCallback = (envelope: WSEnvelope) => void;
|
||||
export type StatusChangeCallback = (status: WsConnectionStatus) => void;
|
||||
export type AuthenticatedCallback = () => void;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const AUTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// WebSocket Client
|
||||
@@ -34,8 +44,11 @@ class WsClient {
|
||||
private status: WsConnectionStatus = 'disconnected';
|
||||
private onMessageCallback: MessageCallback | null = null;
|
||||
private onStatusChangeCallback: StatusChangeCallback | null = null;
|
||||
private onAuthenticatedCallback: AuthenticatedCallback | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private authTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private authState: WsAuthState = 'pending';
|
||||
private destroyFlag = false;
|
||||
|
||||
// ── Connection ───────────────────────────────────────────
|
||||
@@ -45,12 +58,22 @@ class WsClient {
|
||||
* If already connected, it will close and reconnect.
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
return; // already connected
|
||||
// Guard: skip if already connected or connecting (prevents double-connect in StrictMode)
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check auth token before attempting connection
|
||||
const token = auth.getToken();
|
||||
if (!token) {
|
||||
console.warn('[WS] No auth token — skipping connection');
|
||||
return;
|
||||
}
|
||||
|
||||
this.destroyFlag = false;
|
||||
this.authState = 'pending';
|
||||
|
||||
// Paso 0: Connect WITHOUT token in URL — clean WebSocket URL
|
||||
try {
|
||||
this.ws = new WebSocket(WS_URL);
|
||||
} catch (err) {
|
||||
@@ -62,20 +85,71 @@ class WsClient {
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.setStatus('connected');
|
||||
|
||||
// Send In-Band Auth as first message
|
||||
const authMessage = {
|
||||
action: 'auth',
|
||||
token: token,
|
||||
};
|
||||
this.ws?.send(JSON.stringify(authMessage));
|
||||
|
||||
// Start auth timeout: 5s to receive { status: "authenticated" }
|
||||
this.authTimer = setTimeout(() => {
|
||||
if (this.authState !== 'authenticated') {
|
||||
console.warn('[WS] Auth timeout — no auth response within 5s');
|
||||
this.authState = 'failed';
|
||||
this.ws?.close(1008, 'Auth timeout');
|
||||
}
|
||||
}, AUTH_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: MessageEvent) => {
|
||||
if (!this.onMessageCallback) return;
|
||||
|
||||
try {
|
||||
const envelope: WSEnvelope = JSON.parse(event.data as string);
|
||||
this.onMessageCallback(envelope);
|
||||
const data = JSON.parse(event.data as string);
|
||||
|
||||
// Handle auth response first
|
||||
if (data.status === 'authenticated') {
|
||||
this.authState = 'authenticated';
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer);
|
||||
this.authTimer = null;
|
||||
}
|
||||
// Notify listeners that auth is complete
|
||||
this.onAuthenticatedCallback?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// If not yet authenticated, drop business messages
|
||||
if (this.authState !== 'authenticated') {
|
||||
console.warn('[WS] Dropping message — auth not yet complete');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate business events to registered callback
|
||||
if (this.onMessageCallback) {
|
||||
const envelope: WSEnvelope = data;
|
||||
this.onMessageCallback(envelope);
|
||||
}
|
||||
} catch {
|
||||
// Malformed message — silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws.onclose = (event: CloseEvent) => {
|
||||
// Clean up auth timer
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer);
|
||||
this.authTimer = null;
|
||||
}
|
||||
|
||||
// Code 1008 = auth failure — transition to failed
|
||||
if (event.code === 1008) {
|
||||
this.authState = 'failed';
|
||||
this.setStatus('disconnected');
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only transition to reconnecting if we didn't intentionally close
|
||||
if (!this.destroyFlag) {
|
||||
this.setStatus('reconnecting');
|
||||
@@ -99,6 +173,11 @@ class WsClient {
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.authTimer !== null) {
|
||||
clearTimeout(this.authTimer);
|
||||
this.authTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // prevent reconnect trigger
|
||||
this.ws.close();
|
||||
@@ -166,6 +245,26 @@ class WsClient {
|
||||
return this.onStatusChangeCallback;
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a callback for when In-Band Auth completes successfully.
|
||||
*/
|
||||
set onAuthenticated(cb: AuthenticatedCallback | null) {
|
||||
this.onAuthenticatedCallback = cb;
|
||||
}
|
||||
|
||||
get onAuthenticated(): AuthenticatedCallback | null {
|
||||
return this.onAuthenticatedCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current auth state.
|
||||
*/
|
||||
getAuthState(): WsAuthState {
|
||||
return this.authState;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────
|
||||
|
||||
private setStatus(status: WsConnectionStatus): void {
|
||||
|
||||
Reference in New Issue
Block a user