"""Émission d'événements vers le journal du service d'agents (Fusion, Lot 2 vague 5 — contrat ACC-0.2, Roadmap #220). ObsiGate émet ses mutations de notes **explicites** vers ``POST /api/v1/evenements`` du service d'agents, qui les expose dans le journal de sa console. L'enveloppe ne contient qu'une référence, un libellé et un lien — jamais le contenu de la note : ``{id, type, occurred_at, actor, domain, source_app, object{ref, label, url}}``. **Garde-fou (conception validée)** : ce module n'est appelé que depuis les points d'écriture explicites de l'API web — :mod:`backend.routers.files_write` (session utilisateur, acteur ``user``) et la couche outils :mod:`backend.tools.service` (serveur MCP d'ObsiGate, acteur ``agent:mcp`` ; assistant intégré, acteur ``user``). Le **watcher** (:mod:`backend.watcher`) et l'indexeur ne l'appellent **jamais** : le vault principal compte plus de 220 000 fichiers, un branchement sur les événements du système de fichiers inonderait le journal. Une modification faite hors API (éditeur externe, synchro disque…) reste donc invisible du journal — c'est voulu. Authentification : signature HMAC-SHA256 du corps brut, en-tête ``X-Draco-Signature``, clé propre à ObsiGate (``AGENT_EVENEMENTS_CLE``, jamais en dur). Sans clé, le module est **inactif** : aucun appel, aucun bruit. URL surchargée par ``AGENT_EVENEMENTS_URL``. Discipline « au mieux » (même esprit que TikTak et Kanbante) : l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s, et toute erreur est avalée avec une ligne de journal — une panne du récepteur ne doit jamais casser ni ralentir le métier. Domaine : ObsiGate ne modélise pas encore le domaine d'un vault (question Q-A du programme Fusion toujours ouverte). La correspondance peut être déclarée par ``AGENT_EVENEMENTS_DOMAINES`` (JSON ``{"NomDuVault": "famille"}``) ; repli ``pro``, qui est aussi le défaut du journal du service. """ from __future__ import annotations import hashlib import hmac import json import logging import os import threading import uuid from datetime import UTC, datetime from urllib.parse import quote import httpx logger = logging.getLogger("obsigate.agent_journal") SOURCE_APP = "obsigate" URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements" DELAI_S = 2.0 TYPE_CREE = "obsigate.note.created" TYPE_EDITE = "obsigate.note.edited" TYPE_DEPLACE = "obsigate.note.moved" TYPE_SUPPRIME = "obsigate.note.deleted" ACTEUR_USER = "user" ACTEUR_MCP = "agent:mcp" def _cle() -> str: return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip() def _url() -> str: return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT def actif() -> bool: """L'émission n'existe que si la clé d'ObsiGate est configurée.""" return bool(_cle()) def domaine_du_vault(vault: str) -> str: """Domaine journal du vault : correspondance déclarée dans ``AGENT_EVENEMENTS_DOMAINES`` (JSON), repli ``pro``.""" brut = os.environ.get("AGENT_EVENEMENTS_DOMAINES", "").strip() if brut: try: correspondance = json.loads(brut) except ValueError: correspondance = {} if isinstance(correspondance, dict): domaine = correspondance.get(vault) if isinstance(domaine, str) and domaine: return domaine return "pro" def lien_fichier(vault: str, chemin: str) -> str: """Lien profond ObsiGate vers un fichier (format ``#file=`` lu par le frontend : ``#file=:``, décodé en URI).""" return f"/#file={quote(vault, safe='')}:{quote(chemin, safe='/')}" def construire_enveloppe( type_: str, vault: str, chemin: str, actor: str = ACTEUR_USER, ) -> tuple[dict, bytes]: """L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est signé puis envoyé — la signature porte sur ces octets-là). ``chemin`` est le chemin relatif au vault ; pour un déplacement, c'est le **nouveau** chemin (la référence pointe sur l'objet tel qu'il existe après l'opération).""" env = { "id": uuid.uuid4().hex, "type": type_, "occurred_at": datetime.now(UTC).isoformat(), "actor": actor or ACTEUR_USER, "domain": domaine_du_vault(vault), "source_app": SOURCE_APP, "object": { "ref": f"fichier:{vault}/{chemin}", "label": chemin.rsplit("/", 1)[-1] if chemin else vault, "url": lien_fichier(vault, chemin), }, } corps = json.dumps(env, ensure_ascii=False).encode("utf-8") return env, corps def _signer(corps: bytes) -> str: return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest() def _envoyer(corps: bytes) -> None: """Le POST signé, dans le fil d'arrière-plan. Avale tout : le journal distant est un confort, jamais une dépendance.""" try: with httpx.Client(timeout=DELAI_S, trust_env=False) as client: client.post( _url(), content=corps, headers={ "Content-Type": "application/json", "X-Draco-Signature": _signer(corps), }, ) except Exception as exc: # panne, délai, DNS… : on log et on passe logger.warning("événement non livré au journal du service (%s)", exc) def emettre( type_: str, vault: str, chemin: str, actor: str = ACTEUR_USER, ) -> bool: """Émet un événement en tâche de fond. Retourne True si l'envoi a été déclenché, False si le module est inactif (pas de clé) ou si l'enveloppe n'a pas pu être construite. Ne lève jamais.""" if not actif(): return False try: _env, corps = construire_enveloppe(type_, vault, chemin, actor) except Exception: logger.warning("événement %s non construit", type_, exc_info=True) return False threading.Thread( target=_envoyer, args=(corps,), daemon=True, name="obsigate-evenements" ).start() return True