Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
440cfdcc31 | ||
|
|
0ca92c3723 | ||
|
|
f434f30286 | ||
|
|
f4b8935189 |
+39
-1
@@ -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
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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(
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.75.0"
|
||||
version = "2.76.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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,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
@@ -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.**
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user