"""Émission d'événements vers le journal du service d'agents (Lot 2). Kanbante émet ses changements de colonne au journal d'activité cross-app du service d'agents, au contrat ACC-0.2 : enveloppe JSON ``{id, type, occurred_at, actor, domain, source_app, object{ref, label, url}}`` signée HMAC-SHA256 du corps brut (en-tête ``X-Draco-Signature``), avec la clé propre à Kanbante (``AGENT_EVENEMENTS_CLE`` ; le service la connaît sous le nom d'app « kanbante » dans ``AGENT_EVENEMENTS_CLES``). Inactif tant que la clé n'est pas posée. Jamais bloquant, jamais d'échec remonté au métier : l'envoi part dans un fil d'arrière-plan, timeout court, exceptions avalées avec un avertissement au journal local. L'appel sortant suit le patron de la maison (httpx, voir ``briefing.send_ntfy``). """ import hashlib import hmac import json import logging import threading import uuid from datetime import datetime, timezone import httpx from app.config import get_settings logger = logging.getLogger("kanbante") SOURCE_APP = "kanbante" DELAI_S = 2.0 def actif() -> bool: """True quand l'émission est configurée (clé propre posée).""" return bool(get_settings().agent_evenements_cle) def construire_enveloppe( type_: str, ref: str, label: str, url: str, domain: str = "pro", actor: str = "user", ) -> dict: """L'enveloppe ACC-0.2 : une référence et un libellé, jamais le contenu de l'objet.""" return { "id": uuid.uuid4().hex, "type": type_, "occurred_at": datetime.now(timezone.utc).isoformat(), "actor": actor, "domain": domain, "source_app": SOURCE_APP, "object": {"ref": ref, "label": label, "url": url}, } def signer(corps: bytes, cle: str) -> str: """Signature ACC-0.2 : HMAC-SHA256 (hexadécimal) du corps brut.""" return hmac.new(cle.encode(), corps, hashlib.sha256).hexdigest() def _poster(url: str, corps: bytes, signature: str) -> bool: """POST l'enveloppe signée ; False (et un avertissement) sur tout échec — le récepteur en panne ne doit rien casser ici.""" try: reponse = httpx.post( url, content=corps, headers={ "Content-Type": "application/json", "X-Draco-Signature": signature, }, timeout=DELAI_S, ) if reponse.status_code >= 300: logger.warning( "Journal agent : réponse %s de %s", reponse.status_code, url ) return False return True except Exception as exc: logger.warning("Journal agent injoignable (%s) : %s", url, exc) return False def emettre( type_: str, ref: str, label: str, url: str, domain: str = "pro", actor: str = "user", ) -> threading.Thread | None: """Émettre un événement, au mieux, depuis un fil d'arrière-plan. Retourne le fil lancé (les tests peuvent le joindre), ou None quand l'émission n'est pas configurée. Ne lève jamais. """ settings = get_settings() cle = settings.agent_evenements_cle if not cle: return None enveloppe = construire_enveloppe( type_, ref, label, url, domain=domain, actor=actor ) corps = json.dumps(enveloppe, ensure_ascii=False).encode("utf-8") fil = threading.Thread( target=_poster, args=(settings.agent_evenements_url, corps, signer(corps, cle)), name="kanbante-agent-journal", daemon=True, ) fil.start() return fil