0.28.0 — Émission d'événements vers le journal du service d'agents (Lot 2)
CI / test (push) Successful in 2m1s

- app/agent_journal.py : enveloppe ACC-0.2 signée HMAC-SHA256
  (clé AGENT_EVENEMENTS_CLE, URL AGENT_EVENEMENTS_URL), envoi
  en arrière-plan, timeout 2 s, inactif sans clé, échecs avalés
- main.py : _emettre_changement_colonne accolée aux log_event de
  déplacement (interface, étiquettes forge, webhook, réconciliation,
  PR fusionnée, branche) — kanbante.card.moved pour tout changement
  de colonne, kanbante.card.review_requested à l'entrée en Révision
- config : AGENT_EVENEMENTS_URL/_CLE documentés et lus à l'appel
- 247 tests verts (5 nouveaux : signature revérifiable, inactivité
  sans clé, moved, review_requested, panne du récepteur)
This commit is contained in:
Spark
2026-10-22 18:40:00 +00:00
parent 868496891d
commit 3809462cc7
6 changed files with 410 additions and 2 deletions
+119
View File
@@ -0,0 +1,119 @@
"""É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
+17 -1
View File
@@ -96,6 +96,15 @@ Environment variables:
KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie"
on /health (issue #9). Default 30.
AGENT_EVENEMENTS_URL Journal endpoint of the agent
service (Lot 2, contract ACC-0.2)
receiving this app's signed events.
Default https://agent.dracodev.net/api/v1/evenements.
AGENT_EVENEMENTS_CLE Kanbante's own HMAC key signing the
events it emits to that journal (the
service knows it as the "kanbante"
entry of AGENT_EVENEMENTS_CLES).
Empty (default) = emission disabled.
"""
import os
@@ -104,7 +113,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.27.0"
APP_VERSION = "0.28.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -180,6 +189,8 @@ class Settings:
sso_machine_key: str = ""
sso_url: str = "https://og.dracodev.net"
archive_suggest_days: int = 180
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
agent_evenements_cle: str = ""
@property
def auth_enabled(self) -> bool:
@@ -238,6 +249,11 @@ def _env_settings() -> Settings:
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
agent_evenements_url=os.getenv(
"AGENT_EVENEMENTS_URL",
"https://agent.dracodev.net/api/v1/evenements",
).rstrip("/"),
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
)
+69
View File
@@ -58,6 +58,7 @@ from app.briefing import (
send_ntfy,
)
from app.capture import ntfy_capture_loop, parse_capture_text
from app import agent_journal
from app import models
from app.models import (
KANBAN_COLUMNS,
@@ -2020,6 +2021,38 @@ def _merge_issues_with_board(
return columns
def _emettre_changement_colonne(
full_name: str,
number: int,
from_column: str | None,
to_column: str,
source: str,
title: str = "",
) -> None:
"""Émission Lot 2 : refléter un changement de colonne réel dans
le journal du service d'agents (contrat ACC-0.2, voir
app/agent_journal.py).
Appelée à côté des log_event qui consignent un déplacement —
jamais à leur place. ``kanbante.card.moved`` pour tout
changement de colonne, plus ``kanbante.card.review_requested``
quand la carte ENTRE en colonne Révision. Au mieux : sans clé
configurée, rien ne part ; en cas de panne du service, le
déplacement n'est jamais affecté.
"""
if not to_column or from_column == to_column:
return
actor = "user" if source == "ui" else f"agent:{source}"
ref = f"issue:{full_name}#{number}"
label = (title or "").strip() or ref
url = f"{get_settings().gitea_url}/{full_name}/issues/{number}"
agent_journal.emettre("kanbante.card.moved", ref, label, url, actor=actor)
if to_column == "review" and from_column != "review":
agent_journal.emettre(
"kanbante.card.review_requested", ref, label, url, actor=actor
)
def _adopt_label_columns(
full_name: str,
open_issues: list[dict],
@@ -2063,6 +2096,10 @@ def _adopt_label_columns(
from_column=current, to_column=target, source="gitea",
workspace_id=workspace_id, connection_id=connection_id,
)
_emettre_changement_colonne(
full_name, issue["number"], current, target, "gitea",
title=issue.get("title", ""),
)
placement[issue["number"]] = target
@@ -2392,6 +2429,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
full_name, number, "closed", to_column="done", source="gitea",
workspace_id=ws_id, connection_id=conn_id,
)
_emettre_changement_colonne(
full_name, number, None, "done", "gitea",
)
# A kanban:* label changed outside Kanbante wins over local state.
_adopt_label_columns(
@@ -2543,6 +2583,9 @@ def _board_columns_for(
full_name, number, "closed", to_column="done", source="gitea",
workspace_id=workspace_id, connection_id=connection_id,
)
_emettre_changement_colonne(
full_name, number, None, "done", "gitea",
)
_adopt_label_columns(
full_name, open_issues,
get_board_state(
@@ -4138,6 +4181,11 @@ def _apply_external_transition(
from_column="done", to_column=target_column, source="webhook",
workspace_id=workspace_id, connection_id=connection_id,
)
_emettre_changement_colonne(
full_name, number,
current if event_type == "closed" else "done",
target_column, "webhook",
)
return event_type
@@ -4207,6 +4255,10 @@ async def _apply_pr_merged(
to_column="done", source="webhook",
workspace_id=workspace_id, connection_id=connection_id,
)
_emettre_changement_colonne(
full_name, number, None, "done", "webhook",
title=issue.get("title", ""),
)
outcome = "closed"
results.append(f"#{number}:{outcome}")
return results
@@ -4823,6 +4875,19 @@ async def api_move(
from_column or None, to_column,
workspace_id=ws_id, connection_id=conn_id,
)
# Émission Lot 2 (journal du service d'agents) : le titre
# n'est relu que si l'émission est configurée, au mieux.
titre_carte = ""
if agent_journal.actif():
try:
detail = await client.get_issue(owner, repo, issue_number)
titre_carte = detail.get("title", "")
except Exception:
titre_carte = ""
_emettre_changement_colonne(
repo_full_name, issue_number, from_column or None, to_column,
"ui", title=titre_carte,
)
result: dict = {"ok": True, "gitea_sync": "skipped"}
settings = get_settings()
@@ -5156,6 +5221,10 @@ async def api_create_branch(
source="branch",
workspace_id=ws_id, connection_id=conn_id,
)
_emettre_changement_colonne(
full_name, number, current, "in_progress", "branch",
title=issue.get("title", ""),
)
await _sync_kanban_label(
owner, repo, number, "in_progress", client
)