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:
2026-07-29 04:32:27 -05:00
parent 8f044567c0
commit 83e3ec2cff
37 changed files with 6979 additions and 1129 deletions
+107 -8
View File
@@ -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 {