"""backend/services/serialization.py Persistance simple (JSON/YAML) pour les workflows. Auteur : Dom, Alice, Kiro - 08 janvier 2026 Patch #1: - Fournit WorkflowDatabase + WorkflowSerializer utilisés par api/workflows.py - Stockage fichier: un workflow = un fichier .json dans un répertoire Design: - Permissif: on conserve les champs inconnus via models.py - Robuste: erreurs encapsulées, pas de crash au boot """ from __future__ import annotations import json import os from dataclasses import dataclass from typing import Any, Dict, Optional, List import yaml # PyYAML from models import VisualWorkflow, generate_id, WorkflowSettings from datetime import datetime class SerializationError(Exception): """Erreur de sérialisation.""" pass class ValidationError(Exception): """Erreur de validation pour les workflows sérialisés.""" def __init__(self, errors: List[str]): super().__init__(", ".join(errors)) self.errors = errors def create_empty_workflow(name: str, description: str, created_by: str) -> VisualWorkflow: """Crée un workflow vide avec les paramètres de base.""" now = datetime.now() return VisualWorkflow( id=WorkflowSerializer.generate_workflow_id(), name=name, description=description, version="1.0.0", created_at=now, updated_at=now, created_by=created_by, nodes=[], edges=[], variables=[], settings=WorkflowSettings(), ) class WorkflowSerializer: """Sérialise/désérialise les workflows vers/depuis JSON ou YAML.""" @staticmethod def generate_workflow_id() -> str: """Génère un identifiant unique pour un workflow.""" return generate_id("wf") @staticmethod def serialize(workflow: VisualWorkflow, format: str = "json") -> str: """Sérialise un workflow vers une chaîne JSON ou YAML.""" try: data = workflow.to_dict() fmt = (format or "json").lower() if fmt == "json": return json.dumps(data, ensure_ascii=False, indent=2) if fmt in ("yml", "yaml"): return yaml.safe_dump(data, allow_unicode=True, sort_keys=False) raise SerializationError(f"Format non supporté: {format}") except Exception as e: raise SerializationError(str(e)) from e @staticmethod def deserialize(raw: Any, format: str = "json") -> VisualWorkflow: """Désérialise un workflow depuis une chaîne JSON ou YAML.""" try: fmt = (format or "json").lower() if isinstance(raw, (bytes, bytearray)): raw = raw.decode("utf-8", errors="replace") if isinstance(raw, str): if fmt == "json": data = json.loads(raw) elif fmt in ("yml", "yaml"): data = yaml.safe_load(raw) else: raise SerializationError(f"Format non supporté: {format}") elif isinstance(raw, dict): data = raw else: raise SerializationError("Type d'entrée invalide pour la désérialisation") wf = VisualWorkflow.from_dict(data) errors = wf.validate() if errors: raise ValidationError(errors) return wf except ValidationError: raise except Exception as e: raise SerializationError(str(e)) from e @dataclass class WorkflowDatabase: """Stockage de workflows basé sur des fichiers.""" root_dir: str def __post_init__(self) -> None: """Crée le répertoire de stockage s'il n'existe pas.""" os.makedirs(self.root_dir, exist_ok=True) def _path(self, workflow_id: str) -> str: """Retourne le chemin du fichier pour un workflow donné.""" safe_id = "".join(c for c in workflow_id if c.isalnum() or c in ("_", "-")) or workflow_id return os.path.join(self.root_dir, f"{safe_id}.json") def exists(self, workflow_id: str) -> bool: """Vérifie si un workflow existe.""" return os.path.exists(self._path(workflow_id)) def save(self, workflow: VisualWorkflow) -> None: """Sauvegarde un workflow sur disque.""" try: payload = WorkflowSerializer.serialize(workflow, format="json") with open(self._path(workflow.id), "w", encoding="utf-8") as f: f.write(payload) except Exception as e: raise SerializationError(f"Échec de la sauvegarde du workflow: {e}") from e def load(self, workflow_id: str) -> Optional[VisualWorkflow]: """Charge un workflow depuis le disque.""" path = self._path(workflow_id) if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as f: raw = f.read() return WorkflowSerializer.deserialize(raw, format="json") except ValidationError: raise except Exception as e: raise SerializationError(f"Échec du chargement du workflow '{workflow_id}': {e}") from e def delete(self, workflow_id: str) -> None: """Supprime un workflow du disque.""" path = self._path(workflow_id) try: if os.path.exists(path): os.remove(path) except Exception as e: raise SerializationError(f"Échec de la suppression du workflow '{workflow_id}': {e}") from e def list(self) -> List[VisualWorkflow]: """Liste tous les workflows disponibles. Les fichiers invalides sont ignorés silencieusement pour éviter de bloquer le chargement de tous les workflows. """ workflows: List[VisualWorkflow] = [] if not os.path.isdir(self.root_dir): return [] for fname in os.listdir(self.root_dir): if not fname.endswith(".json"): continue wf_id = fname[:-5] try: wf = self.load(wf_id) if wf is not None: workflows.append(wf) except (SerializationError, ValidationError) as e: # Ignorer les fichiers invalides et continuer print(f"⚠️ Workflow ignoré '{wf_id}': {e}") except Exception as e: # Autres erreurs - les ignorer aussi pour ne pas bloquer print(f"⚠️ Erreur inattendue pour '{wf_id}': {e}") return workflows