Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb4c42b0b5 | ||
|
|
73b3fbce6e | ||
|
|
c7da7002ca | ||
|
|
851a7b17d2 |
@@ -1,5 +1,76 @@
|
|||||||
# 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)
|
## v7.79.0 (2026-10-22) — Panneau Spark v2 (Lot 1)
|
||||||
|
|
||||||
- **Panneau Spark** (`draco-panneau.js/css`, vendangés dans les 4
|
- **Panneau Spark** (`draco-panneau.js/css`, vendangés dans les 4
|
||||||
|
|||||||
+25
-1
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
|
|||||||
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:
|
||||||
@@ -265,6 +268,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
|
|||||||
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()
|
||||||
@@ -287,6 +293,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
|
|||||||
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}"}
|
||||||
|
|||||||
@@ -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
|
||||||
+138
-1
@@ -18,6 +18,16 @@
|
|||||||
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
||||||
* (persistée), thème clair/sombre aligné sur l'app hôte
|
* (persistée), thème clair/sombre aligné sur l'app hôte
|
||||||
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
|
* (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 () {
|
||||||
@@ -44,6 +54,124 @@
|
|||||||
catch (e) { return 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 ─────────────────────────────── */
|
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||||
function themeHote() {
|
function themeHote() {
|
||||||
var t = document.documentElement.getAttribute("data-theme");
|
var t = document.documentElement.getAttribute("data-theme");
|
||||||
@@ -57,6 +185,7 @@
|
|||||||
}
|
}
|
||||||
function appliquerTheme() {
|
function appliquerTheme() {
|
||||||
if (root) root.setAttribute("data-draco-theme", themeHote());
|
if (root) root.setAttribute("data-draco-theme", themeHote());
|
||||||
|
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
|
||||||
}
|
}
|
||||||
function surveillerTheme() {
|
function surveillerTheme() {
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +251,7 @@
|
|||||||
' <span class="d-titre">Discussion principale</span>' +
|
' <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="liste">☰ Discussions</button>' +
|
||||||
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</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>' +
|
||||||
'<div class="draco-discussions" hidden></div>' +
|
'<div class="draco-discussions" hidden></div>' +
|
||||||
'<div class="draco-panneau-outils">' +
|
'<div class="draco-panneau-outils">' +
|
||||||
@@ -145,6 +275,7 @@
|
|||||||
root.querySelector(".fermer").addEventListener("click", fermer);
|
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||||
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
||||||
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
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);
|
||||||
@@ -155,7 +286,13 @@
|
|||||||
appliquerLargeur();
|
appliquerLargeur();
|
||||||
window.addEventListener("resize", 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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user