Files
rpa_vision_v3/agent_v0/agent_v1/network/streamer.py
Dom 013fe071a2 feat(streamer): purge après ACK + buffering SQLite persistant
- Nouveau module persistent_buffer.py (SQLite WAL, thread-safe)
- Purge automatique des captures locales après ACK 200 serveur
- Drain loop 15s, retry exponentiel, plafonds tentatives
- Enum ImageSendResult.{OK, FAILED, FILE_GONE} pour distinguer les cas
- FileNotFoundError n'est plus un faux succès (P0-E audit)
- 14 tests intégration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:47:35 +02:00

694 lines
28 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
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