Compare commits

...
9 Commits
Author SHA1 Message Date
Spark 4230ac1052 Panneau Spark : bouton « Console ↗ » vers la console du service (0.28.1)
CI / test (push) Waiting to run
2026-10-22 21:18:49 +00:00
Spark 3809462cc7 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)
2026-10-22 18:40:00 +00:00
Spark 868496891d 0.27.0 — Panneau Spark v2 (Lot 1)
CI / test (push) Canceled after 0s
- draco-panneau vendangé v2 (identique dans les 4 apps) : Markdown
  rendu par le service 0.10.0, largeur réglable, thème aligné,
  discussions façon Muse (principale + parallèles)
- 242 tests verts
2026-10-22 11:54:31 -04:00
Spark 4a947747f2 0.26.0 — panneau Spark intégré (barre commune, assets vendangés)
CI / test (push) Failing after 1m56s
- static/js/draco-panneau.js + static/css/draco-panneau.css (copie
  du panneau partagé : conversation, approbations, missions J5)
- _draco_bar.html : action spark → DRACO_PANNEAU.toggle()
2026-10-22 03:39:34 +00:00
Spark 5ef6c21756 feat(0.26.0): écritures MCP minimales — create_issue + add_comment gardés par jeton kbt_ 2026-10-22 03:31:19 +00:00
Spark 13182f5d53 0.25.0 — Jetons d'API personnels (kbt_) pour les surfaces machine
CI / test (push) Successful in 1m56s
Depuis 0.24.0, /mcp exige session ou Basic : le service d'agents s'y
faisait refuser en 401 (vu en production, panneau Spark du 2026-10-21).

- Table api_tokens (empreinte SHA-256 seule), CRUD dans Mon profil
- Bearer honoré par le middleware sur /mcp et /api/ uniquement —
  jamais l'interface web ; révocation et désactivation immédiates
