# 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 Conformité AI Act (Article 12 — journalisation automatique) : - Purge après ACK : les screenshots locaux sont supprimés après HTTP 200 du serveur (par défaut). Le serveur devient la source de vérité. - Buffer persistant : les events/images prioritaires non envoyés sont persistés dans un SQLite local (agent_v1/buffer/pending_events.db) et rejoués au démarrage et à la reconnexion. """ import enum import io import logging import os import queue import threading import time import requests from PIL import Image from ..config import API_TOKEN, BASE_DIR, STREAMING_ENDPOINT from .persistent_buffer import MAX_ATTEMPTS, PersistentBuffer # Fix P0-E : résultat d'envoi d'image trivaleur (succès / échec réseau / fichier # disparu). On ne doit PAS considérer un FileNotFoundError comme un succès # HTTP 200 — sinon le buffer SQLite supprime l'entrée alors que le serveur n'a # jamais reçu l'image (perte silencieuse). class ImageSendResult(enum.Enum): OK = "ok" # HTTP 200, serveur a accusé réception FAILED = "failed" # Erreur réseau/serveur récupérable (retry OK) FILE_GONE = "file_gone" # Fichier local introuvable (abandon, pas retry) 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"} # Purge locale après ACK serveur (Partie A de l'audit) # Activé par défaut : le serveur conserve déjà les screenshots 180 jours # (conformité AI Act Article 12). Désactivable via RPA_PURGE_AFTER_ACK=0 # pour debugging local. PURGE_AFTER_ACK = os.environ.get("RPA_PURGE_AFTER_ACK", "1").lower() in ( "1", "true", "yes", ) # Chemin du buffer persistant (Partie B de l'audit) BUFFER_DIR = BASE_DIR / "buffer" # Intervalle entre deux tentatives de drain du buffer (secondes) BUFFER_DRAIN_INTERVAL_S = 15 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._drain_thread = None self._server_available = True # Désactivé après trop d'échecs # Buffer persistant — partagé entre sessions (survit au redémarrage) # Initialisé paresseusement pour ne pas payer le coût SQLite en dehors # d'un streaming actif. self._buffer: PersistentBuffer | None = None def _get_buffer(self) -> PersistentBuffer: """Retourne le buffer persistant, en l'initialisant au besoin.""" if self._buffer is None: self._buffer = PersistentBuffer(BUFFER_DIR) return self._buffer @staticmethod def _auth_headers() -> dict: """Headers d'authentification Bearer pour les requêtes API.""" if API_TOKEN: return {"Authorization": f"Bearer {API_TOKEN}"} return {} 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() # Thread de drain du buffer persistant (rejoue les items en attente) self._drain_thread = threading.Thread( target=self._buffer_drain_loop, daemon=True ) self._drain_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) if self._drain_thread: self._drain_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). Si toujours pleine → persistés dans le buffer SQLite pour rejeu ultérieur. - Les heartbeat sont silencieusement droppés. - Si le serveur est marqué indisponible, on persiste immédiatement les items prioritaires (évite de remplir la queue inutilement). """ is_priority = self._is_priority_item(item_type, data) # Serveur indisponible + item prioritaire → on persiste directement # sans polluer la queue RAM (qui ne sera jamais vidée tant que le # serveur est down). if is_priority and not self._server_available: self._persist_to_buffer(item_type, data) return 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: # Persistance disque (ne JAMAIS dropper un prioritaire) persisted = self._persist_to_buffer(item_type, data) if persisted: logger.warning( f"Queue pleine — événement prioritaire persisté " f"sur disque (type={item_type})" ) else: logger.error( f"Queue pleine ET buffer saturé — événement " f"prioritaire perdu (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 def _persist_to_buffer(self, item_type: str, data) -> bool: """Persiste un item dans le buffer SQLite. Retourne True si OK. Utilisé quand la queue est pleine ou le serveur indisponible. """ try: buf = self._get_buffer() if item_type == "event" and isinstance(data, dict): return buf.add_event(self.session_id, data) if item_type == "image": path, shot_id = data return buf.add_image(self.session_id, path, shot_id) except Exception as e: # On n'arrête jamais l'agent si le buffer échoue logger.error(f"Persistance buffer échouée : {e}") 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 is_file_gone = False if item_type == "event": success = self._send_with_retry(self._send_event, data) elif item_type == "image": result = self._send_with_retry(self._send_image, *data) # Fix P0-E : distinguer FILE_GONE du vrai succès HTTP. if result is ImageSendResult.OK: success = True elif result is ImageSendResult.FILE_GONE: # Fichier disparu : pas de retry, pas de persistance # (on ne peut plus le renvoyer). On considère l'item # comme traité sans comptabiliser un succès réseau. is_file_gone = True success = False else: success = False self.queue.task_done() if success: consecutive_failures = 0 elif is_file_gone: # Fichier introuvable — déjà logué ERROR dans _send_image. # On ne persiste PAS dans le buffer (retry voué à échouer). consecutive_failures = 0 else: consecutive_failures += 1 # Après 3 retries infructueux, si l'item est prioritaire, # on le persiste pour ne pas le perdre définitivement. if self._is_priority_item(item_type, data): self._persist_to_buffer(item_type, data) 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): """Tente l'envoi avec retry et backoff exponentiel. 3 tentatives max avec délais de 1s, 2s, 4s entre chaque. Retourne : - True / ImageSendResult.OK si l'envoi a réussi - ImageSendResult.FILE_GONE (images uniquement) — pas de retry - False / ImageSendResult.FAILED sinon """ # Première tentative (sans délai) first = send_fn(*args) if first is ImageSendResult.OK or first is True: return first # Fix P0-E : FILE_GONE → pas de retry, l'erreur est permanente. if first is ImageSendResult.FILE_GONE: return first # 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) result = send_fn(*args) if result is ImageSendResult.OK or result is True: logger.debug(f"Retry {attempt} réussi") return result # FILE_GONE pendant un retry — idem, on arrête if result is ImageSendResult.FILE_GONE: return result 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", headers=self._auth_headers(), 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") # ========================================================================= # Drain du buffer persistant (Partie B) # ========================================================================= def _buffer_drain_loop(self): """Rejoue les items persistés en arrière-plan. Tourne tant que self.running. Essaie de drainer le buffer toutes les BUFFER_DRAIN_INTERVAL_S secondes, mais seulement si : - le serveur est disponible, - il y a effectivement des items en attente. Au premier passage (démarrage agent), on draine immédiatement pour rejouer tout ce qui a été persisté lors de la session précédente. """ # Au démarrage : drain immédiat (pas d'attente) first_pass = True while self.running: if not first_pass: time.sleep(BUFFER_DRAIN_INTERVAL_S) if not self.running: break first_pass = False if not self._server_available: continue try: buf = self._get_buffer() # Abandonner d'abord les items exceeded (évite de les retenter) abandoned = buf.abandon_exceeded() if abandoned: logger.warning( f"Buffer : {abandoned} items abandonnés " f"après {MAX_ATTEMPTS} tentatives" ) counts = buf.counts() if counts["events"] == 0 and counts["images"] == 0: continue logger.info( f"Buffer drain : {counts['events']} events, " f"{counts['images']} images en attente — rejeu" ) self._drain_buffer_once(buf) except Exception as e: logger.error(f"Buffer drain loop échoué : {e}") def _drain_buffer_once(self, buf: PersistentBuffer): """Une passe de drain : envoie ce qui peut l'être, incrémente le reste. On arrête dès qu'un envoi échoue (serveur probablement down). """ # Events d'abord (plus légers, priorité métier AI Act) for row in buf.drain_events(limit=50): if not self._server_available: return try: import json as _json event = _json.loads(row["payload"]) except (ValueError, TypeError): logger.error( f"Buffer : payload event #{row['id']} corrompu, suppression" ) buf.delete_event(row["id"]) continue if self._send_event(event): buf.delete_event(row["id"]) else: buf.increment_attempts(row["id"], "event") # Serveur répond mal — on arrête la passe return # Puis images for row in buf.drain_images(limit=20): if not self._server_available: return image_path = row["image_path"] shot_id = row["shot_id"] if not os.path.exists(image_path): # Fichier local disparu (purge, clean-up) — on abandonne. # Fix P0-E : log ERROR (pas warning) — c'est une perte de donnée. logger.error( f"Buffer : image #{row['id']} introuvable sur disque " f"({image_path}) — entrée abandonnée (le serveur n'a " f"jamais reçu cette image, session={row['session_id']}, " f"shot={shot_id})" ) buf.delete_image(row["id"]) continue result = self._send_image(image_path, shot_id) if result is ImageSendResult.OK or result is True: buf.delete_image(row["id"]) elif result is ImageSendResult.FILE_GONE: # Fix P0-E : fichier disparu pendant l'envoi. # Ce n'est PAS un succès HTTP — ne pas considérer comme tel. # On supprime néanmoins l'entrée (retry voué à échouer) # mais avec un log ERROR explicite. logger.error( f"Buffer : image #{row['id']} disparue pendant l'envoi " f"({image_path}) — entrée abandonnée, pas de retry " f"(session={row['session_id']}, shot={shot_id})" ) buf.delete_image(row["id"]) else: buf.increment_attempts(row["id"], "image") return # ========================================================================= # 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 # ========================================================================= # Purge locale après ACK (Partie A) # ========================================================================= @staticmethod def _purge_local_image(path: str): """Supprime un screenshot local après ACK 200 du serveur. Ne crashe JAMAIS si le fichier est verrouillé (cas Windows) ou déjà supprimé : on log en debug et on continue. L'auto-cleanup de SessionStorage repassera plus tard. """ if not PURGE_AFTER_ACK: return try: os.remove(path) logger.debug(f"Screenshot local purgé après ACK : {path}") except FileNotFoundError: # Déjà supprimé ou chemin invalide — silencieux pass except PermissionError as e: # Windows verrouille parfois les fichiers (antivirus, indexation...) logger.debug( f"Purge différée (fichier verrouillé) : {path} — {e}" ) except OSError as e: logger.debug(f"Purge échouée : {path} — {e}") # ========================================================================= # 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, }, headers=self._auth_headers(), 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, }, headers=self._auth_headers(), 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.warning(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, headers=self._auth_headers(), 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): """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. Partie A (purge après ACK) : en cas de HTTP 200 confirmé, le fichier local est supprimé (le serveur devient la source de vérité). Fix P0-E : retourne `ImageSendResult` (OK / FAILED / FILE_GONE). Les appelants historiques qui attendaient un bool continuent de fonctionner grâce à la truthiness du enum (OK → True, reste → False), MAIS le drain du buffer doit désormais discriminer FILE_GONE pour ne pas confondre "fichier disparu" avec "envoyé avec succès". """ if not self._server_available: return ImageSendResult.FAILED 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, headers=self._auth_headers(), timeout=5, ) if resp.ok: self._purge_local_image(path) return ImageSendResult.OK return ImageSendResult.FAILED 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, headers=self._auth_headers(), timeout=5, ) if resp.ok: self._purge_local_image(path) return ImageSendResult.OK return ImageSendResult.FAILED except FileNotFoundError: # Fix P0-E : fichier local disparu. On NE doit PAS considérer ça # comme un succès HTTP 200. Le serveur n'a rien reçu. On signale # `FILE_GONE` pour que le drain du buffer supprime l'entrée # (pas de retry possible) tout en loguant ERROR (pas debug). logger.error( f"Image {shot_id} introuvable sur disque ({path}) — " f"abandon (serveur n'a rien reçu)" ) return ImageSendResult.FILE_GONE except Exception as e: logger.debug(f"Streaming Image échoué: {e}") return ImageSendResult.FAILED