Compare commits

..
Author SHA1 Message Date
bruno 62b536c865 coffre ObsiGate Phase 4 (issue #22) : docs/coffre-reference-flowdeck.md
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Successful in 11m19s
FlowDeck CI / docker (push) Failing after 2m5s
FlowDeck CI / lint (pull_request) Failing after 0s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-27 23:48:35 -04:00
bruno a3431b18c9 coffre ObsiGate Phase 4 (issue #22) : tests/test_coffre_reference_flowdeck.py
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:31 -04:00
bruno 45940c26bb coffre ObsiGate Phase 4 (issue #22) : .env.example
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:26 -04:00
bruno f827e9fc7d coffre ObsiGate Phase 4 (issue #22) : app/routers/agent.py
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:20 -04:00
bruno cd0835116d coffre ObsiGate Phase 4 (issue #22) : app/config.py
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Successful in 1m56s
2026-10-27 23:48:13 -04:00
bruno b751c81120 coffre ObsiGate Phase 4 (issue #22) : app/migrations.py
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:09 -04:00
bruno 30387c4ed4 coffre ObsiGate Phase 4 (issue #22) : app/services/mcp_client.py
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:03 -04:00
bruno 4f13068c85 coffre ObsiGate Phase 4 (issue #22) : app/services/connectors.py
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:47:58 -04:00
bruno 417c0a23d7 coffre ObsiGate Phase 4 (issue #22) : app/services/coffre_reference.py
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Failing after 3h10m16s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:47:54 -04:00
Spark 913d6137b1 feat(fusion): format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), contrat panneau (ACC-4.4) — 7.84.0
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-26 16:19:16 -04:00
23 changed files with 1835 additions and 52 deletions
+7
View File
@@ -28,6 +28,13 @@ OAUTH_REDIRECT_URI=
# par release (passé par environment:/env_file: au conteneur). # par release (passé par environment:/env_file: au conteneur).
# Laisser vide = défaut officiel indiqué en commentaire. # Laisser vide = défaut officiel indiqué en commentaire.
DRACO_SSO_URL= # ObsiGate, émettrice SSO — défaut https://og.dracodev.net ; dev : https://og-dev.dracodev.net DRACO_SSO_URL= # ObsiGate, émettrice SSO — défaut https://og.dracodev.net ; dev : https://og-dev.dracodev.net
# ── Coffre ObsiGate, côté consommateur Flowdeck (Phase 4, issue ObsiGate #22) ──
# OBSIGATE_COFFRE_URL vide = dérivée de DRACO_SSO_URL (même hôte).
# OBSIGATE_CONSUMER_TOKEN = jeton CONSOMMATEUR Flowdeck enregistré dans ObsiGate
# (octrois) — distinct du jeton MCP de notes et de la session SSO.
# Ne jamais le copier dans un log, un rapport ou une réponse API.
OBSIGATE_COFFRE_URL=
OBSIGATE_CONSUMER_TOKEN=
DRACO_AGENT_URL= # Service d'agents Spark — défaut https://agent.dracodev.net ; dev : https://agent-dev.dracodev.net DRACO_AGENT_URL= # Service d'agents Spark — défaut https://agent.dracodev.net ; dev : https://agent-dev.dracodev.net
TIKTAK_URL= # TikTak (preset MCP, barre, refs) — défaut https://tiktak.dracodev.net ; dev : https://tiktak-dev.dracodev.net TIKTAK_URL= # TikTak (preset MCP, barre, refs) — défaut https://tiktak.dracodev.net ; dev : https://tiktak-dev.dracodev.net
KANBANTE_URL= # Kanbante (barre, refs) — défaut https://kanbante.dracodev.net ; dev : https://kanbante-dev.dracodev.net KANBANTE_URL= # Kanbante (barre, refs) — défaut https://kanbante.dracodev.net ; dev : https://kanbante-dev.dracodev.net
+33
View File
@@ -1,5 +1,38 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.84.0 (2026-10-26) — Fusion : format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), panneau commun (ACC-4.4)
- **ACC-4.5 — format de skill commun** (`app/services/skill_format.py`,
contrat figé ACC-0.3 de `dracodev-commons`). Lecture et validation
d'un `SKILL.md` au format commun (frontmatter YAML : `name`,
`description`-déclencheur, `version`, `apps`, `domaines`, `outils`,
`ecritures` obligatoire dès qu'un outil modifie ; corps en trois
sections ordonnées), conversion vers les champs Flowdeck
(installable via `skill_gallery.upsert_skill`) et conversion
inverse depuis un preset de la galerie. **Preuve du format** :
trois skills de la galerie convertis dans `skills-communs/` —
`compte-rendu-reunion`, `rapport-hebdo`, `resume-document`.
- **ACC-6.2 — pilote d'écriture approuvée** (`app/services/meeting_archive.py`).
Nouvelle route `POST /api/v2/meetings/transcripts/{id}/archiver-obsigate` :
prépare l'archive du résumé de réunion (chemin `Réunions/<date> - <titre>.md`,
contenu Markdown avec lien profond vers la note) et la dépose dans
la **file d'approbations unifiée** (`agent_approvals`, outil
`archiver_reunion_obsigate`) — rien n'est écrit avant approbation.
À la décision favorable (route gouvernance), `propose_write_file`
part vers ObsiGate par le pont MCP (ACC-2.2) et la distribution est
journalisée (nouveau canal `obsigate` de `meeting_distributions`).
Sans connecteur ObsiGate configuré, l'exécution échoue avec un
message explicite. Pilote : usage réel conditionnel au chantier
Meetings (décision A/B) et à la clé ObsiGate dédiée au pont.
- **ACC-4.4 — panneau de l'agent commun** : intégré depuis la 7.81.0
(panneau v3, conversation commune du service d'agents), complété
par le mode console (7.82.x) et la version du service en sidebar
(7.83.1). Cette version fige l'intégration par des tests de
contrat (`tests/test_fusion_panneau_commun.py`) : assets chargés,
configuration injectée par l'hôte (aucune URL publique codée en
dur), conversation du service — et automatisations locales
conservées (décision C5).
## v7.83.1 (2026-10-26) — Panneau Spark : version du service d'agents visible en mode sidebar ## v7.83.1 (2026-10-26) — Panneau Spark : version du service d'agents visible en mode sidebar
- **Panneau Spark** (`static/js/draco-panneau.js`) : le pied du panneau - **Panneau Spark** (`static/js/draco-panneau.js`) : le pied du panneau
+1 -1
View File
@@ -1 +1 @@
7.83.1 7.84.0
+7
View File
@@ -158,6 +158,13 @@ class Settings(BaseSettings):
# KANBANTE_URL=https://kanbante-dev.dracodev.net # KANBANTE_URL=https://kanbante-dev.dracodev.net
# FLOWDECK_URL=https://flowdeck-dev.dracodev.net # FLOWDECK_URL=https://flowdeck-dev.dracodev.net
draco_sso_url: str = "https://og.dracodev.net" # ObsiGate (émettrice SSO) draco_sso_url: str = "https://og.dracodev.net" # ObsiGate (émettrice SSO)
# ── Coffre ObsiGate, côté consommateur Flowdeck (Phase 4, issue #22) ──
# URL de distribution : vide = dérivée de draco_sso_url (même hôte).
# Jeton : le jeton CONSOMMATEUR Flowdeck enregistré dans ObsiGate
# (octrois), distinct du jeton MCP de notes et de la session SSO.
# Jamais en clair dans un log, un rapport ou une réponse API.
obsigate_coffre_url: str = ""
obsigate_consumer_token: str = ""
draco_agent_url: str = "https://agent.dracodev.net" # service d'agents (Spark) draco_agent_url: str = "https://agent.dracodev.net" # service d'agents (Spark)
tiktak_url: str = "https://tiktak.dracodev.net" tiktak_url: str = "https://tiktak.dracodev.net"
kanbante_url: str = "https://kanbante.dracodev.net" kanbante_url: str = "https://kanbante.dracodev.net"
+1 -1
View File
@@ -216,7 +216,7 @@ def _lire_version() -> str:
# Littéral de version synchronisé avec le fichier VERSION : # Littéral de version synchronisé avec le fichier VERSION :
# version="7.83.1" # version="7.84.0"
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche # Patron historique des bumps (voir scripts/bump_version.py, qui cherche
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version # ce littéral) et contrôle de tests/test_pwa_offline.py. La version
# runtime reste lue dynamiquement depuis VERSION par _lire_version() # runtime reste lue dynamiquement depuis VERSION par _lire_version()
+28
View File
@@ -2076,3 +2076,31 @@ def _migration_draco_links(conn: sqlite3.Connection) -> None:
) )
""" """
) )
@register(52, "coffre ObsiGate (Phase 4) : workspace_id + coffre_reference sur agent_connectors")
def _migration_coffre_reference(conn: sqlite3.Connection) -> None:
"""Migration 52 — références ObsiGate pour les connecteurs (issue #22).
* ``workspace_id`` : le connecteur appartient à un workspace (NULL =
connecteur historique global, conservé tel quel — migration
opportuniste, pas de big-bang).
* ``coffre_reference`` : ``coffre:<slug>/<nom>`` (métadonnée, pas une
valeur) ; vide = jeton chiffré local historique.
Idempotente (gardée par ``columns()``), comme les migrations 32/34.
"""
cols = columns(conn, "agent_connectors")
if "workspace_id" not in cols:
conn.execute(
"ALTER TABLE agent_connectors "
"ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)"
)
if "coffre_reference" not in cols:
conn.execute(
"ALTER TABLE agent_connectors "
"ADD COLUMN coffre_reference TEXT NOT NULL DEFAULT ''"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_connectors_workspace "
"ON agent_connectors(workspace_id)"
)
+10 -1
View File
@@ -673,6 +673,8 @@ def create_connectors_route(request: Request, body: dict = Body(default={})):
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""), body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
kind=str(body.get("kind") or "custom"), kind=str(body.get("kind") or "custom"),
auth=str(body.get("auth") or "bearer"), auth=str(body.get("auth") or "bearer"),
workspace_id=body.get("workspace_id"),
coffre_reference=body.get("coffre_reference"),
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -682,11 +684,17 @@ def create_connectors_route(request: Request, body: dict = Body(default={})):
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})): def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
_current_user_id(request) _current_user_id(request)
try: try:
kwargs = {}
if "workspace_id" in body:
kwargs["workspace_id"] = body.get("workspace_id")
if "coffre_reference" in body:
kwargs["coffre_reference"] = body.get("coffre_reference")
row = connectors.update_connector( row = connectors.update_connector(
connector_id, connector_id,
name=body.get("name"), name=body.get("name"),
enabled=body.get("enabled"), enabled=body.get("enabled"),
secret=body.get("secret"), secret=body.get("secret"),
**kwargs,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -711,7 +719,8 @@ async def probe_connectors_route(request: Request, body: dict = Body(default={})
raise HTTPException(status_code=400, detail="connector est requis") raise HTTPException(status_code=400, detail="connector est requis")
user_id = _current_user_id(request) user_id = _current_user_id(request)
try: try:
return await connectors.probe(target, user_id) return await connectors.probe(target, user_id,
workspace_id=body.get("workspace_id"))
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
+9
View File
@@ -87,4 +87,13 @@ def decide_approval(approval_id: int, request: Request, body: dict = Body(defaul
raise HTTPException(404, "Pending approval not found") raise HTTPException(404, "Pending approval not found")
audit_log(user, "agent.approval.decide", "agent_approval", approval_id, audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
out["status"], request) out["status"], request)
# Pilote ACC-6.2 : une demande d'archivage de réunion approuvée
# s'exécute ici (proposition d'écriture vers ObsiGate) ; le
# résultat accompagne la réponse, sans changer le contrat de la
# file pour les autres outils.
from app.services import meeting_archive
archive = meeting_archive.traiter_decision_sync(out)
if archive is not None:
return {**out, "archive": archive}
return out return out
+22
View File
@@ -396,6 +396,28 @@ def get_transcript_row(transcript_id: int, request: Request):
raise HTTPException(404, str(exc)) from None raise HTTPException(404, str(exc)) from None
@router.post("/api/v2/meetings/transcripts/{transcript_id}/archiver-obsigate",
status_code=201)
def archiver_obsigate(transcript_id: int, request: Request):
"""Pilote ACC-6.2 : dépose l'archivage du résumé dans ObsiGate
dans la file d'approbations unifiée (rien n'est écrit avant
l'approbation ; l'exécution part de la décision, voir
governance.decide_approval)."""
user = _auth_user(request, require_write=True)
from app.services import meeting_archive
try:
approval = meeting_archive.demander_approbation(transcript_id, user["id"])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.archive.request", "transcript", transcript_id,
f"approval #{approval['id']}", request)
archive = meeting_archive.construire_archive(meet.get_transcript(transcript_id))
return {"approval": approval, "titre": archive["titre"],
"chemin": archive["chemin"]}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents") @router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})): def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
+206
View File
@@ -0,0 +1,206 @@
"""Références ObsiGate pour les connecteurs Flowdeck (Phase 4, issue ObsiGate #22).
Un connecteur d'un workspace ne stocke plus un jeton en clair ni même un
jeton chiffré local : il stocke une **référence** ``coffre:<slug>/<nom>``,
toujours liée à un ``workspace_id``. À l'appel, le serveur Flowdeck appelle
``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur
Flowdeck, utilise la valeur **en mémoire uniquement**, puis l'oublie :
* aucune valeur n'est persistée (ni en base, ni en fichier, ni en cache) ;
* aucune valeur n'est journalisée (les logs ne portent que le statut) ;
* si ObsiGate est indisponible, l'appel échoue proprement — il n'y a
**aucun repli** sur une valeur en cache, puisqu'il n'y en a pas.
Format validé ici, aligné sur ObsiGate (``backend/coffre_store.py``) :
``slug`` et ``nom`` en minuscules, 3 à 64 / 3 à 128 caractères,
``[a-z0-9_-]`` après le premier caractère. La référence elle-même n'est
pas un secret (c'est une métadonnée, comme un chemin) : seule la valeur
distribuée l'est, et elle ne transite que dans la réponse d'ObsiGate.
Isolation workspace (critère central) : l'octroi ObsiGate est accordé au
**consommateur Flowdeck** (un jeton par application), pas à un workspace.
L'isolation par workspace est donc appliquée **côté Flowdeck, avant tout
appel** — voir ``connectors.resolve_secret`` : un connecteur dont le
``workspace_id`` diffère du workspace demandeur est refusé explicitement
et le distributeur n'est jamais appelé. Une isolation appliquée côté
ObsiGate exigerait un consommateur par workspace (reste à faire, voir
``docs/coffre-reference-flowdeck.md``).
"""
from __future__ import annotations
import json
import logging
import re
import urllib.error
import urllib.request
logger = logging.getLogger(__name__)
#: Contexte envoyé à ObsiGate : le workspace Flowdeck demandeur, pour que
#: la distribution soit traçable côté ObsiGate (journal des distributions).
CONTEXTE_TYPE = "flowdeck_workspace"
#: Regex ObsiGate, recopiées à l'identique (slug 3–64, nom 3–128).
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
#: Délai maximal d'une distribution (secondes) — un coffre injoignable ne
#: doit pas bloquer indéfiniment un appel de connecteur.
TIMEOUT_S = 10
class CoffreReferenceInvalide(ValueError):
"""La référence ne respecte pas le format ``coffre:<slug>/<nom>``."""
class CoffreWorkspaceMismatch(ValueError):
"""Le connecteur appartient à un autre workspace que le demandeur."""
class CoffreAccesRefuse(ValueError):
"""ObsiGate a refusé la distribution (octroi absent, révoqué, expiré…).
``detail`` reprend le code ObsiGate (``octroi_revoque``, …) sans
jamais contenir la valeur du secret.
"""
def __init__(self, message: str, detail: str = "") -> None:
super().__init__(message)
self.detail = detail
class CoffreIndisponible(ValueError):
"""ObsiGate est injoignable ou en erreur technique (pas de repli)."""
def parse_reference(reference: str) -> tuple[str, str]:
"""Valide ``coffre:<slug>/<nom>`` et renvoie ``(slug, nom)`` normalisés.
La casse est ramenée en minuscules, comme côté ObsiGate
(``valider_slug`` / ``valider_nom_secret``).
"""
value = (reference or "").strip().lower()
if not value.startswith("coffre:") or "/" not in value[7:]:
raise CoffreReferenceInvalide(
"référence invalide : format attendu coffre:<slug>/<nom>"
)
slug, nom = value[7:].split("/", 1)
if not SLUG_RE.match(slug):
raise CoffreReferenceInvalide(
"slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
)
if not NOM_SECRET_RE.match(nom):
raise CoffreReferenceInvalide(
"nom de secret invalide : 3 à 128 caractères, minuscules, "
"chiffres, - ou _"
)
return slug, nom
def is_reference(value: str | None) -> bool:
"""Vrai si la valeur ressemble à une référence (sans lever d'erreur)."""
try:
parse_reference(value or "")
except CoffreReferenceInvalide:
return False
return True
def _config() -> tuple[str, str]:
"""(base ObsiGate, jeton consommateur) depuis la configuration.
L'URL explicite ``obsigate_coffre_url`` prime ; sinon on dérive de
``draco_sso_url`` (ObsiGate, émettrice SSO — même hôte). Le jeton est
le jeton **consommateur** Flowdeck enregistré dans ObsiGate, distinct
du jeton MCP de notes et de la session SSO.
"""
from app.config import settings
base = (getattr(settings, "obsigate_coffre_url", "") or "").strip()
if not base:
base = (settings.draco_sso_url or "").strip()
token = (getattr(settings, "obsigate_consumer_token", "") or "").strip()
return base.rstrip("/"), token
def distribuer(reference: str, workspace_id: int, *, base_url: str | None = None,
consumer_token: str | None = None, request=None) -> str:
"""Distribue une valeur ObsiGate **en mémoire** pour un workspace.
``request`` est le point d'injection des tests : un appelable
``(url, payload, headers) -> (status, body_dict)`` qui remplace
l'appel HTTP réel (faux endpoint de distribution, clairement
identifié comme tel dans les tests). Sans lui, l'appel HTTP réel
est effectué via la bibliothèque standard (aucune dépendance).
Ne conserve rien : la valeur renvoyée doit être utilisée puis
oubliée par l'appelant, jamais écrite ni mise en cache.
"""
slug, nom = parse_reference(reference)
if not workspace_id or int(workspace_id) <= 0:
raise ValueError("workspace_id est requis pour distribuer une référence")
workspace_id = int(workspace_id)
base, token = _config()
if base_url is not None:
base = base_url.rstrip("/")
if consumer_token is not None:
token = consumer_token
if not base:
raise CoffreIndisponible("URL ObsiGate non configurée (obsigate_coffre_url)")
if not token:
raise CoffreIndisponible(
"jeton consommateur ObsiGate non configuré (obsigate_consumer_token)"
)
url = f"{base}/api/coffres/distribuer"
payload = {
"reference": f"coffre:{slug}/{nom}",
"contexte_type": CONTEXTE_TYPE,
"contexte_id": str(workspace_id),
}
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
try:
if request is not None:
status, body = request(url, payload, headers)
else:
req = urllib.request.Request(
url, data=json.dumps(payload).encode(), headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
status, body = resp.status, json.loads(resp.read() or b"{}")
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read() or b"{}")
except (ValueError, TypeError):
body = {}
status = exc.code
except CoffreAccesRefuse:
raise
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc:
# ObsiGate injoignable : échec propre, SANS repli sur un cache.
logger.warning("Distribution ObsiGate injoignable (statut seul journalisé)")
raise CoffreIndisponible(
"ObsiGate est indisponible : distribution impossible, "
"aucune valeur en cache n'est utilisée"
) from exc
if status == 200:
valeur = (body or {}).get("valeur") or ""
if not valeur:
raise CoffreIndisponible("ObsiGate a répondu sans valeur")
return valeur
detail = str((body or {}).get("detail") or "")
if status in (401, 403):
if detail in ("octroi_revoque", "consommateur_revoque"):
message = ("accès révoqué, à ré-accorder dans ObsiGate "
"(l'octroi de cette référence a été révoqué)")
elif detail == "octroi_expire":
message = "accès expiré, à ré-accorder dans ObsiGate"
else:
message = "accès refusé par ObsiGate (octroi absent ou invalide)"
raise CoffreAccesRefuse(message, detail=detail)
if status == 410:
raise CoffreAccesRefuse("secret expiré côté ObsiGate", detail="secret_expire")
if status in (400,):
raise CoffreReferenceInvalide(f"référence rejetée par ObsiGate ({detail})")
raise CoffreIndisponible(f"ObsiGate a répondu HTTP {status} (distribution impossible)")
+244 -38
View File
@@ -1,5 +1,15 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF. """Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
Références ObsiGate (Phase 4, issue ObsiGate #22) : un connecteur personnel
peut pointer vers ``coffre:<slug>/<nom>`` au lieu d'un jeton chiffré local.
La référence est **toujours liée à un workspace_id** (voir
``app/services/coffre_reference.py``) ; à l'appel, la valeur est distribuée
en mémoire par le serveur puis oubliée, jamais persistée ni mise en cache.
Les secrets déjà chiffrés localement restent valides (migration
opportuniste, connecteur par connecteur — voir
``docs/coffre-reference-flowdeck.md``).
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée, 5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
plus des connecteurs **personnels** persistés (URL + clé) en base avec un plus des connecteurs **personnels** persistés (URL + clé) en base avec un
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans ``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
@@ -18,7 +28,9 @@ import logging
from app.config import settings from app.config import settings
from app.db import get_conn from app.db import get_conn
from app.services import coffre_reference as coffre_module
from app.services import oauth_connectors from app.services import oauth_connectors
from app.services.coffre_reference import CoffreWorkspaceMismatch
from app.services.sso_provisioning import decrypt_secret, encrypt_secret from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -118,18 +130,62 @@ def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}") return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
#: Colonnes lues pour un connecteur personnel (les deux colonnes coffre
#: sont ajoutées par la migration 52 ; les bases antérieures les ignorent
#: via les requêtes tolérantes de `_select_cols` — voir ci-dessous).
_CONNECTOR_COLS = ("id", "name", "url", "secret_encrypted", "enabled", "status",
"detail", "kind", "auth", "tools_json", "workspace_id",
"coffre_reference")
def _select_cols(conn) -> str:
"""Liste SELECT tolérante : les colonnes coffre n'existent qu'après
la migration 52 (bases créées avant, ou tests à schéma figé)."""
try:
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_connectors)")}
except Exception: # noqa: BLE001 — repli : colonnes historiques seules
cols = set(_CONNECTOR_COLS[:10])
wanted = [c for c in _CONNECTOR_COLS if c in cols]
return ", ".join(wanted) if wanted else ", ".join(_CONNECTOR_COLS[:10])
def _row(r) -> dict: def _row(r) -> dict:
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais).""" """Ligne `agent_connectors` → dict API (le jeton n'y figure jamais).
``coffre_reference`` est une métadonnée (pas une valeur) : elle peut
figurer dans la réponse. ``secret_source`` indique la provenance :
``coffre`` (distribué à l'appel), ``chiffre_local`` (Fernet historique)
ou ``aucun``.
"""
try: try:
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else [] tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
except (ValueError, TypeError): except (ValueError, TypeError):
tools = [] tools = []
try:
reference = r["coffre_reference"] or ""
except (KeyError, IndexError):
reference = ""
try:
workspace_id = r["workspace_id"]
except (KeyError, IndexError):
workspace_id = None
try:
stored = r["secret_encrypted"]
except (KeyError, IndexError):
stored = ""
if reference:
source = "coffre"
elif stored:
source = "chiffre_local"
else:
source = "aucun"
return { return {
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"], "id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]), "url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
"status": r["status"], "detail": r["detail"] or "", "status": r["status"], "detail": r["detail"] or "",
"has_secret": bool(r["secret_encrypted"]), "oauth": False, "has_secret": bool(stored or reference), "oauth": False,
"tools_count": len(tools), "tools_count": len(tools), "workspace_id": workspace_id,
"coffre_reference": reference, "secret_source": source,
} }
@@ -146,28 +202,47 @@ def list_connectors(user_id: int | None = None) -> list[dict]:
"has_secret": False, "oauth": kind in OAUTH_KINDS, "has_secret": False, "oauth": kind in OAUTH_KINDS,
}) })
with get_conn() as conn: with get_conn() as conn:
cols = _select_cols(conn)
rows = conn.execute( rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, " f"SELECT {cols} FROM agent_connectors ORDER BY id"
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
).fetchall() ).fetchall()
return out + [_row(r) for r in rows] return out + [_row(r) for r in rows]
def get(connector_id: int) -> dict | None: def get(connector_id: int) -> dict | None:
with get_conn() as conn: with get_conn() as conn:
cols = _select_cols(conn)
r = conn.execute( r = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, " f"SELECT {cols} FROM agent_connectors WHERE id=?",
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
(connector_id,), (connector_id,),
).fetchone() ).fetchone()
return _row(r) if r else None return _row(r) if r else None
def _workspace_exists(workspace_id: int) -> bool:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE id=?", (workspace_id,)
).fetchone()
return row is not None
# ── CRUD (personnels) ──────────────────────────────────────────────────────── # ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "", *, def create_connector(name: str, url: str, secret: str = "", *,
kind: str = "custom", auth: str = "bearer") -> dict: kind: str = "custom", auth: str = "bearer",
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet).""" workspace_id: int | None = None,
coffre_reference: str | None = None) -> dict:
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet).
Variante coffre (Phase 4) : si ``coffre_reference`` est fourni, le
champ jeton **devient** ce champ référence — aucun jeton n'est stocké
(``secret_encrypted`` reste vide) et un ``workspace_id`` existant est
**obligatoire** (octroi toujours lié à un workspace). ``secret`` et
``coffre_reference`` sont exclusifs. Un connecteur sans référence peut
aussi recevoir un ``workspace_id`` (première étape d'une migration
opportuniste : rattacher d'abord, basculer ensuite).
"""
from app.services.importers.url_fetch import _validate_url from app.services.importers.url_fetch import _validate_url
name = (name or "").strip() name = (name or "").strip()
@@ -177,22 +252,60 @@ def create_connector(name: str, url: str, secret: str = "", *,
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})") raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
if auth not in AUTH_SCHEMES: if auth not in AUTH_SCHEMES:
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})") raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
reference = ""
if coffre_reference is not None and str(coffre_reference).strip():
reference = coffre_module.parse_reference(str(coffre_reference))
reference = f"coffre:{reference[0]}/{reference[1]}"
if (secret or "").strip():
raise ValueError("secret et coffre_reference sont exclusifs "
"(le jeton devient la référence)")
if workspace_id is None:
raise ValueError("coffre_reference exige un workspace_id "
"(octroi toujours lié à un workspace)")
if workspace_id is not None:
workspace_id = int(workspace_id)
if workspace_id <= 0 or not _workspace_exists(workspace_id):
raise ValueError(f"workspace introuvable: {workspace_id}")
url = (url or "").strip() url = (url or "").strip()
if "{secret}" in url and not (secret or "").strip(): if "{secret}" in url and not (secret or "").strip() and not reference:
raise ValueError("clé requise : l'URL contient {secret}") raise ValueError("clé requise : l'URL contient {secret}")
url = _validate_url(url) # lève ValueError si hôte interne url = _validate_url(url) # lève ValueError si hôte interne
with get_conn() as conn: with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_connectors)")}
extra_cols, extra_vals = "", ()
if "workspace_id" in cols:
extra_cols += ", workspace_id"
extra_vals += (workspace_id,)
if "coffre_reference" in cols:
extra_cols += ", coffre_reference"
extra_vals += (reference,)
cur = conn.execute( cur = conn.execute(
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, " "INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)", f"detail, kind, auth{extra_cols}) VALUES (?,?,?,?,?,?,?{',?' * len(extra_vals)})",
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth), (name, url, encrypt_secret(secret or "") if not reference else "",
"unknown", "", kind, auth, *extra_vals),
) )
conn.commit() conn.commit()
cid = cur.lastrowid cid = cur.lastrowid
return get(cid) return get(cid)
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None: _UNSET = object()
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None,
workspace_id=_UNSET, coffre_reference=_UNSET) -> dict | None:
"""Met à jour un connecteur personnel.
Règles coffre (Phase 4) :
* poser une référence exige un workspace (celui du connecteur, ou
celui passé dans le même appel) ; la référence et un jeton local
sont exclusifs dans le même appel ;
* un ``workspace_id`` déjà posé est **immuable** : on ne transfère pas
un octroi d'un workspace à l'autre en éditant le connecteur ;
* effacer la référence (``coffre_reference=""``) réactive l'éventuel
jeton local dormant, sans jamais le réécrire.
"""
row = get(connector_id) row = get(connector_id)
if row is None: if row is None:
return None return None
@@ -206,7 +319,40 @@ def update_connector(connector_id: int, *, name=None, enabled=None, secret=None)
if enabled is not None: if enabled is not None:
sets.append("enabled=?") sets.append("enabled=?")
params.append(1 if enabled else 0) params.append(1 if enabled else 0)
effective_workspace = row.get("workspace_id")
if workspace_id is not _UNSET:
if workspace_id is None:
raise ValueError("workspace_id ne peut pas être effacé")
workspace_id = int(workspace_id)
if workspace_id <= 0 or not _workspace_exists(workspace_id):
raise ValueError(f"workspace introuvable: {workspace_id}")
if row.get("workspace_id") is not None and row["workspace_id"] != workspace_id:
raise ValueError("workspace_id immuable : un connecteur ne change "
"pas de workspace (pas de transfert d'octroi)")
effective_workspace = workspace_id
sets.append("workspace_id=?")
params.append(workspace_id)
reference_cleared = False
if coffre_reference is not _UNSET:
if coffre_reference is None or not str(coffre_reference).strip():
reference_cleared = True
sets.append("coffre_reference=?")
params.append("")
else:
parsed = coffre_module.parse_reference(str(coffre_reference))
if effective_workspace is None:
raise ValueError("coffre_reference exige un workspace_id "
"(octroi toujours lié à un workspace)")
if secret is not None and str(secret).strip():
raise ValueError("secret et coffre_reference sont exclusifs "
"(le jeton devient la référence)")
sets.append("coffre_reference=?")
params.append(f"coffre:{parsed[0]}/{parsed[1]}")
if secret is not None: if secret is not None:
if row.get("coffre_reference") and not reference_cleared:
raise ValueError("ce connecteur pointe vers une référence ObsiGate : "
"effacez d'abord coffre_reference pour revenir "
"à un jeton local")
sets.append("secret_encrypted=?") sets.append("secret_encrypted=?")
params.append(encrypt_secret(str(secret))) params.append(encrypt_secret(str(secret)))
if sets: if sets:
@@ -226,22 +372,72 @@ def delete_connector(connector_id: int) -> bool:
# ── Réseau (toujours gardé SSRF) ───────────────────────────────────────────── # ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
def _headers(connector_id: int | None = None) -> dict: def resolve_secret(connector_id: int, *, workspace_id: int | None = None,
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée. distribute=None) -> str:
"""Résout le jeton d'un connecteur **en mémoire**, jamais persisté.
* Connecteur à référence : le ``workspace_id`` demandé doit être
celui du connecteur, sinon ``CoffreWorkspaceMismatch`` explicite
**avant tout appel** à ObsiGate (le distributeur n'est pas appelé).
La valeur distribuée est renvoyée à l'appelant, qui l'utilise puis
l'oublie — aucune mise en cache ici.
* Connecteur historique : déchiffrement Fernet local (migration
opportuniste : ces connecteurs restent valides tels quels).
* Sans jeton ni référence : chaîne vide.
"""
# Lecture directe en base : le dict API de get() n'expose jamais le
# jeton (chiffré ou non), c'est ici seulement qu'il est résolu.
with get_conn() as conn:
cols = _select_cols(conn)
r = conn.execute(
f"SELECT {cols} FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
if r is None:
raise ValueError(f"Connecteur introuvable: {connector_id}")
try:
reference = r["coffre_reference"] or ""
except (KeyError, IndexError):
reference = ""
try:
owner = r["workspace_id"]
except (KeyError, IndexError):
owner = None
if reference:
if workspace_id is None:
raise ValueError("workspace_id est requis pour un connecteur "
"à référence ObsiGate")
if owner is None or int(owner) != int(workspace_id):
raise CoffreWorkspaceMismatch(
f"workspace {workspace_id} ne peut pas utiliser ce connecteur "
f"(il appartient au workspace {owner}) : octroi d'un autre "
"workspace refusé"
)
return coffre_module.distribuer(
reference, int(workspace_id), request=distribute)
try:
stored = r["secret_encrypted"]
except (KeyError, IndexError):
stored = ""
return decrypt_secret(stored or "")
def _headers(connector_id: int | None = None, *, workspace_id: int | None = None,
distribute=None) -> dict:
"""En-têtes d'appel : la clé n'est lue (et déchiffrée/distribuée) qu'ici.
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs). ``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
Pour un connecteur à référence, ``workspace_id`` est obligatoire et
l'isolation est vérifiée par ``resolve_secret`` avant distribution.
""" """
headers = {"User-Agent": "FlowDeck-Connectors/1.0"} headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret, auth = "", "bearer" secret, auth = "", "bearer"
if connector_id: if connector_id:
with get_conn() as conn: row = get(connector_id)
r = conn.execute( if row:
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?", auth = row["auth"] or "bearer"
(connector_id,), secret = resolve_secret(connector_id, workspace_id=workspace_id,
).fetchone() distribute=distribute)
if r:
secret = decrypt_secret(r["secret_encrypted"] or "")
auth = r["auth"] or "bearer"
if secret and auth == "bot": if secret and auth == "bot":
headers["Authorization"] = f"Bot {secret}" headers["Authorization"] = f"Bot {secret}"
elif secret and auth != "none": elif secret and auth != "none":
@@ -250,16 +446,13 @@ def _headers(connector_id: int | None = None) -> dict:
return headers return headers
def _with_secret(url: str, connector_id: int) -> str: def _with_secret(url: str, connector_id: int, *, workspace_id: int | None = None,
distribute=None) -> str:
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair).""" """Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
if "{secret}" not in url: if "{secret}" not in url:
return url return url
with get_conn() as conn: secret = resolve_secret(connector_id, workspace_id=workspace_id,
r = conn.execute( distribute=distribute)
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
if not secret: if not secret:
raise ValueError("clé manquante pour une URL contenant {secret}") raise ValueError("clé manquante pour une URL contenant {secret}")
return url.replace("{secret}", secret) return url.replace("{secret}", secret)
@@ -303,13 +496,20 @@ def _resolve(connector: str) -> tuple[str, str | int]:
raise ValueError(f"Connecteur inconnu: {token}") raise ValueError(f"Connecteur inconnu: {token}")
async def probe(connector: str, user_id: int | None = None) -> dict: async def probe(connector: str, user_id: int | None = None, *,
"""Teste un connecteur et **persiste** le résultat (personnel uniquement).""" workspace_id: int | None = None, distribute=None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement).
``workspace_id`` est transmis aux chemins à référence ObsiGate ; un
connecteur à référence testé sans workspace échoue proprement
(``error``), jamais avec une valeur d'un autre workspace.
"""
kind, ref = _resolve(connector) kind, ref = _resolve(connector)
try: try:
if kind in MCP_KINDS: if kind in MCP_KINDS:
from app.services import mcp_client from app.services import mcp_client
tools = await mcp_client.initialize_and_list(int(ref)) tools = await mcp_client.initialize_and_list(
int(ref), workspace_id=workspace_id, distribute=distribute)
status, detail = "ok", f"MCP · {len(tools)} outil(s)" status, detail = "ok", f"MCP · {len(tools)} outil(s)"
elif kind in OAUTH_KINDS: elif kind in OAUTH_KINDS:
res = await oauth_connectors.api_get(kind, user_id, res = await oauth_connectors.api_get(kind, user_id,
@@ -326,8 +526,10 @@ async def probe(connector: str, user_id: int | None = None) -> dict:
status, detail = "ok", native_state("web")[1] status, detail = "ok", native_state("web")[1]
else: else:
row = get(int(ref)) row = get(int(ref))
url = _with_secret(row["url"], int(ref)) url = _with_secret(row["url"], int(ref), workspace_id=workspace_id,
code, _ = await _get(url, _headers(int(ref))) distribute=distribute)
code, _ = await _get(url, _headers(int(ref), workspace_id=workspace_id,
distribute=distribute))
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}" status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
logger.info("Connector probe failed (%s): %s", connector, exc) logger.info("Connector probe failed (%s): %s", connector, exc)
@@ -351,7 +553,9 @@ def _gh_headers() -> dict:
async def connector_fetch(connector: str, path: str = "", query: str = "", async def connector_fetch(connector: str, path: str = "", query: str = "",
user_id: int | None = None) -> dict: user_id: int | None = None, *,
workspace_id: int | None = None,
distribute=None) -> dict:
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET.""" """Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
kind, ref = _resolve(connector) kind, ref = _resolve(connector)
path = (path or "").strip() path = (path or "").strip()
@@ -381,10 +585,12 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
return {"status": "error", "text": "Connecteur supprimé"} return {"status": "error", "text": "Connecteur supprimé"}
if not row["enabled"]: if not row["enabled"]:
return {"status": "error", "text": "Connecteur désactivé"} return {"status": "error", "text": "Connecteur désactivé"}
url = _with_secret(row["url"], int(ref)).rstrip("/") url = _with_secret(row["url"], int(ref), workspace_id=workspace_id,
distribute=distribute).rstrip("/")
if path: if path:
url += "/" + path.lstrip("/") url += "/" + path.lstrip("/")
code, text = await _get(url, _headers(int(ref))) code, text = await _get(url, _headers(int(ref), workspace_id=workspace_id,
distribute=distribute))
if code >= 400: if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"} return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:FETCH_LIMIT]} return {"status": "ok", "text": text[:FETCH_LIMIT]}
+19 -9
View File
@@ -189,7 +189,8 @@ async def _handshake(url: str, headers: dict | None) -> dict:
return session return session
def _server(connector_id: int) -> dict: def _server(connector_id: int, *, workspace_id: int | None = None,
distribute=None) -> dict:
row = get(connector_id) row = get(connector_id)
if row is None: if row is None:
raise ValueError("Serveur MCP introuvable") raise ValueError("Serveur MCP introuvable")
@@ -198,7 +199,8 @@ def _server(connector_id: int) -> dict:
if not row["enabled"]: if not row["enabled"]:
raise ValueError("Serveur MCP désactivé") raise ValueError("Serveur MCP désactivé")
return {"id": row["id"], "name": row["name"], "url": row["url"], return {"id": row["id"], "name": row["name"], "url": row["url"],
"headers": _headers(connector_id)} "headers": _headers(connector_id, workspace_id=workspace_id,
distribute=distribute)}
def _normalize(server: dict, raw: list) -> list[dict]: def _normalize(server: dict, raw: list) -> list[dict]:
@@ -226,10 +228,16 @@ def _save_tools(connector_id: int, tools: list[dict]) -> None:
conn.commit() conn.commit()
async def initialize_and_list(connector_id: int) -> list[dict]: async def initialize_and_list(connector_id: int, *, workspace_id: int | None = None,
"""Handshake + ``tools/list`` → met à jour le cache de la base.""" distribute=None) -> list[dict]:
srv = _server(connector_id) """Handshake + ``tools/list`` → met à jour le cache de la base.
url = _with_secret(srv["url"], connector_id)
Pour un serveur à référence ObsiGate, ``workspace_id`` est obligatoire
(isolation vérifiée avant distribution, voir ``connectors``).
"""
srv = _server(connector_id, workspace_id=workspace_id, distribute=distribute)
url = _with_secret(srv["url"], connector_id, workspace_id=workspace_id,
distribute=distribute)
session = await _handshake(url, srv["headers"]) session = await _handshake(url, srv["headers"])
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
"params": {}}, session) "params": {}}, session)
@@ -276,14 +284,16 @@ def _content_text(result: dict) -> str:
return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000] return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000]
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict: async def call_tool(connector_id: int, tool: str, arguments: dict | None = None, *,
workspace_id: int | None = None, distribute=None) -> dict:
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.). """``tools/call`` → {status, text} (isError → error, borné à 20 000 car.).
Le handshake (avec session éventuelle) précède systématiquement Le handshake (avec session éventuelle) précède systématiquement
l'appel : c'est la condition T0 des serveurs à session. l'appel : c'est la condition T0 des serveurs à session.
""" """
srv = _server(connector_id) srv = _server(connector_id, workspace_id=workspace_id, distribute=distribute)
url = _with_secret(srv["url"], connector_id) url = _with_secret(srv["url"], connector_id, workspace_id=workspace_id,
distribute=distribute)
session = await _handshake(url, srv["headers"]) session = await _handshake(url, srv["headers"])
data = await _rpc(url, { data = await _rpc(url, {
"jsonrpc": "2.0", "id": 3, "method": "tools/call", "jsonrpc": "2.0", "id": 3, "method": "tools/call",
+166
View File
@@ -0,0 +1,166 @@
"""Pilote ACC-6.2 — archiver un résumé de réunion Flowdeck dans
ObsiGate, **via la file d'approbations unifiée**.
Chaîne du pilote (patron d'écriture approuvée §7.5) :
1. ``demander_approbation`` — depuis la note de réunion (transcript
avec résumé généré), on prépare l'archive (titre, chemin cible
dans le vault, contenu Markdown) et on dépose une demande dans
``agent_approvals`` (outil ``archiver_reunion_obsigate``) : rien
n'est écrit tant que Bruno n'a pas approuvé.
2. À l'approbation (route gouvernance ``…/agent-approvals/{id}/decide``
qui appelle ``traiter_decision``), ``executer`` envoie le contenu à
ObsiGate par le pont MCP (ACC-2.2) en ``propose_write_file`` —
jamais en écriture directe : ObsiGate garde son propre brouillon,
l'application propriétaire tranche l'application finale.
3. La distribution est journalisée dans ``meeting_distributions``
(canal ``obsigate``), comme les autres canaux de diffusion.
Prérequis réels (gestes de Bruno, inchangés) : connecteur
« obsigate » configuré et sondé dans Flowdeck (clé ObsiGate dédiée
au pont, limitée à un vault) — sans lui, l'exécution échoue avec un
message explicite, la demande reste tracée. Le pilote est
**conditionnel au chantier Meetings** (décision A/B de Bruno) pour
son usage réel ; le code n'en dépend pas : il archive le résumé
d'un transcript existant.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from app.db import get_conn
from app.services import draco_urls, meetings
logger = logging.getLogger(__name__)
#: Nom d'« outil » porté par les demandes d'approbation du pilote
#: dans la file unifiée (`agent_approvals`).
OUTIL = "archiver_reunion_obsigate"
#: Dossier cible dans le vault ObsiGate.
DOSSIER = "Réunions"
def _assainir(nom: str) -> str:
nom = re.sub(r"[\\/:*?\"<>|]+", " ", nom or "")
nom = re.sub(r"\s+", " ", nom).strip().strip(".")
return nom[:80] or "Réunion sans titre"
def construire_archive(tr: dict) -> dict:
"""Transcript (avec résumé) → {titre, chemin, contenu} de l'archive."""
titre = (tr.get("generated_title") or "").strip() or "Réunion"
date = str(tr.get("created_at") or "")[:10]
chemin = f"{DOSSIER}/{date + ' - ' if date else ''}{_assainir(titre)}.md"
lien = f"{draco_urls.flowdeck_url()}/pages/{tr['page_id']}"
contenu = (
f"# {titre}\n\n"
f"> Résumé de réunion archivé depuis Flowdeck "
f"({date or 'date inconnue'}) — note source : {lien}\n\n"
f"{(tr.get('summary') or '').strip()}\n"
)
return {"titre": titre, "chemin": chemin, "contenu": contenu}
def demander_approbation(transcript_id: int, user_id: int) -> dict:
"""Dépose la demande d'archivage dans la file d'approbations.
Lève ValueError si le transcript n'existe pas, si son résumé est
vide (rien à archiver) ou si une demande identique est déjà en
attente (pas de doublon dans la file).
"""
tr = meetings.get_transcript(transcript_id)
if not (tr.get("summary") or "").strip():
raise ValueError("Résumé vide : générer le résumé avant d'archiver")
archive = construire_archive(tr)
args = {"transcript_id": transcript_id, "chemin": archive["chemin"],
"titre": archive["titre"]}
with get_conn() as conn:
en_attente = conn.execute(
"SELECT id, args_json FROM agent_approvals "
"WHERE tool=? AND status='pending'", (OUTIL,)).fetchall()
for row in en_attente:
try:
if json.loads(row["args_json"] or "{}").get("transcript_id") == transcript_id:
raise ValueError(
"Une demande d'archivage est déjà en attente "
f"(approbation #{row['id']})")
except json.JSONDecodeError:
continue
cur = conn.execute(
"INSERT INTO agent_approvals "
"(conversation_id, tool, args_json, status, requester_id) "
"VALUES (0, ?, ?, 'pending', ?)",
(OUTIL, json.dumps(args, ensure_ascii=False), user_id))
conn.commit()
row = conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(cur.lastrowid,)).fetchone()
return dict(row)
def _connecteur_obsigate() -> dict | None:
"""Premier connecteur ObsiGate activé (preset ACC-2.2), ou None."""
from app.services import connectors
for row in connectors.list_connectors():
if row.get("kind") == "obsigate" and row.get("enabled"):
return row
return None
async def executer(approval: dict) -> dict:
"""Exécute un archivage **approuvé** : propose_write_file via le
pont MCP. Retourne {status: ok|erreur, detail} — ne lève pas."""
from app.services import mcp_client
try:
args = json.loads(approval.get("args_json") or "{}")
except json.JSONDecodeError:
args = {}
transcript_id = args.get("transcript_id")
conn = _connecteur_obsigate()
if conn is None:
return {"status": "erreur",
"detail": "Connecteur ObsiGate non configuré dans Flowdeck "
"(Panneau agent → Connecteurs → ObsiGate) : "
"archivage impossible."}
try:
tr = meetings.get_transcript(int(transcript_id))
archive = construire_archive(tr)
res = await mcp_client.call_tool(
conn["id"], "propose_write_file",
{"path": archive["chemin"], "content": archive["contenu"]})
except Exception as exc: # noqa: BLE001 — l'échec est journalisé, pas levé
logger.warning("Archivage réunion ObsiGate échoué: %s", exc)
return {"status": "erreur", "detail": str(exc)[:300]}
if res.get("status") != "ok":
return {"status": "erreur",
"detail": (res.get("text") or "ObsiGate a refusé la proposition")[:300]}
meetings.record_distribution(
int(transcript_id), channel="obsigate",
actor_id=approval.get("approver_id"),
recipients=archive["chemin"], status="propose")
return {"status": "ok",
"detail": f"Proposition d'écriture envoyée à ObsiGate : "
f"{archive['chemin']} (brouillon à appliquer dans ObsiGate)"}
def executer_sync(approval: dict) -> dict:
"""Variante synchrone pour les routes non async (threadpool)."""
return asyncio.run(executer(approval))
def traiter_decision_sync(approval: dict) -> dict | None:
"""Après décision dans la file unifiée : si la demande est du
pilote et approuvée, exécuter l'archivage. Sinon None (autre
outil) — la route gouvernance est synchrone (threadpool)."""
if not approval or approval.get("tool") != OUTIL:
return None
if approval.get("status") != "approved":
return {"status": "rejete",
"detail": "Demande rejetée : rien n'a été envoyé à ObsiGate."}
return executer_sync(approval)
+2 -2
View File
@@ -717,8 +717,8 @@ async def summarize_transcript(transcript_id: int, user_id: int | None = None,
def record_distribution(transcript_id: int, *, channel: str, def record_distribution(transcript_id: int, *, channel: str,
actor_id: int | None = None, actor_id: int | None = None,
recipients: str = "", status: str = "created") -> dict: recipients: str = "", status: str = "created") -> dict:
if channel not in ("copy_link", "email", "slack"): if channel not in ("copy_link", "email", "slack", "obsigate"):
raise ValueError("channel must be copy_link|email|slack") raise ValueError("channel must be copy_link|email|slack|obsigate")
get_transcript(transcript_id) get_transcript(transcript_id)
with get_conn() as conn: with get_conn() as conn:
cur = conn.execute( cur = conn.execute(
+218
View File
@@ -0,0 +1,218 @@
"""Format de skill commun (Fusion ACC-0.3) — lecture, validation,
conversion (ACC-4.5).
Le contrat figé vit dans `dracodev-commons/contrats/skill-format.md` :
un dossier par skill, un `SKILL.md` à frontmatter YAML (`name`,
`description` = déclencheur, `version`, `apps`, `domaines`, `outils`,
section `ecritures` obligatoire dès qu'il y a modification) et un
corps en trois sections ordonnées — **Quand l'utiliser**,
**Procédure**, **Garde-fous**.
Ce module est la part Flowdeck de la preuve du format (ACC-4.5) :
- ``analyser_skill_md`` : SKILL.md → structure (métadonnées + sections) ;
- ``valider`` : contrôle du contrat, liste d'erreurs lisibles ;
- ``vers_champs_flowdeck`` : conversion vers les champs d'un skill
Flowdeck (`agent_skills` / galerie) — les outils `flowdeck.*`
deviennent les `allowed_tools`, la Procédure devient le
`prompt_template` (précédée du déclencheur et suivie des garde-fous) ;
- ``depuis_preset_flowdeck`` : conversion inverse d'un preset de la
galerie vers un SKILL.md au format commun (point de départ d'une
conversion relue par un humain).
Les skills convertis de la galerie vivent dans `skills-communs/` à la
racine du dépôt ; ils sont installables tels quels via
``skill_gallery.upsert_skill(vers_champs_flowdeck(...))``.
"""
from __future__ import annotations
import re
import yaml
#: Sections du corps, dans l'ordre imposé par le contrat.
SECTIONS = ("Quand l'utiliser", "Procédure", "Garde-fous")
#: Valeurs admises pour l'approbation d'une écriture.
APPROBATIONS = ("toujours", "selon-portee", "jamais")
#: Champs de frontmatter obligatoires (contrat § « En-tête »).
CHAMPS_REQUIS = ("name", "description", "version", "apps", "domaines")
_KEBAB = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
_TITRE_SECTION = re.compile(r"^#{1,3}\s+(.+?)\s*$", re.MULTILINE)
def analyser_skill_md(texte: str) -> dict:
"""SKILL.md → {meta, sections, corps}. Lève ValueError si le
frontmatter est absent ou illisible."""
brut = (texte or "").replace("\r\n", "\n")
if not brut.startswith("---\n"):
raise ValueError("SKILL.md doit commencer par un frontmatter YAML (---)")
fin = brut.find("\n---", 4)
if fin < 0:
raise ValueError("Frontmatter YAML non fermé (---)")
try:
meta = yaml.safe_load(brut[4:fin]) or {}
except yaml.YAMLError as exc:
raise ValueError(f"Frontmatter YAML illisible : {exc}") from exc
if not isinstance(meta, dict):
raise ValueError("Frontmatter YAML : un objet est attendu")
corps = brut[fin + 4:].lstrip("\n")
sections: dict[str, str] = {}
marques = [(m.start(), m.group(1).strip()) for m in _TITRE_SECTION.finditer(corps)]
for i, (pos, titre) in enumerate(marques):
if titre in SECTIONS:
debut = corps.find("\n", pos) + 1
fin_s = marques[i + 1][0] if i + 1 < len(marques) else len(corps)
sections[titre] = corps[debut:fin_s].strip()
return {"meta": meta, "sections": sections, "corps": corps}
def _outils_flowdeck(skill: dict) -> list[str]:
"""Noms d'outils Flowdeck déclarés (préfixe `flowdeck.` retiré),
lectures (`outils`) puis écritures (`ecritures`), sans doublons."""
meta = skill["meta"]
noms: list[str] = []
for entree in meta.get("outils") or []:
nom = str(entree).strip()
if nom.startswith("flowdeck."):
noms.append(nom[len("flowdeck."):])
for entree in meta.get("ecritures") or []:
if isinstance(entree, dict):
nom = str(entree.get("action") or "").strip()
if nom.startswith("flowdeck."):
noms.append(nom[len("flowdeck."):])
return list(dict.fromkeys(noms))
def valider(skill: dict) -> list[str]:
"""Contrôle le contrat ACC-0.3 → liste d'erreurs (vide = conforme)."""
erreurs: list[str] = []
meta = skill.get("meta") or {}
for champ in CHAMPS_REQUIS:
valeur = meta.get(champ)
if valeur is None or (isinstance(valeur, str) and not valeur.strip()) \
or (isinstance(valeur, list) and not valeur):
erreurs.append(f"Champ obligatoire manquant ou vide : {champ}")
nom = str(meta.get("name") or "")
if nom and not _KEBAB.match(nom):
erreurs.append(f"name doit être en kebab-case : {nom!r}")
for champ in ("apps", "domaines"):
valeur = meta.get(champ)
if valeur is not None and (not isinstance(valeur, list)
or not all(isinstance(v, str) for v in valeur)):
erreurs.append(f"{champ} doit être une liste de chaînes")
outils = meta.get("outils") or []
if not isinstance(outils, list) or not all(isinstance(o, str) for o in outils):
erreurs.append("outils doit être une liste de noms (app.outil)")
ecritures = meta.get("ecritures") or []
if not isinstance(ecritures, list):
erreurs.append("ecritures doit être une liste d'objets {action, approbation}")
ecritures = []
for entree in ecritures:
if not isinstance(entree, dict) or not str(entree.get("action") or "").strip():
erreurs.append("Chaque écriture doit déclarer une action")
continue
approbation = str(entree.get("approbation") or "selon-portee")
if approbation not in APPROBATIONS:
erreurs.append(
f"approbation inconnue pour {entree['action']} : {approbation!r} "
f"(attendu : {', '.join(APPROBATIONS)})")
# Règle du contrat : « ecritures » obligatoire dès que le skill
# modifie quelque chose — pour la part Flowdeck, un outil déclaré
# qui est un outil d'écriture interne impose la section.
if not ecritures and "flowdeck" in (meta.get("apps") or []):
from app.services.permission_manager import DESTRUCTIVE_TOOLS, WRITE_TOOLS
modifiants = [o for o in _outils_flowdeck(skill)
if o in WRITE_TOOLS or o in DESTRUCTIVE_TOOLS]
if modifiants:
erreurs.append(
"Section ecritures obligatoire : le skill utilise des outils "
f"d'écriture Flowdeck ({', '.join(modifiants)})")
sections = skill.get("sections") or {}
for titre in SECTIONS:
if not sections.get(titre):
erreurs.append(f"Section de corps manquante ou vide : « {titre} »")
return erreurs
def vers_champs_flowdeck(skill: dict) -> dict:
"""Skill au format commun → champs Flowdeck (`agent_skills`).
Le `prompt_template` assemble le déclencheur, la procédure et les
garde-fous ; les écritures déclarées restent visibles dans le
prompt (le skill ne s'exécute jamais sans la politique d'agent).
"""
erreurs = valider(skill)
if erreurs:
raise ValueError("Skill non conforme au format commun : " + " ; ".join(erreurs))
meta = skill["meta"]
sections = skill["sections"]
morceaux = [
"Quand l'utiliser\n" + sections["Quand l'utiliser"],
"Procédure\n" + sections["Procédure"],
"Garde-fous\n" + sections["Garde-fous"],
]
ecritures = meta.get("ecritures") or []
if ecritures:
lignes = "\n".join(
f"- {e['action']} : approbation "
f"{e.get('approbation') or 'selon-portee'}"
for e in ecritures)
morceaux.append(f"Écritures déclarées (format commun)\n{lignes}")
return {
"name": str(meta["name"]),
"description": str(meta["description"]).strip(),
"prompt_template": "\n\n".join(morceaux),
"allowed_tools": _outils_flowdeck(skill),
}
def _slug(nom: str) -> str:
import unicodedata
plat = unicodedata.normalize("NFD", nom.lower())
plat = "".join(c for c in plat if unicodedata.category(c) != "Mn")
slug = re.sub(r"[^a-z0-9]+", "-", plat).strip("-")
return slug or "skill"
def depuis_preset_flowdeck(preset: dict, *, domaines: list[str] | None = None) -> str:
"""Preset de la galerie Flowdeck → SKILL.md au format commun.
Point de départ d'une conversion (ACC-4.5) : les outils
d'écriture du preset deviennent la section `ecritures`
(approbation `selon-portee`, le régime par défaut du contrat) ;
le texte est à relire par un humain avant diffusion.
"""
from app.services.permission_manager import WRITE_TOOLS
outils = [str(o) for o in preset.get("allowed_tools") or []]
lectures = [o for o in outils if o not in WRITE_TOOLS]
ecritures = [o for o in outils if o in WRITE_TOOLS]
meta: dict = {
"name": _slug(str(preset.get("name") or "skill")),
"description": str(preset.get("description") or "").strip(),
"version": "1.0.0",
"apps": ["flowdeck"],
"domaines": domaines or ["pro"],
"outils": [f"flowdeck.{o}" for o in lectures],
}
if ecritures:
meta["ecritures"] = [
{"action": f"flowdeck.{o}", "approbation": "selon-portee"}
for o in ecritures
]
front = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip()
corps = (
f"## Quand l'utiliser\n\n{preset.get('description') or ''}\n\n"
f"## Procédure\n\n{preset.get('prompt_template') or ''}\n\n"
"## Garde-fous\n\n"
"- N'agir que dans les domaines déclarés ; un besoin croisé "
"entre domaines est signalé, jamais contourné.\n"
"- Ne jamais inventer un contenu absent des sources lues.\n"
)
return f"---\n{front}\n---\n\n{corps}"
+117
View File
@@ -0,0 +1,117 @@
# Flowdeck — Connecteurs à référence ObsiGate (Phase 4, issue ObsiGate #22)
## Principe
Un connecteur personnel d'un workspace peut pointer vers une référence
ObsiGate au lieu d'un jeton :
```
coffre:<slug>/<nom> ex. coffre:flowdeck/gitea-lecture
```
* Le champ jeton du connecteur **devient** le champ référence : aucun
jeton n'est stocké pour ce connecteur (`secret_encrypted` reste vide).
* L'octroi est **toujours lié à un `workspace_id`** : créer un connecteur
à référence sans workspace est refusé (HTTP 400 / `ValueError`).
* À l'appel, le **serveur** Flowdeck appelle
`POST /api/coffres/distribuer` d'ObsiGate avec le jeton consommateur
Flowdeck et un contexte `flowdeck_workspace / <workspace_id>`, utilise
la valeur **en mémoire**, puis l'oublie. La valeur n'est jamais
persistée, jamais mise en cache, jamais journalisée, jamais renvoyée
à une réponse API ni au LLM.
## Configuration (serveur Flowdeck)
| Variable | Défaut | Rôle |
| --- | --- | --- |
| `OBSIGATE_COFFRE_URL` | dérivée de `DRACO_SSO_URL` | Base ObsiGate pour `/api/coffres/distribuer` |
| `OBSIGATE_CONSUMER_TOKEN` | (vide = distribution désactivée) | Jeton **consommateur** Flowdeck enregistré dans ObsiGate |
Le jeton consommateur est distinct du jeton MCP de notes et de la
session SSO. Il ne figure dans aucun log, rapport ou réponse.
## Isolation workspace — ce qui est garanti, et sa limite honnête
**Garanti côté Flowdeck, avant tout appel** (`connectors.resolve_secret`) :
* un connecteur à référence ne se résout que pour son propre
`workspace_id` ;
* workspace A tentant d'utiliser le connecteur (donc la référence /
l'octroi) du workspace B reçoit un refus explicite
(`CoffreWorkspaceMismatch` : « … ne peut pas utiliser ce connecteur
(il appartient au workspace …) : octroi d'un autre workspace refusé »)
et **le distributeur n'est pas appelé** ;
* un `workspace_id` déjà posé sur un connecteur est immuable : on ne
transfère pas un octroi en éditant le connecteur ;
* un connecteur à référence testé/appelé sans `workspace_id` échoue
proprement, jamais avec la valeur d'un autre workspace.
**Limite à connaître** : côté ObsiGate, l'octroi est accordé au
consommateur **Flowdeck** (un jeton par application), pas à un workspace
— le `contexte_id` transmis sert à la traçabilité, pas à un contrôle
d'accès côté ObsiGate. L'isolation par workspace repose donc sur le
contrôle Flowdeck ci-dessus. Une isolation appliquée côté ObsiGate
exigerait un consommateur par workspace : c'est un reste à faire
décidé côté ObsiGate (enregistrement de consommateurs par workspace),
pas un prérequis pour utiliser ce mécanisme.
## Échecs propres, sans repli
| Situation | Comportement Flowdeck |
| --- | --- |
| ObsiGate injoignable / timeout / HTTP 5xx | `CoffreIndisponible` — l'appel échoue, **aucune** valeur en cache n'est utilisée (il n'y en a pas) |
| Octroi révoqué / consommateur révoqué (403) | `CoffreAccesRefuse` — message « accès révoqué, à ré-accorder dans ObsiGate » |
| Octroi expiré (403) / secret expiré (410) | `CoffreAccesRefuse` avec le code ObsiGate, sans la valeur |
| Référence mal formée | `CoffreReferenceInvalide` (format `coffre:<slug>/<nom>`, slug 3–64, nom 3–128, minuscules, chiffres, `-`/`_`, aligné sur ObsiGate) |
## Secrets déjà chiffrés : pas de big-bang
Les connecteurs historiques (`secret_encrypted` Fernet) restent valides
tels quels. La migration est **opportuniste, connecteur par
connecteur** :
1. (optionnel) rattacher d'abord le connecteur à son workspace
(`PATCH /api/agent/connectors/{id}` avec `workspace_id`) ;
2. puis basculer vers la référence (`PATCH …` avec
`coffre_reference: "coffre:<slug>/<nom>"`) — le jeton local dormant
n'est pas effacé automatiquement, il est simplement ignoré tant que
la référence est posée ; effacer la référence (`""`) le réactive ;
3. vérifier par un probe avec `workspace_id`, puis supprimer le jeton
local si besoin (rotation côté ObsiGate).
## Chemin de migration par connecteur
| Connecteur actuel | État dans cette branche | Chemin |
| --- | --- | --- |
| Personnels (`custom`, `discord`, `telegram`, `mcp`, `tiktak`, `obsigate`) | **Implémenté** : `workspace_id` + `coffre_reference`, résolution en mémoire, isolation testée | Basculer connecteur par connecteur (ci-dessus) |
| Natifs Gitea / GitHub | **Inchangés** : jetons globaux d'instance (`GITEA_TOKEN`, `GITHUB_TOKEN` en `.env`) | Créer un connecteur personnel workspace-scopé à référence vers le même service et l'utiliser à la place du natif, quand le besoin workspace par workspace se présente ; les natifs ne sont pas supprimés (pas de big-bang) |
| LLM global (`llm_config` id=1) et clés par utilisateur (`user_llm_keys`) | **Inchangés** : clés en clair en base (globale / par utilisateur, pas par workspace) | Travail ultérieur dédié : ajouter une colonne référence sur `user_llm_keys` + résolution par workspace, avec le même patron (distribution en mémoire, workspace obligatoire) ; hors périmètre de cette branche |
| OAuth Google / M365 (`connector_tokens`) | **Inchangés** : jetons OAuth chiffrés par (kind, utilisateur) | Hors périmètre : le flux OAuth ne se prête pas à une référence coffre sans repenser le refresh ; à traiter séparément si besoin |
## API
* `POST /api/agent/connectors` accepte `workspace_id` et
`coffre_reference` (référence sans workspace → 400).
* `PATCH /api/agent/connectors/{id}` accepte `workspace_id`
(premier rattachement seulement) et `coffre_reference` (`""` =
effacer la référence).
* `POST /api/agent/connectors/probe` accepte `workspace_id`.
* Réponses : `workspace_id`, `coffre_reference`, `secret_source`
(`coffre` | `chiffre_local` | `aucun`) — jamais de valeur.
* `GET /api/agent/connectors` liste les connecteurs avec ces champs.
## Ce qui reste à faire (validation réelle)
* Valider contre une vraie instance ObsiGate : créer le consommateur
Flowdeck + les octrois, puis un probe réel avec `workspace_id`
(les tests ici utilisent un faux endpoint de distribution).
* Threader `workspace_id` depuis l'agent : `AgentEngine.workspace_id`
→ `connector_fetch` (outil LLM) et `McpTool.call_tool` — aujourd'hui
ces chemins appellent sans workspace et échouent proprement pour les
connecteurs à référence (refus, pas de fuite) ; les connecteurs
historiques ne sont pas affectés.
* Le cas échéant, consommateur ObsiGate par workspace (voir limite
ci-dessus) et bascule des natifs Gitea/GitHub et des clés LLM
(tableau ci-dessus).
* Interface : sélecteur de référence dans le formulaire connecteur
(aujourd'hui, champ `coffre_reference` via l'API seulement).
@@ -0,0 +1,46 @@
---
name: compte-rendu-reunion
description: >
Transforme une note brute de réunion en compte rendu structuré
(décisions et actions) dans Flowdeck. À utiliser quand Bruno demande
un compte rendu, un CR ou une synthèse décisionnelle à partir d'une
note de réunion.
version: 1.0.0
apps: [flowdeck]
domaines: [pro, famille, perso]
outils:
- flowdeck.search_workspace
- flowdeck.read_document
ecritures:
- action: flowdeck.create_document
approbation: selon-portee
- action: flowdeck.write_blocks
approbation: selon-portee
---
## Quand l'utiliser
Quand Bruno fournit une note de réunion brute — ou demande de la
retrouver dans le workspace — et veut un compte rendu propre :
décisions prises, actions avec responsable et échéance, points
ouverts. Ne pas l'utiliser pour un simple résumé informatif sans
décisions (voir `resume-document`).
## Procédure
1. Retrouver la note de réunion si elle n'est pas fournie
(search_workspace), puis la lire intégralement (read_document).
2. Créer un document « CR — <titre> » (create_document).
3. Y écrire (write_blocks) la structure : Contexte · Décisions
prises · Actions (avec responsable et échéance) · Points ouverts.
4. Ne garder que ce qui est dans la note ; lister explicitement les
points manquants plutôt que de les combler.
## Garde-fous
- Aucune décision ni action inventée : tout vient de la note lue.
- N'agir que dans les domaines déclarés ; un besoin croisé entre
domaines est signalé, jamais contourné.
- L'écriture du document suit la politique d'approbation de l'agent
(section `ecritures`) : le contenu exact est présenté avant
exécution quand la portée l'exige.
+46
View File
@@ -0,0 +1,46 @@
---
name: rapport-hebdo
description: >
Agrège les pages et documents modifiés de la semaine dans Flowdeck
et rédige un rapport structuré de l'équipe. À utiliser quand Bruno
demande le rapport hebdomadaire, un bilan de semaine ou l'état
d'avancement de l'équipe.
version: 1.0.0
apps: [flowdeck]
domaines: [pro]
outils:
- flowdeck.search_workspace
- flowdeck.read_document
ecritures:
- action: flowdeck.create_document
approbation: selon-portee
- action: flowdeck.write_blocks
approbation: selon-portee
---
## Quand l'utiliser
Quand Bruno demande le rapport de la semaine : ce qui a avancé, ce
qui bloque, ce qui a été décidé, et le plan de la semaine suivante.
Ne pas l'utiliser pour une seule réunion (voir
`compte-rendu-reunion`) ni pour un document unique (voir
`resume-document`).
## Procédure
1. Repérer les pages et documents modifiés cette semaine
(search_workspace).
2. Lire les plus significatifs (read_document) et en extraire
avancées, blocages et décisions.
3. Créer un document « Rapport hebdo — <date> » (create_document) et
y écrire (write_blocks) la structure : Résumé · Faits marquants ·
Blocages · Plan de la semaine prochaine.
## Garde-fous
- Chaque affirmation s'appuie sur un document lu, jamais sur une
supposition ; une section sans élément le dit explicitement.
- Domaine professionnel uniquement (domaine déclaré) : ne pas
mélanger des contenus famille ou perso au rapport d'équipe.
- L'écriture suit la politique d'approbation de l'agent (section
`ecritures`).
+45
View File
@@ -0,0 +1,45 @@
---
name: resume-document
description: >
Résume un document long de Flowdeck en une page : points clés,
chiffres marquants, décisions et questions ouvertes. À utiliser
quand Bruno demande de résumer, condenser ou synthétiser un
document précis.
version: 1.0.0
apps: [flowdeck]
domaines: [pro, famille, perso]
outils:
- flowdeck.search_workspace
- flowdeck.read_document
ecritures:
- action: flowdeck.create_document
approbation: selon-portee
- action: flowdeck.write_blocks
approbation: selon-portee
---
## Quand l'utiliser
Quand Bruno désigne un document — ou demande de le retrouver — et
veut l'essentiel en une page. Ne pas l'utiliser pour agréger
plusieurs documents sur une période (voir `rapport-hebdo`) ni pour
produire un compte rendu décisionnel de réunion (voir
`compte-rendu-reunion`).
## Procédure
1. Retrouver le document s'il n'est pas fourni (search_workspace),
puis le lire intégralement (read_document).
2. Créer un document « Résumé — <titre> » (create_document).
3. Y écrire (write_blocks) : 5 points clés, chiffres marquants,
décisions/engagements, questions restées ouvertes. Maximum une
page, phrases courtes.
## Garde-fous
- Aucune reformulation qui change le sens ; en cas de doute,
citer le passage plutôt que l'interpréter.
- N'agir que dans les domaines déclarés ; un besoin croisé entre
domaines est signalé, jamais contourné.
- L'écriture suit la politique d'approbation de l'agent (section
`ecritures`).
+282
View File
@@ -0,0 +1,282 @@
"""Phase 4 (issue ObsiGate #22) — connecteurs Flowdeck à référence ObsiGate.
Couverture : format de référence, création liée à un workspace, isolation
(A ne distribue pas avec l'octroi de B — le faux distributeur n'est même
pas appelé), distribution en mémoire sans persistance ni cache, octroi
révoqué, ObsiGate indisponible (aucun repli), connecteurs historiques
chiffrés toujours valides, routes API.
Le distributeur est un FAUX endpoint (appelable injecté), clairement
identifié comme tel : aucun test ne parle à une vraie instance ObsiGate.
Les valeurs de test sont fictives (``TEST-VALUE-XXXX``).
"""
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import coffre_reference, connectors
from app.services.coffre_reference import (
CoffreAccesRefuse,
CoffreIndisponible,
CoffreReferenceInvalide,
CoffreWorkspaceMismatch,
)
PUBLIC_URL = "https://example.com/api"
REFERENCE = "coffre:flowdeck/gitea-lecture"
FAKE_VALUE = "TEST-VALUE-XXXX"
@pytest.fixture(autouse=True)
def _faux_jeton_consommateur():
"""Le distributeur exige un jeton consommateur : valeur fictive de test.
Sans elle, ``distribuer`` refuserait avant même d'appeler le faux
endpoint (comportement voulu en production).
"""
import app.config
app.config.settings.obsigate_consumer_token = "TEST-CONSUMER-TOKEN-XXXX"
yield
def _workspaces():
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) "
"VALUES (1, 'tester', 'Tester', 1)"
)
conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier A', 1)"
)
conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier B', 1)"
)
conn.commit()
rows = conn.execute(
"SELECT id FROM workspaces ORDER BY id"
).fetchall()
return int(rows[0]["id"]), int(rows[1]["id"])
class _FakeDistributeur:
"""Faux endpoint ObsiGate : compte les appels, ne persiste rien."""
def __init__(self, behaviour="ok"):
self.behaviour = behaviour
self.calls = []
def __call__(self, url, payload, headers):
self.calls.append((url, dict(payload)))
assert url.endswith("/api/coffres/distribuer"), url
assert headers.get("Authorization", "").startswith("Bearer "), headers
if self.behaviour == "ok":
return 200, {"reference": payload["reference"], "valeur": FAKE_VALUE,
"version": 1}
if self.behaviour == "revoque":
return 403, {"detail": "octroi_revoque"}
if self.behaviour == "indisponible":
raise ConnectionError("faux ObsiGate injoignable")
raise AssertionError(self.behaviour)
def _create_coffre(client, ws, **kw):
body = {"name": "Conn coffre", "url": PUBLIC_URL,
"workspace_id": ws, "coffre_reference": REFERENCE, **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
# ── Format ─────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("bad", [
"gitea-lecture", "coffre:", "coffre:slug-seul", "coffre:AB/mauvais",
"coffre:a/b", "coffre:ok/mauvais nom", "coffre:ok/",
])
def test_reference_invalide_rejetee(bad):
with pytest.raises(CoffreReferenceInvalide):
coffre_reference.parse_reference(bad)
def test_reference_normalisee_minuscules():
assert coffre_reference.parse_reference("Coffre:FlowDeck/Gitea-Lecture") == (
"flowdeck", "gitea-lecture")
# ── Création liée au workspace ─────────────────────────────────────────────
def test_creation_coffre_exige_workspace(client):
r = client.post("/api/agent/connectors",
json={"name": "Sans WS", "url": PUBLIC_URL,
"coffre_reference": REFERENCE})
assert r.status_code == 400, r.text
assert "workspace" in r.text.lower()
def test_creation_coffre_refuse_secret_et_reference(client):
ws_a, _ = _workspaces()
r = client.post("/api/agent/connectors",
json={"name": "Ambigu", "url": PUBLIC_URL, "secret": "x",
"workspace_id": ws_a, "coffre_reference": REFERENCE})
assert r.status_code == 400, r.text
def test_creation_coffre_ne_stocke_aucun_jeton(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
assert row["coffre_reference"] == REFERENCE
assert row["workspace_id"] == ws_a
assert row["secret_source"] == "coffre"
assert row["has_secret"] is True
assert FAKE_VALUE not in str(row)
with get_conn() as conn:
stored = conn.execute(
"SELECT secret_encrypted, coffre_reference FROM agent_connectors "
"WHERE id=?", (row["id"],)
).fetchone()
assert stored["secret_encrypted"] == "" # aucun jeton stocké
assert stored["coffre_reference"] == REFERENCE # métadonnée seulement
def test_creation_workspace_introuvable(client):
r = client.post("/api/agent/connectors",
json={"name": "WS ?", "url": PUBLIC_URL,
"workspace_id": 999999, "coffre_reference": REFERENCE})
assert r.status_code == 400, r.text
# ── Résolution + isolation (critère central) ───────────────────────────────
def test_resolve_bon_workspace_distribue_en_memoire(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur()
valeur = connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert valeur == FAKE_VALUE
assert len(fake.calls) == 1
_, payload = fake.calls[0]
assert payload["reference"] == REFERENCE
assert payload["contexte_type"] == "flowdeck_workspace"
assert payload["contexte_id"] == str(ws_a)
# Rien n'a été persisté ni mis en cache par la distribution.
with get_conn() as conn:
stored = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
(row["id"],)
).fetchone()
assert stored["secret_encrypted"] == ""
assert FAKE_VALUE not in str(connectors.get(row["id"]))
def test_isolation_workspace_a_ne_distribue_pas_octroi_de_b(client):
ws_a, ws_b = _workspaces()
row = _create_coffre(client, ws_b) # octroi du workspace B
fake = _FakeDistributeur()
with pytest.raises(CoffreWorkspaceMismatch) as exc:
connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert "ne peut pas utiliser" in str(exc.value)
assert fake.calls == [] # distributeur jamais appelé
def test_resolve_sans_workspace_refuse(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur()
with pytest.raises(ValueError, match="workspace_id"):
connectors.resolve_secret(row["id"], distribute=fake)
assert fake.calls == []
def test_headers_bon_workspace_mauvais_workspace(client):
ws_a, ws_b = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur()
headers = connectors._headers(row["id"], workspace_id=ws_a,
distribute=fake)
assert headers["Authorization"] == f"Bearer {FAKE_VALUE}"
with pytest.raises(CoffreWorkspaceMismatch):
connectors._headers(row["id"], workspace_id=ws_b, distribute=fake)
# ── Échecs propres ─────────────────────────────────────────────────────────
def test_octroi_revoque_message_explicite_sans_repli(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur(behaviour="revoque")
with pytest.raises(CoffreAccesRefuse) as exc:
connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert "révoqué" in str(exc.value)
assert "ré-accorder" in str(exc.value)
assert exc.value.detail == "octroi_revoque"
def test_obsigate_indisponible_sans_valeur_en_cache(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur(behaviour="indisponible")
for _ in range(2): # deux appels : aucun ne doit renvoyer une valeur
with pytest.raises(CoffreIndisponible):
connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert len(fake.calls) == 2 # chaque appel retente, rien en cache
# ── Migration opportuniste ─────────────────────────────────────────────────
def test_connecteur_historique_chiffre_toujours_valide(client):
body = {"name": "Conn perso", "url": PUBLIC_URL, "secret": "sk-live-abc"}
row = client.post("/api/agent/connectors", json=body).json()
assert row["secret_source"] == "chiffre_local"
assert connectors.resolve_secret(row["id"]) == "sk-live-abc"
def test_bascule_patch_vers_reference_puis_retour(client):
ws_a, ws_b = _workspaces()
row = client.post("/api/agent/connectors",
json={"name": "Migrable", "url": PUBLIC_URL,
"secret": "sk-live-abc"}).json()
cid = row["id"]
# 1. rattacher au workspace, 2. basculer vers la référence.
r = client.patch(f"/api/agent/connectors/{cid}",
json={"workspace_id": ws_a})
assert r.status_code == 200, r.text
r = client.patch(f"/api/agent/connectors/{cid}",
json={"coffre_reference": REFERENCE})
assert r.status_code == 200, r.text
assert r.json()["secret_source"] == "coffre"
# Le workspace est immuable : pas de transfert d'octroi vers B.
r = client.patch(f"/api/agent/connectors/{cid}",
json={"workspace_id": ws_b})
assert r.status_code == 400, r.text
# Effacer la référence réactive le jeton local dormant.
r = client.patch(f"/api/agent/connectors/{cid}",
json={"coffre_reference": ""})
assert r.status_code == 200, r.text
assert r.json()["secret_source"] == "chiffre_local"
assert connectors.resolve_secret(cid) == "sk-live-abc"
def test_probe_coffre_sans_workspace_echoue_proprement(client, monkeypatch):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
async def ok_get(url, headers=None):
return 200, "{}"
monkeypatch.setattr(connectors, "_get", ok_get)
r = client.post("/api/agent/connectors/probe",
json={"connector": str(row["id"])})
assert r.status_code == 200
assert r.json()["status"] == "error" # refus, pas de fuite inter-workspace
def test_routes_exigent_session(client):
anon_csrf(client)
assert client.post("/api/agent/connectors",
json={"name": "x", "url": PUBLIC_URL}).status_code == 401
+78
View File
@@ -0,0 +1,78 @@
"""Fusion ACC-4.5 — conversion d'Agents & Skills Flowdeck au format
commun (contrat ACC-0.3, `dracodev-commons/contrats/skill-format.md`).
Preuve du format : trois presets de la galerie Flowdeck convertis en
`skills-communs/<nom>/SKILL.md` (compte-rendu-reunion, rapport-hebdo,
resume-document). Ils doivent être conformes au contrat, se convertir
en champs Flowdeck et s'installer dans un workspace via le chemin
existant (`skill_gallery.upsert_skill`).
"""
from pathlib import Path
import pytest
from app.services import skill_format, skill_gallery
RACINE = Path(__file__).resolve().parent.parent
CONVERTIS = ["compte-rendu-reunion", "rapport-hebdo", "resume-document"]
def _lire(nom: str) -> str:
return (RACINE / "skills-communs" / nom / "SKILL.md").read_text(encoding="utf-8")
def test_trois_skills_convertis_conformes():
for nom in CONVERTIS:
skill = skill_format.analyser_skill_md(_lire(nom))
assert skill_format.valider(skill) == [], (nom, skill_format.valider(skill))
assert skill["meta"]["name"] == nom
assert skill["meta"]["apps"] == ["flowdeck"]
assert skill["meta"]["version"] == "1.0.0"
# Toute écriture est déclarée, avec approbation explicite.
actions = {e["action"] for e in skill["meta"]["ecritures"]}
assert actions == {"flowdeck.create_document", "flowdeck.write_blocks"}
def test_conversion_vers_flowdeck_et_installation(client):
for nom in CONVERTIS:
skill = skill_format.analyser_skill_md(_lire(nom))
champs = skill_format.vers_champs_flowdeck(skill)
assert champs["name"] == nom
assert set(champs["allowed_tools"]) == {
"search_workspace", "read_document", "create_document", "write_blocks"}
assert "Procédure" in champs["prompt_template"]
assert "Garde-fous" in champs["prompt_template"]
row, cree = skill_gallery.upsert_skill(
champs, workspace_id=None, created_by=1)
assert cree is True and row["name"] == nom
def test_validation_rejette_les_ecarts():
incomplet = skill_format.analyser_skill_md(
"---\nname: mauvais nom\napps: [flowdeck]\n---\n\n## Procédure\n\nx\n")
erreurs = skill_format.valider(incomplet)
assert any("kebab" in e for e in erreurs)
assert any("description" in e for e in erreurs)
assert any("Quand l'utiliser" in e for e in erreurs)
with pytest.raises(ValueError):
skill_format.vers_champs_flowdeck(incomplet)
def test_ecritures_obligatoires_si_outil_ecrivant():
texte = (
"---\nname: supprime-tout\ndescription: x\nversion: 1.0.0\n"
"apps: [flowdeck]\ndomaines: [pro]\noutils: [flowdeck.delete_page]\n---\n\n"
"## Quand l'utiliser\n\nx\n\n## Procédure\n\nx\n\n## Garde-fous\n\nx\n")
erreurs = skill_format.valider(skill_format.analyser_skill_md(texte))
assert any("ecritures obligatoire" in e for e in erreurs)
def test_aller_retour_depuis_un_preset():
preset = skill_gallery.get_gallery("compte-rendu-reunion")
texte = skill_format.depuis_preset_flowdeck(preset, domaines=["pro"])
skill = skill_format.analyser_skill_md(texte)
assert skill_format.valider(skill) == []
champs = skill_format.vers_champs_flowdeck(skill)
assert champs["name"] == "compte-rendu-de-reunion"
assert "create_document" in champs["allowed_tools"]
+57
View File
@@ -0,0 +1,57 @@
"""Fusion ACC-4.4 — panneau de l'agent commun intégré à Flowdeck.
Le panneau commun (composant partagé des quatre apps, contrat
d'injection `dracodev-commons`) est intégré depuis la 7.81.0
(panneau v3), complété par le mode console (7.82.x) et la version du
service affichée en mode sidebar (7.83.1, fusion #64). Ces tests
figent le contrat d'intégration côté Flowdeck :
- le gabarit de base charge les assets du panneau et injecte la
configuration (URL du service d'agents résolue par configuration,
jamais un nom public codé en dur dans le JS) ;
- le panneau parle au service d'agents (conversation **commune** :
même discussion perpétuelle que dans les autres apps) ;
- les automatisations locales de Flowdeck restent locales (C5) :
le panneau commun ne remplace pas le moteur d'agent local.
"""
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
def test_gabarit_charge_le_panneau_et_injecte_la_config():
base = (RACINE / "app/templates/base.html").read_text(encoding="utf-8")
assert "/static/css/draco-panneau.css" in base
assert "/static/js/draco-panneau.js" in base
assert "window.DRACO_PANNEAU_CONFIG" in base
assert "draco_agent_url()" in base
def test_panneau_parle_au_service_agent_conversation_commune():
js = (RACINE / "static/js/draco-panneau.js").read_text(encoding="utf-8")
# Même conversation continue que dans les autres apps : le
# panneau s'appuie sur l'API du service (contrat commons).
assert "/api/v1/me" in js
assert "/api/v1/chat" in js
assert "/api/v1/conversations" in js
# La configuration vient de l'hôte ; le seul repli est le nom
# officiel — aucun nom public d'app en dur dans le composant.
assert "DRACO_PANNEAU_CONFIG" in js
for nom in ("flowdeck-dev", "og-dev", "tiktak-dev", "kanbante-dev",
"agent-dev"):
assert f"://{nom}." not in js # aucune URL publique -dev codée en dur
def test_assets_panneau_servis(client):
for chemin in ("/static/js/draco-panneau.js", "/static/css/draco-panneau.css"):
r = client.get(chemin)
assert r.status_code == 200, chemin
def test_automatisations_locales_conservées(client):
# C5 : l'agent local de Flowdeck (automatisations, gouvernance)
# reste en place à côté du panneau commun.
r = client.get("/api/v2/agent-approvals", params={"status": "pending"})
assert r.status_code == 200
assert "approvals" in r.json()
@@ -0,0 +1,191 @@
"""Fusion ACC-6.2 — pilote d'écriture approuvée : archiver un résumé
de réunion Flowdeck dans ObsiGate via la file d'approbations unifiée.
Chaîne testée : demande déposée dans `agent_approvals` (outil
`archiver_reunion_obsigate`) → décision dans la file (route
gouvernance) → si approuvée, `propose_write_file` part vers ObsiGate
par le pont MCP (ACC-2.2) et la distribution est journalisée (canal
`obsigate`). Rejet = rien n'est envoyé. Sans connecteur ObsiGate,
l'exécution échoue proprement avec un message explicite.
La preuve réelle reste conditionnelle (chantier Meetings A/B +
clé ObsiGate dédiée de Bruno) ; ici le serveur MCP est simulé.
"""
import asyncio
import json
import pytest
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import mcp_client, meeting_archive, meetings
from app.services.importers import url_fetch
@pytest.fixture(autouse=True)
def _dns_monde_reel(monkeypatch):
monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip())
monkeypatch.setattr(mcp_client, "_sessions", {})
@pytest.fixture
def admin_cookies():
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin)"
" VALUES (1, 'tester', 'Tester', 1)")
conn.commit()
session = SessionManager.create_session(
{"id": 1, "login": "tester", "is_admin": 1})
return {"flowdeck_session": session}
def _transcript_avec_resume():
with get_conn() as conn:
pid = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format)"
" VALUES ('test', 'Réunion hebdo', '', 'blocks')").lastrowid
tid = conn.execute(
"INSERT INTO meeting_transcripts (page_id, summary)"
" VALUES (?, ?)",
(pid, "## Décisions\n\n- Lancer le pilote d'archivage.\n"
"- Revoir le pont MCP la semaine prochaine.")).lastrowid
conn.commit()
meetings._update(tid, generated_title="Hebdo produit", status="done")
return tid
def _creer_connecteur_obsigate(client):
r = client.post("/api/agent/connectors", json={
"name": "ObsiGate", "kind": "obsigate",
"url": "https://og.dracodev.net/mcp",
"secret": "og_cle_pont_test", "auth": "bearer"})
assert r.status_code == 200, r.text
return r.json()["id"]
def _faux_rpc_obsigate(appels):
async def fake_rpc(url, payload, headers=None):
appels.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return {"result": {"protocolVersion": "2025-03-26",
"serverInfo": {"name": "obsigate"}}}
if method == "tools/call":
params = payload.get("params") or {}
appels.append((params.get("name"), params.get("arguments")))
return {"result": {"content": [
{"type": "text", "text": json.dumps({"draft_id": "b-42"})}]}}
return {}
return fake_rpc
def test_demande_approbation_et_gardes(client, admin_cookies):
tid = _transcript_avec_resume()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
assert r.status_code == 201, r.text
body = r.json()
assert body["titre"] == "Hebdo produit"
assert body["chemin"].startswith("Réunions/")
assert body["chemin"].endswith("Hebdo produit.md")
approval = body["approval"]
assert approval["tool"] == "archiver_reunion_obsigate"
assert approval["status"] == "pending"
assert json.loads(approval["args_json"])["transcript_id"] == tid
# Doublon refusé tant que la demande est en attente.
r2 = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
assert r2.status_code == 400
def test_resume_vide_refuse(client, admin_cookies):
with get_conn() as conn:
pid = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format)"
" VALUES ('test', 'Vide', '', 'blocks')").lastrowid
tid = conn.execute(
"INSERT INTO meeting_transcripts (page_id) VALUES (?)",
(pid,)).lastrowid
conn.commit()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
assert r.status_code == 400
assert "Résumé vide" in r.text
def test_approbation_execute_la_proposition(client, admin_cookies, monkeypatch):
_creer_connecteur_obsigate(client)
appels: list = []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels))
tid = _transcript_avec_resume()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
aid = r.json()["approval"]["id"]
d = client.post(f"/api/v2/agent-approvals/{aid}/decide",
cookies=admin_cookies, json={"approve": True})
assert d.status_code == 200, d.text
body = d.json()
assert body["status"] == "approved"
assert body["archive"]["status"] == "ok", body["archive"]
assert "Réunions/" in body["archive"]["detail"]
# propose_write_file a reçu le résumé, jamais un apply direct.
calls = [a for a in appels if isinstance(a, tuple)]
assert len(calls) == 1 and calls[0][0] == "propose_write_file"
assert "Lancer le pilote" in calls[0][1]["content"]
assert calls[0][1]["path"].endswith("Hebdo produit.md")
# Distribution journalisée, canal obsigate.
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM meeting_distributions WHERE transcript_id=?",
(tid,)).fetchall()
assert len(rows) == 1
assert rows[0]["channel"] == "obsigate" and rows[0]["status"] == "propose"
def test_rejet_ne_envoie_rien(client, admin_cookies, monkeypatch):
_creer_connecteur_obsigate(client)
appels: list = []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels))
tid = _transcript_avec_resume()
aid = meeting_archive.demander_approbation(tid, 1)["id"]
d = client.post(f"/api/v2/agent-approvals/{aid}/decide",
cookies=admin_cookies, json={"approve": False})
assert d.json()["archive"]["status"] == "rejete"
assert not [a for a in appels if isinstance(a, tuple)]
with get_conn() as conn:
n = conn.execute(
"SELECT COUNT(*) AS n FROM meeting_distributions"
" WHERE transcript_id=?", (tid,)).fetchone()["n"]
assert n == 0
def test_sans_connecteur_erreur_explicite(client, admin_cookies):
tid = _transcript_avec_resume()
aid = meeting_archive.demander_approbation(tid, 1)["id"]
d = client.post(f"/api/v2/agent-approvals/{aid}/decide",
cookies=admin_cookies, json={"approve": True})
archive = d.json()["archive"]
assert archive["status"] == "erreur"
assert "Connecteur ObsiGate non configuré" in archive["detail"]
def test_construire_archive_contenu(client):
tr = meetings.get_transcript(_transcript_avec_resume())
archive = meeting_archive.construire_archive(tr)
assert archive["chemin"].startswith("Réunions/")
assert "# Hebdo produit" in archive["contenu"]
assert "Lancer le pilote" in archive["contenu"]
assert "/pages/" in archive["contenu"] # lien profond vers la note
def test_executer_async_direct(client, monkeypatch):
appels: list = []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels))
tid = _transcript_avec_resume()
approval = meeting_archive.demander_approbation(tid, 1)
# Sans connecteur : erreur propre, pas d'exception.
out = asyncio.run(meeting_archive.executer(approval))
assert out["status"] == "erreur"