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).
133 lines
4.5 KiB
Python
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
|