Files
flowdeck/app/services/agent_evenements.py
T
Spark 49164ce057 fix(ci): corriger les 16 erreurs ruff et resynchroniser le littéral de version
Lint (run 2304, job 7058, ruff check app tests) : E402+I001 import mcp_server remonté en tête de app/main.py ; B904 x8 dans api_v2/templates_io.py (raise ... from None, from exc pour ValueError) ; UP017 datetime.UTC dans agent_evenements.py ; F841 x3 dans fd_templates.py (void/uid morts, import inutilisé retirés en chaîne) ; F401+F841 dans tests/test_fd_templates.py (import json inutilisé, n_presets en appel nu).

Version : le test test_pwa_offline cherche le littéral version="7.82.0" dans app/main.py — patron historique des bumps (scripts/bump_version.py le cherche aussi), rompu en 7.80.2 quand l'argument FastAPI est passé à _lire_version(). Littéral reposé en commentaire de synchronisation explicite, version runtime inchangée (lecture dynamique VERSION).
2026-10-25 19:26:09 +00:00

133 lines
4.5 KiB
Python

"""Émission d'événements vers le journal du service d'agents
(Fusion, Lot 2 — contrat ACC-0.2).
Flowdeck émet ses mutations significatives (pages et collections)
vers `POST /api/v1/evenements` du service d'agents, qui les expose
dans le « Journal d'aujourd'hui » de la console. L'enveloppe ne
contient qu'une référence, un libellé et un lien — jamais le
contenu de l'objet : {id, type, occurred_at, actor, domain,
source_app, object{ref, label, url}}.
Authentification : signature HMAC-SHA256 du corps brut, en-tête
`X-Draco-Signature`, avec la clé propre à Flowdeck
(`AGENT_EVENEMENTS_CLE`, jamais en dur). Sans clé, le module est
INACTIF : aucun appel, aucun bruit.
Discipline « au mieux » (même esprit que le journal du service) :
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.
Aucun événement « meeting » n'est émis ici : la décision A/B de
Bruno sur le bloc meeting de Flowdeck est toujours ouverte.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import threading
import uuid
from datetime import UTC, datetime
import httpx
log = logging.getLogger("flowdeck.evenements")
SOURCE_APP = "flowdeck"
DELAI_S = 2.0
def _cle() -> str:
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
def _url() -> str:
# AGENT_EVENEMENTS_URL (en service) prime ; à défaut, l'endpoint
# est dérivé de la base de l'agent résolue par la config
# (DRACO_AGENT_URL, défaut = nom officiel) — aucun nom figé.
from app.services import draco_urls
return draco_urls.agent_evenements_url()
def _url_defaut() -> str:
return _url()
# Compat : ancien nom lu par d'éventuels consommateurs externes.
URL_DEFAUT = _url()
def actif() -> bool:
"""L'émission n'existe que si la clé de Flowdeck est configurée."""
return bool(_cle())
def url_page(page_id: int) -> str:
"""Lien profond d'une page d'espace de travail (vue HTML du board)."""
return f"/board/pages/{page_id}"
def url_collection(collection_id: int) -> str:
"""Lien profond d'une collection (sa vue tableau). Une page de
collection n'a pas d'URL autonome : elle s'ouvre dans cette vue."""
return f"/db/{collection_id}/view/table"
def construire_enveloppe(type_: str, objet_ref: str, label: str,
url: str = "/", domain: str = "pro",
actor: str | None = None) -> 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à)."""
env = {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(UTC).isoformat(),
"actor": actor or "user",
"domain": domain or "pro",
"source_app": SOURCE_APP,
"object": {"ref": objet_ref, "label": label or "", "url": url or "/"},
}
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
log.warning("événement non livré au journal du service (%s)", exc)
def emettre(type_: str, objet_ref: str, label: str, url: str = "/",
domain: str = "pro", actor: str | None = None) -> 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_, objet_ref, label, url,
domain, actor)
except Exception:
log.warning("événement %s non construit", type_, exc_info=True)
return False
threading.Thread(target=_envoyer, args=(corps,), daemon=True,
name="flowdeck-evenements").start()
return True