Compare commits

...
9 Commits
Author SHA1 Message Date
Spark eb4c42b0b5 7.81.0 — Panneau Spark V3 : la console en surimpression pleine page (panneau octet-identique à TikTak 0.60.0)
FlowDeck CI / lint (push) Failing after 1m31s
FlowDeck CI / test (push) Failing after 32m14s
FlowDeck CI / docker (push) Skipped
2026-10-23 04:06:11 +00:00
Spark 73b3fbce6e fix(version): /api/health lit la version depuis le fichier VERSION — fin de la constante 7.74.3 périmée (7.80.2)
FlowDeck CI / test (push) Failing after 29m53s
FlowDeck CI / lint (push) Failing after 1m30s
FlowDeck CI / docker (push) Skipped
2026-10-22 17:41:24 -04:00
Spark c7da7002ca Panneau Spark : bouton « Console ↗ » vers la console du service (7.80.1)
FlowDeck CI / test (push) Failing after 30m58s
FlowDeck CI / lint (push) Failing after 1m31s
FlowDeck CI / docker (push) Skipped
2026-10-22 21:17:39 +00:00
Spark 851a7b17d2 7.80.0 — Émission d'événements vers le journal du service d'agents (Lot 2)
FlowDeck CI / lint (push) Failing after 1m29s
FlowDeck CI / test (push) Failing after 29m9s
FlowDeck CI / docker (push) Skipped
2026-10-22 20:25:36 +00:00
Spark e459b0f9b2 7.79.0 — Panneau Spark v2 (Lot 1)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- draco-panneau vendangé v2 (identique dans les 4 apps) : Markdown
  rendu par le service, largeur réglable, thème aligné, discussions
  façon Muse (principale + parallèles)
- 25 tests du périmètre verts
2026-10-22 11:50:29 -04:00
Spark 133156580c 7.78.0 — MCP : périmètre du propriétaire du jeton
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
list_workspaces/get_workspace filtrés par user_workspace_ids ;
collections filtrées par workspace effectif ; hors périmètre =
introuvable, lectures et écritures. Test deux comptes.
2026-10-22 09:22:19 -04:00
Spark f6782ee5b3 7.77.0 — corbeille réparée (FK favorites/recents) + MCP workspaces
FlowDeck CI / lint (push) Failing after 1m28s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h18m54s
- detach_page_references : nettoyage des références NO ACTION avant
  toute suppression définitive (unitaire, Empty Trash, purge 30 j)
- MCP : list_workspaces / get_workspace ; descriptions collections
  clarifiées (bases de données, pas des workspaces)
