174 lines
5.9 KiB
Python
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
|