Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3964323abd | ||
|
|
4230ac1052 | ||
|
|
3809462cc7 | ||
|
|
868496891d |
@@ -1,5 +1,69 @@
|
||||
# Changelog
|
||||
|
||||
## 0.29.0 — 2026-10-23
|
||||
|
||||
### Modifié — panneau Spark V3 : la console en surimpression pleine page
|
||||
|
||||
Le bouton « Console ↗ » du panneau Spark (demande de Bruno) ne part
|
||||
plus en nouvel onglet : la console web du service d'agents
|
||||
(https://agent.dracodev.net/) s'ouvre désormais **dans la même
|
||||
page**, en surimpression qui recouvre tout l'écran avec la même
|
||||
animation d'ouverture que la barre latérale (panneau vendangé
|
||||
`draco-panneau.js`, recopié octet-identique dans les 4 apps ; les
|
||||
styles de la surimpression sont injectés par le panneau lui-même).
|
||||
La barre de la surimpression offre les deux sorties : « Mode
|
||||
sidebar » referme la console et rouvre l'agent en barre latérale,
|
||||
« Fermer l'agent » ferme la surimpression et le panneau. La
|
||||
discussion affichée et l'app d'origine (`?disc=<id>&app=kanbante`)
|
||||
passent comme avant à la console. Côté service d'agents : la
|
||||
console autorise l'encadrement par les 4 apps (`frame-ancestors`,
|
||||
service 0.20.0). Même livraison : TikTak 0.60.0, Flowdeck 7.81.0,
|
||||
ObsiGate 2.77.0. **Redéploiement Docker requis** pour l'activer.
|
||||
|
||||
## 0.28.1 — 2026-10-22
|
||||
|
||||
### Ajouté — panneau Spark : bouton « Console ↗ »
|
||||
|
||||
Bouton « Console ↗ » dans la barre des discussions du panneau
|
||||
Spark (demande de Bruno) : ouvre la console web du service
|
||||
d'agents (https://agent.dracodev.net/) dans un nouvel onglet,
|
||||
sur la discussion actuellement affichée (`?disc=<id>`, URL
|
||||
construite au clic) et avec l'app d'origine (`?app=kanbante`,
|
||||
déduite du sous-domaine). Panneau recopié octet-identique dans
|
||||
les 4 apps (TikTak 0.59.1, Flowdeck 7.80.1, Kanbante 0.28.1,
|
||||
ObsiGate 2.76.1). **Redéploiement Docker requis** pour l'activer.
|
||||
|
||||
## 0.28.0 — 2026-10-22
|
||||
|
||||
### Ajouté — émission d'événements vers le journal du service d'agents (Lot 2)
|
||||
|
||||
Kanbante émet désormais ses changements de colonne au journal
|
||||
d'activité cross-app du service d'agents (contrat ACC-0.2) :
|
||||
`kanbante.card.moved` pour tout déplacement réel d'une carte —
|
||||
glisser-déposer de l'interface, adoption d'une étiquette
|
||||
`kanban:*` posée dans la forge, fermeture/réouverture venue de la
|
||||
forge (webhook ou réconciliation à l'affichage), fusion de PR,
|
||||
création de branche — et `kanbante.card.review_requested` quand
|
||||
une carte entre en colonne Révision. Enveloppe signée HMAC-SHA256
|
||||
avec la clé propre à Kanbante (`AGENT_EVENEMENTS_CLE`, URL
|
||||
réglable par `AGENT_EVENEMENTS_URL`) ; émission au mieux, en
|
||||
arrière-plan : inactive sans clé, et une panne du service
|
||||
d'agents n'affecte jamais un déplacement. Aucun changement de
|
||||
comportement visible. Redéploiement Docker requis.
|
||||
|
||||
## 0.27.0 — 2026-10-22
|
||||
|
||||
### Ajouté — panneau Spark v2 (Lot 1)
|
||||
|
||||
Le panneau vendangé passe en v2, à l'identique dans les 4 apps :
|
||||
réponses de l'agent en **Markdown rendu** (HTML du service
|
||||
d'agents 0.10.0, sanitizé côté serveur, rapports de missions
|
||||
compris), **largeur réglable** persistée, **thème clair/sombre**
|
||||
aligné sur l'app hôte ou le système, et **discussions façon
|
||||
Muse** : discussion principale perpétuelle + parallèles
|
||||
créées/archivées/supprimées depuis le tiroir du panneau.
|
||||
Redéploiement Docker requis.
|
||||
|
||||
## 0.26.0 — 2026-10-21
|
||||
|
||||
### Ajouté — panneau Spark intégré
|
||||
|
||||
@@ -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.26.0"
|
||||
APP_VERSION = "0.29.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
|
||||
)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.26.0"
|
||||
version = "0.29.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+143
-37
@@ -1,83 +1,189 @@
|
||||
/* Panneau Spark — branchement au service d'agents (Fusion, jalon J1).
|
||||
Composant d'intégration TikTak : le panneau définitif sera vendangé
|
||||
depuis dracodev-commons ; celui-ci prouve le chemin panneau → service. */
|
||||
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
|
||||
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
|
||||
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
|
||||
prefers-color-scheme) et suit les bascules en direct. Toutes les
|
||||
couleurs passent par les variables ci-dessous — en-tête et fenêtre
|
||||
partagent donc toujours le même mode. */
|
||||
.draco-panneau {
|
||||
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 94vw;
|
||||
background: #fbfbfa; color: #242624; z-index: 400;
|
||||
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
|
||||
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
|
||||
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
|
||||
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
|
||||
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
|
||||
--p-accent: #7c5cd6; --p-lien: #5b3df0;
|
||||
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
|
||||
background: var(--p-fond); color: var(--p-texte); z-index: 400;
|
||||
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(105%); transition: transform .28s ease;
|
||||
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
|
||||
}
|
||||
.draco-panneau[data-draco-theme="dark"] {
|
||||
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
|
||||
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
|
||||
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
|
||||
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
|
||||
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
|
||||
--p-lien: #b39df5;
|
||||
}
|
||||
.draco-panneau.open { transform: translateX(0); }
|
||||
.draco-poignee {
|
||||
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
|
||||
cursor: ew-resize; z-index: 5; touch-action: none;
|
||||
}
|
||||
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
|
||||
@media (max-width: 700px) { .draco-poignee { display: none; } }
|
||||
.draco-panneau-hd {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 13px 15px; border-bottom: 1px solid #e4e5e0; background: #fff;
|
||||
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
||||
.draco-panneau-hd .soustitre { font-size: 12px; color: #6c7268; }
|
||||
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-panneau-hd .fermer {
|
||||
margin-left: auto; border: 0; background: #eef0ea; border-radius: 8px;
|
||||
width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
||||
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
|
||||
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||
.draco-panneau-etat.ok .point { background: #2ea36b; }
|
||||
.draco-panneau-etat.err .point { background: #cf4b32; }
|
||||
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; }
|
||||
.draco-panneau-outils button {
|
||||
border: 1px solid #d6d8d1; background: #f4f5f1; border-radius: 999px;
|
||||
padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||
/* Barre des discussions (façon Muse) */
|
||||
.draco-disc-bar {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-panneau-outils button:hover { background: #e9ebe4; }
|
||||
.draco-disc-bar .d-titre {
|
||||
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.draco-disc-bar .d-btn {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
|
||||
}
|
||||
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
|
||||
.draco-discussions {
|
||||
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
max-height: 46%; overflow-y: auto; padding: 8px 12px;
|
||||
}
|
||||
.draco-discussions .d-section {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
|
||||
}
|
||||
.draco-discussions .d-section:first-child { margin-top: 0; }
|
||||
.draco-discussions .d-item {
|
||||
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-item:hover { background: var(--p-chip); }
|
||||
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
|
||||
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
|
||||
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
|
||||
.draco-discussions .d-actions button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
|
||||
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
|
||||
.draco-discussions .d-vide, .draco-discussions .d-chargement {
|
||||
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
|
||||
}
|
||||
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
|
||||
.draco-panneau-outils button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
|
||||
.draco-appro {
|
||||
align-self: stretch; background: #fdf6ec; border: 1.6px solid #e8a13c;
|
||||
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
|
||||
border-radius: 12px; padding: 10px 12px;
|
||||
}
|
||||
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
|
||||
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
|
||||
.draco-appro .resume { margin: 5px 0 8px; }
|
||||
.draco-appro .btns { display: flex; gap: 7px; }
|
||||
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; }
|
||||
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
||||
.draco-auj { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 6px 0 4px; }
|
||||
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
|
||||
.draco-auj h5:first-child { margin-top: 0; }
|
||||
.draco-auj ul { margin: 0; padding-left: 17px; }
|
||||
.draco-auj .etat-source { color: #6c7268; font-size: 12.5px; }
|
||||
.draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.draco-msg.agent { align-self: flex-start; background: #eef0ea; border-bottom-left-radius: 4px; }
|
||||
.draco-msg.sys { align-self: center; background: none; color: #6c7268; font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||
.draco-panneau-ft { border-top: 1px solid #e4e5e0; padding: 11px 12px; background: #fff; }
|
||||
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
||||
.draco-panneau-form { display: flex; gap: 8px; }
|
||||
.draco-panneau-form input {
|
||||
flex: 1; border: 1px solid #d6d8d1; border-radius: 10px; padding: 9px 11px; font: inherit;
|
||||
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
||||
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
||||
}
|
||||
.draco-panneau-form button {
|
||||
border: 0; border-radius: 10px; background: #7c5cd6; color: #fff;
|
||||
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
||||
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
|
||||
.draco-panneau-note { font-size: 11.5px; color: #8a8f84; margin-top: 8px; text-align: center; }
|
||||
.draco-missions { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 0 0 6px; }
|
||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: #6c7268; margin: 8px 0 3px; }
|
||||
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
|
||||
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid #d6d8d1; border-radius: 8px;
|
||||
padding: 7px 9px; font: inherit; font-size: 13px; background: #fff;
|
||||
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
|
||||
}
|
||||
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
|
||||
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; }
|
||||
.draco-missions .btns button.go { background: #7c5cd6; color: #fff; }
|
||||
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
|
||||
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.draco-missions .etat-source { color: #6c7268; font-size: 12.5px; }
|
||||
.draco-missions .m-item { border: 1px solid #e4e5e0; border-radius: 10px; padding: 8px 10px; }
|
||||
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||
.draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
|
||||
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
|
||||
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: #6c7268; white-space: nowrap; }
|
||||
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
|
||||
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
|
||||
.draco-missions .m-statut.st-failed { color: #cf4b32; }
|
||||
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
|
||||
.draco-missions .m-meta { font-size: 12px; color: #6c7268; }
|
||||
.draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: #f6e3de; color: #a03a28; }
|
||||
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
|
||||
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
|
||||
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
|
||||
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
|
||||
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
|
||||
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
|
||||
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
|
||||
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
|
||||
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
|
||||
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
|
||||
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
|
||||
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
|
||||
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
|
||||
.draco-msg.md code, .m-rapport.md code {
|
||||
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
|
||||
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
.draco-msg.md pre, .m-rapport.md pre {
|
||||
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
|
||||
overflow-x: auto; margin: 7px 0;
|
||||
}
|
||||
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
|
||||
.draco-msg.md blockquote, .m-rapport.md blockquote {
|
||||
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
|
||||
color: var(--p-doux);
|
||||
}
|
||||
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
|
||||
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
|
||||
background: var(--p-chip);
|
||||
}
|
||||
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
|
||||
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
|
||||
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
|
||||
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
|
||||
.draco-msg.md table, .m-rapport.md table {
|
||||
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
|
||||
display: block; overflow-x: auto; max-width: 100%;
|
||||
}
|
||||
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
|
||||
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
|
||||
}
|
||||
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
|
||||
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
|
||||
|
||||
+483
-47
@@ -1,43 +1,259 @@
|
||||
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5).
|
||||
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
|
||||
*
|
||||
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
|
||||
* GET /api/v1/me — identité (cookie draco_session)
|
||||
* POST /api/v1/chat — conversation réelle du noyau (J2) :
|
||||
* LLM DeepSeek + outils MCP des apps
|
||||
* GET /api/v1/conversations/… — historique de la conversation
|
||||
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
||||
* LLM DeepSeek + outils MCP des apps ;
|
||||
* la réponse arrive avec son HTML
|
||||
* Markdown rendu côté service (v2)
|
||||
* GET /api/v1/conversations — discussions façon Muse (v2) : une
|
||||
* principale perpétuelle + des
|
||||
* parallèles créées/archivées/
|
||||
* supprimées par l'utilisateur
|
||||
* GET /api/v1/conversations/… — historique d'une discussion (les
|
||||
* messages agent portent leur html)
|
||||
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
||||
* externes J4 : état de configuration)
|
||||
* POST /api/v1/missions — missions longues (J5) : lancement,
|
||||
* suivi, rapport, annulation
|
||||
* POST /api/v1/missions — missions longues (J5)
|
||||
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
||||
* (persistée), thème clair/sombre aligné sur l'app hôte
|
||||
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
|
||||
* Bouton « Console » (barre des discussions) : ouvre la console web
|
||||
* du même service (agent.dracodev.net) DANS la page, en surimpression
|
||||
* pleine page (iframe) qui naît à la géométrie du panneau puis
|
||||
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
|
||||
* courante, ?app= app d'origine déduite du sous-domaine) est celle
|
||||
* du premier chargement — l'iframe est créé paresseusement et
|
||||
* conservé, la console garde son état entre ouvertures. La barre
|
||||
* d'outils de la surimpression offre « Mode sidebar » (retour au
|
||||
* panneau seul, animation inverse) et « Fermer l'agent » (referme
|
||||
* surimpression et panneau).
|
||||
* États honnêtes : service injoignable / session non reconnue / LLM non
|
||||
* configuré / exécuteur non configuré sont affichés tels quels. */
|
||||
(function () {
|
||||
"use strict";
|
||||
var API = "https://agent.dracodev.net";
|
||||
var convId = null;
|
||||
try { convId = sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { /* stockage indispo */ }
|
||||
var convKind = null; // 'principale' | 'parallele'
|
||||
var convTitre = "";
|
||||
var etatConnu = false;
|
||||
var historiqueCharge = false;
|
||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||
var titreEl = null, tiroirEl = null;
|
||||
|
||||
function domaine() {
|
||||
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
||||
}
|
||||
function stockerConv() {
|
||||
try {
|
||||
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
|
||||
else sessionStorage.removeItem("draco_panneau_conv");
|
||||
} catch (e) { /* stockage indisponible */ }
|
||||
}
|
||||
function convStockee() {
|
||||
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
/* ── Console web (même service, surimpression pleine page) ─── */
|
||||
function appHote() {
|
||||
var h = (window.location.hostname || "").split(".")[0];
|
||||
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
|
||||
kanbante: "kanbante", og: "obsigate" };
|
||||
return correspondance[h] || null;
|
||||
}
|
||||
function urlConsole() {
|
||||
var params = [];
|
||||
var id = convId || convStockee();
|
||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||
var app = appHote();
|
||||
if (app) params.push("app=" + encodeURIComponent(app));
|
||||
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||
}
|
||||
|
||||
/* La surimpression vit entièrement dans ce fichier (DOM + styles
|
||||
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
|
||||
* thème suit celui du panneau (data-draco-theme synchronisé par
|
||||
* appliquerTheme) et sa palette reprend les mêmes valeurs que
|
||||
* draco-panneau.css. L'iframe est créé au premier clic puis
|
||||
* conservé : rouvrir la console ne le recharge pas. */
|
||||
var surEl = null, surCadre = null, surOuverte = false;
|
||||
var STYLES_CONSOLE =
|
||||
".draco-console-sur{" +
|
||||
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
|
||||
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
|
||||
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
|
||||
"background:var(--p-fond);color:var(--p-texte);" +
|
||||
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
|
||||
"display:flex;flex-direction:column;transition:width .3s ease;" +
|
||||
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
|
||||
".draco-console-sur[data-draco-theme=\"dark\"]{" +
|
||||
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
|
||||
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
|
||||
".draco-console-sur[hidden]{display:none}" +
|
||||
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
|
||||
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
|
||||
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
|
||||
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
|
||||
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
|
||||
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
|
||||
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
|
||||
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
|
||||
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
|
||||
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
|
||||
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
|
||||
|
||||
function assurerSurimpression() {
|
||||
if (surEl) return;
|
||||
if (!document.getElementById("draco-console-styles")) {
|
||||
var st = document.createElement("style");
|
||||
st.id = "draco-console-styles";
|
||||
st.textContent = STYLES_CONSOLE;
|
||||
document.head.appendChild(st);
|
||||
}
|
||||
surEl = document.createElement("div");
|
||||
surEl.className = "draco-console-sur";
|
||||
surEl.setAttribute("aria-label", "Console Spark");
|
||||
surEl.hidden = true;
|
||||
surEl.innerHTML =
|
||||
'<div class="draco-console-barre">' +
|
||||
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
|
||||
' <span class="dc-actions">' +
|
||||
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
|
||||
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
|
||||
' </span></div>' +
|
||||
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
|
||||
document.body.appendChild(surEl);
|
||||
surCadre = surEl.querySelector("iframe");
|
||||
surEl.querySelector('[data-dc="sidebar"]')
|
||||
.addEventListener("click", modeSidebarConsole);
|
||||
surEl.querySelector('[data-dc="fermer"]')
|
||||
.addEventListener("click", fermerAgentConsole);
|
||||
appliquerTheme();
|
||||
}
|
||||
|
||||
function largeurPanneau() {
|
||||
if (root) {
|
||||
var w = root.getBoundingClientRect().width;
|
||||
if (w > 0) return Math.round(w);
|
||||
}
|
||||
return 388;
|
||||
}
|
||||
|
||||
function ouvrirConsole() {
|
||||
assurerSurimpression();
|
||||
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
||||
if (surOuverte) return;
|
||||
surOuverte = true;
|
||||
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||
* extension à toute la page ; le double rAF laisse le temps de
|
||||
* peindre la largeur de départ, sinon la transition ne joue pas. */
|
||||
surEl.style.width = largeurPanneau() + "px";
|
||||
surEl.hidden = false;
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () { surEl.style.width = "100%"; });
|
||||
});
|
||||
}
|
||||
|
||||
function retrecirConsole() {
|
||||
if (!surEl || !surOuverte) return;
|
||||
surOuverte = false;
|
||||
var fini = false;
|
||||
function terminer() {
|
||||
if (fini) return;
|
||||
fini = true;
|
||||
surEl.removeEventListener("transitionend", surFin);
|
||||
surEl.hidden = true; // l'iframe reste chargé, juste masqué
|
||||
}
|
||||
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
|
||||
surEl.addEventListener("transitionend", surFin);
|
||||
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||
surEl.style.width = largeurPanneau() + "px";
|
||||
}
|
||||
function modeSidebarConsole() { retrecirConsole(); }
|
||||
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||
|
||||
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||
function themeHote() {
|
||||
var t = document.documentElement.getAttribute("data-theme");
|
||||
if (t === "dark" || t === "light") return t;
|
||||
if (document.documentElement.classList.contains("dark") ||
|
||||
document.body.classList.contains("dark")) return "dark";
|
||||
try {
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
|
||||
} catch (e) { /* pas de matchMedia */ }
|
||||
return "light";
|
||||
}
|
||||
function appliquerTheme() {
|
||||
if (root) root.setAttribute("data-draco-theme", themeHote());
|
||||
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
|
||||
}
|
||||
function surveillerTheme() {
|
||||
try {
|
||||
new MutationObserver(appliquerTheme).observe(document.documentElement,
|
||||
{ attributes: true, attributeFilter: ["data-theme", "class"] });
|
||||
window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", appliquerTheme);
|
||||
} catch (e) { /* surveillance facultative */ }
|
||||
}
|
||||
|
||||
/* ── Largeur réglable ──────────────────────────────────────── */
|
||||
function largeurStockee() {
|
||||
try {
|
||||
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
|
||||
return isNaN(v) ? null : v;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function appliquerLargeur() {
|
||||
if (!root) return;
|
||||
var v = largeurStockee();
|
||||
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
|
||||
}
|
||||
function brancherPoignee(poignee) {
|
||||
poignee.addEventListener("pointerdown", function (ev) {
|
||||
if (window.innerWidth <= 700) return; // mobile : pleine largeur
|
||||
ev.preventDefault();
|
||||
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
|
||||
function bouger(e2) {
|
||||
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
|
||||
root.style.width = w + "px";
|
||||
}
|
||||
function lacher(e2) {
|
||||
document.removeEventListener("pointermove", bouger);
|
||||
document.removeEventListener("pointerup", lacher);
|
||||
var w = Math.round(root.getBoundingClientRect().width);
|
||||
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
|
||||
}
|
||||
document.addEventListener("pointermove", bouger);
|
||||
document.addEventListener("pointerup", lacher);
|
||||
});
|
||||
poignee.addEventListener("dblclick", function () {
|
||||
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
|
||||
appliquerLargeur();
|
||||
});
|
||||
}
|
||||
|
||||
function construire() {
|
||||
root = document.createElement("aside");
|
||||
root.className = "draco-panneau";
|
||||
root.setAttribute("aria-label", "Spark, l'agent commun");
|
||||
root.innerHTML =
|
||||
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||
'<div class="draco-panneau-hd">' +
|
||||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||
' <div><div class="titre">Spark — agent commun</div>' +
|
||||
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
||||
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-panneau-hd draco-panneau-etat" style="border-bottom:1px solid #e4e5e0;padding-top:8px;padding-bottom:8px">' +
|
||||
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
|
||||
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||||
'</div>' +
|
||||
'<div class="draco-disc-bar">' +
|
||||
' <span class="d-titre">Discussion principale</span>' +
|
||||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
||||
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-discussions" hidden></div>' +
|
||||
'<div class="draco-panneau-outils">' +
|
||||
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
|
||||
' <button type="button" data-outil="approbations">Approbations</button>' +
|
||||
@@ -54,15 +270,30 @@
|
||||
etatEl = root.querySelector(".draco-panneau-etat");
|
||||
inputEl = root.querySelector("input");
|
||||
sendBtn = root.querySelector("button[type=submit]");
|
||||
titreEl = root.querySelector(".d-titre");
|
||||
tiroirEl = root.querySelector(".draco-discussions");
|
||||
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
||||
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
||||
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
|
||||
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
|
||||
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||
root.querySelector("form").addEventListener("submit", envoyer);
|
||||
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||
appliquerTheme();
|
||||
surveillerTheme();
|
||||
appliquerLargeur();
|
||||
window.addEventListener("resize", appliquerLargeur);
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
|
||||
if (ev.key !== "Escape") return;
|
||||
/* Surimpression ouverte : Échap = retour au panneau seul
|
||||
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
|
||||
* console (autre origine), l'événement ne remonte pas
|
||||
* jusqu'ici : il ne peut donc pas fermer par surprise. */
|
||||
if (surOuverte) { modeSidebarConsole(); return; }
|
||||
if (root.classList.contains("open")) fermer();
|
||||
});
|
||||
ajouterMsg("sys", "Conversation avec le service d'agents — domaine : " + domaine() + ".");
|
||||
}
|
||||
|
||||
function ajouterMsg(kind, texte) {
|
||||
@@ -74,10 +305,26 @@
|
||||
return d;
|
||||
}
|
||||
|
||||
/* Message agent rendu en Markdown : le HTML vient du service
|
||||
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
|
||||
function ajouterMsgMd(texte, html) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "draco-msg agent md";
|
||||
if (html) d.innerHTML = html;
|
||||
else d.textContent = texte;
|
||||
msgsEl.appendChild(d);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
return d;
|
||||
}
|
||||
|
||||
function majEtat(cls, texte) {
|
||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||||
etatEl.querySelector(".txt").textContent = texte;
|
||||
}
|
||||
function majTitre() {
|
||||
titreEl.textContent = convTitre || "Discussion principale";
|
||||
titreEl.title = titreEl.textContent;
|
||||
}
|
||||
|
||||
function verifierIdentite() {
|
||||
if (etatConnu) return;
|
||||
@@ -109,6 +356,200 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Discussions (façon Muse) ────────────────────────────── */
|
||||
function chargerListeDiscussions() {
|
||||
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
|
||||
{ credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
}
|
||||
|
||||
function demarrerDiscussions() {
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
var precedente = convStockee();
|
||||
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||
var active = trouvee || d.principale;
|
||||
if (!active) return;
|
||||
convId = active.id; convKind = active.kind; convTitre = active.title;
|
||||
stockerConv(); majTitre();
|
||||
chargerHistorique();
|
||||
})
|
||||
.catch(function () {
|
||||
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||
});
|
||||
}
|
||||
|
||||
function basculerDiscussion(c) {
|
||||
convId = c.id; convKind = c.kind; convTitre = c.title;
|
||||
stockerConv(); majTitre();
|
||||
tiroirEl.hidden = true;
|
||||
msgsEl.innerHTML = "";
|
||||
ajouterMsg("sys", (c.kind === "principale"
|
||||
? "Discussion principale — le fil sans fin."
|
||||
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
|
||||
chargerHistorique();
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
function nouvelleDiscussion() {
|
||||
fetch(API + "/api/v1/conversations", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain: domaine() })
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (c) {
|
||||
basculerDiscussion(c);
|
||||
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
|
||||
})
|
||||
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
|
||||
}
|
||||
|
||||
function actionDiscussion(id, action) {
|
||||
var methode = action === "supprimer" ? "DELETE" : "POST";
|
||||
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
|
||||
return fetch(url, { method: methode, credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
|
||||
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function ligneDiscussion(c, actions) {
|
||||
var ligne = document.createElement("div");
|
||||
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "d-meta";
|
||||
var quand = "";
|
||||
try {
|
||||
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
|
||||
{ day: "numeric", month: "short" });
|
||||
} catch (e) { /* date illisible */ }
|
||||
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
|
||||
ligne.appendChild(meta);
|
||||
var sous = document.createElement("div");
|
||||
sous.className = "d-sous";
|
||||
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
|
||||
(c.kind === "principale" ? " · perpétuelle" : "");
|
||||
ligne.appendChild(sous);
|
||||
meta.addEventListener("click", function () { basculerDiscussion(c); });
|
||||
sous.addEventListener("click", function () { basculerDiscussion(c); });
|
||||
if (actions && actions.length) {
|
||||
var btns = document.createElement("div");
|
||||
btns.className = "d-actions";
|
||||
actions.forEach(function (a) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button"; b.textContent = a.label;
|
||||
if (a.danger) b.className = "danger";
|
||||
b.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (a.confirmer && !window.confirm(a.confirmer)) return;
|
||||
actionDiscussion(c.id, a.action)
|
||||
.then(function () {
|
||||
if (c.id === convId && a.action !== "desarchiver") {
|
||||
chargerListeDiscussions().then(function (d) {
|
||||
if (d.principale) {
|
||||
basculerDiscussion(d.principale);
|
||||
ajouterMsg("sys", a.action === "supprimer"
|
||||
? "Discussion supprimée — retour à la discussion principale."
|
||||
: "Discussion archivée — retour à la discussion principale.");
|
||||
}
|
||||
});
|
||||
}
|
||||
remplirTiroir();
|
||||
})
|
||||
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
|
||||
});
|
||||
btns.appendChild(b);
|
||||
});
|
||||
ligne.appendChild(btns);
|
||||
}
|
||||
return ligne;
|
||||
}
|
||||
|
||||
function remplirTiroir() {
|
||||
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
tiroirEl.innerHTML = "";
|
||||
var h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Discussion principale";
|
||||
tiroirEl.appendChild(h);
|
||||
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
|
||||
h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Discussions parallèles";
|
||||
tiroirEl.appendChild(h);
|
||||
if (!d.paralleles.length) {
|
||||
var vide = document.createElement("div");
|
||||
vide.className = "d-vide";
|
||||
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
|
||||
tiroirEl.appendChild(vide);
|
||||
}
|
||||
d.paralleles.forEach(function (c) {
|
||||
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||
{ label: "Archiver", action: "archive" },
|
||||
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||
]));
|
||||
});
|
||||
if (d.archivees && d.archivees.length) {
|
||||
h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Archivées";
|
||||
tiroirEl.appendChild(h);
|
||||
d.archivees.forEach(function (c) {
|
||||
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||
{ label: "Désarchiver", action: "desarchiver" },
|
||||
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||
]));
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
|
||||
});
|
||||
}
|
||||
|
||||
function basculerTiroir() {
|
||||
tiroirEl.hidden = !tiroirEl.hidden;
|
||||
if (!tiroirEl.hidden) remplirTiroir();
|
||||
}
|
||||
|
||||
function chargerHistorique() {
|
||||
if (!convId) return;
|
||||
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) { convId = null; stockerConv(); return null; }
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
convKind = d.kind; convTitre = d.title || convTitre;
|
||||
majTitre();
|
||||
if (!msgsEl.querySelector(".draco-msg")) {
|
||||
ajouterMsg("sys", (d.kind === "principale"
|
||||
? "Discussion principale — le fil sans fin."
|
||||
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
|
||||
}
|
||||
var sys = msgsEl.querySelector(".draco-msg.sys");
|
||||
(d.messages || []).forEach(function (m) {
|
||||
var el = m.role === "user"
|
||||
? ajouterMsg("user", m.content)
|
||||
: ajouterMsgMd(m.content, m.html);
|
||||
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
|
||||
});
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () { /* historique indisponible : on repart à neuf */ });
|
||||
}
|
||||
|
||||
function chargerAujourdhui() {
|
||||
var bloc = document.createElement("div");
|
||||
bloc.className = "draco-auj";
|
||||
@@ -178,7 +619,7 @@
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
bloc.style.opacity = ".55";
|
||||
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px;color:#6c7268'>Décision enregistrée : " + esc(d.status) + "</span>";
|
||||
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
|
||||
})
|
||||
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
|
||||
}
|
||||
@@ -239,12 +680,24 @@
|
||||
d.missions.slice(0, 10).forEach(function (m) {
|
||||
var ligne = document.createElement("div");
|
||||
ligne.className = "m-item";
|
||||
var rapport = m.result ? "<div class='m-rapport'>" + esc(String(m.result).slice(0, 700)) + "</div>" : "";
|
||||
ligne.innerHTML = "<div class='m-entete'><b>" + esc(m.title) + "</b>" +
|
||||
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span></div>" +
|
||||
"<div class='m-meta'>" + esc(m.executor) +
|
||||
(m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") +
|
||||
(m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "</div>" + rapport;
|
||||
var entete = document.createElement("div");
|
||||
entete.className = "m-entete";
|
||||
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
|
||||
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
|
||||
ligne.appendChild(entete);
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "m-meta";
|
||||
meta.textContent = m.executor +
|
||||
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
|
||||
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
|
||||
ligne.appendChild(meta);
|
||||
if (m.result) {
|
||||
var rapport = document.createElement("div");
|
||||
rapport.className = "m-rapport md";
|
||||
if (m.result_html) rapport.innerHTML = m.result_html;
|
||||
else rapport.textContent = String(m.result).slice(0, 1200);
|
||||
ligne.appendChild(rapport);
|
||||
}
|
||||
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
|
||||
@@ -290,31 +743,6 @@
|
||||
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||
}
|
||||
|
||||
function chargerHistorique() {
|
||||
if (historiqueCharge || !convId) return;
|
||||
historiqueCharge = true;
|
||||
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) {
|
||||
convId = null;
|
||||
try { sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* ok */ }
|
||||
return null;
|
||||
}
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d || !d.messages || !d.messages.length) return;
|
||||
var sys = msgsEl.querySelector(".draco-msg.sys");
|
||||
d.messages.forEach(function (m) {
|
||||
var el = ajouterMsg(m.role === "user" ? "user" : "agent", m.content);
|
||||
if (sys) msgsEl.insertBefore(el, sys.nextSibling), sys = el;
|
||||
});
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () { /* historique indisponible : la conversation repart à neuf */ });
|
||||
}
|
||||
|
||||
function envoyer(ev) {
|
||||
ev.preventDefault();
|
||||
var texte = inputEl.value.trim();
|
||||
@@ -347,8 +775,16 @@
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
convId = d.conversation_id;
|
||||
try { sessionStorage.setItem("draco_panneau_conv", convId); } catch (e) { /* ok */ }
|
||||
attente.textContent = d.reply;
|
||||
convKind = d.kind || convKind;
|
||||
if (d.title) convTitre = d.title;
|
||||
stockerConv(); majTitre();
|
||||
if (d.html) {
|
||||
attente.className = "draco-msg agent md";
|
||||
attente.innerHTML = d.html;
|
||||
} else {
|
||||
attente.textContent = d.reply;
|
||||
}
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
if (d.tools_used && d.tools_used.length) {
|
||||
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
|
||||
}
|
||||
@@ -362,10 +798,10 @@
|
||||
}
|
||||
|
||||
function ouvrir() {
|
||||
if (!root) construire();
|
||||
if (!root) { construire(); demarrerDiscussions(); }
|
||||
root.classList.add("open");
|
||||
appliquerTheme();
|
||||
verifierIdentite();
|
||||
chargerHistorique();
|
||||
setTimeout(function () { inputEl.focus(); }, 300);
|
||||
}
|
||||
function fermer() { if (root) root.classList.remove("open"); }
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Lot 2, vague 2 — émission d'événements de Kanbante vers le
|
||||
journal du service d'agents (contrat ACC-0.2).
|
||||
|
||||
Couvre : l'enveloppe signée (HMAC recalculé côté test), l'inactivité
|
||||
sans clé, l'émission sur déplacement de carte (``kanbante.card.moved``)
|
||||
et sur entrée en Révision (``kanbante.card.review_requested``), et
|
||||
l'absence totale d'effet d'une panne du récepteur sur le métier.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from app import agent_journal, models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
CLE = "cle-kanbante-de-test"
|
||||
|
||||
|
||||
class Recepteur:
|
||||
"""Faux service d'agents : capture corps bruts et signatures."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.recus: list[tuple[bytes, str]] = []
|
||||
self._verrou = threading.Lock()
|
||||
recus = self.recus
|
||||
verrou = self._verrou
|
||||
|
||||
class Mains(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
corps = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
with verrou:
|
||||
recus.append((corps, self.headers.get("X-Draco-Signature", "")))
|
||||
self.send_response(201)
|
||||
self.end_headers()
|
||||
|
||||
self._serveur = ThreadingHTTPServer(("127.0.0.1", 0), Mains)
|
||||
threading.Thread(target=self._serveur.serve_forever, daemon=True).start()
|
||||
self.url = f"http://127.0.0.1:{self._serveur.server_address[1]}/api/v1/evenements"
|
||||
|
||||
def enveloppes(self, type_: str | None = None, ref: str | None = None) -> list[dict]:
|
||||
with self._verrou:
|
||||
bruts = list(self.recus)
|
||||
envs = [json.loads(corps) for corps, _ in bruts]
|
||||
if type_ is not None:
|
||||
envs = [e for e in envs if e["type"] == type_]
|
||||
if ref is not None:
|
||||
envs = [e for e in envs if e["object"]["ref"] == ref]
|
||||
return envs
|
||||
|
||||
def attendre(self, type_: str, ref: str, timeout: float = 5.0) -> dict:
|
||||
"""Poll jusqu'à l'arrivée de l'événement voulu (envoi en
|
||||
arrière-plan : l'attente fait partie du contrat de test)."""
|
||||
limite = time.monotonic() + timeout
|
||||
while time.monotonic() < limite:
|
||||
trouves = self.enveloppes(type_, ref)
|
||||
if trouves:
|
||||
return trouves[0]
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f"Événement {type_} pour {ref} jamais reçu")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recepteur():
|
||||
return Recepteur()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def journal_actif(monkeypatch, recepteur):
|
||||
"""Émission configurée vers le faux récepteur local."""
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
|
||||
return recepteur
|
||||
|
||||
|
||||
def test_enveloppe_signee_verifiable(journal_actif):
|
||||
fil = agent_journal.emettre(
|
||||
"kanbante.card.moved",
|
||||
"issue:Projets/demo#1",
|
||||
"Tâche un",
|
||||
"http://gitea.test/Projets/demo/issues/1",
|
||||
actor="user",
|
||||
)
|
||||
assert fil is not None
|
||||
fil.join(timeout=5)
|
||||
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
|
||||
corps, signature = journal_actif.recus[0]
|
||||
# La signature se revérifie indépendamment, comme le fait le service.
|
||||
assert signature == hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
|
||||
assert json.loads(corps) == env
|
||||
assert env["id"]
|
||||
assert env["source_app"] == "kanbante"
|
||||
assert env["domain"] == "pro"
|
||||
assert env["actor"] == "user"
|
||||
assert env["object"] == {
|
||||
"ref": "issue:Projets/demo#1",
|
||||
"label": "Tâche un",
|
||||
"url": "http://gitea.test/Projets/demo/issues/1",
|
||||
}
|
||||
# occurred_at : ISO 8601 lisible par le service.
|
||||
datetime.fromisoformat(env["occurred_at"])
|
||||
|
||||
|
||||
def test_inactif_sans_cle(monkeypatch, recepteur):
|
||||
monkeypatch.delenv("AGENT_EVENEMENTS_CLE", raising=False)
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
|
||||
assert agent_journal.actif() is False
|
||||
assert agent_journal.emettre(
|
||||
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
|
||||
"http://gitea.test/Projets/demo/issues/1",
|
||||
) is None
|
||||
time.sleep(0.3)
|
||||
assert recepteur.recus == []
|
||||
|
||||
|
||||
def _deplacer(client, numero: int, de: str, vers: str):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo",
|
||||
"issue_number": numero,
|
||||
"to_column": vers,
|
||||
"from_column": de,
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["ok"] is True
|
||||
return r
|
||||
|
||||
|
||||
def test_deplacement_emet_moved(client, journal_actif):
|
||||
_deplacer(client, 1, "backlog", "in_progress")
|
||||
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
|
||||
assert env["actor"] == "user"
|
||||
assert env["object"]["label"] == "Tâche un"
|
||||
assert env["object"]["url"] == "http://gitea.test/Projets/demo/issues/1"
|
||||
# Un déplacement hors Révision n'émet pas de review_requested.
|
||||
time.sleep(0.3)
|
||||
assert journal_actif.enveloppes(
|
||||
"kanbante.card.review_requested", "issue:Projets/demo#1"
|
||||
) == []
|
||||
|
||||
|
||||
def test_entree_en_revision_emet_review_requested(client, journal_actif):
|
||||
_deplacer(client, 1, "backlog", "review")
|
||||
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
|
||||
env = journal_actif.attendre(
|
||||
"kanbante.card.review_requested", "issue:Projets/demo#1"
|
||||
)
|
||||
assert env["object"]["label"] == "Tâche un"
|
||||
assert env["actor"] == "user"
|
||||
# Sortir de Révision ne redemande pas de révision.
|
||||
_deplacer(client, 1, "review", "done")
|
||||
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
|
||||
time.sleep(0.3)
|
||||
assert len(journal_actif.enveloppes(
|
||||
"kanbante.card.review_requested", "issue:Projets/demo#1"
|
||||
)) == 1
|
||||
|
||||
|
||||
def test_panne_du_recepteur_sans_effet_sur_le_deplacement(client, monkeypatch):
|
||||
# Récepteur mort (port 1 fermé) mais émission configurée.
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL", "http://127.0.0.1:1/api/v1/evenements")
|
||||
_deplacer(client, 1, "backlog", "in_progress")
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in state["in_progress"]] == [1]
|
||||
# L'échec d'envoi, lui, reste silencieux et borné dans le temps.
|
||||
fil = agent_journal.emettre(
|
||||
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
|
||||
"http://gitea.test/Projets/demo/issues/1",
|
||||
)
|
||||
assert fil is not None
|
||||
fil.join(timeout=10)
|
||||
assert not fil.is_alive()
|
||||
Reference in New Issue
Block a user