feat: émission d'événements vers le journal du service d'agents #220
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"""É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
|
||||
Reference in New Issue
Block a user