- 96 tests verts sur le périmètre (MCP, trash, cohérence)
2026-10-22 09:03:43 -04:00
Spark 215990adc0 doc : entrée CHANGELOG 7.76.1
FlowDeck CI / lint (push) Failing after 1m28s
FlowDeck CI / test (push) Failing after 22m38s
FlowDeck CI / docker (push) Skipped
2026-10-22 08:42:52 -04:00
Spark fae335f808 7.76.1 — correctif : /mcp exempté du CSRF (client Bearer machine-à-machine)
FlowDeck CI / lint (push) Failing after 1m28s
FlowDeck CI / test (push) Failing after 22m55s
FlowDeck CI / docker (push) Skipped
Le middleware CSRF rejetait tout POST /mcp en 403 avant la route ;
les tests passaient car leur client porte session + CSRF. Test de
régression : client anonyme Bearer seul (200 avec jeton, 401 sans).
2026-10-22 08:42:42 -04:00
20 changed files with 1449 additions and 104 deletions
+135
View File
@@ -1,5 +1,140 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.81.0 (2026-10-22) — Panneau Spark V3 : la console en surimpression pleine page
- **Fini le nouvel onglet** (demande de Bruno) : le bouton
« Console ↗ » du panneau Spark devient « Console ⤢ » et ouvre
la console du service d'agents **dans la même page**, en
surimpression qui naît à la géométrie du panneau (ancrée à
droite, sa largeur) puis **s'étend en ~300 ms jusqu'à recouvrir
toute la fenêtre**. L'URL profonde est inchangée
(`?disc=<discussion affichée>`, `?app=flowdeck`).
- **Barre d'outils de la surimpression** : « ◧ Mode sidebar »
(animation inverse, retour au panneau latéral resté ouvert
dessous) et « ✕ Fermer l'agent » (referme surimpression **et**
panneau). Échap hors iframe = « Mode sidebar ».
- **Iframe créé paresseusement et conservé** : chargé au premier
clic, puis masqué/réaffiché sans rechargement — l'état de la
console persiste entre ouvertures. `allow="microphone"` pour
la dictée encadrée (aucune Permissions-Policy de Flowdeck ne
bloque le micro, vérifié) ; côté service, la 0.20.0 autorise
l'encadrement par les seules origines des 4 applications
(CSP `frame-ancestors`).
- La surimpression vit entièrement dans le fichier panneau
(DOM + styles injectés, thème synchronisé) ; seul
`static/js/draco-panneau.js` change ici, **octet-identique**
au canonique TikTak 0.60.0 (md5 commun vérifié). Kanbante et
ObsiGate suivront en vague 2 du panneau V3.
## v7.80.2 (2026-10-22) — /api/health : version lue depuis le fichier VERSION
- La version de l'app FastAPI était une constante codée en dur
dans `app/main.py` (restée à **7.74.3**) : `/api/health`
affichait donc une version périmée alors que l'app déployée
était en 7.80.1 (signalé par Bruno). Elle est maintenant lue
depuis le fichier `VERSION` à la racine (source unique, même
repli `/app/VERSION` que le tableau de bord) — elle ne pourra
plus dériver.
## v7.80.1 (2026-10-22) — Panneau Spark : bouton « Console ↗ »
- **Bouton « Console ↗ »** dans la barre des discussions du
panneau Spark (demande de Bruno) : ouvre la console web du
service d'agents (https://agent.dracodev.net/) dans un nouvel
onglet, sur la discussion actuellement affichée (`?disc=<id>`,
URL construite au clic) et avec l'app d'origine
(`?app=flowdeck`, déduite du sous-domaine). Panneau recopié
octet-identique dans les 4 apps (TikTak 0.59.1, Flowdeck
7.80.1, Kanbante 0.28.1, ObsiGate 2.76.1).
## v7.80.0 (2026-10-22) — Émission d'événements vers le journal du service d'agents (Lot 2)
- **Nouveau module `app/services/agent_evenements.py`** : Flowdeck
émet ses mutations significatives vers le journal d'activité du
service d'agents (`POST /api/v1/evenements`, contrat ACC-0.2) :
enveloppe {id, type, occurred_at, actor, domain, source_app,
object{ref, label, url}} — une référence et un libellé, jamais le
contenu — signée **HMAC-SHA256** (en-tête `X-Draco-Signature`)
avec la clé propre à Flowdeck. Envoi en fil d'arrière-plan, délai
maximal 2 s, erreurs avalées : **inactif tant que
`AGENT_EVENEMENTS_CLE` n'est pas posée** dans l'environnement, et
une panne du service ne casse ni ne ralentit jamais le métier.
- **Événements émis** : `flowdeck.page.created` /
`flowdeck.page.updated` / `flowdeck.page.deleted` et
`flowdeck.collection.created`, branchés sur les surfaces réelles
de mutation : API de session (pages du board, pages et
collections des bases), API publique v2 (acteur « user ») et
outils MCP `create_page` / `update_page` / `create_collection`
(acteur « agent:mcp », dans le périmètre du jeton). Références
`page:<id>` / `collection:<id>`, liens profonds vers la page
(`/board/pages/<id>`) ou la vue de sa collection.
- **Aucun événement meeting** : la décision A/B sur le bloc
meeting reste ouverte, le sujet est explicitement exclu.
## v7.79.0 (2026-10-22) — Panneau Spark v2 (Lot 1)
- **Panneau Spark** (`draco-panneau.js/css`, vendangés dans les 4
apps) : réponses de l'agent en **Markdown rendu** (HTML du
service d'agents 0.10.0, sanitizé côté serveur), rapports de
missions compris ; **largeur réglable** persistée ; **thème
clair/sombre aligné** sur le thème de Flowdeck
(`html[data-theme]`, suivi en direct) ; **discussions façon
Muse** : discussion principale perpétuelle + parallèles
créées/archivées/supprimées depuis le tiroir du panneau.
## v7.78.0 (2026-10-22) — MCP : périmètre du jeton (enfin)
### Corrigé
- **`list_workspaces` rendait les 52 workspaces de la base** — tous
les comptes et les résidus de tests : le MCP vérifiait le jeton
(qui tu es) mais n'appliquait aucun périmètre (ce que ce compte a
le droit de voir). Cas réel de Bruno, quelques heures après la
7.77.0. Désormais : workspaces du propriétaire du jeton
(propriétaire ou membre, via `user_workspace_ids`), collections
filtrées par leur workspace effectif (direct ou via la page hôte ;
les collections sans rattachement restent visibles, comme dans
l'API publique historique), et tout objet hors périmètre répond
« introuvable » — lectures **et** écritures. Test de régression
à deux comptes.
## v7.77.0 (2026-10-22) — Corbeille réparée + MCP : les workspaces
### Corrigé
- **La suppression définitive depuis la corbeille échouait (500)**
pour toute page réelle : `favorites.page_id` et `recents.page_id`
référencent `pages(id)` sans CASCADE, avec les clés étrangères
actives — un `IntegrityError` dès que la page avait été ouverte
ou mise en favori. La purge automatique des 30 jours mourait sur
la même erreur, en silence. Nouveau nettoyage partagé
`detach_page_references`, appliqué aux trois chemins (suppression
unitaire, Empty Trash, purge planifiée). Cas réel de Bruno :
4 pages impossibles à supprimer. Test de régression ajouté.
### Ajouté
- **MCP : `list_workspaces` et `get_workspace`** — les workspaces
(conteneurs de pages : Techno, Famille Charest…) sont enfin
exposés au service d'agents, et les descriptions des outils
collections disent désormais clairement qu'une collection est une
base de données, pas un workspace (la réponse « 17 collections
(espaces de travail) » du panneau a induit Bruno en erreur).
## v7.76.1 (2026-10-22) — Correctif : le MCP était bloqué par le CSRF
### Corrigé
- **Tout client MCP réel recevait 403 sur `initialize`** : le
middleware CSRF exigeait un jeton de session pour tout POST hors
préfixes exemptés, et `/mcp` n'y était pas — alors que c'est du
machine-à-machine authentifié par jeton d'API (Bearer), comme
`/scim/v2`. Les tests ne voyaient rien : leur client porte une
session et un CSRF. `/mcp` rejoint les préfixes exemptés du CSRF
(son authentification propre est inchangée : sans jeton valide,
toujours 401). Test de régression ajouté : client Bearer anonyme,
sans session ni CSRF.
## v7.76.0 (2026-10-21) — Fusion : serveur MCP en écriture ## v7.76.0 (2026-10-21) — Fusion : serveur MCP en écriture
### Ajouté ### Ajouté
+1 -1
View File
@@ -1 +1 @@
7.76.0 7.81.0
+25 -1
View File
@@ -190,9 +190,33 @@ async def lifespan(_app: FastAPI):
pass pass
def _lire_version() -> str:
"""Version de l'app depuis le fichier VERSION (source unique).
Avant : une constante codée en dur dans FastAPI(...) — restée à
7.74.3 alors que l'app était en 7.80.1, donc /api/health mentait.
Même logique de repli que dashboard._common._get_app_version.
"""
import os
for chemin in (
os.path.join(os.path.dirname(__file__), "..", "VERSION"),
"/app/VERSION",
):
try:
if os.path.exists(chemin):
with open(chemin) as f:
v = f.read().strip()
if v:
return v
except Exception:
pass
return "0.0.0"
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.74.3", version=_lire_version(),
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+6 -1
View File
@@ -20,6 +20,11 @@ class CSRFMiddleware(BaseHTTPMiddleware):
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not # NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token. # apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
# Même logique pour ``/mcp`` (Fusion, 7.76.1) : le serveur MCP est
# machine-à-machine, authentifié par les jetons d'API personnels
# (Bearer) — le service d'agents ne peut pas envoyer de X-CSRF-Token.
# Sans cette exemption, tout POST /mcp mourait en 403 avant la route
# (les tests passaient : leur client porte session + CSRF).
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté. # A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token` # Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace, # (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
@@ -30,7 +35,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
# - publics : /s/ (sites), /f/ (forms) # - publics : /s/ (sites), /f/ (forms)
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error # - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
EXCLUDED_PATHS = { EXCLUDED_PATHS = {
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/mcp",
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn", "/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error", "/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
+12
View File
@@ -102,6 +102,9 @@ def create_collection_v2(request: Request, authorization: str | None = Header(de
conn.commit() conn.commit()
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone() row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
audit_log(user, "collection.create", "collection", cid, name, request) audit_log(user, "collection.create", "collection", cid, name, request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{cid}", name,
url=url_collection(cid), actor="user")
data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)} data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)}
key = (request.headers.get("Idempotency-Key") or "").strip() key = (request.headers.get("Idempotency-Key") or "").strip()
if key: if key:
@@ -349,6 +352,9 @@ def create_collection_page_v2(collection_id: int, request: Request, authorizatio
conn.commit() conn.commit()
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone() row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
audit_log(user, "page.create", "collection_page", pid, title, request) audit_log(user, "page.create", "collection_page", pid, title, request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{pid}", title,
url=url_collection(collection_id), actor="user")
try: try:
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})) run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
except Exception: except Exception:
@@ -426,6 +432,9 @@ def patch_page_v2(page_id: int, request: Request, authorization: str | None = He
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id)) conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
conn.commit() conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request) audit_log(user, "page.update", "collection_page", page_id, "", request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(row["collection_id"]), actor="user")
try: try:
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})) run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
except Exception: except Exception:
@@ -444,6 +453,9 @@ def delete_page_v2(page_id: int, request: Request, authorization: str | None = H
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
conn.commit() conn.commit()
audit_log(user, "page.delete", "collection_page", page_id, "", request) audit_log(user, "page.delete", "collection_page", page_id, "", request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"],
url=url_collection(row["collection_id"]), actor="user")
try: try:
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})) run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
except Exception: except Exception:
+3
View File
@@ -86,6 +86,9 @@ def delete_page(request: Request, page_id: int):
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,)) conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
conn.commit() conn.commit()
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})) run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
from app.services.agent_evenements import emettre, url_page
emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"] or "",
url=url_page(page_id), actor="user")
return {"status": "ok", "deleted": page_id, "title": row["title"]} return {"status": "ok", "deleted": page_id, "title": row["title"]}
+8
View File
@@ -55,6 +55,9 @@ def create_page(request: Request, title: str = Query(default=""),
page_id = cur.lastrowid page_id = cur.lastrowid
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title, run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
"workspace": ws_key, "parent_id": parent_id})) "workspace": ws_key, "parent_id": parent_id}))
from app.services.agent_evenements import emettre, url_page
emettre("flowdeck.page.created", f"page:{page_id}", page_title,
url=url_page(page_id), actor="user")
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id} return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
except Exception as e: except Exception as e:
logger.error("create_page failed: %s", e) logger.error("create_page failed: %s", e)
@@ -109,9 +112,14 @@ def update_page(request: Request, page_id: int, title: str = Query(default=""),
if content_format: if content_format:
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id)) conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
conn.commit() conn.commit()
trow = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
page_label = (trow["title"] if trow else title) or ""
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title, run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
"content_format": content_format or "markdown", "content_format": content_format or "markdown",
"actor_id": user.get("id")})) "actor_id": user.get("id")}))
from app.services.agent_evenements import emettre, url_page
emettre("flowdeck.page.updated", f"page:{page_id}", page_label,
url=url_page(page_id), actor="user")
return {"status": "ok"} return {"status": "ok"}
+3
View File
@@ -16,6 +16,7 @@ from app.services.collection_lifecycle import (
collections_hosted_by_page, collections_hosted_by_page,
collections_hosted_by_pages, collections_hosted_by_pages,
delete_collections, delete_collections,
detach_page_references,
) )
from app.services.publish import fire_published, fire_unpublished, publish, unpublish from app.services.publish import fire_published, fire_unpublished, publish, unpublish
@@ -199,6 +200,7 @@ def permanent_delete(request: Request, page_id: int):
# tâches sans document. # tâches sans document.
collections = collections_hosted_by_page(conn, page_id) collections = collections_hosted_by_page(conn, page_id)
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
detach_page_references(conn, [page_id])
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
delete_collections(conn, collections) delete_collections(conn, collections)
conn.commit() conn.commit()
@@ -229,6 +231,7 @@ def empty_trash(request: Request):
"UPDATE pages SET parent_id=NULL WHERE parent_id IN " "UPDATE pages SET parent_id=NULL WHERE parent_id IN "
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)" "(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
) )
detach_page_references(conn, trashed)
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL") conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
delete_collections(conn, collections) delete_collections(conn, collections)
conn.commit() conn.commit()
+3
View File
@@ -118,6 +118,9 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
conn.commit() conn.commit()
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})) run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{collection_id}", name,
url=url_collection(collection_id), actor="user")
return {"id": collection_id, "name": name, "status": "created"} return {"id": collection_id, "name": name, "status": "created"}
+3
View File
@@ -119,4 +119,7 @@ def create_page_api(request: Request, collection_id: int, body: dict = Body(defa
"collection_id": collection_id, "collection_id": collection_id,
"title": title, "title": title,
})) }))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{page_id}", title,
url=url_collection(collection_id), actor="user")
return {"id": page_id, "title": title, "status": "created"} return {"id": page_id, "title": title, "status": "created"}
+6
View File
@@ -149,6 +149,9 @@ def update_page_api(request: Request, page_id: int, body: dict = Body(default={}
user = _current_user(request) user = _current_user(request)
notify_assignment(existing["collection_id"], page_id, title, notify_assignment(existing["collection_id"], page_id, title,
stored, props, user.get("id")) stored, props, user.get("id"))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(existing["collection_id"]), actor="user")
return {"id": page_id, "status": "updated"} return {"id": page_id, "status": "updated"}
@@ -178,6 +181,9 @@ def delete_page_api(request: Request, page_id: int):
"page_id": page_id, "page_id": page_id,
"collection_id": existing["collection_id"], "collection_id": existing["collection_id"],
})) }))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.deleted", f"page:{page_id}", existing["title"],
url=url_collection(existing["collection_id"]), actor="user")
return {"id": page_id, "status": "deleted"} return {"id": page_id, "status": "deleted"}
+94 -16
View File
@@ -10,9 +10,11 @@ Authentification : les jetons d'API personnels existants
Le service d'agents reçoit un jeton dédié créé par l'utilisateur ; Le service d'agents reçoit un jeton dédié créé par l'utilisateur ;
il lit et écrit en son nom. il lit et écrit en son nom.
Objet exposé : les **collections** (les projets/espaces de travail Objets exposés : les **workspaces** (conteneurs de pages) et leurs
de Flowdeck) et leurs pages, la recherche par titre, et le registre pages, les **collections** (bases de données type Notion — ne pas
des dépôts (table ``projects``). Doctrine du programme : Flowdeck confondre : une collection vit dans un workspace ou une page), la
recherche par titre, et le registre des dépôts (table ``projects``).
Doctrine du programme : Flowdeck
référence les tâches, il ne les copie pas — aucune tâche TikTak ne référence les tâches, il ne les copie pas — aucune tâche TikTak ne
transite par ici. Écritures limitées à la création/mise à jour de transite par ici. Écritures limitées à la création/mise à jour de
pages et à la création de collections ; aucune suppression. pages et à la création de collections ; aucune suppression.
@@ -26,13 +28,33 @@ from fastapi.responses import JSONResponse, Response
from app.db import get_conn from app.db import get_conn
from app.routers.public_api import _token_owner from app.routers.public_api import _token_owner
from app.services.collection_lifecycle import (
effective_workspace_sql,
user_workspace_ids,
)
router = APIRouter(tags=["mcp"]) router = APIRouter(tags=["mcp"])
OUTILS = [ OUTILS = [
{"name": "list_workspaces",
"description": "Liste les WORKSPACES Flowdeck du propriétaire du jeton "
"(propriétaire ou membre) — ses conteneurs de pages "
"(ex. Techno, Famille Charest), avec leur nombre de pages "
"actives. À ne pas confondre avec les collections "
"(bases de données).",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_workspace",
"description": "Lit un workspace et l'arborescence de ses pages "
"(titres, parent, section) — pages classiques, pas les "
"lignes des collections.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "list_collections", {"name": "list_collections",
"description": "Liste les collections Flowdeck (projets / espaces de travail) " "description": "Liste les COLLECTIONS Flowdeck — les bases de données "
"avec leur nombre de pages.", "de type Notion (tables, CRM, calendriers, formulaires), "
"avec leur nombre de lignes. Ce ne sont PAS les workspaces : "
"une collection vit dans un workspace ou dans une page.",
"inputSchema": {"type": "object", "properties": {}}}, "inputSchema": {"type": "object", "properties": {}}},
{"name": "get_collection", {"name": "get_collection",
"description": "Lit une collection et ses pages (titres et propriétés).", "description": "Lit une collection et ses pages (titres et propriétés).",
@@ -95,21 +117,65 @@ def _page_resume(row) -> dict:
"updated_at": row["updated_at"]} "updated_at": row["updated_at"]}
def _appel_outil(nom: str, args: dict): def _appel_outil(nom: str, args: dict, user_id: int):
"""Exécute un outil DANS LE PÉRIMÈTRE du propriétaire du jeton.
Correctif 7.78.0 (cas réel de Bruno) : la première version listait
les 52 workspaces de la base — tous les comptes, résidus de tests
compris. Règle : workspaces dont l'utilisateur est propriétaire
ou membre ; collections rattachées (directement ou via leur page
hôte) à l'un de ces workspaces, plus les collections sans aucun
rattachement (héritage global, comme l'API publique historique).
Un objet hors périmètre répond « introuvable », sans fuiter son
existence.
"""
with get_conn() as conn: with get_conn() as conn:
accessible = set(user_workspace_ids(conn, user_id))
eff_sql = effective_workspace_sql("c")
def collection_visible(coll_id: int) -> bool:
row = conn.execute(
f"SELECT {eff_sql} AS eff FROM collections c WHERE c.id = ?",
(coll_id,)).fetchone()
if row is None:
return False
return row["eff"] is None or row["eff"] in accessible
if nom == "list_workspaces":
rows = conn.execute(
"SELECT w.id, w.name, w.owner_id, "
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id "
" AND p.deleted_at IS NULL) AS page_count "
"FROM workspaces w ORDER BY w.name").fetchall()
return [dict(r) for r in rows if r["id"] in accessible]
if nom == "get_workspace":
ws = conn.execute("SELECT * FROM workspaces WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
if ws is None or ws["id"] not in accessible:
return {"erreur": "workspace introuvable"}
pages = conn.execute(
"SELECT id, title, parent_id, parent_section, updated_at "
"FROM pages WHERE workspace_id = ? AND deleted_at IS NULL "
"ORDER BY parent_id, title LIMIT 300", (ws["id"],)).fetchall()
return {"workspace": {"id": ws["id"], "name": ws["name"],
"owner_id": ws["owner_id"]},
"pages": [dict(p) for p in pages]}
if nom == "list_collections": if nom == "list_collections":
rows = conn.execute( rows = conn.execute(
"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p " f"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
"WHERE p.collection_id = c.id) AS page_count " f"WHERE p.collection_id = c.id) AS page_count, "
f"{eff_sql} AS eff_ws "
"FROM collections c ORDER BY c.name").fetchall() "FROM collections c ORDER BY c.name").fetchall()
return [{"id": r["id"], "name": r["name"], return [{"id": r["id"], "name": r["name"],
"description": r["description"], "icon": r["icon"], "description": r["description"], "icon": r["icon"],
"is_task": bool(r["is_task"]) if "is_task" in r.keys() else False, "is_task": bool(r["is_task"]) if "is_task" in r.keys() else False,
"page_count": r["page_count"]} for r in rows] "page_count": r["page_count"]}
for r in rows
if r["eff_ws"] is None or r["eff_ws"] in accessible]
if nom == "get_collection": if nom == "get_collection":
coll = conn.execute("SELECT * FROM collections WHERE id = ?", coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(int(args.get("id", 0)),)).fetchone() (int(args.get("id", 0)),)).fetchone()
if coll is None: if coll is None or not collection_visible(coll["id"]):
return {"erreur": "collection introuvable"} return {"erreur": "collection introuvable"}
pages = conn.execute( pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id = ? " "SELECT * FROM collection_pages WHERE collection_id = ? "
@@ -121,7 +187,7 @@ def _appel_outil(nom: str, args: dict):
if nom == "get_page": if nom == "get_page":
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?", p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(int(args.get("id", 0)),)).fetchone() (int(args.get("id", 0)),)).fetchone()
if p is None: if p is None or not collection_visible(p["collection_id"]):
return {"erreur": "page introuvable"} return {"erreur": "page introuvable"}
res = _page_resume(p) res = _page_resume(p)
for cle in ("content", "blocks", "blocks_json"): for cle in ("content", "blocks", "blocks_json"):
@@ -135,7 +201,8 @@ def _appel_outil(nom: str, args: dict):
rows = conn.execute( rows = conn.execute(
"SELECT * FROM collection_pages WHERE title LIKE ? " "SELECT * FROM collection_pages WHERE title LIKE ? "
"ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall() "ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall()
return [_page_resume(p) for p in rows] return [_page_resume(p) for p in rows
if collection_visible(p["collection_id"])]
if nom == "list_repos": if nom == "list_repos":
rows = conn.execute( rows = conn.execute(
"SELECT id, name, proj_type, owner, language, description " "SELECT id, name, proj_type, owner, language, description "
@@ -151,7 +218,7 @@ def _appel_outil(nom: str, args: dict):
return {"erreur": "titre vide"} return {"erreur": "titre vide"}
coll = conn.execute("SELECT * FROM collections WHERE id = ?", coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(collection_id,)).fetchone() (collection_id,)).fetchone()
if coll is None: if coll is None or not collection_visible(collection_id):
return {"erreur": "collection introuvable"} return {"erreur": "collection introuvable"}
props = args.get("properties") props = args.get("properties")
if props is None: if props is None:
@@ -170,6 +237,9 @@ def _appel_outil(nom: str, args: dict):
conn.commit() conn.commit()
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?", page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(cur.lastrowid,)).fetchone() (cur.lastrowid,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{page['id']}", title,
url=url_collection(collection_id), actor="agent:mcp")
return _page_resume(page) return _page_resume(page)
if nom == "update_page": if nom == "update_page":
try: try:
@@ -178,7 +248,7 @@ def _appel_outil(nom: str, args: dict):
return {"erreur": "id requis (entier)"} return {"erreur": "id requis (entier)"}
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?", page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone() (page_id,)).fetchone()
if page is None: if page is None or not collection_visible(page["collection_id"]):
return {"erreur": "page introuvable"} return {"erreur": "page introuvable"}
title = page["title"] title = page["title"]
if "title" in args and args.get("title") is not None: if "title" in args and args.get("title") is not None:
@@ -198,6 +268,9 @@ def _appel_outil(nom: str, args: dict):
conn.commit() conn.commit()
updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?", updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone() (page_id,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(page["collection_id"]), actor="agent:mcp")
return _page_resume(updated) return _page_resume(updated)
if nom == "create_collection": if nom == "create_collection":
name = str(args.get("name") or "").strip() name = str(args.get("name") or "").strip()
@@ -220,6 +293,9 @@ def _appel_outil(nom: str, args: dict):
conn.commit() conn.commit()
coll = conn.execute("SELECT * FROM collections WHERE id = ?", coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(new_id,)).fetchone() (new_id,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{new_id}", name,
url=url_collection(new_id), actor="agent:mcp")
return {"id": coll["id"], "name": coll["name"], return {"id": coll["id"], "name": coll["name"],
"description": coll["description"], "icon": coll["icon"]} "description": coll["description"], "icon": coll["icon"]}
return {"erreur": f"outil inconnu : {nom}"} return {"erreur": f"outil inconnu : {nom}"}
@@ -245,7 +321,8 @@ def mcp_info():
async def mcp(request: Request): async def mcp(request: Request):
auth = request.headers.get("authorization", "") auth = request.headers.get("authorization", "")
token = auth[7:].strip() if auth.lower().startswith("bearer ") else "" token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
if not token or _token_owner(token) is None: proprietaire = _token_owner(token) if token else None
if proprietaire is None:
return JSONResponse( return JSONResponse(
{"jsonrpc": "2.0", "id": None, {"jsonrpc": "2.0", "id": None,
"error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}}, "error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}},
@@ -272,7 +349,8 @@ async def mcp(request: Request):
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}} return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}}
if methode == "tools/call": if methode == "tools/call":
params = data.get("params") or {} params = data.get("params") or {}
res = _appel_outil(params.get("name", ""), params.get("arguments") or {}) res = _appel_outil(params.get("name", ""), params.get("arguments") or {},
proprietaire["user_id"])
return {"jsonrpc": "2.0", "id": rid, return {"jsonrpc": "2.0", "id": rid,
"result": {"content": [{"type": "text", "result": {"content": [{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}} "text": json.dumps(res, ensure_ascii=False)}]}}
+120
View File
@@ -0,0 +1,120 @@
"""Émission d'événements vers le journal du service d'agents
(Fusion, Lot 2 — contrat ACC-0.2).
Flowdeck émet ses mutations significatives (pages et collections)
vers `POST /api/v1/evenements` du service d'agents, qui les expose
dans le « Journal d'aujourd'hui » de la console. L'enveloppe ne
contient qu'une référence, un libellé et un lien — jamais le
contenu de l'objet : {id, type, occurred_at, actor, domain,
source_app, object{ref, label, url}}.
Authentification : signature HMAC-SHA256 du corps brut, en-tête
`X-Draco-Signature`, avec la clé propre à Flowdeck
(`AGENT_EVENEMENTS_CLE`, jamais en dur). Sans clé, le module est
INACTIF : aucun appel, aucun bruit.
Discipline « au mieux » (même esprit que le journal du service) :
l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s,
et toute erreur est avalée avec une ligne de journal — une panne
du récepteur ne doit jamais casser ni ralentir le métier.
Aucun événement « meeting » n'est émis ici : la décision A/B de
Bruno sur le bloc meeting de Flowdeck est toujours ouverte.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import threading
import uuid
from datetime import datetime, timezone
import httpx
log = logging.getLogger("flowdeck.evenements")
SOURCE_APP = "flowdeck"
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
DELAI_S = 2.0
def _cle() -> str:
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
def _url() -> str:
return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT
def actif() -> bool:
"""L'émission n'existe que si la clé de Flowdeck est configurée."""
return bool(_cle())
def url_page(page_id: int) -> str:
"""Lien profond d'une page d'espace de travail (vue HTML du board)."""
return f"/board/pages/{page_id}"
def url_collection(collection_id: int) -> str:
"""Lien profond d'une collection (sa vue tableau). Une page de
collection n'a pas d'URL autonome : elle s'ouvre dans cette vue."""
return f"/db/{collection_id}/view/table"
def construire_enveloppe(type_: str, objet_ref: str, label: str,
url: str = "/", domain: str = "pro",
actor: str | None = None) -> tuple[dict, bytes]:
"""L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est
signé puis envoyé — la signature porte sur ces octets-là)."""
env = {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(timezone.utc).isoformat(),
"actor": actor or "user",
"domain": domain or "pro",
"source_app": SOURCE_APP,
"object": {"ref": objet_ref, "label": label or "", "url": url or "/"},
}
corps = json.dumps(env, ensure_ascii=False).encode("utf-8")
return env, corps
def _signer(corps: bytes) -> str:
return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest()
def _envoyer(corps: bytes) -> None:
"""Le POST signé, dans le fil d'arrière-plan. Avale tout : le
journal distant est un confort, jamais une dépendance."""
try:
with httpx.Client(timeout=DELAI_S, trust_env=False) as client:
client.post(
_url(), content=corps,
headers={"Content-Type": "application/json",
"X-Draco-Signature": _signer(corps)},
)
except Exception as exc: # panne, délai, DNS… : on log et on passe
log.warning("événement non livré au journal du service (%s)", exc)
def emettre(type_: str, objet_ref: str, label: str, url: str = "/",
domain: str = "pro", actor: str | None = None) -> bool:
"""Émet un événement en tâche de fond. Retourne True si l'envoi
a été déclenché, False si le module est inactif (pas de clé) ou
si l'enveloppe n'a pas pu être construite. Ne lève jamais."""
if not actif():
return False
try:
_env, corps = construire_enveloppe(type_, objet_ref, label, url,
domain, actor)
except Exception:
log.warning("événement %s non construit", type_, exc_info=True)
return False
threading.Thread(target=_envoyer, args=(corps,), daemon=True,
name="flowdeck-evenements").start()
return True
+20
View File
@@ -51,6 +51,26 @@ EFFECTIVE_WORKSPACE_SQL = (
) )
def detach_page_references(conn, page_ids) -> None:
"""Supprime les lignes qui référencent des pages en ``NO ACTION``.
``favorites.page_id`` et ``recents.page_id`` référencent
``pages(id)`` sans CASCADE et la connexion applique
``PRAGMA foreign_keys=ON`` : supprimer définitivement une page
réelle — ouverte un jour (recents) ou mise en favori — levait un
``IntegrityError`` (500 côté API, échec silencieux de la purge
automatique des 30 jours). À appeler avant tout
``DELETE FROM pages`` définitif. (Régression du 2026-10-22 :
les pages synthétiques des tests n'avaient ni favori ni récent.)
"""
ids = [int(i) for i in page_ids]
if not ids:
return
ph = ",".join("?" * len(ids))
conn.execute(f"DELETE FROM favorites WHERE page_id IN ({ph})", ids)
conn.execute(f"DELETE FROM recents WHERE page_id IN ({ph})", ids)
def effective_workspace_sql(alias: str = "c") -> str: def effective_workspace_sql(alias: str = "c") -> str:
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline).""" """Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
return EFFECTIVE_WORKSPACE_SQL.format(a=alias) return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
+2
View File
@@ -16,6 +16,7 @@ from app.db import get_conn
from app.services.collection_lifecycle import ( from app.services.collection_lifecycle import (
collections_hosted_by_page, collections_hosted_by_page,
delete_collections, delete_collections,
detach_page_references,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -71,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
"WHERE parent_id=?", "WHERE parent_id=?",
(page_id, page_id), (page_id, page_id),
) )
detach_page_references(conn, [page_id])
conn.execute("DELETE FROM pages WHERE id=?", (page_id,)) conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
delete_collections(conn, collections) delete_collections(conn, collections)
purged.append(page_id) purged.append(page_id)
+143 -37
View File
@@ -1,83 +1,189 @@
/* Panneau Spark — branchement au service d'agents (Fusion, jalon J1). /* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
Composant d'intégration TikTak : le panneau définitif sera vendangé Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
depuis dracodev-commons ; celui-ci prouve le chemin panneau → service. */ le JS le cale sur le thème de l'app hôte (html[data-theme], repli
prefers-color-scheme) et suit les bascules en direct. Toutes les
couleurs passent par les variables ci-dessous — en-tête et fenêtre
partagent donc toujours le même mode. */
.draco-panneau { .draco-panneau {
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 94vw; --p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
background: #fbfbfa; color: #242624; z-index: 400; --p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
--p-accent: #7c5cd6; --p-lien: #5b3df0;
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
background: var(--p-fond); color: var(--p-texte); z-index: 400;
box-shadow: -12px 0 36px rgb(0 0 0 / .22); box-shadow: -12px 0 36px rgb(0 0 0 / .22);
display: flex; flex-direction: column; display: flex; flex-direction: column;
transform: translateX(105%); transition: transform .28s ease; transform: translateX(105%); transition: transform .28s ease;
font: 14px/1.5 system-ui, "Segoe UI", sans-serif; font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
} }
.draco-panneau[data-draco-theme="dark"] {
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
--p-lien: #b39df5;
}
.draco-panneau.open { transform: translateX(0); } .draco-panneau.open { transform: translateX(0); }
.draco-poignee {
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
cursor: ew-resize; z-index: 5; touch-action: none;
}
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
@media (max-width: 700px) { .draco-poignee { display: none; } }
.draco-panneau-hd { .draco-panneau-hd {
display: flex; align-items: center; gap: 9px; display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
} }
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; } .draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
.draco-panneau-hd .soustitre { font-size: 12px; color: #6c7268; } .draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
.draco-panneau-hd .fermer { .draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: #eef0ea; border-radius: 8px; margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
width: 30px; height: 30px; cursor: pointer; font-size: 14px; border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
} }
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; } .draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; } .draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; } .draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; } .draco-panneau-etat.err .point { background: #cf4b32; }
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; } /* Barre des discussions (façon Muse) */
.draco-panneau-outils button { .draco-disc-bar {
border: 1px solid #d6d8d1; background: #f4f5f1; border-radius: 999px; display: flex; align-items: center; gap: 7px;
padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer; padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
} }
.draco-panneau-outils button:hover { background: #e9ebe4; } .draco-disc-bar .d-titre {
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-disc-bar .d-btn {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
}
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
.draco-discussions {
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
max-height: 46%; overflow-y: auto; padding: 8px 12px;
}
.draco-discussions .d-section {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
}
.draco-discussions .d-section:first-child { margin-top: 0; }
.draco-discussions .d-item {
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
}
.draco-discussions .d-item:hover { background: var(--p-chip); }
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
.draco-discussions .d-actions button {
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
}
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
.draco-discussions .d-vide, .draco-discussions .d-chargement {
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
}
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
.draco-panneau-outils button {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
}
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
.draco-appro { .draco-appro {
align-self: stretch; background: #fdf6ec; border: 1.6px solid #e8a13c; align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
border-radius: 12px; padding: 10px 12px; border-radius: 12px; padding: 10px 12px;
} }
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; } .draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
.draco-appro .resume { margin: 5px 0 8px; } .draco-appro .resume { margin: 5px 0 8px; }
.draco-appro .btns { display: flex; gap: 7px; } .draco-appro .btns { display: flex; gap: 7px; }
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; } .draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-appro .btns button.go { background: #2ea36b; color: #fff; } .draco-appro .btns button.go { background: #2ea36b; color: #fff; }
.draco-auj { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; } .draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 6px 0 4px; } .draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
.draco-auj h5:first-child { margin-top: 0; } .draco-auj h5:first-child { margin-top: 0; }
.draco-auj ul { margin: 0; padding-left: 17px; } .draco-auj ul { margin: 0; padding-left: 17px; }
.draco-auj .etat-source { color: #6c7268; font-size: 12.5px; } .draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; } .draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; } .draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; } .draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: #eef0ea; border-bottom-left-radius: 4px; } .draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
.draco-msg.sys { align-self: center; background: none; color: #6c7268; font-size: 12.5px; text-align: center; max-width: 96%; } .draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
.draco-panneau-ft { border-top: 1px solid #e4e5e0; padding: 11px 12px; background: #fff; } .draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
.draco-panneau-form { display: flex; gap: 8px; } .draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input { .draco-panneau-form input {
flex: 1; border: 1px solid #d6d8d1; border-radius: 10px; padding: 9px 11px; font: inherit; flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
font: inherit; background: var(--p-champ); color: var(--p-texte);
} }
.draco-panneau-form button { .draco-panneau-form button {
border: 0; border-radius: 10px; background: #7c5cd6; color: #fff; border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer; padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
} }
.draco-panneau-form button:disabled { opacity: .5; cursor: default; } .draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: #8a8f84; margin-top: 8px; text-align: center; } .draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; } .draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 0 0 6px; } .draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: #6c7268; margin: 8px 0 3px; } .draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
.draco-missions select, .draco-missions input, .draco-missions textarea { .draco-missions select, .draco-missions input, .draco-missions textarea {
width: 100%; box-sizing: border-box; border: 1px solid #d6d8d1; border-radius: 8px; width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
padding: 7px 9px; font: inherit; font-size: 13px; background: #fff; padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
} }
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; } .draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; } .draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-missions .btns button.go { background: #7c5cd6; color: #fff; } .draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; } .draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.draco-missions .etat-source { color: #6c7268; font-size: 12.5px; } .draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-missions .m-item { border: 1px solid #e4e5e0; border-radius: 10px; padding: 8px 10px; } .draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; } .draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: #6c7268; white-space: nowrap; } .draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
.draco-missions .m-statut.st-succeeded { color: #2ea36b; } .draco-missions .m-statut.st-succeeded { color: #2ea36b; }
.draco-missions .m-statut.st-failed { color: #cf4b32; } .draco-missions .m-statut.st-failed { color: #cf4b32; }
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; } .draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
.draco-missions .m-meta { font-size: 12px; color: #6c7268; } .draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; } .draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: #f6e3de; color: #a03a28; } .draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
.draco-msg.md code, .m-rapport.md code {
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
}
.draco-msg.md pre, .m-rapport.md pre {
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
overflow-x: auto; margin: 7px 0;
}
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
.draco-msg.md blockquote, .m-rapport.md blockquote {
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
color: var(--p-doux);
}
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
background: var(--p-chip);
}
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
.draco-msg.md table, .m-rapport.md table {
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
display: block; overflow-x: auto; max-width: 100%;
}
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
}
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
+483 -47
View File
@@ -1,43 +1,259 @@
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5). /* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
* *
* Parle à l'API du service d'agents (contrat de dracodev-commons) : * Parle à l'API du service d'agents (contrat de dracodev-commons) :
* GET /api/v1/me — identité (cookie draco_session) * GET /api/v1/me — identité (cookie draco_session)
* POST /api/v1/chat — conversation réelle du noyau (J2) : * POST /api/v1/chat — conversation réelle du noyau (J2) :
* LLM DeepSeek + outils MCP des apps * LLM DeepSeek + outils MCP des apps ;
* GET /api/v1/conversations/… — historique de la conversation * la réponse arrive avec son HTML
* GET /api/v1/executors — registre (noyau, sous-agents J3, * Markdown rendu côté service (v2)
* GET /api/v1/conversations — discussions façon Muse (v2) : une
* principale perpétuelle + des
* parallèles créées/archivées/
* supprimées par l'utilisateur
* GET /api/v1/conversations/… — historique d'une discussion (les
* messages agent portent leur html)
* GET /api/v1/executors — registre (noyau, sous-agents J3,
* externes J4 : état de configuration) * externes J4 : état de configuration)
* POST /api/v1/missions — missions longues (J5) : lancement, * POST /api/v1/missions — missions longues (J5)
* suivi, rapport, annulation * Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
* (persistée), thème clair/sombre aligné sur l'app hôte
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
* Bouton « Console » (barre des discussions) : ouvre la console web
* du même service (agent.dracodev.net) DANS la page, en surimpression
* pleine page (iframe) qui naît à la géométrie du panneau puis
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
* courante, ?app= app d'origine déduite du sous-domaine) est celle
* du premier chargement — l'iframe est créé paresseusement et
* conservé, la console garde son état entre ouvertures. La barre
* d'outils de la surimpression offre « Mode sidebar » (retour au
* panneau seul, animation inverse) et « Fermer l'agent » (referme
* surimpression et panneau).
* États honnêtes : service injoignable / session non reconnue / LLM non * États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */ * configuré / exécuteur non configuré sont affichés tels quels. */
(function () { (function () {
"use strict"; "use strict";
var API = "https://agent.dracodev.net"; var API = "https://agent.dracodev.net";
var convId = null; var convId = null;
try { convId = sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { /* stockage indispo */ } var convKind = null; // 'principale' | 'parallele'
var convTitre = "";
var etatConnu = false; var etatConnu = false;
var historiqueCharge = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null; var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
function domaine() { function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
} }
function stockerConv() {
try {
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
else sessionStorage.removeItem("draco_panneau_conv");
} catch (e) { /* stockage indisponible */ }
}
function convStockee() {
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
catch (e) { return null; }
}
/* ── Console web (même service, surimpression pleine page) ─── */
function appHote() {
var h = (window.location.hostname || "").split(".")[0];
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
kanbante: "kanbante", og: "obsigate" };
return correspondance[h] || null;
}
function urlConsole() {
var params = [];
var id = convId || convStockee();
if (id) params.push("disc=" + encodeURIComponent(id));
var app = appHote();
if (app) params.push("app=" + encodeURIComponent(app));
return API + "/" + (params.length ? "?" + params.join("&") : "");
}
/* La surimpression vit entièrement dans ce fichier (DOM + styles
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
* thème suit celui du panneau (data-draco-theme synchronisé par
* appliquerTheme) et sa palette reprend les mêmes valeurs que
* draco-panneau.css. L'iframe est créé au premier clic puis
* conservé : rouvrir la console ne le recharge pas. */
var surEl = null, surCadre = null, surOuverte = false;
var STYLES_CONSOLE =
".draco-console-sur{" +
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
"background:var(--p-fond);color:var(--p-texte);" +
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
"display:flex;flex-direction:column;transition:width .3s ease;" +
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
".draco-console-sur[data-draco-theme=\"dark\"]{" +
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
".draco-console-sur[hidden]{display:none}" +
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
function assurerSurimpression() {
if (surEl) return;
if (!document.getElementById("draco-console-styles")) {
var st = document.createElement("style");
st.id = "draco-console-styles";
st.textContent = STYLES_CONSOLE;
document.head.appendChild(st);
}
surEl = document.createElement("div");
surEl.className = "draco-console-sur";
surEl.setAttribute("aria-label", "Console Spark");
surEl.hidden = true;
surEl.innerHTML =
'<div class="draco-console-barre">' +
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
' <span class="dc-actions">' +
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
' </span></div>' +
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
document.body.appendChild(surEl);
surCadre = surEl.querySelector("iframe");
surEl.querySelector('[data-dc="sidebar"]')
.addEventListener("click", modeSidebarConsole);
surEl.querySelector('[data-dc="fermer"]')
.addEventListener("click", fermerAgentConsole);
appliquerTheme();
}
function largeurPanneau() {
if (root) {
var w = root.getBoundingClientRect().width;
if (w > 0) return Math.round(w);
}
return 388;
}
function ouvrirConsole() {
assurerSurimpression();
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
if (surOuverte) return;
surOuverte = true;
/* Naissance à la géométrie du panneau (ancrée à droite), puis
* extension à toute la page ; le double rAF laisse le temps de
* peindre la largeur de départ, sinon la transition ne joue pas. */
surEl.style.width = largeurPanneau() + "px";
surEl.hidden = false;
requestAnimationFrame(function () {
requestAnimationFrame(function () { surEl.style.width = "100%"; });
});
}
function retrecirConsole() {
if (!surEl || !surOuverte) return;
surOuverte = false;
var fini = false;
function terminer() {
if (fini) return;
fini = true;
surEl.removeEventListener("transitionend", surFin);
surEl.hidden = true; // l'iframe reste chargé, juste masqué
}
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
surEl.addEventListener("transitionend", surFin);
setTimeout(terminer, 380); // filet si transitionend ne vient pas
surEl.style.width = largeurPanneau() + "px";
}
function modeSidebarConsole() { retrecirConsole(); }
function fermerAgentConsole() { fermer(); retrecirConsole(); }
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
function themeHote() {
var t = document.documentElement.getAttribute("data-theme");
if (t === "dark" || t === "light") return t;
if (document.documentElement.classList.contains("dark") ||
document.body.classList.contains("dark")) return "dark";
try {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
} catch (e) { /* pas de matchMedia */ }
return "light";
}
function appliquerTheme() {
if (root) root.setAttribute("data-draco-theme", themeHote());
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
}
function surveillerTheme() {
try {
new MutationObserver(appliquerTheme).observe(document.documentElement,
{ attributes: true, attributeFilter: ["data-theme", "class"] });
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", appliquerTheme);
} catch (e) { /* surveillance facultative */ }
}
/* ── Largeur réglable ──────────────────────────────────────── */
function largeurStockee() {
try {
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
return isNaN(v) ? null : v;
} catch (e) { return null; }
}
function appliquerLargeur() {
if (!root) return;
var v = largeurStockee();
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
}
function brancherPoignee(poignee) {
poignee.addEventListener("pointerdown", function (ev) {
if (window.innerWidth <= 700) return; // mobile : pleine largeur
ev.preventDefault();
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
function bouger(e2) {
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
root.style.width = w + "px";
}
function lacher(e2) {
document.removeEventListener("pointermove", bouger);
document.removeEventListener("pointerup", lacher);
var w = Math.round(root.getBoundingClientRect().width);
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
}
document.addEventListener("pointermove", bouger);
document.addEventListener("pointerup", lacher);
});
poignee.addEventListener("dblclick", function () {
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
appliquerLargeur();
});
}
function construire() { function construire() {
root = document.createElement("aside"); root = document.createElement("aside");
root.className = "draco-panneau"; root.className = "draco-panneau";
root.setAttribute("aria-label", "Spark, l'agent commun"); root.setAttribute("aria-label", "Spark, l'agent commun");
root.innerHTML = root.innerHTML =
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
'<div class="draco-panneau-hd">' + '<div class="draco-panneau-hd">' +
' <span style="font-size:19px;color:#7c5cd6">✦</span>' + ' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
' <div><div class="titre">Spark — agent commun</div>' + ' <div><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' + ' <div class="soustitre">Service d\'agents dracodev</div></div>' +
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' + ' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
'</div>' + '</div>' +
'<div class="draco-panneau-hd draco-panneau-etat" style="border-bottom:1px solid #e4e5e0;padding-top:8px;padding-bottom:8px">' + '<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
' <span class="point"></span><span class="txt">Connexion au service…</span>' + ' <span class="point"></span><span class="txt">Connexion au service…</span>' +
'</div>' + '</div>' +
'<div class="draco-disc-bar">' +
' <span class="d-titre">Discussion principale</span>' +
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
'</div>' +
'<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' + '<div class="draco-panneau-outils">' +
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' + ' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
' <button type="button" data-outil="approbations">Approbations</button>' + ' <button type="button" data-outil="approbations">Approbations</button>' +
@@ -54,15 +270,30 @@
etatEl = root.querySelector(".draco-panneau-etat"); etatEl = root.querySelector(".draco-panneau-etat");
inputEl = root.querySelector("input"); inputEl = root.querySelector("input");
sendBtn = root.querySelector("button[type=submit]"); sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
root.querySelector(".fermer").addEventListener("click", fermer); root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui); root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations); root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions); root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer); root.querySelector("form").addEventListener("submit", envoyer);
brancherPoignee(root.querySelector(".draco-poignee"));
appliquerTheme();
surveillerTheme();
appliquerLargeur();
window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) { document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer(); if (ev.key !== "Escape") return;
/* Surimpression ouverte : Échap = retour au panneau seul
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
* console (autre origine), l'événement ne remonte pas
* jusqu'ici : il ne peut donc pas fermer par surprise. */
if (surOuverte) { modeSidebarConsole(); return; }
if (root.classList.contains("open")) fermer();
}); });
ajouterMsg("sys", "Conversation avec le service d'agents — domaine : " + domaine() + ".");
} }
function ajouterMsg(kind, texte) { function ajouterMsg(kind, texte) {
@@ -74,10 +305,26 @@
return d; return d;
} }
/* Message agent rendu en Markdown : le HTML vient du service
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
function ajouterMsgMd(texte, html) {
var d = document.createElement("div");
d.className = "draco-msg agent md";
if (html) d.innerHTML = html;
else d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
function majEtat(cls, texte) { function majEtat(cls, texte) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls; etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
etatEl.querySelector(".txt").textContent = texte; etatEl.querySelector(".txt").textContent = texte;
} }
function majTitre() {
titreEl.textContent = convTitre || "Discussion principale";
titreEl.title = titreEl.textContent;
}
function verifierIdentite() { function verifierIdentite() {
if (etatConnu) return; if (etatConnu) return;
@@ -109,6 +356,200 @@
}); });
} }
/* ── Discussions (façon Muse) ────────────────────────────── */
function chargerListeDiscussions() {
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
{ credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
}
function demarrerDiscussions() {
chargerListeDiscussions()
.then(function (d) {
var precedente = convStockee();
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
var active = trouvee || d.principale;
if (!active) return;
convId = active.id; convKind = active.kind; convTitre = active.title;
stockerConv(); majTitre();
chargerHistorique();
})
.catch(function () {
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
});
}
function basculerDiscussion(c) {
convId = c.id; convKind = c.kind; convTitre = c.title;
stockerConv(); majTitre();
tiroirEl.hidden = true;
msgsEl.innerHTML = "";
ajouterMsg("sys", (c.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
chargerHistorique();
inputEl.focus();
}
function nouvelleDiscussion() {
fetch(API + "/api/v1/conversations", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain: domaine() })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (c) {
basculerDiscussion(c);
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
})
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
}
function actionDiscussion(id, action) {
var methode = action === "supprimer" ? "DELETE" : "POST";
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
return fetch(url, { method: methode, credentials: "include" })
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
});
}
function ligneDiscussion(c, actions) {
var ligne = document.createElement("div");
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
var meta = document.createElement("div");
meta.className = "d-meta";
var quand = "";
try {
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
{ day: "numeric", month: "short" });
} catch (e) { /* date illisible */ }
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
ligne.appendChild(meta);
var sous = document.createElement("div");
sous.className = "d-sous";
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
(c.kind === "principale" ? " · perpétuelle" : "");
ligne.appendChild(sous);
meta.addEventListener("click", function () { basculerDiscussion(c); });
sous.addEventListener("click", function () { basculerDiscussion(c); });
if (actions && actions.length) {
var btns = document.createElement("div");
btns.className = "d-actions";
actions.forEach(function (a) {
var b = document.createElement("button");
b.type = "button"; b.textContent = a.label;
if (a.danger) b.className = "danger";
b.addEventListener("click", function (ev) {
ev.stopPropagation();
if (a.confirmer && !window.confirm(a.confirmer)) return;
actionDiscussion(c.id, a.action)
.then(function () {
if (c.id === convId && a.action !== "desarchiver") {
chargerListeDiscussions().then(function (d) {
if (d.principale) {
basculerDiscussion(d.principale);
ajouterMsg("sys", a.action === "supprimer"
? "Discussion supprimée — retour à la discussion principale."
: "Discussion archivée — retour à la discussion principale.");
}
});
}
remplirTiroir();
})
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
});
btns.appendChild(b);
});
ligne.appendChild(btns);
}
return ligne;
}
function remplirTiroir() {
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
chargerListeDiscussions()
.then(function (d) {
tiroirEl.innerHTML = "";
var h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussion principale";
tiroirEl.appendChild(h);
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussions parallèles";
tiroirEl.appendChild(h);
if (!d.paralleles.length) {
var vide = document.createElement("div");
vide.className = "d-vide";
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
tiroirEl.appendChild(vide);
}
d.paralleles.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Archiver", action: "archive" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
if (d.archivees && d.archivees.length) {
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Archivées";
tiroirEl.appendChild(h);
d.archivees.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Désarchiver", action: "desarchiver" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
}
})
.catch(function () {
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
});
}
function basculerTiroir() {
tiroirEl.hidden = !tiroirEl.hidden;
if (!tiroirEl.hidden) remplirTiroir();
}
function chargerHistorique() {
if (!convId) return;
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
.then(function (r) {
if (r.status === 404) { convId = null; stockerConv(); return null; }
if (!r.ok) return null;
return r.json();
})
.then(function (d) {
if (!d) return;
convKind = d.kind; convTitre = d.title || convTitre;
majTitre();
if (!msgsEl.querySelector(".draco-msg")) {
ajouterMsg("sys", (d.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
}
var sys = msgsEl.querySelector(".draco-msg.sys");
(d.messages || []).forEach(function (m) {
var el = m.role === "user"
? ajouterMsg("user", m.content)
: ajouterMsgMd(m.content, m.html);
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { /* historique indisponible : on repart à neuf */ });
}
function chargerAujourdhui() { function chargerAujourdhui() {
var bloc = document.createElement("div"); var bloc = document.createElement("div");
bloc.className = "draco-auj"; bloc.className = "draco-auj";
@@ -178,7 +619,7 @@
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) { .then(function (d) {
bloc.style.opacity = ".55"; bloc.style.opacity = ".55";
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px;color:#6c7268'>Décision enregistrée : " + esc(d.status) + "</span>"; bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
}) })
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); }); .catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
} }
@@ -239,12 +680,24 @@
d.missions.slice(0, 10).forEach(function (m) { d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div"); var ligne = document.createElement("div");
ligne.className = "m-item"; ligne.className = "m-item";
var rapport = m.result ? "<div class='m-rapport'>" + esc(String(m.result).slice(0, 700)) + "</div>" : ""; var entete = document.createElement("div");
ligne.innerHTML = "<div class='m-entete'><b>" + esc(m.title) + "</b>" + entete.className = "m-entete";
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span></div>" + entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
"<div class='m-meta'>" + esc(m.executor) + "<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
(m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") + ligne.appendChild(entete);
(m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "</div>" + rapport; var meta = document.createElement("div");
meta.className = "m-meta";
meta.textContent = m.executor +
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
ligne.appendChild(meta);
if (m.result) {
var rapport = document.createElement("div");
rapport.className = "m-rapport md";
if (m.result_html) rapport.innerHTML = m.result_html;
else rapport.textContent = String(m.result).slice(0, 1200);
ligne.appendChild(rapport);
}
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") { if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
var btn = document.createElement("button"); var btn = document.createElement("button");
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler"; btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
@@ -290,31 +743,6 @@
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); }); .catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
} }
function chargerHistorique() {
if (historiqueCharge || !convId) return;
historiqueCharge = true;
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
.then(function (r) {
if (r.status === 404) {
convId = null;
try { sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* ok */ }
return null;
}
if (!r.ok) return null;
return r.json();
})
.then(function (d) {
if (!d || !d.messages || !d.messages.length) return;
var sys = msgsEl.querySelector(".draco-msg.sys");
d.messages.forEach(function (m) {
var el = ajouterMsg(m.role === "user" ? "user" : "agent", m.content);
if (sys) msgsEl.insertBefore(el, sys.nextSibling), sys = el;
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { /* historique indisponible : la conversation repart à neuf */ });
}
function envoyer(ev) { function envoyer(ev) {
ev.preventDefault(); ev.preventDefault();
var texte = inputEl.value.trim(); var texte = inputEl.value.trim();
@@ -347,8 +775,16 @@
.then(function (d) { .then(function (d) {
if (!d) return; if (!d) return;
convId = d.conversation_id; convId = d.conversation_id;
try { sessionStorage.setItem("draco_panneau_conv", convId); } catch (e) { /* ok */ } convKind = d.kind || convKind;
attente.textContent = d.reply; if (d.title) convTitre = d.title;
stockerConv(); majTitre();
if (d.html) {
attente.className = "draco-msg agent md";
attente.innerHTML = d.html;
} else {
attente.textContent = d.reply;
}
msgsEl.scrollTop = msgsEl.scrollHeight;
if (d.tools_used && d.tools_used.length) { if (d.tools_used && d.tools_used.length) {
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?")); ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
} }
@@ -362,10 +798,10 @@
} }
function ouvrir() { function ouvrir() {
if (!root) construire(); if (!root) { construire(); demarrerDiscussions(); }
root.classList.add("open"); root.classList.add("open");
appliquerTheme();
verifierIdentite(); verifierIdentite();
chargerHistorique();
setTimeout(function () { inputEl.focus(); }, 300); setTimeout(function () { inputEl.focus(); }, 300);
} }
function fermer() { if (root) root.classList.remove("open"); } function fermer() { if (root) root.classList.remove("open"); }
+282
View File
@@ -0,0 +1,282 @@
"""Émission d'événements vers le journal du service d'agents
(Fusion, Lot 2 — contrat ACC-0.2).
Un faux récepteur local capture les POST signés HMAC et les vérifie
comme le fait le service (journal.verifier_signature) ; on prouve
aussi l'inactivité sans clé et la non-régression métier quand le
récepteur est en panne. Surfaces couvertes : API de session (board
et collections), API publique v2 et MCP (acteur « agent:mcp »).
"""
import hashlib
import hmac
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from app.db import get_conn
CLE = "cle-flowdeck-de-test"
class _Recepteur(BaseHTTPRequestHandler):
recus = []
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
corps = self.rfile.read(n)
type(self).recus.append(
{"headers": {k.lower(): v for k, v in self.headers.items()},
"corps": corps})
self.send_response(201)
self.end_headers()
def log_message(self, *a):
pass
def _demarrer(monkeypatch):
_Recepteur.recus = []
serveur = HTTPServer(("127.0.0.1", 0), _Recepteur)
port = serveur.server_address[1]
threading.Thread(target=serveur.serve_forever, daemon=True).start()
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
f"http://127.0.0.1:{port}/api/v1/evenements")
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
return serveur
def _attendre(nombre, delai=6.0):
"""Attend `nombre` événements ; vérifie la signature HMAC de
chacun (comme le service) et retourne la liste des enveloppes."""
fin = time.time() + delai
while time.time() < fin and len(_Recepteur.recus) < nombre:
time.sleep(0.05)
assert len(_Recepteur.recus) >= nombre, \
f"{len(_Recepteur.recus)} événement(s) reçu(s), {nombre} attendus"
envs = []
for recu in _Recepteur.recus:
sig = recu["headers"].get("x-draco-signature", "")
attendu = hmac.new(CLE.encode(), recu["corps"],
hashlib.sha256).hexdigest()
assert hmac.compare_digest(sig.lower(), attendu), \
"signature HMAC invalide"
env = json.loads(recu["corps"].decode("utf-8"))
assert env["source_app"] == "flowdeck"
assert env["id"] and env["occurred_at"]
envs.append(env)
return envs
def _par_type(envs, type_):
return [e for e in envs if e["type"] == type_]
def test_enveloppe_et_signature():
from app.services import agent_evenements
env, corps = agent_evenements.construire_enveloppe(
"flowdeck.page.created", "page:42", "Page de test",
url="/board/pages/42", actor="user")
assert env["source_app"] == "flowdeck"
assert env["domain"] == "pro"
assert env["actor"] == "user"
assert env["object"] == {"ref": "page:42", "label": "Page de test",
"url": "/board/pages/42"}
assert json.loads(corps.decode("utf-8")) == env
sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
assert len(sig) == 64
def test_cycle_de_vie_api_session(client, monkeypatch):
serveur = _demarrer(monkeypatch)
try:
# Page d'espace de travail (board) : création, màj, suppression.
r = client.post("/board/api/pages", params={"title": "Page journal"})
assert r.status_code == 200, r.text
page_id = r.json()["id"]
r = client.put(f"/board/api/pages/{page_id}",
params={"title": "Page journal (éditée)"})
assert r.status_code == 200, r.text
r = client.delete(f"/board/api/pages/{page_id}")
assert r.status_code == 200, r.text
# Collection puis sa page : création, màj, suppression.
r = client.post("/db/api", json={"name": "Collection journal"})
assert r.status_code == 200, r.text
cid = r.json()["id"]
r = client.post(f"/db/{cid}/pages/api",
json={"title": "Ligne journal"})
assert r.status_code == 200, r.text
ligne_id = r.json()["id"]
r = client.put(f"/db/pages/{ligne_id}/api",
json={"title": "Ligne journal (éditée)"})
assert r.status_code == 200, r.text
r = client.delete(f"/db/pages/{ligne_id}/api")
assert r.status_code == 200, r.text
envs = _attendre(7)
crees = _par_type(envs, "flowdeck.page.created")
assert len(crees) == 2
urls = {e["object"]["url"] for e in crees}
assert f"/board/pages/{page_id}" in urls
assert f"/db/{cid}/view/table" in urls
board = next(e for e in crees
if e["object"]["url"] == f"/board/pages/{page_id}")
assert board["object"]["ref"] == f"page:{page_id}"
assert board["object"]["label"] == "Page journal"
assert board["actor"] == "user"
maj = _par_type(envs, "flowdeck.page.updated")
assert len(maj) == 2
labels = {e["object"]["label"] for e in maj}
assert labels == {"Page journal (éditée)", "Ligne journal (éditée)"}
suppr = _par_type(envs, "flowdeck.page.deleted")
assert len(suppr) == 2
refs = {e["object"]["ref"] for e in suppr}
assert refs == {f"page:{page_id}", f"page:{ligne_id}"}
coll = _par_type(envs, "flowdeck.collection.created")
assert len(coll) == 1
assert coll[0]["object"] == {
"ref": f"collection:{cid}", "label": "Collection journal",
"url": f"/db/{cid}/view/table"}
assert coll[0]["actor"] == "user"
finally:
serveur.shutdown()
def test_api_publique_v2(client, monkeypatch):
serveur = _demarrer(monkeypatch)
try:
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123",
"name": "journal"})
assert r.status_code == 200, r.text
tok = client.post("/api/v1/token").json()["token"]
h1 = {"Authorization": f"Bearer {tok}"}
r = client.post("/api/v2/tokens",
json={"name": "ci", "scopes": "read,write"},
headers=h1)
assert r.status_code == 200, r.text
h = {"Authorization": f"Bearer {r.json()['token']}"}
r = client.post("/api/v2/collections",
json={"name": "Collection v2"}, headers=h)
assert r.status_code == 201, r.text
cid = r.json()["id"]
r = client.post(f"/api/v2/collections/{cid}/pages",
json={"title": "Page v2"}, headers=h)
assert r.status_code == 201, r.text
pid = r.json()["id"]
r = client.patch(f"/api/v2/pages/{pid}",
json={"title": "Page v2 (éditée)"}, headers=h)
assert r.status_code == 200, r.text
r = client.delete(f"/api/v2/pages/{pid}", headers=h)
assert r.status_code == 200, r.text
envs = _attendre(4)
types = [e["type"] for e in envs]
assert types.count("flowdeck.collection.created") == 1
assert types.count("flowdeck.page.created") == 1
assert types.count("flowdeck.page.updated") == 1
assert types.count("flowdeck.page.deleted") == 1
assert all(e["actor"] == "user" for e in envs)
coll = _par_type(envs, "flowdeck.collection.created")[0]
assert coll["object"]["ref"] == f"collection:{cid}"
page = _par_type(envs, "flowdeck.page.created")[0]
assert page["object"]["ref"] == f"page:{pid}"
assert page["object"]["url"] == f"/db/{cid}/view/table"
finally:
serveur.shutdown()
@pytest.fixture()
def jeton_mcp(client):
brut = "fd_test_token_journal"
with get_conn() as conn:
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) "
"VALUES (1, 'service-agents', ?, ?, 0)",
(hashlib.sha256(brut.encode()).hexdigest(), brut[:12]))
conn.commit()
return brut
def _appel_mcp(client, jeton, rid, nom, args=None):
r = client.post(
"/mcp",
json={"jsonrpc": "2.0", "id": rid, "method": "tools/call",
"params": {"name": nom, "arguments": args or {}}},
headers={"Authorization": f"Bearer {jeton}"})
assert r.status_code == 200, r.text
return json.loads(r.json()["result"]["content"][0]["text"])
def test_mcp_acteur_agent(client, monkeypatch, jeton_mcp):
serveur = _demarrer(monkeypatch)
try:
coll = _appel_mcp(client, jeton_mcp, 1, "create_collection",
{"name": "Collection MCP"})
cid = coll["id"]
page = _appel_mcp(client, jeton_mcp, 2, "create_page",
{"collection_id": cid, "title": "Page MCP"})
pid = page["id"]
_appel_mcp(client, jeton_mcp, 3, "update_page",
{"id": pid, "title": "Page MCP (éditée)"})
envs = _attendre(3)
assert all(e["actor"] == "agent:mcp" for e in envs)
coll_env = _par_type(envs, "flowdeck.collection.created")
assert len(coll_env) == 1
assert coll_env[0]["object"] == {
"ref": f"collection:{cid}", "label": "Collection MCP",
"url": f"/db/{cid}/view/table"}
creee = _par_type(envs, "flowdeck.page.created")
assert len(creee) == 1
assert creee[0]["object"]["ref"] == f"page:{pid}"
assert creee[0]["object"]["label"] == "Page MCP"
maj = _par_type(envs, "flowdeck.page.updated")
assert len(maj) == 1
assert maj[0]["object"]["label"] == "Page MCP (éditée)"
finally:
serveur.shutdown()
def test_inactif_sans_cle(client, monkeypatch):
serveur = _demarrer(monkeypatch)
monkeypatch.delenv("AGENT_EVENEMENTS_CLE")
try:
from app.services import agent_evenements
assert agent_evenements.actif() is False
assert agent_evenements.emettre(
"flowdeck.page.created", "page:1", "x") is False
r = client.post("/board/api/pages", params={"title": "Sans journal"})
assert r.status_code == 200, r.text
r = client.post("/db/api", json={"name": "Collection sans journal"})
assert r.status_code == 200, r.text
time.sleep(0.6)
assert _Recepteur.recus == []
finally:
serveur.shutdown()
def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch):
# Port fermé : connexion refusée dans le fil d'arrière-plan.
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
"http://127.0.0.1:59998/api/v1/evenements")
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
r = client.post("/board/api/pages", params={"title": "Malgré la panne"})
assert r.status_code == 200, r.text
page_id = r.json()["id"]
r = client.get(f"/board/api/pages/{page_id}")
assert r.status_code == 200, r.text
assert r.json()["title"] == "Malgré la panne"
r = client.post("/db/api", json={"name": "Collection malgré la panne"})
assert r.status_code == 200, r.text
+74 -1
View File
@@ -68,11 +68,35 @@ def test_mcp_handshake_et_outils(client, jeton):
assert r.json()["result"]["serverInfo"]["name"] == "flowdeck" assert r.json()["result"]["serverInfo"]["name"] == "flowdeck"
r = _rpc(client, jeton, 2, "tools/list") r = _rpc(client, jeton, 2, "tools/list")
noms = [t["name"] for t in r.json()["result"]["tools"]] noms = [t["name"] for t in r.json()["result"]["tools"]]
assert noms == ["list_collections", "get_collection", "get_page", assert noms == ["list_workspaces", "get_workspace",
"list_collections", "get_collection", "get_page",
"search_pages", "list_repos", "create_page", "search_pages", "list_repos", "create_page",
"update_page", "create_collection"] "update_page", "create_collection"]
def test_mcp_workspaces(client, jeton):
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Techno', 1)")
wid = cur.lastrowid
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, parent_section) "
"VALUES ('Techno', ?, 'Page racine', 'Private')", (wid,))
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, parent_section, deleted_at) "
"VALUES ('Techno', ?, 'Page corbeille', 'Private', '2026-10-11 10:00:00')", (wid,))
conn.commit()
ws = _appel(client, jeton, 70, "list_workspaces")
techno = [w for w in ws if w["name"] == "Techno"]
assert techno and techno[0]["page_count"] == 1 # la corbeille ne compte pas
detail = _appel(client, jeton, 71, "get_workspace", {"id": wid})
assert detail["workspace"]["name"] == "Techno"
assert [p["title"] for p in detail["pages"]] == ["Page racine"]
res = _appel(client, jeton, 72, "get_workspace", {"id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_collections_et_pages(client, jeton, donnees): def test_mcp_collections_et_pages(client, jeton, donnees):
cols = _appel(client, jeton, 1, "list_collections") cols = _appel(client, jeton, 1, "list_collections")
alpha = [c for c in cols if c["name"] == "Projet Alpha"] alpha = [c for c in cols if c["name"] == "Projet Alpha"]
@@ -156,6 +180,55 @@ def test_mcp_ecriture_sans_jeton_401(client, donnees):
assert r.status_code == 401 assert r.status_code == 401
def test_mcp_client_bearer_sans_session_ni_csrf(client, jeton, donnees):
"""Régression 7.76.1 : un vrai client MCP est machine-à-machine
(Bearer seul, ni session ni jeton CSRF) — exactement le service
d'agents. Le client de test habituel porte une session + un CSRF,
ce qui masquait le 403 du middleware en production."""
from tests.conftest import anon
anon(client)
r = _rpc(client, jeton, 60, "initialize", {})
assert r.status_code == 200, r.text
cols = _appel(client, jeton, 61, "list_collections")
assert any(c["name"] == "Projet Alpha" for c in cols)
r = _rpc(client, None, 62, "tools/list")
assert r.status_code == 401, r.text
def test_mcp_perimetre_du_jeton(client, jeton, donnees):
"""Régression 7.78.0 (cas réel de Bruno) : list_workspaces rendait
les 52 workspaces de la base, tous comptes confondus. Chaque jeton
ne voit que les workspaces de son propriétaire (ou ceux dont il
est membre) et les collections rattachées à ce périmètre."""
autre = "fd_token_autre_compte"
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (2, 'autre')")
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) "
"VALUES (2, 'autre', ?, ?, 0)",
(hashlib.sha256(autre.encode()).hexdigest(), autre[:12]))
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Espace de lautre', 2)")
wid_autre = cur.lastrowid
cur = conn.execute(
"INSERT INTO collections (name, description, icon, workspace_id) "
"VALUES ('Base de lautre', '', '📋', ?)", (wid_autre,))
base_autre = cur.lastrowid
conn.commit()
# Bruno (jeton principal) ne voit ni le workspace ni la base de l'autre.
noms = [w["name"] for w in _appel(client, jeton, 80, "list_workspaces")]
assert "Espace de lautre" not in noms
res = _appel(client, jeton, 81, "get_workspace", {"id": wid_autre})
assert "introuvable" in res["erreur"]
cols = [c["name"] for c in _appel(client, jeton, 82, "list_collections")]
assert "Base de lautre" not in cols and "Projet Alpha" in cols
res = _appel(client, jeton, 83, "get_collection", {"id": base_autre})
assert "introuvable" in res["erreur"]
# Et l'autre ne voit pas le workspace de Bruno, mais voit le sien.
noms2 = [w["name"] for w in _appel(client, autre, 84, "list_workspaces")]
assert noms2 == ["Espace de lautre"]
def test_mcp_info_get(client, jeton): def test_mcp_info_get(client, jeton):
r = client.get("/mcp") r = client.get("/mcp")
assert r.status_code == 200 assert r.status_code == 200
+26
View File
@@ -32,6 +32,32 @@ def _row(page_id):
).fetchone() ).fetchone()
def test_permanent_delete_page_referenced_by_favorites_and_recents(client):
"""Régression 7.77.0 (cas réel de Bruno, 2026-10-22) : une page
réelle — ouverte (recents) et/ou favorite — ne pouvait plus être
supprimée définitivement : favorites.page_id et recents.page_id
référencent pages(id) sans CASCADE, PRAGMA foreign_keys=ON →
IntegrityError → 500 « Delete failed », et Empty Trash (une seule
transaction) échouait en bloc. Les pages synthétiques des anciens
tests n'avaient ni favori ni récent : elles passaient."""
from app.db import get_conn
login_test_client(client)
pid = _mk_page(title="Page réelle de Bruno")
with get_conn() as conn:
conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (1, ?, 0)", (pid,))
conn.execute("INSERT INTO recents (user_id, page_id, workspace) VALUES (1, ?, 'x')", (pid,))
conn.commit()
r = client.post(f"/api/pages/{pid}/trash")
assert r.status_code == 200
r = client.delete(f"/board/api/trash/{pid}")
assert r.status_code == 200, r.text
assert _row(pid) is None
with get_conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM favorites WHERE page_id=?", (pid,)).fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM recents WHERE page_id=?", (pid,)).fetchone()[0] == 0
def test_trash_requires_session(client): def test_trash_requires_session(client):
pid = _mk_page() pid = _mk_page()
anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403 anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403