Suppression du .git embarqué dans agent_v0/ — le code est maintenant tracké normalement dans le repo principal. Inclut : agent_v1 (client), server_v1 (streaming), lea_ui (chat client) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
399 lines
15 KiB
Python
399 lines
15 KiB
Python
# agent_v1/network/streamer.py
|
|
"""
|
|
Streaming temps réel pour Agent V1.
|
|
Exploite la fibre pour envoyer les événements au fur et à mesure.
|
|
|
|
Endpoints serveur (api_stream.py, port 5005) :
|
|
POST /api/v1/traces/stream/register — enregistrer la session
|
|
POST /api/v1/traces/stream/event — événement temps réel
|
|
POST /api/v1/traces/stream/image — screenshot (full ou crop)
|
|
POST /api/v1/traces/stream/finalize — clôturer et construire le workflow
|
|
|
|
Robustesse (P0-2) :
|
|
- Retry avec backoff exponentiel (1s/2s/4s, max 3 tentatives)
|
|
- Health-check périodique (30s) pour recovery du flag _server_available
|
|
- Compression JPEG qualité 85 pour les images (réduction ~5-10x)
|
|
- Backpressure : queue bornée (maxsize=100), drop des heartbeat si pleine
|
|
"""
|
|
|
|
import io
|
|
import logging
|
|
import queue
|
|
import threading
|
|
import time
|
|
|
|
import requests
|
|
from PIL import Image
|
|
|
|
from ..config import STREAMING_ENDPOINT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Paramètres de retry
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAYS = [1.0, 2.0, 4.0] # Backoff exponentiel
|
|
|
|
# Paramètres de health-check
|
|
HEALTH_CHECK_INTERVAL_S = 30
|
|
|
|
# Paramètres de compression
|
|
JPEG_QUALITY = 85
|
|
|
|
# Taille max de la queue (backpressure)
|
|
QUEUE_MAX_SIZE = 100
|
|
|
|
# Types d'événements à ne jamais dropper
|
|
PRIORITY_EVENT_TYPES = {"click", "key", "scroll", "action", "screenshot"}
|
|
|
|
|
|
class TraceStreamer:
|
|
def __init__(self, session_id: str, machine_id: str = "default"):
|
|
self.session_id = session_id
|
|
self.machine_id = machine_id # Identifiant machine pour le multi-machine
|
|
self.queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
self.running = False
|
|
self._thread = None
|
|
self._health_thread = None
|
|
self._server_available = True # Désactivé après trop d'échecs
|
|
|
|
def start(self):
|
|
"""Démarrer le streaming et enregistrer la session côté serveur."""
|
|
self.running = True
|
|
self._register_session()
|
|
# Thread principal d'envoi
|
|
self._thread = threading.Thread(target=self._stream_loop, daemon=True)
|
|
self._thread.start()
|
|
# Thread de health-check pour recovery
|
|
self._health_thread = threading.Thread(
|
|
target=self._health_check_loop, daemon=True
|
|
)
|
|
self._health_thread.start()
|
|
logger.info(f"Streamer pour {self.session_id} démarré")
|
|
|
|
def stop(self):
|
|
"""Arrêter le streaming et finaliser la session côté serveur.
|
|
|
|
Attend que la queue se vide (max 30s) avant de finaliser,
|
|
pour que toutes les images soient envoyées au serveur.
|
|
"""
|
|
self.running = False
|
|
|
|
# Attendre que la queue se vide (les images doivent être envoyées)
|
|
if self._thread:
|
|
drain_start = time.time()
|
|
while not self.queue.empty() and (time.time() - drain_start) < 30:
|
|
time.sleep(0.5)
|
|
if not self.queue.empty():
|
|
logger.warning(
|
|
f"Queue non vide après 30s ({self.queue.qsize()} items restants)"
|
|
)
|
|
self._thread.join(timeout=5.0)
|
|
|
|
if self._health_thread:
|
|
self._health_thread.join(timeout=2.0)
|
|
|
|
self._finalize_session()
|
|
logger.info(f"Streamer pour {self.session_id} arrêté")
|
|
|
|
def push_event(self, event_data: dict):
|
|
"""Enfile un événement pour envoi immédiat.
|
|
|
|
Si la queue est pleine (backpressure), les heartbeat sont droppés
|
|
tandis que les événements utilisateur (click, key, scroll, action)
|
|
et screenshots sont toujours conservés.
|
|
"""
|
|
self._enqueue_with_backpressure("event", event_data)
|
|
|
|
def push_image(self, image_path: str, screenshot_id: str):
|
|
"""Enfile une image pour envoi asynchrone."""
|
|
if not image_path:
|
|
return # Ignorer les chemins vides (heartbeat sans changement)
|
|
self._enqueue_with_backpressure("image", (image_path, screenshot_id))
|
|
|
|
# =========================================================================
|
|
# Backpressure — gestion de la queue bornée
|
|
# =========================================================================
|
|
|
|
def _enqueue_with_backpressure(self, item_type: str, data):
|
|
"""Ajouter un item à la queue avec gestion du backpressure.
|
|
|
|
Quand la queue est pleine :
|
|
- Les événements prioritaires (click, key, action, screenshot) sont
|
|
ajoutés en bloquant brièvement (0.5s)
|
|
- Les heartbeat sont silencieusement droppés
|
|
"""
|
|
is_priority = self._is_priority_item(item_type, data)
|
|
|
|
try:
|
|
self.queue.put_nowait((item_type, data))
|
|
except queue.Full:
|
|
if is_priority:
|
|
# Événement prioritaire : on attend un peu pour l'ajouter
|
|
try:
|
|
self.queue.put((item_type, data), timeout=0.5)
|
|
except queue.Full:
|
|
logger.warning(
|
|
f"Queue pleine — événement prioritaire droppé "
|
|
f"(type={item_type})"
|
|
)
|
|
else:
|
|
# Heartbeat ou événement non-critique : on drop silencieusement
|
|
logger.debug(
|
|
f"Queue pleine — heartbeat/non-prioritaire droppé "
|
|
f"(type={item_type})"
|
|
)
|
|
|
|
def _is_priority_item(self, item_type: str, data) -> bool:
|
|
"""Vérifie si un item est prioritaire (ne doit pas être droppé).
|
|
|
|
Les images sont toujours prioritaires. Pour les événements,
|
|
on regarde le type d'événement (click, key, scroll, action).
|
|
"""
|
|
if item_type == "image":
|
|
return True
|
|
if item_type == "event" and isinstance(data, dict):
|
|
event_type = data.get("type", "").lower()
|
|
return event_type in PRIORITY_EVENT_TYPES
|
|
return False
|
|
|
|
# =========================================================================
|
|
# Boucle d'envoi
|
|
# =========================================================================
|
|
|
|
def _stream_loop(self):
|
|
"""Boucle d'envoi asynchrone (thread daemon)."""
|
|
consecutive_failures = 0
|
|
while self.running or not self.queue.empty():
|
|
try:
|
|
item_type, data = self.queue.get(timeout=0.5)
|
|
success = False
|
|
if item_type == "event":
|
|
success = self._send_with_retry(self._send_event, data)
|
|
elif item_type == "image":
|
|
success = self._send_with_retry(self._send_image, *data)
|
|
self.queue.task_done()
|
|
|
|
if success:
|
|
consecutive_failures = 0
|
|
else:
|
|
consecutive_failures += 1
|
|
if consecutive_failures >= 10:
|
|
logger.warning(
|
|
"10 échecs consécutifs — serveur marqué indisponible"
|
|
)
|
|
self._server_available = False
|
|
consecutive_failures = 0
|
|
|
|
except queue.Empty:
|
|
continue
|
|
except Exception as e:
|
|
logger.error(f"Erreur Streaming Loop: {e}")
|
|
|
|
# =========================================================================
|
|
# Retry avec backoff exponentiel
|
|
# =========================================================================
|
|
|
|
def _send_with_retry(self, send_fn, *args) -> bool:
|
|
"""Tente l'envoi avec retry et backoff exponentiel.
|
|
|
|
3 tentatives max avec délais de 1s, 2s, 4s entre chaque.
|
|
Retourne True si l'envoi a réussi, False sinon.
|
|
"""
|
|
# Première tentative (sans délai)
|
|
if send_fn(*args):
|
|
return True
|
|
|
|
# Retries avec backoff
|
|
for attempt, delay in enumerate(RETRY_DELAYS, start=1):
|
|
if not self.running:
|
|
# On arrête les retries si le streamer est en cours d'arrêt
|
|
break
|
|
logger.debug(
|
|
f"Retry {attempt}/{MAX_RETRIES} dans {delay}s..."
|
|
)
|
|
time.sleep(delay)
|
|
if send_fn(*args):
|
|
logger.debug(f"Retry {attempt} réussi")
|
|
return True
|
|
|
|
logger.debug(f"Envoi échoué après {MAX_RETRIES} retries")
|
|
return False
|
|
|
|
# =========================================================================
|
|
# Health-check périodique pour recovery
|
|
# =========================================================================
|
|
|
|
def _health_check_loop(self):
|
|
"""Vérifie périodiquement si le serveur est redevenu disponible.
|
|
|
|
Toutes les 30s, tente un GET /stats. Si le serveur répond,
|
|
remet _server_available = True et ré-enregistre la session.
|
|
"""
|
|
while self.running:
|
|
time.sleep(HEALTH_CHECK_INTERVAL_S)
|
|
if not self.running:
|
|
break
|
|
if self._server_available:
|
|
# Serveur déjà disponible, rien à faire
|
|
continue
|
|
# Tenter un health-check
|
|
try:
|
|
resp = requests.get(
|
|
f"{STREAMING_ENDPOINT}/stats",
|
|
timeout=3,
|
|
)
|
|
if resp.ok:
|
|
logger.info(
|
|
"Health-check OK — serveur redevenu disponible, "
|
|
"ré-enregistrement de la session"
|
|
)
|
|
self._server_available = True
|
|
self._register_session()
|
|
except Exception:
|
|
logger.debug("Health-check échoué — serveur toujours indisponible")
|
|
|
|
# =========================================================================
|
|
# Compression JPEG
|
|
# =========================================================================
|
|
|
|
def _compress_image_to_jpeg(self, path: str) -> tuple:
|
|
"""Compresse une image (PNG ou autre) en JPEG qualité 85 en mémoire.
|
|
|
|
Retourne un tuple (bytes_io, content_type, filename_suffix).
|
|
Si la compression échoue, renvoie le fichier original en PNG.
|
|
"""
|
|
try:
|
|
img = Image.open(path)
|
|
# Convertir en RGB si nécessaire (JPEG ne supporte pas l'alpha)
|
|
if img.mode in ("RGBA", "LA", "P"):
|
|
img = img.convert("RGB")
|
|
buf = io.BytesIO()
|
|
img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True)
|
|
buf.seek(0)
|
|
return buf, "image/jpeg", ".jpg"
|
|
except FileNotFoundError:
|
|
# Fichier introuvable — propager l'erreur (pas de fallback possible)
|
|
logger.warning(f"Fichier image introuvable pour compression : {path}")
|
|
raise
|
|
except Exception as e:
|
|
logger.warning(f"Compression JPEG échouée, envoi PNG brut: {e}")
|
|
return None, None, None
|
|
|
|
# =========================================================================
|
|
# Envois HTTP
|
|
# =========================================================================
|
|
|
|
def _register_session(self):
|
|
"""Enregistrer la session auprès du serveur (avec identifiant machine)."""
|
|
try:
|
|
resp = requests.post(
|
|
f"{STREAMING_ENDPOINT}/register",
|
|
params={
|
|
"session_id": self.session_id,
|
|
"machine_id": self.machine_id,
|
|
},
|
|
timeout=3,
|
|
)
|
|
if resp.ok:
|
|
logger.info(
|
|
f"Session {self.session_id} enregistrée sur le serveur "
|
|
f"(machine={self.machine_id})"
|
|
)
|
|
self._server_available = True
|
|
else:
|
|
logger.warning(f"Enregistrement session échoué: {resp.status_code}")
|
|
except Exception as e:
|
|
logger.debug(f"Serveur indisponible pour register: {e}")
|
|
self._server_available = False
|
|
|
|
def _finalize_session(self):
|
|
"""Finaliser la session (construction du workflow côté serveur).
|
|
|
|
IMPORTANT : tente TOUJOURS l'envoi, indépendamment de _server_available.
|
|
C'est la dernière chance de sauver les données de la session.
|
|
"""
|
|
try:
|
|
resp = requests.post(
|
|
f"{STREAMING_ENDPOINT}/finalize",
|
|
params={
|
|
"session_id": self.session_id,
|
|
"machine_id": self.machine_id,
|
|
},
|
|
timeout=30, # Le build workflow peut prendre du temps
|
|
)
|
|
if resp.ok:
|
|
result = resp.json()
|
|
logger.info(f"Session finalisée: {result}")
|
|
else:
|
|
logger.warning(f"Finalisation échouée: {resp.status_code}")
|
|
except Exception as e:
|
|
logger.debug(f"Finalisation échouée: {e}")
|
|
|
|
def _send_event(self, event: dict) -> bool:
|
|
"""Envoyer un événement au serveur (avec identifiant machine)."""
|
|
if not self._server_available:
|
|
return False
|
|
try:
|
|
payload = {
|
|
"session_id": self.session_id,
|
|
"timestamp": time.time(),
|
|
"event": event,
|
|
"machine_id": self.machine_id,
|
|
}
|
|
resp = requests.post(
|
|
f"{STREAMING_ENDPOINT}/event",
|
|
json=payload,
|
|
timeout=2,
|
|
)
|
|
return resp.ok
|
|
except Exception as e:
|
|
logger.debug(f"Streaming Event échoué: {e}")
|
|
return False
|
|
|
|
def _send_image(self, path: str, shot_id: str) -> bool:
|
|
"""Envoyer un screenshot au serveur, compressé en JPEG.
|
|
|
|
Utilise un context manager pour le fallback PNG afin d'éviter
|
|
les fuites de descripteurs de fichier.
|
|
"""
|
|
if not self._server_available:
|
|
return False
|
|
try:
|
|
# Tenter la compression JPEG (réduction ~5-10x vs PNG)
|
|
jpeg_buf, content_type, suffix = self._compress_image_to_jpeg(path)
|
|
|
|
params = {
|
|
"session_id": self.session_id,
|
|
"shot_id": shot_id,
|
|
"machine_id": self.machine_id,
|
|
}
|
|
|
|
if jpeg_buf is not None:
|
|
# Envoi du JPEG compressé (BytesIO, pas de fuite possible)
|
|
files = {
|
|
"file": (f"{shot_id}{suffix}", jpeg_buf, content_type)
|
|
}
|
|
resp = requests.post(
|
|
f"{STREAMING_ENDPOINT}/image",
|
|
files=files,
|
|
params=params,
|
|
timeout=5,
|
|
)
|
|
return resp.ok
|
|
else:
|
|
# Fallback : envoi PNG original avec context manager
|
|
with open(path, "rb") as f:
|
|
files = {
|
|
"file": (f"{shot_id}.png", f, "image/png")
|
|
}
|
|
resp = requests.post(
|
|
f"{STREAMING_ENDPOINT}/image",
|
|
files=files,
|
|
params=params,
|
|
timeout=5,
|
|
)
|
|
return resp.ok
|
|
except Exception as e:
|
|
logger.debug(f"Streaming Image échoué: {e}")
|
|
return False
|