feat: émission d'événements vers le journal du service d'agents #220
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user