Compare commits

...
4 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
17 changed files with 816 additions and 21 deletions
+39 -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.75.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.76.1**.
---
@@ -14,6 +14,44 @@ 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é
+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.75.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.75.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.75.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.75.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.75.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.76.1).
---
*Project: ObsiGate | Version: 2.75.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.76.1 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.75.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
+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.75.0"
version = "2.76.1"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.75.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.75.0",
"version": "2.76.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+43 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.75.0 | **Dernière mise à jour :** 2026-10-22
> **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,47 @@
## 🔵 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)
@@ -50,7 +91,7 @@
### 219. Fusion — panneau Spark v2 (Lot 1)
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** 🔵 en cours
- **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) :
@@ -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.**
+23
View File
@@ -18,6 +18,10 @@
* 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 () {
@@ -44,6 +48,23 @@
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");
@@ -122,6 +143,7 @@
' <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">' +
@@ -145,6 +167,7 @@
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);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.75.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
+17
View File
@@ -58,6 +58,23 @@ def test_panneau_spark_integre_218():
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.