Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Pipeline E2E complet validé : Capture VM → streaming → serveur → cleaner → replay → audit trail Mode apprentissage supervisé fonctionne (Léa échoue → humain → reprise) Dashboard : - Cleanup 14→10 onglets (RCE supprimée) - Fleet : enregistrer/révoquer agents, tokens, ZIP pré-configuré téléchargeable - Audit trail MVP (/audit) : filtres, tableau, export CSV, conformité AI Act/RGPD - Formulaire Fleet simplifié (nom + email, machine_id auto) VWB bridge Léa→VWB : - Compound décomposés en N steps (saisie + raccourci visibles) - Layout serpentin 3 colonnes (plus colonne verticale) - Badge OS 🪟/🐧, filtre OS retiré (admin Linux voit Windows) - Fix import SQLite readonly Cleaner intelligent : - Descriptions lisibles (UIA/C2) + détection doublons - Logique C2 : UIElement identifié = jamais parasite - Patterns parasites resserrés - Message Léa : "Je n'y arrive pas, montrez-moi comment faire" Config agent (INC-1 à INC-7) : - SERVER_URL + SERVER_BASE unifiés - RPA_OLLAMA_HOST séparé - allow_redirects=False sur POST - Middleware réécriture URL serveur CI Gitea : fix token + Flask-SocketIO + ruff propre Fleet endpoints : /agents/enroll|uninstall|fleet + agent_registry SQLite Backup : script quotidien workflows.db + audit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
735 lines
30 KiB
Python
735 lines
30 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}")
|
|
|
|
# =========================================================================
|
|
# Protection redirect POST→GET (INC-7)
|
|
# =========================================================================
|
|
|
|
@staticmethod
|
|
def _check_redirect(resp, url: str):
|
|
"""Detecter et logger une redirection sur un POST.
|
|
|
|
La lib requests transforme un POST en GET sur 301/302 (RFC 7231).
|
|
Avec allow_redirects=False, on recoit le 301/302 directement.
|
|
On log un WARNING explicite pour que l'admin corrige l'URL.
|
|
"""
|
|
if resp.status_code in (301, 302, 307, 308):
|
|
location = resp.headers.get("Location", "?")
|
|
logger.warning(
|
|
f"Redirection {resp.status_code} detectee sur POST {url} "
|
|
f"→ {location}. Verifiez que RPA_SERVER_URL utilise "
|
|
f"https:// si le serveur redirige."
|
|
)
|
|
return True
|
|
return False
|
|
|
|
# =========================================================================
|
|
# Envois HTTP
|
|
# =========================================================================
|
|
|
|
def _register_session(self):
|
|
"""Enregistrer la session auprès du serveur (avec identifiant machine)."""
|
|
try:
|
|
url = f"{STREAMING_ENDPOINT}/register"
|
|
resp = requests.post(
|
|
url,
|
|
params={
|
|
"session_id": self.session_id,
|
|
"machine_id": self.machine_id,
|
|
},
|
|
headers=self._auth_headers(),
|
|
timeout=3,
|
|
allow_redirects=False,
|
|
)
|
|
if self._check_redirect(resp, url):
|
|
logger.warning("Enregistrement session échoué (redirect)")
|
|
return
|
|
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:
|
|
url = f"{STREAMING_ENDPOINT}/finalize"
|
|
resp = requests.post(
|
|
url,
|
|
params={
|
|
"session_id": self.session_id,
|
|
"machine_id": self.machine_id,
|
|
},
|
|
headers=self._auth_headers(),
|
|
timeout=30, # Le build workflow peut prendre du temps
|
|
allow_redirects=False,
|
|
)
|
|
self._check_redirect(resp, url)
|
|
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:
|
|
url = f"{STREAMING_ENDPOINT}/event"
|
|
payload = {
|
|
"session_id": self.session_id,
|
|
"timestamp": time.time(),
|
|
"event": event,
|
|
"machine_id": self.machine_id,
|
|
}
|
|
resp = requests.post(
|
|
url,
|
|
json=payload,
|
|
headers=self._auth_headers(),
|
|
timeout=2,
|
|
allow_redirects=False,
|
|
)
|
|
if self._check_redirect(resp, url):
|
|
return False
|
|
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,
|
|
}
|
|
|
|
url = f"{STREAMING_ENDPOINT}/image"
|
|
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(
|
|
url,
|
|
files=files,
|
|
params=params,
|
|
headers=self._auth_headers(),
|
|
timeout=5,
|
|
allow_redirects=False,
|
|
)
|
|
if self._check_redirect(resp, url):
|
|
return ImageSendResult.FAILED
|
|
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(
|
|
url,
|
|
files=files,
|
|
params=params,
|
|
headers=self._auth_headers(),
|
|
timeout=5,
|
|
allow_redirects=False,
|
|
)
|
|
if self._check_redirect(resp, url):
|
|
return ImageSendResult.FAILED
|
|
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
|