- 235 tests verts (3 nouveaux : cycle de vie, compte désactivé,
  handshake MCP par jeton quand l'auth est active)
2026-10-21 21:38:16 -04:00
Spark 2381ae0b35 fix(0.24.2): base sombre pour tous les champs de saisie (#29)
CI / test (push) Successful in 1m54s
2026-10-21 21:31:57 -04:00
bruno 7b6e72e6ce chore: APP_VERSION 0.24.1
CI / test (push) Successful in 1m53s
2026-10-21 15:31:43 -04:00
bruno 6e31122c46 fix: cryptography manquant dans dependencies (forge_crypto.py) — crash ModuleNotFoundError au démarrage v0.24.0
CI / test (push) Successful in 1m53s
2026-10-21 15:26:40 -04:00
17 changed files with 2072 additions and 44 deletions
+131
View File
@@ -1,5 +1,136 @@
# Changelog
## 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é
Le bouton Spark de la barre commune ouvre désormais le panneau de
l'agent commun (conversation, Aujourd'hui, approbations, missions),
comme dans TikTak et Flowdeck : assets `draco-panneau.js/css`
vendangés à l'identique, action `spark` branchée sur
`DRACO_PANNEAU.toggle()`. Redéploiement Docker requis (avec le reste
de la 0.26.0).
### Ajouté — écritures MCP minimales et sûres (service d'agents)
Le serveur MCP n'était qu'en lecture depuis la 0.5.0. Le service
d'agents (jeton `kbt_` posé en production) peut désormais créer
des cartes et commenter, et rien d'autre : aucune suppression,
aucun changement d'état.
- **`create_issue`** — arguments : `owner` (chaîne, requis),
`repo` (chaîne, requis), `title` (chaîne, requis), `body`
(chaîne, optionnel), `column` (optionnel, une des colonnes du
board, `backlog` par défaut). Réutilise le service de création
existant (`_do_capture` → `create_issue` du client de forge,
Gitea ou GitHub selon la connexion qui détient le dépôt) : la
carte est placée sur le board et journalisée (source `mcp`).
Réponse JSON : `number`, `title`, `html_url`, `repo_full_name`,
`column`.
- **`add_comment`** — arguments : `owner`, `repo`, `number`
(entier), `body` — tous requis. Réutilise `create_comment` du
client de forge (mêmes validations que l'API web : corps non
vide, 10 000 caractères max) et journalise l'événement
`commented` (source `mcp`). Réponse JSON : `id`, `body`,
`html_url`, `repo_full_name`, `number`.
- **Garde inchangée** : les deux outils passent par le même
`/mcp` et le même middleware que les lectures — session,
Basic ou `Authorization: Bearer kbt_…` ; un jeton ne
déverrouille toujours pas l'interface web, et sa révocation
bloque aussitôt les écritures. Erreurs métier au format
existant du serveur (résultat `isError` avec le message).
- `GET /mcp` déclare désormais `read_only: false` et la liste
`write_tools` (honnêteté de la découverte).
- **Redéploiement Docker de Kanbante à la charge de Bruno**
pour activer cette version en production.
## 0.25.0 — 2026-10-21
### Ajouté — jetons d'API personnels pour les surfaces machine (MCP)
Depuis la 0.24.0, tout Kanbante exige une authentification (session
ou Basic) — y compris `/mcp`. Le service d'agents s'y faisait refuser
en 401 (vu en production le 2026-10-21, panneau Spark : « Source
Kanbante injoignable »). Il manquait une identité machine propre.
- Table `api_tokens` : jetons personnels préfixés `kbt_`, seule
l'empreinte SHA-256 est conservée ; création, liste et révocation
dans **Mon profil → Jetons d'API (MCP)**, texte clair affiché une
seule fois à la création.
- Le middleware honore `Authorization: Bearer kbt_…` **uniquement**
sur `/mcp` et `/api/` : un jeton ne déverrouille jamais l'interface
web. Révocation et désactivation du compte ont effet immédiat ;
`last_used_at` journalise l'usage.
- Usage visé : créer un jeton « service-agents » et le poser en
`AGENT_MCP_KANBANTE_KEY` dans le `.env` du conteneur agent.
## 0.24.2 — 2026-10-21
### Corrigé — boîtes d'édition blanches dans l'administration (#29)
Signalé par Bruno (captures) : le champ « Nouveau mot de passe »
(Administration), la zone « Sélection de projets » (Espaces) et
les champs « Valeur du panneau » (Paramètres) s'affichaient en
**blanc**. Il n'existait aucune règle de base pour les champs de
saisie : seuls les champs couverts par un contexte précis
(`.login-form`, `.capture-form`, `.admin-form` pour text/password/
select seulement) étaient sombres — les textarea et tout champ
hors de ces contextes gardaient le fond du navigateur.
Correctif dans `style.css` : une **règle de base unique en
spécificité zéro** (`:where(...)`) — fond `--surface-2`, texte
`--text`, bordure `--border` — pour `input`, `textarea` et
`select`, qui comble les vides sans jamais écraser les règles de
contexte existantes ; plus `color-scheme: dark` pour les
contrôles natifs. Styles calculés vérifiés sur les trois pages
(fond identique aux champs de référence, palette de commandes
inchangée).
## 0.24.0 — 2026-10-21
### Ajouté — multi-utilisateurs, multi-forges, espaces, archives
+13 -7
View File
@@ -33,7 +33,7 @@
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
- 🤖 **Serveur MCP** — 7 outils de lecture (boards, focus, recherche, métriques) + 2 écritures minimales (`create_issue`, `add_comment`) exposés en JSON-RPC sur `/mcp` pour vos agents
- 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail
- 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique
- 📝 **Notes** — bloc-notes par projet
@@ -212,15 +212,17 @@ kanbante/
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
| GET/POST | `/mcp` | Serveur MCP (JSON-RPC 2.0) : lectures + `create_issue` / `add_comment` |
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
## Brancher un client MCP
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
les lectures d'abord). Configuration type (transport HTTP) :
Kanbante expose un serveur MCP : lectures d'abord, plus deux
écritures minimales et sûres pour les agents — créer une carte et
commenter (aucune suppression, aucun changement d'état).
Configuration type (transport HTTP) :
```json
{
@@ -233,9 +235,13 @@ les lectures d'abord). Configuration type (transport HTTP) :
```
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
Kanbante est active, le client doit envoyer l'en-tête
`Authorization` correspondant.
`search_issues`, `get_activity`, `get_metrics`, `create_issue`
(`owner`, `repo`, `title`, `body` et `column` optionnels),
`add_comment` (`owner`, `repo`, `number`, `body`). Si
l'authentification Kanbante est active, le client doit envoyer
l'en-tête `Authorization` correspondant : Basic, ou un jeton
d'API personnel `Bearer kbt_…` (Mon profil → Jetons d'API) —
honoré uniquement sur `/mcp` et `/api/`, jamais sur le web.
## Activer le temps réel (webhook Gitea)
+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.24.0"
APP_VERSION = "0.28.1"
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", ""),
)
+269 -32
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,
@@ -308,6 +309,18 @@ def _basic_ok(request: Request) -> bool:
) and secrets.compare_digest(password, settings.auth_password)
def _bearer_user(request: Request) -> dict | None:
"""Jeton d'API personnel (0.25.0) : Authorization: Bearer kbt_…
Réservé aux surfaces machine (/mcp, /api/) — le middleware ne
l'honore que là. La résolution vérifie l'empreinte, la révocation
et que le compte est actif, et journalise l'usage (last_used_at)."""
header = request.headers.get("authorization", "")
if not header.lower().startswith("bearer "):
return None
return models.user_by_api_token(header[7:].strip())
def _current_user(request: Request) -> dict | None:
"""The database user behind the session, or None.
@@ -367,6 +380,11 @@ class AuthMiddleware(BaseHTTPMiddleware):
session_user = None # disabled account: session is dead
if session_user is not None or _basic_ok(request):
return await call_next(request)
# Jetons d'API (0.25.0) : surfaces machine seulement — un jeton
# ne déverrouille jamais l'interface web.
if (path.startswith("/mcp") or path.startswith("/api/")) \
and _bearer_user(request) is not None:
return await call_next(request)
# API consumers get a plain 401 (no browser prompt, no
# redirect they cannot follow); browsers get the login page.
if path.startswith("/api/") or "text/html" not in request.headers.get(
@@ -1541,11 +1559,8 @@ async def admin_settings_save(request: Request, csrf_token: str = Form("")):
)
@app.get("/profile")
async def profile_page(request: Request, msg: str = "", err: str = ""):
user = _current_user(request)
if user is None:
return RedirectResponse("/login", status_code=303)
def _render_profile(request: Request, user: dict, msg: str = "", err: str = "",
nouveau_jeton: str = ""):
from app import forge_crypto
mine = []
@@ -1566,10 +1581,64 @@ async def profile_page(request: Request, msg: str = "", err: str = ""):
else []
),
my_connections=mine,
api_tokens=(
models.list_api_tokens(user["id"])
if user.get("id") is not None
else []
),
nouveau_jeton=nouveau_jeton,
msg=msg, err=err,
)
@app.get("/profile")
async def profile_page(request: Request, msg: str = "", err: str = ""):
user = _current_user(request)
if user is None:
return RedirectResponse("/login", status_code=303)
return _render_profile(request, user, msg=msg, err=err)
@app.post("/profile/tokens")
async def profile_token_create(
request: Request,
name: str = Form(""),
csrf_token: str = Form(""),
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
nom = name.strip() or "Sans nom"
_row, plaintext = models.create_api_token(user["id"], nom)
models.log_admin(user["id"], "profile.token.create", nom)
# Le texte clair n'est montré qu'ici, une seule fois : la base ne
# conserve que l'empreinte.
return _render_profile(
request, user,
msg="Jeton créé — copie-le maintenant, il ne sera plus jamais affiché.",
nouveau_jeton=plaintext,
)
@app.post("/profile/tokens/{token_id}/revoke")
async def profile_token_revoke(
request: Request,
token_id: int,
csrf_token: str = Form(""),
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
if models.revoke_api_token(user["id"], token_id):
models.log_admin(user["id"], "profile.token.revoke", str(token_id))
return RedirectResponse("/profile?msg=Jeton+r%C3%A9voqu%C3%A9", status_code=303)
return RedirectResponse("/profile?err=Jeton+introuvable", status_code=303)
@app.post("/profile")
async def profile_update(
request: Request,
@@ -1952,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],
@@ -1995,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
@@ -2324,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(
@@ -2475,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(
@@ -4070,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
@@ -4139,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
@@ -4281,27 +4401,14 @@ async def sse_event_stream(request: Request):
hub.unsubscribe(queue)
async def _do_capture(
title: str, body: str, repo_full_name: str, column: str = "backlog"
) -> dict:
"""Create a Gitea issue from a capture and place its card.
async def _client_for_repo(repo_full_name: str):
"""(client, connection_id) holding this repo, default first.
Shared by POST /api/capture, the /capture page and the ntfy
listener. Raises ValueError for bad input (callers map to 400).
Board-less callers (capture, serveur MCP) have no request /
workspace scope: the connection holding the repo among the
global ones wins, so a card can target a GitHub project too
(0.24.0). Falls back to the historical client.
"""
title = (title or "").strip()
if not title or len(title) > 255:
raise ValueError("Title required (max 255 chars)")
if column not in KANBAN_COLUMNS:
raise ValueError("Invalid column")
parts = _split_full_name(repo_full_name)
if parts is None:
raise ValueError("Invalid repo (expected owner/name)")
owner, repo = parts
# Capture has no board context: find the connection holding
# this repo among the global ones (default first), so a card
# can be captured into a GitHub project too (0.24.0).
client, conn_id = gitea, registry.default_id
for candidate in registry.global_clients():
if candidate is gitea:
continue
@@ -4312,8 +4419,33 @@ async def _do_capture(
except Exception:
continue
if repo_full_name in names:
client, conn_id = candidate, candidate.connection_id
break
return candidate, candidate.connection_id
return gitea, registry.default_id
async def _do_capture(
title: str,
body: str,
repo_full_name: str,
column: str = "backlog",
source: str = "capture",
) -> dict:
"""Create a Gitea issue from a capture and place its card.
Shared by POST /api/capture, the /capture page, the ntfy
listener and the MCP create_issue tool (0.26.0, source="mcp").
Raises ValueError for bad input (callers map to 400).
"""
title = (title or "").strip()
if not title or len(title) > 255:
raise ValueError("Title required (max 255 chars)")
if column not in KANBAN_COLUMNS:
raise ValueError("Invalid column")
parts = _split_full_name(repo_full_name)
if parts is None:
raise ValueError("Invalid repo (expected owner/name)")
owner, repo = parts
client, conn_id = await _client_for_repo(repo_full_name)
issue = await client.create_issue(owner, repo, title, body or "")
# La carte vit dans le premier espace qui détient la connexion.
ws_id = next(
@@ -4330,7 +4462,7 @@ async def _do_capture(
)
log_event(
repo_full_name, issue["number"], "created",
to_column=column, source="capture",
to_column=column, source=source,
workspace_id=ws_id, connection_id=conn_id,
)
await _publish_board_changed(repo_full_name)
@@ -4743,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()
@@ -5076,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
)
@@ -5296,7 +5445,11 @@ async def api_toggle_issue_label(
}
# ── MCP server (read-only tools over JSON-RPC) ─────────────
# ── MCP server (tools over JSON-RPC) ───────────────────────
# Lectures depuis la 0.5.0 ; écritures minimales (create_issue,
# add_comment) depuis la 0.26.0, gardées par la même authentification
# que tout /mcp (session, Basic ou jeton d'API kbt_ — voir le
# middleware : un jeton n'ouvre jamais l'interface web).
MCP_TOOLS = [
{
@@ -5362,11 +5515,49 @@ MCP_TOOLS = [
"properties": {"repo": {"type": "string"}},
},
},
{
"name": "create_issue",
"description": "Create an issue (card) in a repo and place it on "
"the board (backlog by default). Write tool — same /mcp "
"authentication as the reads (kbt_ API token, session or Basic).",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"title": {"type": "string"},
"body": {"type": "string"},
"column": {"type": "string", "enum": list(KANBAN_COLUMNS)},
},
"required": ["owner", "repo", "title"],
},
},
{
"name": "add_comment",
"description": "Add a comment to an existing issue (card). "
"Write tool — same /mcp authentication as the reads "
"(kbt_ API token, session or Basic).",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"number": {"type": "integer"},
"body": {"type": "string"},
},
"required": ["owner", "repo", "number", "body"],
},
},
]
async def _mcp_call_tool(name: str, args: dict) -> str:
"""Execute one read-only MCP tool and return text content."""
"""Execute one MCP tool and return text content.
Reads hit the forge's read API; the two write tools
(create_issue, add_comment) reuse the existing creation /
comment services — never a reimplemented forge call.
"""
if name == "list_projects":
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
@@ -5443,6 +5634,49 @@ async def _mcp_call_tool(name: str, args: dict) -> str:
)
if name == "get_metrics":
return json.dumps(get_metrics(args.get("repo") or None), ensure_ascii=False)
if name == "create_issue":
owner, repo = args["owner"], args["repo"]
created = await _do_capture(
str(args["title"]),
str(args.get("body") or ""),
f"{owner}/{repo}",
str(args.get("column") or "backlog"),
source="mcp",
)
return json.dumps(created, ensure_ascii=False)
if name == "add_comment":
owner, repo = args["owner"], args["repo"]
number = int(args["number"])
body = str(args["body"] or "").strip()
if not body:
raise ValueError("Comment must not be empty")
if len(body) > 10_000:
raise ValueError("Comment too large (max 10000 chars)")
full_name = f"{owner}/{repo}"
client, conn_id = await _client_for_repo(full_name)
comment = await client.create_comment(owner, repo, number, body)
ws_id = next(
(
ws["id"]
for ws in models.list_workspaces()
if conn_id in models.workspace_connection_ids(ws["id"])
),
1,
)
log_event(
full_name, number, "commented", source="mcp",
workspace_id=ws_id, connection_id=conn_id,
)
return json.dumps(
{
"id": comment.get("id"),
"body": comment.get("body", body),
"html_url": comment.get("html_url", ""),
"repo_full_name": full_name,
"number": number,
},
ensure_ascii=False,
)
raise ValueError(f"Unknown tool: {name}")
@@ -5453,7 +5687,8 @@ async def mcp_info():
"name": "kanbante",
"version": APP_VERSION,
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": True,
"read_only": False,
"write_tools": ["create_issue", "add_comment"],
"tools": [t["name"] for t in MCP_TOOLS],
}
@@ -5462,8 +5697,10 @@ async def mcp_info():
async def mcp_endpoint(request: Request):
"""Minimal MCP server: initialize / tools/list / tools/call / ping.
Read-only by design (Bruno's rule: cross-platform reads first,
writes go through approval flows elsewhere).
Reads since 0.5.0; since 0.26.0, two minimal write tools
(create_issue, add_comment) for the agent service, guarded by
the same /mcp authentication as the reads (session, Basic or
kbt_ API token — a token never unlocks the web UI).
"""
try:
payload = await request.json()
+79
View File
@@ -11,8 +11,10 @@ and upgrades older databases in place (position column, notes
updated_at, cleanup of rows carrying an unknown column name).
"""
import hashlib
import json
import os
import secrets
import sqlite3
from contextlib import contextmanager
from typing import Iterator
@@ -134,6 +136,16 @@ def init_db() -> None:
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
@@ -900,6 +912,73 @@ def get_user_by_username(username: str) -> dict | None:
return dict(row) if row else None
# --- Jetons d'API (0.25.0) -------------------------------------------------
# Jetons personnels pour les consommateurs machine (le service d'agents
# et son client MCP, en premier lieu). Le texte clair (préfixe kbt_)
# n'existe qu'à la création ; la base ne garde que l'empreinte SHA-256.
# Un jeton authentifie uniquement les surfaces machine (/mcp, /api/) —
# jamais l'interface web.
API_TOKEN_PREFIX = "kbt_"
def _hash_api_token(plaintext: str) -> str:
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
def create_api_token(user_id: int, name: str) -> tuple[dict, str]:
plaintext = API_TOKEN_PREFIX + secrets.token_urlsafe(32)
with get_db() as conn:
cur = conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)",
(user_id, name.strip(), _hash_api_token(plaintext)),
)
row = conn.execute(
"SELECT * FROM api_tokens WHERE id = ?", (cur.lastrowid,)
).fetchone()
return dict(row), plaintext
def list_api_tokens(user_id: int) -> list[dict]:
with get_db() as conn:
return [
dict(r)
for r in conn.execute(
"SELECT * FROM api_tokens WHERE user_id = ? ORDER BY id",
(user_id,),
).fetchall()
]
def revoke_api_token(user_id: int, token_id: int) -> bool:
with get_db() as conn:
cur = conn.execute(
"UPDATE api_tokens SET revoked_at = datetime('now')"
" WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
(token_id, user_id),
)
return cur.rowcount > 0
def user_by_api_token(plaintext: str) -> dict | None:
if not plaintext or not plaintext.startswith(API_TOKEN_PREFIX):
return None
with get_db() as conn:
row = conn.execute(
"SELECT u.* FROM api_tokens t JOIN users u ON u.id = t.user_id"
" WHERE t.token_hash = ? AND t.revoked_at IS NULL AND u.active = 1",
(_hash_api_token(plaintext),),
).fetchone()
if row is None:
return None
conn.execute(
"UPDATE api_tokens SET last_used_at = datetime('now')"
" WHERE token_hash = ?",
(_hash_api_token(plaintext),),
)
return dict(row)
def get_user_by_draco(sub: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
+7 -1
View File
@@ -6,6 +6,7 @@
Inclus par _header.html (toutes les pages applicatives ; ni le
wallboard ni l'écran de connexion, volontaires). #}
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
<script>document.body.classList.add('has-draco-bar');</script>
<header class="draco-bar" data-current="kanbante">
<div class="draco-bar-zone draco-bar-gauche">
@@ -86,7 +87,11 @@
if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
return;
}
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
@@ -96,3 +101,4 @@
};
</script>
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
+37
View File
@@ -46,6 +46,43 @@
{% endif %}
</section>
<section class="briefing-card">
<h2>🗝️ Jetons d'API (MCP)</h2>
<p class="muted">Pour les consommateurs machine — le service d'agents, en premier lieu. Un jeton (préfixe <code>kbt_</code>) authentifie uniquement <code>/mcp</code> et <code>/api/</code>, jamais l'interface web. Il se présente en en-tête <code>Authorization: Bearer kbt_…</code>. Le texte clair n'est affiché qu'à la création ; ensuite, seule son empreinte est conservée.</p>
{% if profile_user.id %}
{% if nouveau_jeton %}
<p>✅ Nouveau jeton — <strong>copie-le maintenant</strong> :</p>
<p><code style="font-size:15px">{{ nouveau_jeton }}</code></p>
{% endif %}
{% if api_tokens %}
<ul>
{% for t in api_tokens %}
<li>
<strong>{{ t.name }}</strong> · créé le {{ t.created_at }}
· {% if t.last_used_at %}dernier usage : {{ t.last_used_at }}{% else %}jamais utilisé{% endif %}
{% if t.revoked_at %} · <span class="chip-warn">révoqué le {{ t.revoked_at }}</span>
{% else %}
<form method="post" action="/profile/tokens/{{ t.id }}/revoke" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Révoquer</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Aucun jeton pour l'instant.</p>
{% endif %}
<form method="post" action="/profile/tokens" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom du jeton <input type="text" name="name" placeholder="service-agents" required></label>
<button type="submit" class="btn-save btn-inline">Créer un jeton</button>
</form>
{% else %}
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : les jetons appartiennent aux comptes de la base — connecte-toi avec ton compte personnel pour en créer un.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>✦ Liaison draco (SSO)</h2>
{% if profile_user.draco_sub %}
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.24.0"
version = "0.28.1"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
@@ -10,6 +10,7 @@ dependencies = [
"httpx>=0.28",
"python-dotenv>=1.0",
"python-multipart>=0.0.18",
"cryptography>=42",
]
[tool.setuptools.packages.find]
+189
View File
@@ -0,0 +1,189 @@
/* 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 {
--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 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: var(--p-doux); }
.draco-panneau-hd .fermer {
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; }
/* 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-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: 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: var(--p-agent); color: var(--p-texte); }
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
.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: 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: 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 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: 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: 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 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: 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: 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: 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: 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: 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; }
+22
View File
@@ -60,6 +60,28 @@ a:hover { text-decoration: underline; }
button, input, select, textarea { font: inherit; }
/* — Base des champs de saisie (#29) : sans règle de fond, tout
champ hors d'un contexte stylé (.login-form, .capture-form,
.admin-form…) gardait le fond BLANC du navigateur — vu sur la
réinitialisation de mot de passe (Administration), la sélection
de projets (Espaces, textarea jamais couverte) et les valeurs
du panneau (Paramètres). Sélecteur en :where() = spécificité
zéro : cette base ne peut écraser AUCUNE règle de contexte
existante, elle ne fait que combler les vides. */
:root { color-scheme: dark; }
:where(input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="image"]):not([type="range"]),
textarea, select) {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
:where(input, textarea, select):focus-visible {
outline: 2px solid rgba(124, 92, 252, 0.55);
outline-offset: 0;
}
/* ── App header ─────────────────────────────── */
.app-header, header {
position: sticky;
+702
View File
@@ -0,0 +1,702 @@
/* 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 ;
* 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)
* 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 un nouvel onglet, sur la
* discussion courante (?disc=) et avec l'app d'origine (?app=),
* déduite du sous-domaine de l'hôte.
* É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;
var convKind = null; // 'principale' | 'parallele'
var convTitre = "";
var etatConnu = 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, nouvel onglet) ─────────────── */
function appHote() {
var h = (window.location.hostname || "").split(".")[0];
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
kanbante: "kanbante", og: "obsigate" };
return correspondance[h] || null;
}
function ouvrirConsole() {
var params = [];
var id = convId || convStockee();
if (id) params.push("disc=" + encodeURIComponent(id));
var app = appHote();
if (app) params.push("app=" + encodeURIComponent(app));
window.open(API + "/" + (params.length ? "?" + params.join("&") : ""),
"_blank", "noopener");
}
/* ── 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());
}
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="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) dans un nouvel onglet, 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>' +
' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' +
'<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
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();
});
}
function ajouterMsg(kind, texte) {
var d = document.createElement("div");
d.className = "draco-msg " + kind;
d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
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;
fetch(API + "/api/v1/me", { credentials: "include" })
.then(function (r) {
if (r.status === 401) {
majEtat("err", "Session partagée non reconnue");
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
etatConnu = true;
return null;
}
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (me) {
if (!me) return;
etatConnu = true;
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
})
.catch(function () {
majEtat("err", "Service d'agents injoignable");
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
});
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
/* ── 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";
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
var t = d.sources.tiktak;
h += "<h5>Tâches du jour — TikTak</h5>";
if (t.status === "ok") {
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (t.status === "non_configure") {
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
} else if (t.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
} else {
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
}
var k = d.sources.kanbante;
h += "<h5>Cartes en Révision — Kanbante</h5>";
if (k.status === "ok") {
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (k.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
} else {
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
}
bloc.innerHTML = h;
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () {
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
});
}
function chargerApprobations() {
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
d.approvals.forEach(function (a) {
var bloc = document.createElement("div");
bloc.className = "draco-appro";
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
"<div class='resume'>" + esc(a.summary) + "</div>" +
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
"<button type='button' class='non'>Rejeter</button></div>";
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
msgsEl.appendChild(bloc);
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
}
function decider(id, decision, bloc) {
fetch(API + "/api/v1/approvals/" + id + "/decide", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: decision })
})
.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'>Décision enregistrée : " + esc(d.status) + "</span>";
})
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
}
/* ── Missions longues (J5) ───────────────────────────────────
* Sélecteur d'exécuteur alimenté par le registre du service :
* les externes non configurés sont montrés mais désactivés —
* jamais un lancement qui simule. La liste se rafraîchit à
* l'ouverture et après chaque lancement/annulation. */
var STATUTS_MISSION = {
queued: "En file", running: "En cours…", paused_question: "Question en attente",
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
};
function ouvrirMissions() {
var bloc = document.createElement("div");
bloc.className = "draco-missions";
bloc.innerHTML =
"<h5>Missions longues</h5>" +
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
msgsEl.appendChild(bloc);
msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/executors", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var sel = bloc.querySelector(".m-exec");
sel.innerHTML = "";
d.executors.forEach(function (e) {
if (e.family !== "sous-agent" && e.family !== "externe") return;
var opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
(e.status === "non_configure" ? " — non configuré" : "");
if (e.status === "non_configure") opt.disabled = true;
sel.appendChild(opt);
});
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
})
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
chargerMissions(bloc);
}
function chargerMissions(bloc) {
var zone = bloc.querySelector(".m-liste");
fetch(API + "/api/v1/missions", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
zone.innerHTML = "";
d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div");
ligne.className = "m-item";
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";
btn.addEventListener("click", function () {
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, body: "{}"
}).then(function () { chargerMissions(bloc); })
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
});
ligne.appendChild(btn);
}
zone.appendChild(ligne);
});
})
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
}
function lancerMission(bloc) {
var executor = bloc.querySelector(".m-exec").value;
var titre = bloc.querySelector(".m-titre").value.trim();
var prompt = bloc.querySelector(".m-prompt").value.trim();
var carte = bloc.querySelector(".m-carte").value;
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
if (carte) corps.kanbante_number = parseInt(carte, 10);
fetch(API + "/api/v1/missions", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps)
})
.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();
})
.then(function () {
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
chargerMissions(bloc);
})
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
}
function envoyer(ev) {
ev.preventDefault();
var texte = inputEl.value.trim();
if (!texte) return;
inputEl.value = "";
ajouterMsg("user", texte);
sendBtn.disabled = true;
var attente = ajouterMsg("agent", "Spark réfléchit…");
fetch(API + "/api/v1/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
})
.then(function (r) {
if (r.status === 401) {
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
majEtat("err", "Session partagée non reconnue");
return null;
}
if (!r.ok) {
return r.json().catch(function () { return null; }).then(function (e) {
attente.textContent = (e && e.detail) ? e.detail :
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
return null;
});
}
return r.json();
})
.then(function (d) {
if (!d) return;
convId = d.conversation_id;
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 || "?"));
}
majEtat("ok", "Connecté au service — domaine " + d.domain);
})
.catch(function () {
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
majEtat("err", "Service d'agents injoignable");
})
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
}
function ouvrir() {
if (!root) { construire(); demarrerDiscussions(); }
root.classList.add("open");
appliquerTheme();
verifierIdentite();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
window.DRACO_PANNEAU = {
open: ouvrir,
close: fermer,
toggle: function () {
if (root && root.classList.contains("open")) fermer(); else ouvrir();
}
};
})();
+186
View File
@@ -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()
+71
View File
@@ -0,0 +1,71 @@
"""Jetons d'API personnels (0.25.0) : modèle + accès MCP par Bearer.
Le service d'agents se fait refuser par Kanbante depuis la 0.24.0
(auth partout, cookie/Basic seulement). Les jetons kbt_ lui donnent
une identité machine propre, révocable, limitée à /mcp et /api/.
"""
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, models
@pytest.fixture()
def utilisateur():
# La base de test est partagée par toute la session : nom unique,
# et nettoyage complet au teardown — le pont SSO (test_draco_sso)
# a une sémantique « un seul compte en base » dont hérite la suite.
import uuid
uid = models.create_user(
f"machine-{uuid.uuid4().hex[:8]}",
accounts.hash_password("motdepasse"), role="member")
user = models.get_user(uid)
yield user
with models.get_db() as conn:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
def test_jeton_cycle_de_vie(utilisateur):
row, clair = models.create_api_token(utilisateur["id"], "service-agents")
assert clair.startswith("kbt_")
# La base ne connaît que l'empreinte, jamais le texte clair.
assert row["token_hash"] != clair and len(row["token_hash"]) == 64
trouve = models.user_by_api_token(clair)
assert trouve is not None and trouve["username"] == utilisateur["username"]
assert models.list_api_tokens(utilisateur["id"])[0]["last_used_at"] is not None
# Révocation : effet immédiat.
assert models.revoke_api_token(utilisateur["id"], row["id"]) is True
assert models.user_by_api_token(clair) is None
# On ne révoque pas le jeton d'autrui.
row2, _clair2 = models.create_api_token(utilisateur["id"], "autre")
assert models.revoke_api_token(utilisateur["id"] + 999, row2["id"]) is False
def test_jeton_invalide_ou_compte_desactive(utilisateur):
_row, clair = models.create_api_token(utilisateur["id"], "x")
assert models.user_by_api_token("kbt_nimportequoi") is None
assert models.user_by_api_token("") is None
assert models.user_by_api_token("tck_autreformat") is None
models.update_user(utilisateur["id"], active=0)
assert models.user_by_api_token(clair) is None
def test_mcp_par_jeton_quand_auth_active(monkeypatch, utilisateur):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
corps = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05"}}
with TestClient(main.app) as client:
# Sans jeton : 401 (le refus vu en production le 2026-10-21).
assert client.post("/mcp", json=corps).status_code == 401
# Avec le jeton Bearer : le handshake passe.
r = client.post("/mcp", json=corps,
headers={"Authorization": f"Bearer {clair}"})
assert r.status_code == 200
assert r.json()["result"]["serverInfo"]["name"] == "kanbante"
# Mais un jeton n'ouvre pas l'interface web.
assert client.get("/", headers={"Authorization": f"Bearer {clair}",
"Accept": "application/json"}).status_code == 401
+202
View File
@@ -0,0 +1,202 @@
"""Écritures MCP minimales (0.26.0) : create_issue + add_comment.
Le service d'agents doit pouvoir proposer des cartes et commenter
via Kanbante, sans passer par l'interface web. Les deux outils
réutilisent les services existants (création via _do_capture →
client de forge, commentaire via client.create_comment) et sont
gardés par la même authentification que les lectures /mcp :
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
"""
import json
import uuid
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, models
def _call(client, name, arguments, headers=None):
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": name, "arguments": arguments},
}
return client.post("/mcp", json=payload, headers=headers or {})
def _result_text(response):
result = response.json()["result"]
return result, result["content"][0]["text"]
# ── Création de carte ──────────────────────────────────────
def test_mcp_create_issue_places_card(client, fake_gitea):
r = _call(client, "create_issue", {
"owner": "Projets", "repo": "demo",
"title": "Carte créée par MCP", "body": "Corps de la carte",
})
result, text = _result_text(r)
assert result["isError"] is False
created = json.loads(text)
assert created["title"] == "Carte créée par MCP"
assert created["repo_full_name"] == "Projets/demo"
assert created["column"] == "backlog"
assert created["number"] > 0
# L'écriture est passée par le client de forge existant.
assert fake_gitea.created == [("Projets", "demo", "Carte créée par MCP")]
# La carte est placée sur le board (lecture MCP relue).
r = _call(client, "get_board", {"owner": "Projets", "repo": "demo"})
board = json.loads(_result_text(r)[1])
assert any(
c["title"] == "Carte créée par MCP" for c in board["backlog"]
)
# Journal : création tracée avec la source « mcp ».
events = models.get_events(repo_full_name="Projets/demo", limit=10)
created_events = [
e for e in events
if e["event_type"] == "created"
and e["issue_number"] == created["number"]
]
assert created_events and created_events[0]["source"] == "mcp"
def test_mcp_create_issue_column_choice(client, fake_gitea):
r = _call(client, "create_issue", {
"owner": "Projets", "repo": "demo",
"title": "Directement à faire", "column": "todo",
})
result, text = _result_text(r)
assert result["isError"] is False
assert json.loads(text)["column"] == "todo"
def test_mcp_create_issue_validation_errors(client, fake_gitea):
# Titre manquant : « Missing argument » (patron d'erreur du serveur).
r = _call(client, "create_issue", {"owner": "Projets", "repo": "demo"})
result, text = _result_text(r)
assert result["isError"] is True
assert "Missing argument" in text
# Titre vide et colonne inconnue : erreurs métier en isError.
for arguments in (
{"owner": "Projets", "repo": "demo", "title": " "},
{"owner": "Projets", "repo": "demo", "title": "X",
"column": "nimporte"},
):
result, _text = _result_text(_call(client, "create_issue", arguments))
assert result["isError"] is True
assert fake_gitea.created == []
# ── Commentaire ────────────────────────────────────────────
def test_mcp_add_comment_posts_on_forge(client, fake_gitea):
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1,
"body": "Commentaire du service d'agents",
})
result, text = _result_text(r)
assert result["isError"] is False
payload = json.loads(text)
assert payload["number"] == 1
assert payload["repo_full_name"] == "Projets/demo"
assert payload["body"] == "Commentaire du service d'agents"
assert fake_gitea.posted_comments == [
("Projets", "demo", 1, "Commentaire du service d'agents")
]
events = models.get_events(repo_full_name="Projets/demo", limit=10)
commented = [e for e in events if e["event_type"] == "commented"]
assert commented and commented[0]["source"] == "mcp"
def test_mcp_add_comment_validation_errors(client, fake_gitea):
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1, "body": " ",
})
assert _result_text(r)[0]["isError"] is True
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "body": "sans numéro",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Missing argument" in text
assert fake_gitea.posted_comments == []
def test_mcp_write_tools_are_listed(client):
r = client.post("/mcp", json={
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
assert tools["create_issue"]["inputSchema"]["required"] == [
"owner", "repo", "title",
]
assert tools["add_comment"]["inputSchema"]["required"] == [
"owner", "repo", "number", "body",
]
# Aucun outil destructeur n'apparaît.
assert not any(
name.startswith(("delete", "remove", "close"))
for name in tools
)
# ── Garde par jeton kbt_ (auth active) ─────────────────────
@pytest.fixture()
def utilisateur():
# Base de test partagée : nom unique, nettoyage complet au
# teardown (le pont SSO a une sémantique « un seul compte »).
uid = models.create_user(
f"mcp-{uuid.uuid4().hex[:8]}",
accounts.hash_password("motdepasse"), role="member")
user = models.get_user(uid)
yield user
with models.get_db() as conn:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
def test_mcp_writes_guarded_by_api_token(monkeypatch, fake_gitea, utilisateur):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
jeton = {"Authorization": f"Bearer {clair}"}
create_args = {
"owner": "Projets", "repo": "demo", "title": "Carte par jeton",
}
with TestClient(main.app) as client:
# Sans jeton : refusé (401), aucune écriture sur la forge.
assert _call(client, "create_issue", create_args).status_code == 401
# Jeton inconnu : refusé aussi.
faux = {"Authorization": "Bearer kbt_inconnu"}
assert _call(
client, "create_issue", create_args, headers=faux
).status_code == 401
assert fake_gitea.created == []
# Jeton valide : création et commentaire passent.
r = _call(client, "create_issue", create_args, headers=jeton)
assert r.status_code == 200
assert _result_text(r)[0]["isError"] is False
assert fake_gitea.created == [("Projets", "demo", "Carte par jeton")]
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1,
"body": "Vu par le service d'agents",
}, headers=jeton)
assert _result_text(r)[0]["isError"] is False
# Le même jeton n'ouvre jamais l'interface web.
assert client.get(
"/", headers={**jeton, "Accept": "application/json"}
).status_code == 401
# Révocation : effet immédiat sur les écritures.
assert models.revoke_api_token(utilisateur["id"], _row["id"]) is True
assert _call(
client, "create_issue", create_args, headers=jeton
).status_code == 401
assert _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1, "body": "x",
}, headers=jeton).status_code == 401
+6 -2
View File
@@ -121,6 +121,7 @@ def test_mcp_initialize_and_tools_list(client):
assert names == [
"list_projects", "get_board", "get_issue", "get_focus",
"search_issues", "get_activity", "get_metrics",
"create_issue", "add_comment",
]
@@ -153,7 +154,7 @@ def test_mcp_unknown_method_and_tool(client):
assert r.json()["error"]["code"] == -32601
r = _rpc(client, "tools/call", {"name": "delete_everything", "arguments": {}})
result = r.json()["result"]
assert result["isError"] is True # read-only server: no such tool
assert result["isError"] is True # aucun outil de ce nom (pas de suppression)
def test_mcp_notification_gets_202(client):
@@ -164,7 +165,10 @@ def test_mcp_notification_gets_202(client):
def test_mcp_info_endpoint(client):
r = client.get("/mcp")
assert r.status_code == 200
assert r.json()["read_only"] is True
# 0.26.0 : écritures minimales ajoutées — le serveur n'est
# plus en lecture seule, et il le déclare honnêtement.
assert r.json()["read_only"] is False
assert r.json()["write_tools"] == ["create_issue", "add_comment"]
# ── Board : gestion des étiquettes ────────────────────────
+20
View File
@@ -0,0 +1,20 @@
"""0.24.2 — #29 : base sombre pour tous les champs de saisie.
Sans règle de fond, les champs hors contexte stylé gardaient le
fond blanc du navigateur (réinitialisation de mot de passe,
textarea des espaces, valeurs du panneau des paramètres).
"""
from pathlib import Path
R = Path(__file__).resolve().parents[1]
def test_base_champs_sombre():
css = (R / "static/css/style.css").read_text(encoding="utf-8")
assert "color-scheme: dark" in css
assert ":where(input:not([type=\"checkbox\"])" in css
assert "textarea, select" in css
assert "background: var(--surface-2);" in css
# la base est en spécificité zéro : les contextes restent maîtres
assert ".palette-input-row input" in css
assert ".login-form input" in css