Files
rpa_vision_v3/docs/recherche/AXE_B1_REPLAY_TRANSPORT.md

30 KiB
Raw Blame History

AXE B1 — Refonte du transport replay + watchdog d'orphelins

Date : 2026-05-23 Auteur : Claude (recherche dispatchée, lecture seule sur code) Périmètre : B1 (transport) + B3 (watchdog _retry_pending) Statut : Étude — aucun changement de code. Pseudo-code prêt à coller.

Lecture pré-requise : docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md (diagnostic 9 actions perdues en 33 s) et docs/SYNTHESE_TECHNOS_REPLAY_2026-05-23.md §4.


1. TL;DR — recommandation

Migration cible : SSE (sse-starlette). En complément immédiat, watchdog _retry_pending indépendant activable sous flag, gardé même après bascule SSE (ceinture+bretelles).

WebSocket est techniquement supérieur mais coûte 2× plus cher à mettre en place pour un gain marginal vu notre besoin (push serveur → client, l'upload events/screenshots reste sur les endpoints HTTP POST existants, déjà robustes). L'asymétrie « push descendant lourd / upload léger » colle exactement à SSE.

HTTP/2 server push éliminé : déprécié au niveau protocole, non supporté par uvicorn, non implémenté côté requests Python. Hors course en 2026.

Effort estimé :

  • Watchdog seul : 2-4 h dev + 1 h test E2E. Déployable indépendamment.
  • Endpoint SSE serveur + client + bascule progressive : 2 j dev + 1 j test sur Léa Windows + démo de non-régression.
  • Total reco : 3-4 jours pour aboutir à un transport robuste.

Risque principal : NoMachine 9.5.7 sur lien LAN peut intercaler un proxy implicite ou couper l'idle TCP. SSE expose X-Accel-Buffering: no et un ping=15 qui couvrent ce cas — WebSocket exigerait un ping/pong applicatif explicite. Avantage SSE.


2. Table comparative

Critère Pull/Poll actuel SSE WebSocket HTTP/2 push
Reconnexion auto manuelle native (EventSource/sseclient) + Last-Event-ID code applicatif non std
Latence push 01 s (polling) + timeout 5 s <50 ms <50 ms <50 ms
Trafic 1 GET/s minimum, headers à chaque appel 0 quand idle (juste ping 15 s) 0 quand idle (ping app) minimal
Détection déco client indirecte (échec POST report) await request.is_disconnected() immédiat WebSocketDisconnect exception n/a
Détection déco serveur timeout client reconnect auto via EventSource/sseclient ping/pong manuel n/a
Complexité serveur basse (mais bug doc'd) basse (EventSourceResponse + asyncio.Queue) moyenne (gestion connexions, locks, broadcast) élevée (hypercorn req, peu testé)
Complexité client Léa basse basse (sseclient-py + boucle for) moyenne (websockets lib + reconnect manuel) non supporté requests
Compat. proxy / NoMachine / NPM OK (HTTP standard) OK avec X-Accel-Buffering: no + ping 15 s OK si proxy autorise Upgrade headers mauvais
Compat. firewall entreprise excellent excellent (HTTP) bon (Upgrade) mais parfois bloqué mauvais
Auth Bearer token (existant) OK OK (header Authorization) OK (header initial) OK
Idempotence actions perdues non géré → bug 8 mai gérée via ack POST + watchdog gérée via ack ws + watchdog n/a
Ressources Python basses (1 req à la fois) basses (1 connexion persistante par client) basses (idem) élevées (h2 stack)
Maturité 2026 éprouvé mais inadapté mature, recommandé par Anthropic/Skyvern docs mature en déclin (deprecated HTTP/3)

Verdict : SSE remporte sur tous les axes pertinents pour notre cas. WebSocket reste optionnel si on a un besoin futur de bidirectionnel synchrone (ex. validation interactive temps réel pendant un step). Pour l'instant : dispatch d'actions = unidirectionnel descendant, parfait pour SSE.


3. Patterns adoptés par les frameworks de référence (2025-2026)

3.1. Anthropic Computer Use SDK

Architecture observée : boucle in-process (pas de transport réseau entre orchestrateur et exécuteur — c'est le SDK qui exécute localement les tool_use retournés par le LLM). Pattern « Gather Context → Take Action → Verify Work → Repeat ». Référence : claude-quickstarts/computer-use-demo/computer_use_demo/loop.py.

Notre cas est différent : agent distant Windows ≠ machine du LLM. Mais la philosophie « boucle d'ack avec screenshot après chaque action » est exactement ce qu'on a déjà côté report_action_result. À conserver.

3.2. OpenAI Operator / CUA

Architecture observée : cloud-based virtual browser ; le modèle retourne un computer_call que le client SDK exécute dans son environnement puis renvoie le résultat + screenshot via le prochain messages.create. Pas de queue côté serveur OpenAI — c'est l'orchestrateur client qui maintient l'état. Source : Computer use docs.

→ Notre architecture serveur autoritaire (queue + replay_states) est légitime ; la « source de vérité » côté OpenAI est portée par le client, chez nous par le serveur. Différence de philosophie mais validée.

3.3. Skyvern (Planner-Actor-Validator)

Architecture observée d'après docs publiques et structure repo : /skyvern/{cli, client, core, errors, forge, services, webeye}. Le core orchestre, webeye exécute (Playwright). Communication interne via objets Python — monolithe côté serveur Skyvern. L'agent Skyvern parle au browser via CDP (websocket), pas une queue HTTP. Source : github.com/Skyvern-AI/skyvern, Skyvern 2.0 blog.

Pas un précédent direct : Skyvern contrôle Chrome via CDP intra-machine. Notre Léa = poste Windows distant. Mais validation du loop Planner-Actor-Validator (déjà signalée comme convergence dans INSPIRATION_FRAMEWORKS_2026-05-10.md).

3.4. browser-use

Architecture observée : WebSocket CDP direct vers le navigateur (event-driven), pas de queue HTTP intermédiaire. Migration explicite de Playwright vers CDP brut pour réduire la latence. Source : browser-use.com/posts/playwright-to-cdp, cdp-use.

Précédent intéressant : choix WebSocket pour un transport descendant. Mais leur cas est intra-machine (LLM ↔ Chrome local) ; le nôtre est inter-machine Linux ↔ Windows. Notre raison de préférer SSE (asymétrie push/upload) ne s'applique pas chez eux.

3.5. Playwright MCP

Architecture observée : modèle client/serveur, transport stdio (local) OU HTTP/SSE (remote). Le MCP client (LLM) envoie des tool calls, le MCP server exécute Playwright et renvoie un snapshot de l'accessibility tree. Source : Playwright MCP 2026 architecture, doc officielle.

Précédent direct et fort : Microsoft a explicitement choisi SSE pour le remote transport. C'est le standard MCP. Notre cas (LLM/serveur Linux ↔ exécuteur Léa Windows) est isomorphe. Confirmation que SSE est la bonne route.

3.6. Cradle (Microsoft, agent jeu vidéo)

Agent monolithe local, pas de transport distant. Hors périmètre.

3.7. Synthèse patterns externes

Pattern dominant 2025-2026 pour dispatcher des actions à un agent distant = SSE quand asymétrique, WebSocket quand bidirectionnel. Playwright MCP = précédent le plus proche de notre cas → SSE.


4. Pseudo-code endpoint serveur (FastAPI + sse-starlette)

À placer en complément de get_next_action (sans la supprimer pendant la phase 3 de migration — coexistence sous flag). Bibliothèque : pip install sse-starlette>=2.1.

# api_stream.py — nouveau bloc, à insérer près de get_next_action

import asyncio
from sse_starlette.sse import EventSourceResponse, ServerSentEvent

# Une file asyncio par (session_id, machine_id) — décorrélée de _replay_queues
# qui reste source de vérité de l'ordre des steps.
_sse_subscribers: dict[tuple[str, str], asyncio.Queue] = {}
_sse_lock = asyncio.Lock()


async def _sse_subscribe(session_id: str, machine_id: str) -> asyncio.Queue:
    """Crée ou récupère la queue de notifications SSE d'un client connecté."""
    key = (session_id, machine_id)
    async with _sse_lock:
        if key not in _sse_subscribers:
            _sse_subscribers[key] = asyncio.Queue(maxsize=64)
        return _sse_subscribers[key]


async def _sse_unsubscribe(session_id: str, machine_id: str) -> None:
    key = (session_id, machine_id)
    async with _sse_lock:
        _sse_subscribers.pop(key, None)


def sse_notify_new_action(session_id: str, machine_id: str) -> None:
    """À appeler chaque fois qu'une action visuelle est mise en queue.
    Pousse un signal léger ; le serveur prépare ensuite l'action complète
    (avec resolve serveur côté get_next_action) et la pousse au client.
    Aucun ack ici — c'est le client qui POSTera /replay/result."""
    key = (session_id, machine_id)
    q = _sse_subscribers.get(key)
    if q is None:
        return  # client pas (encore) connecté → restera dans _replay_queues
    try:
        q.put_nowait("dispatch")
    except asyncio.QueueFull:
        logger.warning("SSE queue pleine pour %s — drop signal", key)


@app.get("/api/v1/traces/stream/replay/events")
async def replay_events_sse(
    request: Request,
    session_id: str,
    machine_id: str = "default",
):
    """Endpoint SSE — push d'actions au client Léa Windows.

    Le client se connecte une fois, reste connecté tant que la session vit.
    Chaque action prête à être exécutée arrive comme event JSON.
    Heartbeat 15s : maintient la connexion à travers NPM/NoMachine/proxies.
    Reconnexion automatique côté sseclient-py.
    """

    queue = await _sse_subscribe(session_id, machine_id)

    async def event_generator():
        # Au connect : si des actions sont déjà en queue (cas reconnect),
        # les pousser immédiatement avant d'attendre.
        try:
            # Drain initial : récupérer ce qui est déjà dans _replay_queues
            # pour cette machine et l'envoyer immédiatement.
            initial = await _drain_pending_actions(session_id, machine_id)
            for action in initial:
                yield ServerSentEvent(
                    data=json.dumps(action),
                    event="action",
                    id=action.get("action_id"),
                )
                _mark_retry_pending(action)

            while True:
                if await request.is_disconnected():
                    logger.info("SSE client %s/%s disconnect détecté",
                                session_id, machine_id)
                    break

                try:
                    # Attendre une notification (timeout = laisser is_disconnected
                    # check passer). 5 s = compromis trafic / réactivité.
                    signal = await asyncio.wait_for(queue.get(), timeout=5.0)
                except asyncio.TimeoutError:
                    continue  # repart sur is_disconnected check
                if signal == "shutdown":
                    break

                # Récupérer la(les) action(s) à dispatcher.
                # Réutilise toute la logique server-side existante (pause_for_human,
                # extract_text, t2a_decision, condition…) — refactor `get_next_action`
                # pour en extraire une coroutine `_resolve_next_visual_action()`.
                action = await _resolve_next_visual_action(session_id, machine_id)
                if action is None:
                    continue

                yield ServerSentEvent(
                    data=json.dumps(action),
                    event="action",
                    id=action.get("action_id"),
                )
                _mark_retry_pending(action)
                logger.info("[REPLAY] DISPATCH(SSE) action_id=%s",
                            action.get("action_id"))

        except asyncio.CancelledError:
            logger.info("SSE %s/%s cancelled (server shutdown)",
                        session_id, machine_id)
            raise
        finally:
            await _sse_unsubscribe(session_id, machine_id)

    return EventSourceResponse(
        event_generator(),
        ping=15,                  # ping toutes les 15 s (NPM/NoMachine OK)
        ping_message_factory=lambda: ServerSentEvent(comment="hb"),
        headers={"X-Accel-Buffering": "no"},  # bypass nginx/NPM buffer
    )

Points clés :

  1. EventSourceResponse(ping=15) → heartbeat « : hb\n\n » toutes les 15 s. Tient les proxies NPM et NoMachine ouverts (idle timeout typique 60 s).
  2. X-Accel-Buffering: no → désactive le buffering nginx/NPM (sinon l'event reste bloqué côté reverse-proxy jusqu'à ~16 KB).
  3. request.is_disconnected() → détection instantanée de déconnexion Léa (NoMachine freeze, redémarrage Windows). Le serveur libère immédiatement les ressources.
  4. id=action_id sur chaque event → permet au client de demander Last-Event-ID au reconnect (sseclient-py le gère automatiquement). À combiner avec le watchdog § 6 pour garantir zéro perte.
  5. Coexistence avec pull-poll : ajouter env RPA_REPLAY_TRANSPORT=sse|poll côté serveur ET côté client (rollback 1-ligne en cas de pépin démo).

Hook côté queue producer (à insérer là où _replay_queues[session_id].append(action) est appelé, p. ex. dans start_replay) :

# Après chaque append d'action visuelle dans _replay_queues :
sse_notify_new_action(session_id, machine_id)

5. Pseudo-code client Léa Windows (remplace polling actuel)

À placer dans agent_v0/agent_v1/network/streamer.py ou un nouveau module replay_subscriber.py. Bibliothèque : pip install sseclient-py>=1.8.

# replay_subscriber.py — boucle de réception SSE côté Léa

import json
import time
import requests
import sseclient
from ..config import API_TOKEN, STREAMING_ENDPOINT

RECONNECT_BACKOFF = [1.0, 2.0, 5.0, 10.0, 30.0]


class ReplaySubscriber:
    def __init__(self, session_id, machine_id, on_action):
        self.session_id = session_id
        self.machine_id = machine_id
        self.on_action = on_action  # callback exécuteur
        self.running = False
        self._last_event_id = None
        self._thread = None

    def start(self):
        self.running = True
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def stop(self):
        self.running = False

    def _run(self):
        attempt = 0
        while self.running:
            try:
                url = (
                    f"{STREAMING_ENDPOINT}/api/v1/traces/stream/replay/events"
                    f"?session_id={self.session_id}&machine_id={self.machine_id}"
                )
                headers = {
                    "Authorization": f"Bearer {API_TOKEN}",
                    "Accept": "text/event-stream",
                    "Cache-Control": "no-cache",
                }
                if self._last_event_id:
                    headers["Last-Event-ID"] = self._last_event_id

                # IMPORTANT : pas de read_timeout — c'est tout l'intérêt SSE.
                # Le ping serveur 15 s + le TCP keepalive OS suffisent.
                # On garde un connect_timeout pour ne pas bloquer si serveur down.
                resp = requests.get(
                    url, headers=headers, stream=True,
                    timeout=(10, None),  # (connect, read) ; read=None = infini
                )
                if resp.status_code != 200:
                    raise RuntimeError(f"SSE refusé HTTP {resp.status_code}")

                attempt = 0  # reset backoff dès qu'on a une connexion stable
                client = sseclient.SSEClient(resp)
                for event in client.events():
                    if not self.running:
                        break
                    if event.event == "action" and event.data:
                        action = json.loads(event.data)
                        if event.id:
                            self._last_event_id = event.id
                        # Exécuter via le callback (cascade replay existante)
                        try:
                            result = self.on_action(action)
                        except Exception as e:
                            logger.exception("on_action levé : %s", e)
                            result = {"success": False, "error": str(e)}
                        # Reporter résultat — endpoint inchangé
                        self._post_result(action, result)
                    # event.event == "ping" → ignorer, c'est le heartbeat

            except (requests.ConnectionError, requests.Timeout, RuntimeError) as e:
                if not self.running:
                    break
                delay = RECONNECT_BACKOFF[min(attempt, len(RECONNECT_BACKOFF)-1)]
                logger.warning("SSE déconnecté (%s) → retry dans %ss", e, delay)
                time.sleep(delay)
                attempt += 1

    def _post_result(self, action, result):
        """Réutilise l'endpoint POST /replay/result existant (inchangé)."""
        # … identique à _report_action_result actuel d'executor.py

Compatibilité avec agent_frozen : côté Léa cliente, l'ajout est un nouveau module (pas un patch de code chaud). Modifier main.py pour instancier ReplaySubscriber au lieu du polling, sous flag env RPA_REPLAY_TRANSPORT. Redéploiement SCP vers dom@192.168.1.11 requis (cf. feedback_scp_auto_modif_client_windows.md).


6. Watchdog _retry_pending (à brancher MAINTENANT, indépendamment de SSE)

Justification : même avec SSE, un crash entre le yield serveur et le POST /replay/result client peut laisser une action dans _retry_pending sans report. Le watchdog est l'ultime filet.

À insérer dans api_stream.py au démarrage de l'app (event startup) :

# api_stream.py — watchdog d'orphelins

_RETRY_WATCHDOG_INTERVAL_S = 10.0
_RETRY_ORPHAN_THRESHOLD_S = 30.0   # action sans report depuis 30 s → re-dispatch
_RETRY_MAX_RESENDS = 2             # éviter boucle infinie


async def _retry_pending_watchdog():
    """Re-dispatche les actions dispatched depuis > 30s sans report.
    Idempotence garantie par report_action_result qui pop _retry_pending."""
    while True:
        try:
            await asyncio.sleep(_RETRY_WATCHDOG_INTERVAL_S)
            now = time.time()
            orphans = []
            # Snapshot pour éviter mutation concurrente
            async with _async_replay_lock():
                for aid, info in list(_retry_pending.items()):
                    dispatched_at = info.get("dispatched_at", 0)
                    resent = info.get("resent_count", 0)
                    if dispatched_at == 0:
                        continue
                    if now - dispatched_at < _RETRY_ORPHAN_THRESHOLD_S:
                        continue
                    if resent >= _RETRY_MAX_RESENDS:
                        logger.error(
                            "[BUS] lea:dispatch_orphan_giveup action_id=%s "
                            "resent=%d age=%.1fs",
                            aid, resent, now - dispatched_at,
                        )
                        _retry_pending.pop(aid, None)
                        continue
                    orphans.append((aid, info))

            for aid, info in orphans:
                action = info["action"]
                session_id = action.get("session_id") or info.get("session_id")
                if not session_id:
                    continue
                async with _async_replay_lock():
                    q = _replay_queues.setdefault(session_id, [])
                    # Repush en TÊTE (sinon ordre des steps cassé)
                    q.insert(0, action)
                    info["resent_count"] = info.get("resent_count", 0) + 1
                    info["dispatched_at"] = 0  # sera reset au prochain DISPATCH
                logger.warning(
                    "[BUS] lea:dispatch_orphan_resent action_id=%s "
                    "resent=%d session=%s",
                    aid, info["resent_count"], session_id,
                )
                # Si SSE actif : notifier le subscriber
                machine_id = info.get("machine_id", "default")
                sse_notify_new_action(session_id, machine_id)

        except asyncio.CancelledError:
            break
        except Exception:
            logger.exception("Watchdog _retry_pending levé — continue")


@app.on_event("startup")
async def _start_retry_watchdog():
    if os.environ.get("RPA_RETRY_WATCHDOG_ENABLED", "1") == "1":
        app.state._retry_watchdog_task = asyncio.create_task(
            _retry_pending_watchdog()
        )
        logger.info("Watchdog _retry_pending démarré (orphan>%.0fs, every %.0fs)",
                    _RETRY_ORPHAN_THRESHOLD_S, _RETRY_WATCHDOG_INTERVAL_S)

Modifications minimales requises pour que le watchdog ait les bonnes infos :

  1. Au point de dispatch (ligne ~3354 actuelle), ajouter dispatched_at: time.time() et session_id / machine_id dans _retry_pending[action_id].
  2. Dans report_action_result (ligne 3491), pop reste la clé d'idempotence — aucun changement, le code actuel fonctionne déjà parfaitement avec resends.

Concurrence avec un report tardif : si le client renvoie un report APRÈS le re-dispatch (race), le second pop retourne Nonereport_action_result répond {"status": "no_active_replay"} (ligne 3488) ou un retry de retry — tous les chemins sont déjà idempotents grâce au pop.

Kill-switch : RPA_RETRY_WATCHDOG_ENABLED=0 (mode legacy). Pattern aligné sur QW1/QW2/QW4 (cf. LESSONS_LEARNED_GHT_2026-05.md §kill-switches).


7. Plan de migration en 3 étapes

Étape 1 — Watchdog SEUL (2-4 h, déployable demain)

  • Ajouter _retry_pending_watchdog + champs dispatched_at/session_id/machine_id dans _retry_pending.
  • Flag RPA_RETRY_WATCHDOG_ENABLED=1 par défaut, désactivable.
  • Garder le client à read_timeout=30 (déjà recommandé quick fix démo).
  • Effet immédiat : plus aucune action perdue silencieusement, même sur le transport pull-poll actuel.
  • Test : injecter un sleep 35 s avant report_action_result côté client → vérifier lea:dispatch_orphan_resent dans logs et que l'action ré-arrive.

Étape 2 — Endpoint SSE serveur en parallèle (1 j)

  • Ajouter /api/v1/traces/stream/replay/events (code §4).
  • Refactoriser get_next_action pour extraire _resolve_next_visual_action(session_id, machine_id) réutilisable depuis le SSE (DRY — c'est la même cascade pause_for_human / extract_text / t2a_decision / clic conditionnel).
  • Tests serveur : pytest tests/integration/test_stream_processor.py + nouveau test_sse_dispatch.py (connect, push, disconnect, ping, Last-Event-ID).
  • Le pull-poll continue de tourner en parallèle (zéro impact démo).

Étape 3 — Bascule client Léa (1 j + redéploiement SCP)

  • Ajouter replay_subscriber.py côté agent_v1.
  • Flag RPA_REPLAY_TRANSPORT=sse|poll côté client, valeur par défaut = poll pendant 1 semaine, puis bascule à sse après runs validés.
  • SCP vers dom@192.168.1.11 : network/streamer.py, network/replay_subscriber.py, core/executor.py, main.py.
  • Test E2E sur Demo_urgence_3_db (46 steps) avec NoMachine freeze simulé (cf. feedback_agent_frozen.md) → vérifier reconnect SSE + Last-Event-ID résume sans perte.
  • Si OK : flag par défaut sse ; le watchdog reste actif comme filet.

8. Risques et tests E2E

Risques techniques

Risque Mitigation
NoMachine 9.5.7 coupe la connexion idle même avec ping 15 s ping=10 au lieu de 15, et tcp_keepalive côté socket Python (setsockopt SO_KEEPALIVE)
NPM reverse-proxy bufferise SSE X-Accel-Buffering: no + vérifier proxy_buffering off dans la conf NPM lea.labs.laurinebazin.design
Léa Windows freeze longue (>2 min) → SSE socket morte mais OS pense vivante watchdog côté serveur tue la connexion si pas d'ack report_action_result reçu depuis 60 s (à ajouter)
Double-dispatch (race watchdog + reconnect Last-Event-ID) idempotence côté client : if action_id in self._processed: skip (set bounded LRU 256)
Gemma cloud 503 (vécu 12 mai) bloque t2a_decision >> 30 s watchdog re-pushe → mais le 2e essai re-bloque. Plafond _RETRY_MAX_RESENDS=2 puis abandon → pause supervisée
Drift exemption template ≥0.95 / hybrid ≥0.80 (contournement actif) aucun impact — c'est une logique de resolve, pas de transport
Fallback heartbeat capture <1200×800 (contournement) aucun impact — c'est sur l'upload, pas le dispatch

Tests E2E à passer avant bascule

  1. Smoke : démarrer replay 5 steps, vérifier dispatch SSE et arrivée chez client.
  2. Long action serveur : step avec extract_text 8 s puis click — l'action click doit arriver SANS perte (le test 8 mai en a perdu 9).
  3. Déconnexion brutale : taskkill /F /IM python.exe côté Léa puis relancer → SSE reconnect + Last-Event-ID résume sans re-dispatcher les actions déjà acquittées.
  4. NoMachine freeze simulé : couper VPN 90 s → reconnect, vérifier que les actions empilées arrivent en rafale propre.
  5. Watchdog isolé : passer RPA_REPLAY_TRANSPORT=poll + RPA_RETRY_WATCHDOG_ENABLED=1, faire dropper le report manuellement (sleep 35 s avant POST /replay/result) → vérifier resend + idempotence.
  6. Démo complète Demo_urgence_3_db (46 steps, MOREL Catherine UHCD) : 0 action perdue, comparaison logs avant/après.

Liens avec autres axes

  • AXE B2 (Validator) : un Validator strict (vérif sémantique post-clic) n'a de sens que si on est sûr que toutes les actions arrivent. B1 est prérequis de B2.
  • AXE B4 (ORA — Observe Reason Act) : ORA pousse des actions dans la queue exactement comme le replay classique. Le SSE bénéficie à ORA gratuitement (pas de refacto supplémentaire). B1 dé-risque B4.

9. Sources

SSE / FastAPI

WebSocket / FastAPI

HTTP/2 status Python

Frameworks externes

Client SSE Python

Patterns retry / idempotence / orphans

Proxy / NoMachine


Document destiné à servir de base de décision avant chiffrage final et implémentation. Lecture seule sur le code, aucune modification. À discuter avec Dom avant toute bascule.