Compare commits

...
12 Commits
Author SHA1 Message Date
Spark 440cfdcc31 fix: bouton « Console ↗ » dans le panneau Spark #221
CI / lint (push) Waiting to run
CI / test (push) Blocked by required conditions
CI / security (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / e2e (push) Blocked by required conditions
2026-10-22 21:20:51 +00:00
Spark 0ca92c3723 docs: #220 livré (2.76.0) — statut Roadmap
CI / lint (push) Successful in 2m52s
CI / security (push) Failing after 1m38s
CI / test (push) Successful in 5m3s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 17m47s
2026-10-22 19:59:24 +00:00
Spark f434f30286 feat: émission d'événements vers le journal du service d'agents #220
CI / lint (push) Successful in 2m53s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m57s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 17m40s
2026-10-22 19:58:25 +00:00
Spark f4b8935189 docs: #219 livré (2.75.0) — statut Roadmap
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 12:07:49 -04:00
Spark 014df322e7 feat: panneau Spark v2 — Markdown, largeur, thème, discussions #219
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 12:06:12 -04:00
bruno 39f9cc16e4 fix(deploy): retirer DIR_1 Bruno — doublon avec le vault par défaut home-user (#194), surconsommation mémoire
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 10:10:18 -04:00
Spark b4b3032456 docs: #218 livré (2.74.0) — statut Roadmap
CI / lint (push) Successful in 2m49s
CI / security (push) Failing after 1m40s
CI / test (push) Successful in 5m4s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 18m6s
2026-10-22 03:43:24 +00:00
Spark edef1ea708 feat: panneau Spark intégré à la barre commune (Fusion) #218 2026-10-22 03:43:17 +00:00
Spark 7523670949 fix: barre commune v0.1.4, palier mobile élargi à 900 px (suite BUG-118, #215)
CI / lint (push) Successful in 2m54s
CI / security (push) Failing after 1m34s
CI / test (push) Successful in 4m54s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 18m28s
2026-10-21 12:47:16 -04:00
Spark d81f328859 feat: menu des applications par la marque ✦ de la barre commune (commons v0.1.3, #215)
CI / lint (push) Successful in 2m47s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m55s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 18m20s
2026-10-21 12:27:12 -04:00
Spark 1a39d601de fix: barre d'apps mobile — CSS commune v0.1.2 et rail contextuel (BUG-118, #215)
CI / lint (push) Successful in 2m51s
CI / security (push) Successful in 1m33s
CI / test (push) Successful in 4m58s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 18m3s
2026-10-21 11:36:35 -04:00
Spark 68d3dd85a9 feat: validation locale du jeton draco_session — repli SSO dans get_current_user (#217)
CI / lint (push) Successful in 3m34s
CI / security (push) Successful in 2m17s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 2m15s
CI / e2e (push) Successful in 18m31s
2026-10-20 23:47:45 -04:00
31 changed files with 2260 additions and 50 deletions
+129 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.71.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.76.1**.
---
@@ -14,6 +14,134 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.76.1] — 2026-10-22
### Ajouté
- **Panneau Spark : bouton « Console ↗ » (#221, Fusion)** — la
barre des discussions du panneau Spark (asset vendangé,
octet-identique dans les 4 apps) gagne un bouton « Console ↗ »
qui 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=obsigate`, déduite du
sous-domaine `og`). Même livraison dans TikTak 0.59.1,
Flowdeck 7.80.1 et Kanbante 0.28.1. Redéploiement Docker
requis pour l'activer côté ObsiGate.
---
## [2.76.0] — 2026-10-22
### Ajouté
- **Émission d'événements vers le journal du service d'agents
(#220, Fusion — Lot 2 vague 5)** — ObsiGate émet ses mutations de
notes **explicites** au journal d'activité du service d'agents
(contrat ACC-0.2) : `obsigate.note.created / edited / moved /
deleted`, enveloppes signées HMAC-SHA256 (clé
`AGENT_EVENEMENTS_CLE`), envoyées en arrière-plan sans jamais
bloquer le métier, inactives tant que la clé n'est pas posée.
Points d'accroche : les endpoints d'écriture de l'API web (acteur
`user`) et la couche outils / serveur MCP (acteur `agent:mcp`).
**Garde-fou : jamais le watcher** — les modifications faites hors
API (Obsidian, éditeur externe) n'émettent rien. Domaine par vault
via `AGENT_EVENEMENTS_DOMAINES`, repli `pro` (Q-A ouverte).
Redéploiement Docker requis, puis pose de la clé des deux côtés
pour activer.
---
## [2.75.0] — 2026-10-22
### Ajouté
- **Panneau Spark v2 (#219, Fusion — Lot 1)** — les assets partagés
`frontend/draco-panneau.js` / `frontend/draco-panneau.css` passent
en v2, à l'identique dans les quatre applications : réponses de
l'agent en **Markdown rendu** (HTML produit par le service
d'agents 0.10.0, sanitizé côté serveur), **largeur réglable**
persistée, **thème clair/sombre** aligné sur l'application hôte
(suivi en direct de `html[data-theme]`), et **discussions façon
Muse** — discussion principale perpétuelle et discussions
parallèles créées, archivées, supprimées depuis le tiroir du
panneau. Aucun code propre à ObsiGate n'est modifié ;
redéploiement Docker requis.
---
## [2.74.0] — 2026-10-22
### Ajouté
- **Panneau Spark intégré (#218, Fusion)** — le bouton Spark de la
barre commune ouvre le panneau de l'agent commun (conversation,
Aujourd'hui, approbations, missions longues), comme dans TikTak,
Flowdeck et Kanbante : assets partagés `frontend/draco-panneau.js`
et `frontend/draco-panneau.css` vendangés à l'identique, action
`spark` branchée sur `DRACO_PANNEAU.toggle()`. La CSP nomme
`https://agent.dracodev.net` en `connect-src` (seul hôte ajouté).
Redéploiement Docker requis.
---
## [2.73.1] — 2026-10-21
### Corrigé
- **Barre commune v0.1.4 — palier mobile élargi de 700 à 900 px**
(suite de BUG-118) : entre les deux seuils, la barre complète
ne tenait pas (fenêtres réduites, tablettes, webviews) alors
que les apps sont déjà en mise en page mobile ; le resserré
(app courante, alertes, Spark, avatar) s'applique maintenant
dès 900 px, et le décalage de `.app-container` suit le même
seuil. Le changement d'app reste assuré par le menu du ✦.
---
## [2.73.0] — 2026-10-21
### Ajouté
- **Menu des applications par la marque ✦** (barre commune
**v0.1.3**, #215) : cliquer le ✦ — un peu plus gros (24 px) —
ouvre le menu des 4 applications (liens, icônes, badges), sur
mobile comme sur bureau. C'est le changement d'app en mobile,
où les onglets sont repliés depuis la v0.1.2. Fermeture au
clic extérieur ou à Échap. Choix de Bruno (2026-10-21).
---
## [2.72.1] — 2026-10-21
### Corrigé
- **BUG-118 — barre d'apps Fusion en mobile** : la barre commune
passe en **v0.1.2** (dracodev-commons) — au palier ≤ 700 px elle
ne garde que la marque, l'onglet de l'app courante, les alertes,
Spark et l'avatar (avant : onglets et pastilles domaines entiers,
barre débordante sur téléphone). Le bouton ☰ de la barre pilote
désormais le bon bouton selon la largeur : barre latérale bureau
au-delà de 768 px, **vrai hamburger mobile** (tiroir) en dessous —
avant, il cliquait le bouton bureau et ne faisait rien sur
téléphone, donnant l'impression d'un hamburger mort à côté de
celui d'ObsiGate. Test : `tests/test_draco_barre.py`.
---
## [2.72.0] — 2026-10-20
### Ajouté
- **Fusion ACC-3.2 (#217) — validation locale du jeton
`draco_session`** : `get_current_user` accepte le cookie partagé à
défaut de jeton natif valide ; le `sub` étant l'identifiant
ObsiGate, la correspondance vers le compte local est directe
(compte actif et garde-fou « changement de mot de passe » exigés).
Fiche : `docs/features/fusion-acc32-validation-locale-217.md`.
---
## [2.71.0] — 2026-10-20
### Ajouté
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.71.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.76.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.71.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.76.1).
---
*Projet : ObsiGate | Version : 2.71.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.76.1 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.71.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.76.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.71.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.76.1).
---
*Project: ObsiGate | Version: 2.71.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.76.1 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.71.0
2.76.1
+173
View File
@@ -0,0 +1,173 @@
"""Émission d'événements vers le journal du service d'agents
(Fusion, Lot 2 vague 5 — contrat ACC-0.2, Roadmap #220).
ObsiGate émet ses mutations de notes **explicites** vers
``POST /api/v1/evenements`` du service d'agents, qui les expose dans
le journal de sa console. L'enveloppe ne contient qu'une référence,
un libellé et un lien — jamais le contenu de la note :
``{id, type, occurred_at, actor, domain, source_app,
object{ref, label, url}}``.
**Garde-fou (conception validée)** : ce module n'est appelé que
depuis les points d'écriture explicites de l'API web —
:mod:`backend.routers.files_write` (session utilisateur, acteur
``user``) et la couche outils :mod:`backend.tools.service`
(serveur MCP d'ObsiGate, acteur ``agent:mcp`` ; assistant intégré,
acteur ``user``). Le **watcher** (:mod:`backend.watcher`) et
l'indexeur ne l'appellent **jamais** : le vault principal compte
plus de 220 000 fichiers, un branchement sur les événements du
système de fichiers inonderait le journal. Une modification faite
hors API (éditeur externe, synchro disque…) reste donc invisible
du journal — c'est voulu.
Authentification : signature HMAC-SHA256 du corps brut, en-tête
``X-Draco-Signature``, clé propre à ObsiGate
(``AGENT_EVENEMENTS_CLE``, jamais en dur). Sans clé, le module est
**inactif** : aucun appel, aucun bruit. URL surchargée par
``AGENT_EVENEMENTS_URL``.
Discipline « au mieux » (même esprit que TikTak et Kanbante) :
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.
Domaine : ObsiGate ne modélise pas encore le domaine d'un vault
(question Q-A du programme Fusion toujours ouverte). La
correspondance peut être déclarée par ``AGENT_EVENEMENTS_DOMAINES``
(JSON ``{"NomDuVault": "famille"}``) ; repli ``pro``, qui est aussi
le défaut du journal du service.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import threading
import uuid
from datetime import UTC, datetime
from urllib.parse import quote
import httpx
logger = logging.getLogger("obsigate.agent_journal")
SOURCE_APP = "obsigate"
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
DELAI_S = 2.0
TYPE_CREE = "obsigate.note.created"
TYPE_EDITE = "obsigate.note.edited"
TYPE_DEPLACE = "obsigate.note.moved"
TYPE_SUPPRIME = "obsigate.note.deleted"
ACTEUR_USER = "user"
ACTEUR_MCP = "agent:mcp"
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é d'ObsiGate est configurée."""
return bool(_cle())
def domaine_du_vault(vault: str) -> str:
"""Domaine journal du vault : correspondance déclarée dans
``AGENT_EVENEMENTS_DOMAINES`` (JSON), repli ``pro``."""
brut = os.environ.get("AGENT_EVENEMENTS_DOMAINES", "").strip()
if brut:
try:
correspondance = json.loads(brut)
except ValueError:
correspondance = {}
if isinstance(correspondance, dict):
domaine = correspondance.get(vault)
if isinstance(domaine, str) and domaine:
return domaine
return "pro"
def lien_fichier(vault: str, chemin: str) -> str:
"""Lien profond ObsiGate vers un fichier (format ``#file=`` lu
par le frontend : ``#file=<vault>:<chemin>``, décodé en URI)."""
return f"/#file={quote(vault, safe='')}:{quote(chemin, safe='/')}"
def construire_enveloppe(
type_: str,
vault: str,
chemin: str,
actor: str = ACTEUR_USER,
) -> 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à).
``chemin`` est le chemin relatif au vault ; pour un déplacement,
c'est le **nouveau** chemin (la référence pointe sur l'objet tel
qu'il existe après l'opération)."""
env = {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(UTC).isoformat(),
"actor": actor or ACTEUR_USER,
"domain": domaine_du_vault(vault),
"source_app": SOURCE_APP,
"object": {
"ref": f"fichier:{vault}/{chemin}",
"label": chemin.rsplit("/", 1)[-1] if chemin else vault,
"url": lien_fichier(vault, chemin),
},
}
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
logger.warning("événement non livré au journal du service (%s)", exc)
def emettre(
type_: str,
vault: str,
chemin: str,
actor: str = ACTEUR_USER,
) -> 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_, vault, chemin, actor)
except Exception:
logger.warning("événement %s non construit", type_, exc_info=True)
return False
threading.Thread(
target=_envoyer, args=(corps,), daemon=True, name="obsigate-evenements"
).start()
return True
+20 -6
View File
@@ -9,6 +9,7 @@ import sys
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import draco_session
from backend.services.net import get_client_ip
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
@@ -86,20 +87,22 @@ def get_current_user(
token = request.cookies["access_token"]
if not token:
return None
return _draco_fallback(request)
payload = decode_token(token)
if not payload or payload.get("type") != "access":
return None
return _draco_fallback(request)
# BUG-027: access tokens revoked at logout must be rejected immediately.
# (Le repli draco reste sûr : la déconnexion globale révoque aussi
# le jti draco et efface son cookie.)
jti = payload.get("jti")
if jti and is_token_revoked(jti):
return None
return _draco_fallback(request)
user = get_user(payload["sub"])
if not user or not user.get("active"):
return None
return _draco_fallback(request)
# BUG-028: a password change invalidates every token issued before it.
pca = user.get("password_changed_at")
@@ -107,9 +110,9 @@ def get_current_user(
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return None
return _draco_fallback(request)
except (TypeError, ValueError):
return None
return _draco_fallback(request)
# Attach vault permissions from the token (snapshot at login time)
user["_token_vaults"] = payload.get("vaults", [])
@@ -127,6 +130,17 @@ def get_current_user(
return user
def _draco_fallback(request: Request) -> dict | None:
"""Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie
partagé draco_session authentifie l'utilisateur (sub = id
ObsiGate, correspondance directe — voir draco_session.user_for_draco).
"""
user = draco_session.user_for_draco(request)
if user is not None:
user["_request_ip"] = get_client_ip(request)
return user
def require_auth(current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
+40
View File
@@ -175,6 +175,46 @@ def clear_cookie(response) -> None:
response.delete_cookie(COOKIE_NAME, path="/", **extra)
# ── Validation locale (ACC-3.2, #9) ─────────────────────────────────────
def user_for_draco(request) -> dict | None:
"""Utilisateur ObsiGate prouvé par le cookie draco_session.
ObsiGate est à la fois émettrice et consommatrice : le `sub` du
jeton EST son identifiant d'utilisateur — la correspondance est
directe, par id. Le garde-fou « jeton antérieur au dernier
changement de mot de passe » (BUG-028 côté natif) est appliqué
à l'identique via le claim `iat`.
"""
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return None
sub = str(claims.get("sub", ""))
if not sub:
return None
from backend.auth.user_store import get_all_users
for candidate in get_all_users():
if str(candidate.get("id")) != sub:
continue
if not candidate.get("active"):
return None
pca = candidate.get("password_changed_at")
iat = claims.get("iat")
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return None
except (TypeError, ValueError):
return None
user = dict(candidate)
user["_token_vaults"] = candidate.get("vaults", [])
user["_token_scope"] = "full"
user["_draco_sub"] = sub
return user
return None
# ── Renouvellement glissant (middleware) ────────────────────────────────
class DracoRenewalMiddleware:
+1 -1
View File
@@ -191,7 +191,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://agent.dracodev.net; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
"frame-src 'self' blob:; "
+22
View File
@@ -15,6 +15,7 @@ from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend import agent_journal
from backend.audit import log_file_delete, log_file_save
from backend.auth.middleware import check_vault_access, require_auth
from backend.history import (
@@ -133,6 +134,9 @@ async def api_file_save(
client_ip = current_user.get("_request_ip", "unknown")
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
@@ -201,6 +205,7 @@ def api_file_xlsx_save(
sum(len(str(v)) for v in cells.values()),
current_user.get("_request_ip", "unknown"),
)
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": result["size"], "revision": result.get("revision"),
@@ -250,6 +255,7 @@ def api_file_csv_save(
sum(len(str(v)) for v in cells.values()),
current_user.get("_request_ip", "unknown"),
)
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": result["size"], "revision": result.get("revision"),
@@ -313,6 +319,7 @@ def api_file_xlsx_structure(
len(actions),
current_user.get("_request_ip", "unknown"),
)
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": len(result["applied"]), "revision": result.get("revision"),
@@ -381,6 +388,7 @@ def api_file_xlsx_style(
len(ops),
current_user.get("_request_ip", "unknown"),
)
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": len(result["applied"]), "revision": result.get("revision"),
@@ -405,6 +413,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
result = service_delete_file(vault_name, path)
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
agent_journal.emettre(agent_journal.TYPE_SUPPRIME, vault_name, result["path"])
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_delete(current_user["username"], vault_name, path, client_ip)
@@ -577,6 +588,9 @@ async def api_file_create(
result = service_create_file(vault_name, body.path, body.content)
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
agent_journal.emettre(agent_journal.TYPE_CREE, vault_name, result["path"])
# Update index
await update_single_file(vault_name, result["path"])
@@ -686,6 +700,9 @@ async def api_file_rename(
old_path_str = result["old_path"]
new_path_str = result["new_path"]
# Journal du service d'agents (#220) — la référence pointe sur le nouveau chemin.
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
# Update index
await handle_file_move(vault_name, old_path_str, new_path_str)
@@ -731,6 +748,11 @@ async def api_file_move(
new_path_str = result["new_path"]
item_type = result["item_type"]
# Journal du service d'agents (#220) — fichiers seulement (le catalogue
# est note.*) ; les déplacements de dossiers ne sont pas émis.
if item_type == "file":
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
# Update index
if item_type == "directory":
from backend.indexer import reload_single_vault
+46 -7
View File
@@ -14,6 +14,7 @@ import logging
import os
from typing import Any
from backend import agent_journal
from backend.indexer import get_backlinks as _get_backlinks
from backend.indexer import get_vault_names
from backend.services.backups import diff_backup as _diff_backup
@@ -58,7 +59,7 @@ from backend.services.search import advanced_search_vaults, search_vaults
from backend.services.search import list_tags as _list_tags
from backend.services.search import search_paths as _search_paths
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
from backend.tools.context import ToolContext, ToolRisk
from backend.tools.context import ToolContext, ToolMode, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import (
AppendToFileInput,
@@ -360,6 +361,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
# ── D. Mutations ───────────────────────────────────────────────────────────
def _acteur_journal(ctx: ToolContext) -> str:
"""Acteur journal (#220) : ``agent:mcp`` quand l'appel vient du
serveur MCP d'ObsiGate, ``user`` pour l'assistant intégré."""
return agent_journal.ACTEUR_MCP if ctx.mode == ToolMode.MCP else agent_journal.ACTEUR_USER
@tool(
name="create_file",
description=(
@@ -374,7 +381,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
)
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
"""Create a vault file (fails if it already exists)."""
return _create_file(params.vault, params.path, params.content)
result = _create_file(params.vault, params.path, params.content)
agent_journal.emettre(
agent_journal.TYPE_CREE, params.vault, result.get("path", params.path),
actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -402,7 +414,12 @@ def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str
)
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
"""Replace a vault file's content."""
return _edit_file(params.vault, params.path, params.content)
result = _edit_file(params.vault, params.path, params.content)
agent_journal.emettre(
agent_journal.TYPE_EDITE, params.vault, result.get("path", params.path),
actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -414,7 +431,12 @@ def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
)
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
"""Append content to a vault file."""
return _append_to_file(params.vault, params.path, params.content)
result = _append_to_file(params.vault, params.path, params.content)
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
agent_journal.emettre(
agent_journal.TYPE_EDITE, params.vault, chemin, actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -426,7 +448,12 @@ def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any
)
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
"""Rename a vault file."""
return _rename_file(params.vault, params.path, params.new_name)
result = _rename_file(params.vault, params.path, params.new_name)
agent_journal.emettre(
agent_journal.TYPE_DEPLACE, params.vault,
result.get("new_path", params.path), actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -450,7 +477,14 @@ def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str
)
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
"""Move a vault file or directory."""
return _move_path(params.vault, params.source_path, params.destination_dir)
result = _move_path(params.vault, params.source_path, params.destination_dir)
# Journal (#220) : fichiers seulement — les dossiers sont hors catalogue.
if result.get("item_type") == "file":
agent_journal.emettre(
agent_journal.TYPE_DEPLACE, params.vault,
result.get("new_path", params.source_path), actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -497,7 +531,12 @@ def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str,
)
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
"""Delete a vault file."""
return _delete_file(params.vault, params.path)
result = _delete_file(params.vault, params.path)
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
agent_journal.emettre(
agent_journal.TYPE_SUPPRIME, params.vault, chemin, actor=_acteur_journal(ctx),
)
return result
@tool(
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.71.0"
version = "2.76.1"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.71.0"
version = "2.76.1"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.71.0",
"version": "2.76.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
-2
View File
@@ -51,8 +51,6 @@ services:
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
- VAULT_5_NAME=Sessions
- VAULT_5_PATH=/vaults/SessionsManager
- DIR_1_NAME=Bruno
- DIR_1_PATH=/vaults/bruno
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
- OBSIGATE_HOME_ROOT=/vaults/Home
- OBSIGATE_AUTH_ENABLED=true
+1
View File
@@ -354,6 +354,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-14 | BUG-114 | Correction | `frontend/js/ui.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-114 — popup « currentPath is not defined » après suppression/renommage de répertoire.** Les branches répertoire de `confirmDeleteDirectory` (ligne ~1054) et du renommage inline (lignes ~967-968) référençaient `currentPath` nu au lieu de `state.currentPath` (ui.js ne déclare pas `currentPath`, contrairement à app.js) : `ReferenceError` levée dans le `try`, avalée par le `catch`, affichée en toast d'erreur alors que l'opération (DELETE/PATCH) avait réussi. Trois usages corrigés. Vérifié en Playwright sur l'instance E2E (flux réel clic droit → Supprimer) : toast de succès, 0 fantôme dans paths, 0 erreur JS ; validate-imports 42 modules / 373 exports, unit.test.mjs 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-14 | BUG-115 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-115 — trois ajustements de l'éditeur Forge (aperçu + menus).** (1) Cases à cocher collées au texte : le rendu serveur (mistune task_lists) ne met pas d'espace après l'`<input>` et l'`<style>` de `editor-poc.html` n'avait pas l'équivalent de la règle `.md-content .task-list-item` de `style.css` → deux règles ajoutées (margin-right 6 px, list-style none). (2) Flash de l'aperçu : `renderPreview` écrasait le contenu par « Rendering... » à chaque frappe (debounce 500 ms) — remplacé par un rafraîchissement silencieux (l'ancien rendu reste affiché, spinner seulement au premier rendu) + `_previewDirty` qui rejoue la dernière frappe tombée pendant un fetch (état fini correct, sans clignotement). (3) Menu « dimensions du tableau » coupé sur un document d'une ligne : `.inline-form`/`.lang-picker` en `position: absolute` recevaient des coordonnées **viewport** (`wr.left+pt.x`) → double décalage et clipping par `.editor-pane { overflow: hidden }` ; passés en `position: fixed` avec clamp viewport (convention du menu slash, déjà dans le fichier) ; au passage le clic sur un item qi/slash bullait jusqu'au listener document qui refermait la forme aussitôt ouverte → `stopPropagation`. Vérifié en Playwright sur l'instance E2E (serveur réel) : checkbox `marginRight: 6px`, aucun « Rendering... » observé pendant la frappe et dernier état rendu, forme table entièrement visible (`bottom 193 < vh 900`) et insertion OK. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-14 | BUG-116 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-116 — la textarea Forge ne grandissait pas après une insertion programmatique.** Après un tableau de 4 lignes inséré via le menu, l'éditeur restait d'une ligne : le contenu était bien multiligne mais `autoHeight()` n'était rappelé que par l'événement `input`, que `ta.value = …` ne déclenche pas. Les handlers clavier appelaient `autoHeight(); updateLineNumbers();` à la main ; `insertAtCursor` et `wrapSelection` — les deux fonctions par lesquelles passent toutes les insertions (tableau, bloc de code, templates mermaid, checklist, mise en forme) — oubliaient les deux appels. Corrigé à la source dans les deux fonctions plutôt que chez chaque appelant. Vérifié en Playwright (instance E2E, chemin réel qi → Table, 4 lignes sur document d'une ligne) : hauteur 51 → 189 px, gutter = nb de lignes | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-21 | BUG-118 | Correction | `frontend/index.html`, `frontend/draco-barre.css`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-118 — la barre d'apps Fusion était inutilisable en mobile** (retours de Bruno sur captures téléphone). Deux causes : (1) le palier ≤ 700 px de la CSS commune gardait onglets entiers et pastilles domaines — la barre débordait ; la source dracodev-commons passe en **v0.1.2** (spec des barres, § Mobile : marque, onglet de l'app courante, alertes, Spark, avatar ; rail/recherche/domaines/menu masqués au profit des contrôles natifs). (2) Le bouton ☰ de la barre cliquait `sidebar-toggle-btn` (bureau) au lieu de `hamburger-btn` (tiroir mobile) : sur téléphone il ne faisait rien, doublé du vrai hamburger d'ObsiGate juste en dessous. Rail devenu contextuel (`matchMedia` ≤ 768 px, point de bascule d'ObsiGate). Test de non-régression `tests/test_draco_barre.py` (2 tests) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
+93 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.71.0 | **Dernière mise à jour :** 2026-10-20
> **Version :** 2.76.1 | **Dernière mise à jour :** 2026-10-22
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -30,6 +30,82 @@
## 🔵 En cours
### 221. Fusion — panneau Spark : bouton « Console ↗ » vers la console du service
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** 🔵 en cours
- **Fiche :** [features/fusion-panneau-console-221.md](./features/fusion-panneau-console-221.md)
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
octet-identique dans les 4 apps) gagne un bouton « Console ↗ » dans sa barre
des discussions (demande de Bruno, 2026-10-22) : il 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=obsigate`, déduite du sous-domaine `og`). La
console consomme déjà ces deux paramètres (lien profond de discussion +
puce « Ouvert depuis »). Même livraison : TikTak 0.59.1, Flowdeck 7.80.1,
Kanbante 0.28.1.
- **Sous-tâches :**
- [x] Bouton + `ouvrirConsole()` / `appHote()` dans le panneau canonique
(TikTak), recopié octet-identique dans les 4 apps
- [x] Test `tests/test_draco_barre.py::test_panneau_bouton_console_221`
- [ ] CI verte + redéploiement Docker (geste de Bruno)
### 220. Fusion — émission d'événements vers le journal du service d'agents (Lot 2, vague 5)
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.76.0, redéploiement Docker en attente)
- **Fiche :** [features/fusion-journal-evenements-220.md](./features/fusion-journal-evenements-220.md)
- **Description :** ObsiGate émet ses mutations de notes **explicites**
vers le journal d'activité du service d'agents (contrat ACC-0.2,
carte fusion-trois-apps #18) : `obsigate.note.created / edited /
moved / deleted`, signées HMAC, en arrière-plan, inactives sans la
clé `AGENT_EVENEMENTS_CLE`. Points d'accroche : les endpoints
d'écriture de l'API web (acteur `user`) et la couche outils /
serveur MCP (acteur `agent:mcp`). **Garde-fou : jamais le
watcher** — seules les opérations explicites via l'API émettent.
- **Sous-tâches :**
- [x] Module `backend/agent_journal.py` (enveloppe ACC-0.2,
signature HMAC-SHA256, envoi « au mieux »)
- [x] Branchements `backend/routers/files_write.py` (créer,
sauvegarder, renommer, déplacer, supprimer)
- [x] Branchements `backend/tools/service.py` (create/edit/
append/rename/move/delete, acteur selon le mode)
- [x] Tests `tests/test_agent_journal.py` — dont la preuve du
garde-fou watcher (écriture disque + indexation = silence)
### 218. Fusion — panneau Spark intégré (agent commun)
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.74.0, redéploiement Docker en attente)
- **Fiche :** [features/fusion-panneau-spark-218.md](./features/fusion-panneau-spark-218.md)
- **Description :** le bouton Spark de la barre commune (#215) ouvre le
panneau de l'agent commun (service d'agents dracodev : conversation,
Aujourd'hui, approbations, missions longues), comme dans TikTak,
Flowdeck et Kanbante. Assets partagés vendangés à l'identique ;
la CSP nomme le service en `connect-src`.
- **Sous-tâches :**
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
vendangés depuis le panneau partagé du programme
- [x] `frontend/index.html` : link CSS + script, action `spark` →
`DRACO_PANNEAU.toggle()` (repli honnête si le panneau ne charge pas)
- [x] CSP `connect-src` : `https://agent.dracodev.net` ajouté
(`backend/main.py`, seul hôte ajouté)
- [x] Tests `test_draco_barre.py` (intégration + CSP verrouillées)
### 219. Fusion — panneau Spark v2 (Lot 1)
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.75.0, redéploiement Docker en attente)
- **Fiche :** [features/fusion-panneau-spark-v2-219.md](./features/fusion-panneau-spark-v2-219.md)
- **Description :** le panneau Spark (#218) passe en v2, à l'identique
dans les quatre applications (carte fusion-trois-apps #17) :
réponses de l'agent en **Markdown rendu** (HTML produit par le
service d'agents 0.10.0, sanitizé côté serveur), **largeur
réglable** persistée, **thème clair/sombre aligné** sur
l'application hôte, **discussions façon Muse** (principale
perpétuelle + parallèles créées/archivées/supprimées). Côté
ObsiGate : assets vendangés uniquement, aucun code propre touché.
- **Sous-tâches :**
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
remplacés par la v2 partagée du programme
- [x] Fiche de conception `features/fusion-panneau-spark-v2-219.md`
### 215. Fusion bloc 1 — barre d'apps commune et références croisées des notes
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-20 | **Statut :** ✅ livré le 2026-10-20 (2.70.0)
@@ -79,6 +155,22 @@
re-validé côté frontend (`auth.js`)
- [x] Tag OpenAPI « SSO », tests `test_draco_session.py` (22)
### 217. Fusion ACC-3.2 — validation locale du jeton `draco_session`
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.72.0)
- **Fiche :** [features/fusion-acc32-validation-locale-217.md](./features/fusion-acc32-validation-locale-217.md)
- **Description :** pendant consommateur de #216 — `get_current_user`
accepte le cookie partagé à défaut de jeton natif valide ; le `sub`
étant l'identifiant ObsiGate, la correspondance vers le compte local
est directe (compte actif exigé, garde-fou BUG-028 appliqué).
Issue Gitea ObsiGate #9 ; épopée Projets/fusion-trois-apps#7.
- **Sous-tâches :**
- [x] `draco_session.user_for_draco()` : résolution par id, `_draco_sub`
- [x] Repli `_draco_fallback()` sur toutes les sorties « non
authentifié » du chemin natif (`backend/auth/middleware.py`)
- [x] Tests `test_draco_session.py` (+4 : cookie seul, révoqué,
sub inconnu, inerte sans secret)
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
@@ -0,0 +1,51 @@
# #217 — Validation locale du jeton draco_session (Fusion ACC-3.2)
Statut : ✅ livré le 2026-10-21 (2.72.0)
## Contexte
Suite de l'émettrice SSO (#216, ACC-3.1). Ici, ObsiGate joue son rôle
de **consommatrice** : `get_current_user` accepte le cookie partagé
`draco_session` à défaut de jeton natif valide. C'est la brique qui
servira aussi à l'API de l'agent commun (ACC-4) pour authentifier les
requêtes SSO. Les validateurs des autres applications sont leurs
tickets respectifs (Flowdeck #21, TikTak #213 — TikTak 0.52.0).
## Correspondance sub → compte local
ObsiGate est émettrice ET consommatrice : le `sub` du jeton **est**
son identifiant d'utilisateur — la résolution est directe, par `id`,
dans `draco_session.user_for_draco()` :
- signature/expiration/révocation vérifiées par `verify_token` (#216) ;
- compte **actif** exigé ;
- garde-fou BUG-028 appliqué à l'identique : un jeton draco émis
avant le dernier changement de mot de passe (`password_changed_at`)
est refusé ;
- le dict utilisateur retourné porte `_token_vaults` (droits du
compte), `_token_scope = "full"` et `_draco_sub` (traçabilité).
## Branchement
Dans `backend/auth/middleware.py`, chaque sortie « non authentifié »
du chemin natif (aucun jeton, jeton illisible, jti natif révoqué,
compte introuvable/inactif, jeton antérieur au changement de mot de
passe) passe par `_draco_fallback()`. Sûreté du repli après
déconnexion globale : le logout révoque aussi le `jti` draco et
efface le cookie (#216), donc un ancien cookie rejoué est refusé par
`verify_token`.
## Limite connue
Le repli authentifie les **appels d'API**. Le bootstrap d'une
session complète du SPA (jetons natifs émis à partir du seul cookie
draco) n'est pas couvert : pour ObsiGate, émettrice, la connexion
native EST la connexion SSO — le cas d'usage du repli est la
continuité d'API quand le jeton natif a expiré avant le cookie
partagé, et les consommateurs machine de l'API.
## Tests
`tests/test_draco_session.py` (+4) : `/api/auth/me` avec le seul
cookie draco (200, bon utilisateur), jti révoqué refusé, sub inconnu
refusé, inactivité sans secret configuré.
@@ -0,0 +1,98 @@
# #220 — Fusion : émission d'événements vers le journal du service d'agents
**Contexte.** Le Lot 2 du programme Fusion (carte fusion-trois-apps
#18) dote le service d'agents d'un **journal d'activité** cross-apps :
chaque application y émet ses mutations significatives au contrat
**ACC-0.2** — enveloppe `{id, type, occurred_at, actor, domain,
source_app, object{ref, label, url}}` signée **HMAC-SHA256** du corps
brut (en-tête `X-Draco-Signature`, clé propre à chaque application),
que le service expose dans la vue Journal de sa console (agent-commun
0.11.0). TikTak (0.59.0) et Kanbante (0.28.0) émettent déjà ; ObsiGate
est la troisième source, après Flowdeck.
**Principe directeur : référence, jamais contenu.** L'enveloppe ne
contient que la référence de la note (`fichier:<vault>/<chemin>`),
son nom (libellé) et un lien profond (`/#file=<vault>:<chemin>`) —
jamais son contenu.
## Garde-fou central : jamais le watcher
ObsiGate surveille les vaults par un **watcher** de système de
fichiers (`backend/watcher.py`) qui réindexe chaque changement
disque — le vault principal compte **222 393 fichiers**. Brancher
l'émission sur le watcher inonderait le journal (et confondrait les
gestes d'Obsidian, de la synchro de fichiers ou d'un éditeur externe
avec des opérations faites *dans* ObsiGate).
L'émission n'a donc lieu que pour les **opérations explicites via
l'API web**, à deux points d'accroche, et nulle part ailleurs :
| Source | Point d'accroche | Acteur |
|---|---|---|
| Interface web / clients REST | `backend/routers/files_write.py` | `user` |
| Serveur MCP d'ObsiGate (outils) | `backend/tools/service.py`, mode `mcp` | `agent:mcp` |
| Assistant intégré (outils, mode `in_app`) | `backend/tools/service.py` | `user` |
Conséquences assumées :
- une note créée/modifiée **hors API** (Obsidian, éditeur externe,
copie de fichiers) n'apparaît **pas** au journal — c'est voulu ;
- les écritures internes qui passent par la couche mutations sans
passer par ces points (planificateur, crawler, fusion de doublons,
restauration de sauvegarde) n'émettent pas non plus ;
- le test `test_watcher_et_indexation_sans_evenement` verrouille le
garde-fou : écriture disque directe + gestionnaires du watcher +
`update_single_file` de l'indexeur → **zéro émission**.
## Catalogue d'événements émis
| Type | Déclencheur |
|---|---|
| `obsigate.note.created` | `POST /api/file/{vault}` ; outil `create_file` |
| `obsigate.note.edited` | `PUT /api/file/{vault}/save` (+ variantes xlsx/csv : cellules, structure, style) ; outils `edit_file`, `append_to_file` |
| `obsigate.note.moved` | `PATCH /api/file/{vault}` (renommage), `POST /api/move/{vault}` (**fichiers seulement**) ; outils `rename_file`, `move_path` (fichiers seulement) — la référence pointe sur le **nouveau** chemin |
| `obsigate.note.deleted` | `DELETE /api/file/{vault}` ; outil `delete_file` |
Hors catalogue (non émis, volontairement) : dossiers (création,
renommage, déplacement, suppression — le catalogue est `note.*`),
téléversement par lots (`batch-upload`), duplication, restauration
de sauvegarde, `replace_in_files` (multi-fichiers).
## Module `backend/agent_journal.py`
- **Activation** : `AGENT_EVENEMENTS_CLE` (clé propre à ObsiGate,
jamais en dur). Sans clé, le module est **inactif** : aucun appel,
aucun bruit. URL surchargée par `AGENT_EVENEMENTS_URL` (défaut
`https://agent.dracodev.net/api/v1/evenements`).
- **Discipline « au mieux »** (identique à TikTak/Kanbante) : envoi
dans un fil d'arrière-plan démon via httpx, délai maximal 2 s,
exceptions avalées avec une ligne de journal local — une panne du
service d'agents ne casse ni ne ralentit jamais le métier (prouvé
par `test_recepteur_en_panne_ne_casse_rien`).
- **Domaine** : ObsiGate ne modélise pas encore le domaine d'un
vault (la question Q-A du programme — les domaines sont-ils des
vaults distincts ? — est toujours ouverte). La correspondance peut
être déclarée par `AGENT_EVENEMENTS_DOMAINES` (JSON
`{"NomDuVault": "famille"}`) ; repli `pro`, qui est aussi le
défaut du journal du service.
## Activation en production (gestes de Bruno)
1. Générer une clé propre à ObsiGate (`openssl rand -hex 32`).
2. Service d'agents : ajouter l'entrée `obsigate` à
`AGENT_EVENEMENTS_CLES` (JSON des clés par application), recréer
le conteneur.
3. ObsiGate : poser `AGENT_EVENEMENTS_CLE=<même valeur>` dans son
environnement et **redéployer** (Docker).
Tant que la clé n'est pas posée des deux côtés, ObsiGate n'émet
rien — c'est le comportement voulu.
## Tests
`tests/test_agent_journal.py` (7 tests) : enveloppe et signature
revérifiées comme le fait le service ; cycle de vie complet en REST
(créer → éditer → renommer → déplacer → supprimer, acteur `user`) ;
chemin outils/MCP (`create/edit/rename/delete`, acteur
`agent:mcp`) ; inactivité sans clé ; garde-fou watcher/indexation ;
panne du récepteur sans effet métier ; correspondance de domaines.
@@ -0,0 +1,42 @@
# #221 — Fusion : bouton « Console ↗ » dans le panneau Spark
**Contexte.** Le panneau Spark (#218) et la console web du service
d'agents (Lot 2, https://agent.dracodev.net/) sont deux faces du même
service : mêmes discussions, mêmes approbations, mêmes missions. Il
manquait le lien de navigation entre les deux. Demande de Bruno
(2026-10-22) : un bouton dans le panneau qui ouvre la console.
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
maintenu en canonique dans TikTak et recopié **octet-identique** dans
les 4 applications (TikTak, Flowdeck, Kanbante, ObsiGate) :
- un bouton fantôme « Console ↗ » dans la barre des discussions, à
côté de « ☰ Discussions » et « + Nouvelle » (même classe `d-btn`,
donc visible en thème clair comme sombre, le panneau suivant le
thème de l'app hôte) ;
- au clic, `ouvrirConsole()` construit l'URL **à ce moment-là** (la
discussion peut avoir changé sans recharger le panneau) :
`https://agent.dracodev.net/?disc=<id de la discussion affichée>&app=<app hôte>` ;
- la discussion vient de l'état du panneau (`convId`, repli
`sessionStorage draco_panneau_conv`) ; l'app hôte est déduite du
sous-domaine par `appHote()` (`og` → `obsigate`, `tiktak`,
`flowdeck`, `kanbante` ; hôte inconnu → pas de paramètre) ;
- ouverture en **nouvel onglet** (`window.open(..., "_blank",
"noopener")`). Sans discussion connue : lien simple vers la
racine de la console.
La console consomme déjà les deux paramètres : `?disc=` ouvre la
discussion demandée (lien profond), `?app=` s'affiche en puce
« Ouvert depuis : … » dans « Ce que Spark voit ».
**Vérifications.** Syntaxe JS (`node --check`), test fonctionnel de
la construction d'URL (4 cas : app connue/inconnue × discussion
présente/absente), test de non-régression
`tests/test_draco_barre.py::test_panneau_bouton_console_221`
(marqueurs du bouton et de la logique dans l'asset servi), somme
md5 commune des 4 copies : `c67cc2ab8e657b4e545fa93b28c3a0c4`.
**Versions.** ObsiGate 2.76.1 (cette livraison) ; TikTak 0.59.1 et
Flowdeck 7.80.1 déployées automatiquement sur release ; Kanbante
0.28.1 publiée. **Activation côté ObsiGate : redéploiement Docker
par Bruno.**
+38
View File
@@ -0,0 +1,38 @@
# #218 — Fusion : panneau Spark intégré (agent commun)
**Contexte.** Le programme Fusion a livré le service d'agents commun
(agent-commun : conversation LLM, lectures/écritures des quatre
applications via approbations, sous-agents, missions longues). Trois
applications affichaient déjà son panneau partagé (TikTak, Flowdeck,
Kanbante) ; ObsiGate, pourtant émettrice de l'identité `draco_session`,
gardait le message d'attente « arrive en phase 4 » sur le bouton Spark
de sa barre commune (#215).
**Livraison.**
- Assets vendangés à l'identique des autres applications :
`frontend/draco-panneau.js` et `frontend/draco-panneau.css`, à la
racine de `frontend/` comme les autres assets draco (servis par le
montage `/static`, `Cache-Control: no-cache` déjà en place — aucun
mécanisme de versionnage à ajouter, `index.html` n'est pas un
gabarit Jinja).
- `frontend/index.html` : lien CSS après celui de la barre, script
différé après `draco-liens.js`, action `spark` de
`DRACO_BAR_CONFIG.onAction` branchée sur
`window.DRACO_PANNEAU.toggle()` avec repli explicite si l'asset ne
charge pas.
- CSP (`backend/main.py`, `SecurityHeadersMiddleware`) :
`https://agent.dracodev.net` ajouté à `connect-src` — le panneau
appelle le service en `fetch` avec le cookie de session ; aucun
autre hôte n'est ouvert (précédent : Flowdeck, même exception).
**Vérifications.** Tests `tests/test_draco_barre.py` étendus
(présence et références des assets, branche spark, hôte CSP) ;
vérification visuelle et E2E à confirmer après redéploiement Docker
(le panneau, `z-index: 400`, cohabite avec des surfaces ObsiGate à
z-index plus élevé — à observer en réel).
**Hors périmètre.** Les textes du panneau vivent dans l'asset partagé
du programme (français, identique dans les quatre applications) ; ils
ne passent pas par les locales FR/EN d'ObsiGate — écart assumé au
niveau du programme, pas de cette fiche.
@@ -0,0 +1,29 @@
# #219 — Fusion : panneau Spark v2 (Lot 1)
**Contexte.** Le panneau Spark livré en 2.74.0 (#218) affichait les
réponses de l'agent en texte brut (les `##` et `**` du Markdown
restaient visibles), avait une largeur fixe, un thème clair codé en
dur déconnecté de l'application hôte, et une seule conversation sans
gestion. Le Lot 1 du « Panneau v2 » (carte fusion-trois-apps #17,
arbitrage de Bruno du 2026-10-22) corrige ces quatre points, à
l'identique dans les quatre applications du programme.
**Livraison (côté ObsiGate : assets vendangés uniquement).**
- `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
remplacés par la v2 partagée (octet pour octet avec TikTak,
Flowdeck et Kanbante) :
- **Markdown rendu** : les réponses et les rapports de missions
affichent le HTML produit par le service d'agents (0.10.0 :
mistune + sanitizer côté serveur) ;
- **largeur réglable** à la souris (poignée, persistée,
double-clic = largeur par défaut) ;
- **thème clair/sombre aligné** sur l'application hôte
(`html[data-theme]`, ici celui d'ObsiGate, suivi en direct) ;
- **discussions façon Muse** : discussion principale
perpétuelle + discussions parallèles créées, archivées et
supprimées depuis le tiroir du panneau.
- Le travail de fond est dans le service d'agents (agent-commun
0.10.0) ; ObsiGate ne change aucun code propre. Le `/static` est
déjà servi en `Cache-Control: no-cache` : aucun versionnage
d'asset à ajouter. Redéploiement Docker requis.
+48 -9
View File
@@ -1,4 +1,8 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -7,6 +11,7 @@
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
position: relative;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
align-items: center; gap: 16px; padding: 0 12px;
@@ -24,9 +29,29 @@
.draco-bar-centre { justify-content: center; }
.draco-bar-droite { justify-content: flex-end; }
/* Marque */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
.draco-brand-mark { color: var(--agent); }
/* Marque — bouton du menu des applications (v0.1.3) */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
/* Menu des applications — construit par le JS depuis les onglets */
.draco-apps-menu {
position: absolute; top: calc(100% + 6px); left: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-apps-menu[hidden] { display: none; }
.draco-apps-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
color: var(--ink); text-decoration: none;
}
.draco-apps-item:hover, .draco-apps-item:focus-visible { background: var(--surface-2); }
.draco-apps-item[data-active="true"] { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--agent); }
.draco-apps-item-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.draco-apps-item-nom { font-weight: 700; font-size: 14px; }
.draco-apps-item-role { font-size: 12px; color: var(--ink-2); }
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
.draco-apptabs { display: flex; gap: 6px; }
@@ -119,9 +144,23 @@
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
}
@media (max-width: 700px) {
/* Mobile : la barre du haut se condense ; la navigation d'apps passe
en barre basse propre à chaque app (patron du prototype v5+). */
.draco-apptabs, .draco-domaines-lbl { display: none; }
.draco-bar { height: 52px; }
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche, les domaines et le menu « ••• »
disparaissent : chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
restaient affichés et la barre débordait sur téléphone.
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
barre complète ne tenait pas (fenêtres réduites, tablettes,
webviews) alors que les apps sont déjà en mise en page mobile.) */
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
.draco-apptab { display: none; }
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
}
+79 -7
View File
@@ -1,4 +1,5 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -14,6 +15,9 @@
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
* des applications, construit depuis les onglets ; c'est le moyen de
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
*/
(function () {
"use strict";
@@ -21,15 +25,16 @@
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
if (!b) return;
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
});
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab").forEach(function (t) {
t.dataset.active = t.dataset.app === app ? "true" : "false";
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
@@ -38,8 +43,70 @@
});
}
/* ── Menu des applications (v0.1.3) ──────────────────────────────
* Construit depuis les onglets présents dans la barre : mêmes
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
* choix d'une application (la navigation suit son cours). */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var menu = null;
function buildMenu() {
if (!bar || !brand || menu) return;
menu = document.createElement("div");
menu.className = "draco-apps-menu";
menu.hidden = true;
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = t.getAttribute("href");
a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico");
if (ico) a.appendChild(ico.cloneNode(true));
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var fort = t.querySelector(".draco-apptab-txt strong");
var petit = t.querySelector(".draco-apptab-txt small");
var nom = document.createElement("span");
nom.className = "draco-apps-item-nom";
nom.textContent = fort ? fort.textContent : t.dataset.app;
txt.appendChild(nom);
if (petit) {
var role = document.createElement("span");
role.className = "draco-apps-item-role";
role.textContent = petit.textContent;
txt.appendChild(role);
}
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a);
});
bar.appendChild(menu);
}
function toggleMenu(force) {
buildMenu();
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
@@ -47,9 +114,14 @@
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false);
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
buildMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+189
View File
@@ -0,0 +1,189 @@
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
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 {
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
--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);
display: flex; flex-direction: column;
transform: translateX(105%); transition: transform .28s ease;
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-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 {
display: flex; align-items: center; gap: 9px;
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 .soustitre { font-size: 12px; color: var(--p-doux); }
.draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
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 .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; }
/* Barre des discussions (façon Muse) */
.draco-disc-bar {
display: flex; align-items: center; gap: 7px;
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
}
.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 {
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
border-radius: 12px; padding: 10px 12px;
}
.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 .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: var(--p-agent); color: var(--p-texte); }
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
.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: var(--p-doux); margin: 6px 0 4px; }
.draco-auj h5:first-child { margin-top: 0; }
.draco-auj ul { margin: 0; padding-left: 17px; }
.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-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.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
.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 var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
.draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input {
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 {
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
}
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
.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: var(--p-doux); margin: 0 0 6px; }
.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 {
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: var(--p-champ); color: var(--p-texte);
}
.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: var(--p-agent); color: var(--p-texte); }
.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 .etat-source { color: var(--p-doux); font-size: 12.5px; }
.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-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-failed { color: #cf4b32; }
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
.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-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; }
+702
View File
@@ -0,0 +1,702 @@
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
*
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
* GET /api/v1/me — identité (cookie draco_session)
* POST /api/v1/chat — conversation réelle du noyau (J2) :
* LLM DeepSeek + outils MCP des apps ;
* la réponse arrive avec son HTML
* 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)
* POST /api/v1/missions — missions longues (J5)
* 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 un nouvel onglet, sur la
* discussion courante (?disc=) et avec l'app d'origine (?app=),
* déduite du sous-domaine de l'hôte.
* États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
"use strict";
var API = "https://agent.dracodev.net";
var convId = null;
var convKind = null; // 'principale' | 'parallele'
var convTitre = "";
var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
function domaine() {
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, nouvel onglet) ─────────────── */
function appHote() {
var h = (window.location.hostname || "").split(".")[0];
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
kanbante: "kanbante", og: "obsigate" };
return correspondance[h] || null;
}
function ouvrirConsole() {
var params = [];
var id = convId || convStockee();
if (id) params.push("disc=" + encodeURIComponent(id));
var app = appHote();
if (app) params.push("app=" + encodeURIComponent(app));
window.open(API + "/" + (params.length ? "?" + params.join("&") : ""),
"_blank", "noopener");
}
/* ── 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());
}
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() {
root = document.createElement("aside");
root.className = "draco-panneau";
root.setAttribute("aria-label", "Spark, l'agent commun");
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">' +
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
' <div><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
'</div>' +
'<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>' +
'</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) dans un nouvel onglet, sur cette discussion">Console ↗</button>' +
'</div>' +
'<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' +
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
' <button type="button" data-outil="approbations">Approbations</button>' +
' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' +
'<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
etatEl = root.querySelector(".draco-panneau-etat");
inputEl = root.querySelector("input");
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
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="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer);
brancherPoignee(root.querySelector(".draco-poignee"));
appliquerTheme();
surveillerTheme();
appliquerLargeur();
window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
});
}
function ajouterMsg(kind, texte) {
var d = document.createElement("div");
d.className = "draco-msg " + kind;
d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
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) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
etatEl.querySelector(".txt").textContent = texte;
}
function majTitre() {
titreEl.textContent = convTitre || "Discussion principale";
titreEl.title = titreEl.textContent;
}
function verifierIdentite() {
if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" })
.then(function (r) {
if (r.status === 401) {
majEtat("err", "Session partagée non reconnue");
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
etatConnu = true;
return null;
}
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (me) {
if (!me) return;
etatConnu = true;
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
})
.catch(function () {
majEtat("err", "Service d'agents injoignable");
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
});
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
/* ── 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() {
var bloc = document.createElement("div");
bloc.className = "draco-auj";
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
var t = d.sources.tiktak;
h += "<h5>Tâches du jour — TikTak</h5>";
if (t.status === "ok") {
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (t.status === "non_configure") {
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
} else if (t.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
} else {
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
}
var k = d.sources.kanbante;
h += "<h5>Cartes en Révision — Kanbante</h5>";
if (k.status === "ok") {
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (k.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
} else {
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
}
bloc.innerHTML = h;
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () {
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
});
}
function chargerApprobations() {
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
d.approvals.forEach(function (a) {
var bloc = document.createElement("div");
bloc.className = "draco-appro";
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
"<div class='resume'>" + esc(a.summary) + "</div>" +
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
"<button type='button' class='non'>Rejeter</button></div>";
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
msgsEl.appendChild(bloc);
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
}
function decider(id, decision, bloc) {
fetch(API + "/api/v1/approvals/" + id + "/decide", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: decision })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
bloc.style.opacity = ".55";
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 ?)."); });
}
/* ── Missions longues (J5) ───────────────────────────────────
* Sélecteur d'exécuteur alimenté par le registre du service :
* les externes non configurés sont montrés mais désactivés —
* jamais un lancement qui simule. La liste se rafraîchit à
* l'ouverture et après chaque lancement/annulation. */
var STATUTS_MISSION = {
queued: "En file", running: "En cours…", paused_question: "Question en attente",
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
};
function ouvrirMissions() {
var bloc = document.createElement("div");
bloc.className = "draco-missions";
bloc.innerHTML =
"<h5>Missions longues</h5>" +
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
msgsEl.appendChild(bloc);
msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/executors", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var sel = bloc.querySelector(".m-exec");
sel.innerHTML = "";
d.executors.forEach(function (e) {
if (e.family !== "sous-agent" && e.family !== "externe") return;
var opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
(e.status === "non_configure" ? " — non configuré" : "");
if (e.status === "non_configure") opt.disabled = true;
sel.appendChild(opt);
});
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
})
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
chargerMissions(bloc);
}
function chargerMissions(bloc) {
var zone = bloc.querySelector(".m-liste");
fetch(API + "/api/v1/missions", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
zone.innerHTML = "";
d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div");
ligne.className = "m-item";
var entete = document.createElement("div");
entete.className = "m-entete";
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
ligne.appendChild(entete);
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") {
var btn = document.createElement("button");
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
btn.addEventListener("click", function () {
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, body: "{}"
}).then(function () { chargerMissions(bloc); })
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
});
ligne.appendChild(btn);
}
zone.appendChild(ligne);
});
})
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
}
function lancerMission(bloc) {
var executor = bloc.querySelector(".m-exec").value;
var titre = bloc.querySelector(".m-titre").value.trim();
var prompt = bloc.querySelector(".m-prompt").value.trim();
var carte = bloc.querySelector(".m-carte").value;
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
if (carte) corps.kanbante_number = parseInt(carte, 10);
fetch(API + "/api/v1/missions", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps)
})
.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();
})
.then(function () {
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
chargerMissions(bloc);
})
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
}
function envoyer(ev) {
ev.preventDefault();
var texte = inputEl.value.trim();
if (!texte) return;
inputEl.value = "";
ajouterMsg("user", texte);
sendBtn.disabled = true;
var attente = ajouterMsg("agent", "Spark réfléchit…");
fetch(API + "/api/v1/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
})
.then(function (r) {
if (r.status === 401) {
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
majEtat("err", "Session partagée non reconnue");
return null;
}
if (!r.ok) {
return r.json().catch(function () { return null; }).then(function (e) {
attente.textContent = (e && e.detail) ? e.detail :
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
return null;
});
}
return r.json();
})
.then(function (d) {
if (!d) return;
convId = d.conversation_id;
convKind = d.kind || convKind;
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) {
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
}
majEtat("ok", "Connecté au service — domaine " + d.domain);
})
.catch(function () {
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
majEtat("err", "Service d'agents injoignable");
})
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
}
function ouvrir() {
if (!root) { construire(); demarrerDiscussions(); }
root.classList.add("open");
appliquerTheme();
verifierIdentite();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
window.DRACO_PANNEAU = {
open: ouvrir,
close: fermer,
toggle: function () {
if (root && root.classList.contains("open")) fermer(); else ouvrir();
}
};
})();
+20 -4
View File
@@ -76,6 +76,7 @@
</style>
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/draco-barre.css" />
<link rel="stylesheet" href="/static/draco-panneau.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
@@ -331,14 +332,14 @@
<!-- Main Application (wrapped for show/hide) -->
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.0
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
évolution passe par une nouvelle version de commons.
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" aria-label="dracodev">
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d&#39;application" title="Changer d&#39;application">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
@@ -409,11 +410,25 @@
setTimeout(function () { d.remove(); }, 3400);
};
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var b = document.getElementById("sidebar-toggle-btn"); if (b) b.click(); return; }
if (act === "rail") {
// Bureau : afficher/masquer la barre latérale.
// Mobile (≤ 768 px, point de bascule d'ObsiGate) :
// le vrai hamburger, qui ouvre le tiroir — avant
// ce correctif (BUG-118), le rail cliquait le
// bouton bureau et ne faisait rien sur téléphone.
var mobile = window.matchMedia("(max-width: 768px)").matches;
var b = document.getElementById(mobile ? "hamburger-btn" : "sidebar-toggle-btn");
if (b) b.click();
return;
}
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
return;
}
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
@@ -6042,5 +6057,6 @@ curl -X POST https://votre-serveur.com/webhook \
</div>
<script defer src="/static/draco-barre.js"></script>
<script defer src="/static/draco-liens.js"></script>
<script defer src="/static/draco-panneau.js"></script>
</body>
</html>
+1 -1
View File
@@ -14296,7 +14296,7 @@ textarea.file-chat-input {
/* — Barre d'apps commune dracodev + section « Liens » des notes
(Fusion #215, snippet figé dracodev-commons v0.1.0) — */
body.has-draco-bar .app-container { height: calc(100vh - 60px); }
@media (max-width: 700px) {
@media (max-width: 900px) {
body.has-draco-bar .app-container { height: calc(100vh - 52px); }
}
.draco-liens {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.71.0",
"version": "2.76.1",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+302
View File
@@ -0,0 +1,302 @@
"""Émission d'événements vers le journal du service d'agents (#220,
Fusion Lot 2 vague 5 — contrat ACC-0.2).
Un faux récepteur local capture les POST signés HMAC et les vérifie
comme le fait le service d'agents ; on prouve aussi l'inactivité sans
clé, la non-régression métier quand le récepteur est en panne, et le
garde-fou central : le watcher / l'indexation n'émettent JAMAIS
(seules les écritures explicites via l'API ou les outils le font).
"""
import asyncio
import hashlib
import hmac
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from types import SimpleNamespace
import pytest
CLE = "cle-obsigate-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(types, delai=6.0):
"""Attend que chaque type demandé soit arrivé ; retourne
{type: enveloppe} — la signature HMAC de CHAQUE envoi est
revérifiée ici, comme le fait le service."""
vus = {}
fin = time.time() + delai
while time.time() < fin and not all(t in vus for t in types):
for recu in list(_Recepteur.recus):
env = json.loads(recu["corps"].decode("utf-8"))
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"
vus.setdefault(env["type"], env)
if not all(t in vus for t in types):
time.sleep(0.05)
manquants = [t for t in types if t not in vus]
assert not manquants, f"événements jamais reçus : {manquants}"
return vus
# ── Enveloppe ────────────────────────────────────────────────────────
def test_enveloppe_et_signature(monkeypatch):
from backend import agent_journal
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
env, corps = agent_journal.construire_enveloppe(
agent_journal.TYPE_CREE, "TestVault", "Journal/nouvelle.md")
assert env["source_app"] == "obsigate"
assert env["domain"] == "pro" # repli : aucun domaine modélisé (Q-A)
assert env["actor"] == "user"
assert env["object"] == {
"ref": "fichier:TestVault/Journal/nouvelle.md",
"label": "nouvelle.md",
"url": "/#file=TestVault:Journal/nouvelle.md",
}
assert json.loads(corps.decode("utf-8")) == env
assert env["id"] and env["occurred_at"]
sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
assert len(sig) == 64
def test_domaine_par_vault(monkeypatch):
from backend import agent_journal
monkeypatch.setenv("AGENT_EVENEMENTS_DOMAINES",
json.dumps({"TestVault": "famille"}))
assert agent_journal.domaine_du_vault("TestVault") == "famille"
assert agent_journal.domaine_du_vault("AutreVault") == "pro"
# ── Écritures REST (acteur « user ») ─────────────────────────────────
def test_cycle_de_vie_rest(client, monkeypatch):
serveur = _demarrer(monkeypatch)
try:
r = client.post("/api/file/TestVault", json={
"path": "Journal/nouvelle.md", "content": "# Nouvelle\n"})
assert r.status_code == 200
r = client.put("/api/file/TestVault/save",
params={"path": "Journal/nouvelle.md"},
json={"content": "# Nouvelle v2\n"})
assert r.status_code == 200
r = client.patch("/api/file/TestVault", json={
"path": "Journal/nouvelle.md", "new_name": "renommee.md"})
assert r.status_code == 200
r = client.post("/api/move/TestVault", json={
"source_path": "Journal/renommee.md", "destination_dir": "Projets"})
assert r.status_code == 200
r = client.delete("/api/file/TestVault",
params={"path": "Projets/renommee.md"})
assert r.status_code == 200
vus = _attendre([
"obsigate.note.created", "obsigate.note.edited",
"obsigate.note.moved", "obsigate.note.deleted"])
env = vus["obsigate.note.created"]
assert env["actor"] == "user"
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
assert env["object"]["label"] == "nouvelle.md"
assert env["object"]["url"] == "/#file=TestVault:Journal/nouvelle.md"
env = vus["obsigate.note.edited"]
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
# moved : émis deux fois (renommage puis déplacement). Les
# envois partent en fils d'arrière-plan — l'ordre d'arrivée
# n'est pas garanti, on compare l'ensemble des références.
refs_deplacees = {
json.loads(recu["corps"].decode("utf-8"))["object"]["ref"]
for recu in _Recepteur.recus
if json.loads(recu["corps"].decode("utf-8"))["type"]
== "obsigate.note.moved"}
assert refs_deplacees == {
"fichier:TestVault/Journal/renommee.md",
"fichier:TestVault/Projets/renommee.md"}
env = vus["obsigate.note.deleted"]
assert env["object"]["ref"] == "fichier:TestVault/Projets/renommee.md"
finally:
serveur.shutdown()
# ── Écritures par les outils / MCP (acteur « agent:mcp ») ───────────
@pytest.fixture
def vault_outils(tmp_path, monkeypatch):
"""Vault minimal pour la couche outils (patron test_document_tools)."""
import backend.indexer
vault_dir = tmp_path / "Vault"
vault_dir.mkdir()
monkeypatch.setitem(
backend.indexer.index, "Vault",
{"name": "Vault", "path": str(vault_dir), "config": {}})
return vault_dir
def test_outils_mcp_emettent_agent_mcp(vault_outils, monkeypatch):
from backend.tools.api import ToolContext, ToolMode, call_tool
serveur = _demarrer(monkeypatch)
try:
ctx = ToolContext(
user={"username": "tester", "role": "admin", "vaults": ["*"]},
mode=ToolMode.MCP, audit_enabled=False)
out = call_tool("create_file", ctx, {
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP\n",
}, confirm=True)
assert out.ok
out = call_tool("edit_file", ctx, {
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP v2\n",
}, confirm=True)
assert out.ok
out = call_tool("rename_file", ctx, {
"vault": "Vault", "path": "par-mcp.md", "new_name": "renomme-mcp.md",
}, confirm=True)
assert out.ok
out = call_tool("delete_file", ctx, {
"vault": "Vault", "path": "renomme-mcp.md",
}, confirm=True)
assert out.ok
vus = _attendre([
"obsigate.note.created", "obsigate.note.edited",
"obsigate.note.moved", "obsigate.note.deleted"])
for env in vus.values():
assert env["actor"] == "agent:mcp"
assert vus["obsigate.note.created"]["object"]["ref"] == \
"fichier:Vault/par-mcp.md"
assert vus["obsigate.note.moved"]["object"]["ref"] == \
"fichier:Vault/renomme-mcp.md"
finally:
serveur.shutdown()
# ── Inactivité sans clé ──────────────────────────────────────────────
def test_inactif_sans_cle(client, monkeypatch):
serveur = _demarrer(monkeypatch)
monkeypatch.delenv("AGENT_EVENEMENTS_CLE")
try:
from backend import agent_journal
assert agent_journal.actif() is False
assert agent_journal.emettre(
agent_journal.TYPE_CREE, "TestVault", "x.md") is False
r = client.post("/api/file/TestVault", json={
"path": "sans-journal.md", "content": "# Rien\n"})
assert r.status_code == 200
time.sleep(0.6)
assert _Recepteur.recus == []
finally:
serveur.shutdown()
# ── Garde-fou : watcher et indexation n'émettent jamais ─────────────
def test_watcher_et_indexation_sans_evenement(client, test_vault_dir,
monkeypatch):
"""Une écriture disque externe (éditeur, synchro de fichiers…)
traitée par le watcher puis l'indexeur ne produit AUCUN
événement : seuls les appels explicites à l'API émettent."""
from pathlib import Path
appels = []
monkeypatch.setattr(
"backend.agent_journal.emettre",
lambda *a, **k: appels.append((a, k)) or False)
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
cible = Path(test_vault_dir) / "externe.md"
cible.write_text("# Écrit hors API\n", encoding="utf-8")
# Chemin watcher : les gestionnaires d'événements filesystem.
from backend.watcher import VaultEventHandler
boucle = asyncio.new_event_loop()
try:
file_attente: asyncio.Queue = asyncio.Queue()
handler = VaultEventHandler("TestVault", file_attente, boucle)
evt = SimpleNamespace(is_directory=False, src_path=str(cible),
dest_path=None)
handler.on_created(evt)
handler.on_modified(evt)
# Chemin indexation : la même fonction que le watcher appelle
# après debounce pour intégrer le fichier à l'index.
from backend.indexer import update_single_file
boucle.run_until_complete(update_single_file("TestVault", "externe.md"))
finally:
boucle.close()
assert appels == []
# ── Panne du récepteur : le métier n'est jamais affecté ─────────────
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:59999/api/v1/evenements")
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
r = client.post("/api/file/TestVault", json={
"path": "malgre-panne.md", "content": "# OK\n"})
assert r.status_code == 200
r = client.put("/api/file/TestVault/save",
params={"path": "malgre-panne.md"},
json={"content": "# OK v2\n"})
assert r.status_code == 200
r = client.get("/api/file/TestVault", params={"path": "malgre-panne.md"})
assert r.status_code == 200
assert "OK v2" in r.json()["html"] # le GET renvoie le Markdown rendu
+90
View File
@@ -0,0 +1,90 @@
"""BUG-118 — barre d'apps commune en mobile (Fusion ACC-1, #215).
Retours de Bruno (2026-10-21) sur téléphone : la barre débordait
(pastilles domaines et onglets entiers affichés) et son bouton ☰
ne faisait rien — il cliquait le bouton bureau
``sidebar-toggle-btn`` au lieu du vrai hamburger mobile
``hamburger-btn``. Correctifs verrouillés ici : CSS commune v0.1.2
(palier 700 px resserré selon la spec) et rail contextuel dans
index.html (mobile ≤ 768 px → hamburger-btn).
"""
from pathlib import Path
FRONTEND = Path(__file__).resolve().parent.parent / "frontend"
def _css() -> str:
return (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
def _index() -> str:
return (FRONTEND / "index.html").read_text(encoding="utf-8")
def test_palier_mobile_resserre_v012():
css = _css()
assert "@media (max-width: 900px)" in css
# Seul l'onglet de l'app courante reste visible en mobile.
assert '.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"]' in css
# Domaines et boutons icônes (rail, menu) masqués en mobile.
bloc = css.split("@media (max-width: 900px)", 1)[1]
assert ".draco-domaines" in bloc
assert "display: none" in bloc
def test_menu_des_applications_par_la_marque():
# v0.1.3 : la marque ✦ ouvre le menu des 4 applications
# (changement d'app en mobile), logo à 24 px.
html = _index()
assert 'data-act="apps"' in html
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert "draco-apps-menu" in js
assert "font-size: 24px" in _css()
def test_panneau_spark_integre_218():
# #218 : le bouton Spark de la barre ouvre le panneau de l'agent
# commun (mêmes assets partagés que TikTak / Flowdeck / Kanbante).
html = _index()
assert (FRONTEND / "draco-panneau.js").is_file()
assert (FRONTEND / "draco-panneau.css").is_file()
assert '<link rel="stylesheet" href="/static/draco-panneau.css" />' in html
assert '<script defer src="/static/draco-panneau.js"></script>' in html
# L'action spark bascule sur le panneau — plus le message d'attente.
assert "window.DRACO_PANNEAU.toggle()" in html
assert "arrive en phase 4 du programme Fusion" not in html
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert "https://agent.dracodev.net" in panneau
def test_panneau_bouton_console_221():
# #221 : le panneau Spark (asset vendangé, octet-identique dans
# les 4 apps) porte un bouton « Console ↗ » qui ouvre la console
# web du service dans un nouvel onglet, sur la discussion
# courante (?disc=) et avec l'app d'origine (?app= déduite du
# sous-domaine : og → obsigate).
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert 'data-disc="console"' in panneau
assert "Console ↗" in panneau
assert "ouvrirConsole" in panneau
assert '"disc=" + encodeURIComponent(id)' in panneau
assert '"app=" + encodeURIComponent(app)' in panneau
assert 'og: "obsigate"' in panneau
assert 'window.open(API + "/"' in panneau
assert '"_blank", "noopener"' in panneau
def test_csp_ouvre_le_service_d_agents_218():
# Le panneau parle au service d'agents : la CSP doit nommer son
# hôte en connect-src, sans ouvrir de générique supplémentaire.
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
ligne = next(l for l in main.splitlines() if "connect-src" in l)
assert "https://agent.dracodev.net" in ligne
def test_rail_mobile_vers_le_vrai_hamburger():
html = _index()
assert 'matchMedia("(max-width: 768px)")' in html
assert '"hamburger-btn"' in html
assert '"sidebar-toggle-btn"' in html
+35
View File
@@ -185,6 +185,41 @@ def test_sso_authorize_liste_blanche(client):
assert r.headers["location"] == "/" # refusé, pas de relais
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
def test_me_avec_cookie_draco_seul(admin_client):
# Aucune session native : le cookie partagé suffit (sub = id).
token, _jti, _exp = draco_session.issue_token(USER)
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
assert r.json()["username"] == "admin"
def test_me_draco_revoque_refuse(admin_client):
token, jti, exp = draco_session.issue_token(USER)
draco_session.revoke(jti, exp)
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_me_draco_sub_inconnu_refuse(admin_client):
token, _j, _e = draco_session.issue_token(
{"id": "personne-99", "username": "x", "display_name": "X"})
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_me_draco_inerte_sans_secret(admin_client, monkeypatch):
token, _j, _e = draco_session.issue_token(USER)
monkeypatch.delenv("DRACO_SESSION_SECRET")
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
token, jti, exp = draco_session.issue_token(USER)
draco_session.revoke(jti, exp)