Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b536c865 | ||
|
|
a3431b18c9 | ||
|
|
45940c26bb | ||
|
|
f827e9fc7d | ||
|
|
cd0835116d | ||
|
|
b751c81120 | ||
|
|
30387c4ed4 | ||
|
|
4f13068c85 | ||
|
|
417c0a23d7 | ||
|
|
913d6137b1 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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]}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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.
|
||||||
@@ -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`).
|
||||||
@@ -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`).
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user