Compare commits

..
4 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
13 changed files with 681 additions and 3 deletions
+71
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
7.79.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,
+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
@@ -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"}
+9
View File
@@ -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}"}
+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
+138 -1
View File
@@ -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();
}); });
} }
+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