Files
flowdeck/app/services/agent_evenements.py
T
Spark f8449409b2
FlowDeck CI / test (push) Failing after 35m34s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m41s
refactor: noms publics dracodev par configuration (dev/prod)
Aucun nom public d'une autre app dracodev n'est codé en dur dans le code vivant : nouveau module app/services/draco_urls.py (env > config > défaut officiel, jamais de -dev en défaut). CSP connect-src/frame-src, bouton SSO ObsiGate, révocations SSO, événements agent, preset MCP TikTak, barre commune, panneau Spark (injection hôte DRACO_PANNEAU_CONFIG), refs croisées (hôte configuré + officiel reconnus, URL d'affichage reconstruite, aucune donnée stockée migrée), User-Agent OG, liens Gitea. Variables ajoutées DRACO_AGENT_URL/TIKTAK_URL/KANBANTE_URL/FLOWDECK_URL, réutilisées DRACO_SSO_URL/OAUTH_REDIRECT_URI/AGENT_EVENEMENTS_URL/GITEA_URL/APP_BASE_URL. Version 7.82.0.
2026-10-25 18:19:30 +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 datetime, timezone
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(timezone.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