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

This commit is contained in:
Spark
2026-10-22 19:58:25 +00:00
parent f4b8935189
commit f434f30286
14 changed files with 697 additions and 20 deletions
+22 -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.0**.
---
@@ -14,6 +14,27 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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.0-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.0).
---
*Projet : ObsiGate | Version : 2.75.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.76.0 | 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.0-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.0).
---
*Project: ObsiGate | Version: 2.75.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.76.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.75.0
2.76.0
+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.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.75.0"
version = "2.76.0"
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.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+23 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.75.0 | **Dernière mise à jour :** 2026-10-22
> **Version :** 2.76.0 | **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,28 @@
## 🔵 En cours
### 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 :** 🔵 en cours
- **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)
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.75.0",
"version": "2.76.0",
"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