0.28.0 — Émission d'événements vers le journal du service d'agents (Lot 2)
CI / test (push) Successful in 2m1s
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:
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user