Files
ObsiGate/backend/agent_journal.py
T
Spark f434f30286
CI / lint (push) Successful in 2m53s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m57s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 17m40s
feat: émission d'événements vers le journal du service d'agents #220
2026-10-22 19:58:25 +00:00

174 lines
5.9 KiB
Python

"""É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=<vault>:<chemin>``, 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