feat(ai): catalogue d'outils mutations + confirmations two-step (#79 phase D)
CI / lint (push) Successful in 59s
CI / security (push) Successful in 45s
CI / test (push) Successful in 1m22s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m37s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 20:52:02 -04:00
parent 31b65ade5b
commit 83c412b33d
13 changed files with 1747 additions and 589 deletions
+14
View File
@@ -14,6 +14,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Ajouté ### Ajouté
- **#79 Phase D — Catalogue d'outils mutations + confirmations two-step** — 11 nouveaux outils IA
destructifs ou d'écriture exposés par la couche partagée (`backend/tools/service.py`) :
`create_file`, `create_directory`, `edit_file`, `append_to_file`, `rename_file`,
`rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory`,
`restore_backup`. Nouveau service réutilisable `backend/services/mutations.py` (anti
path-traversal, garde lecture seule, **backup automatique** avant écriture/suppression/restauration) ;
les routes `/api/file/{vault}` (POST/PATCH/DELETE), `/api/file/{vault}/save`, `/api/directory/{vault}`
(POST/PATCH/DELETE), `/api/move/{vault}`, `/api/file/{vault}/restore` et `/api/search/replace`
délèguent désormais à cette couche. `backend/services/backups.py` expose `create_backup`
(source unique, réutilisée par `main._backup_file`). Confirmations **two-step** : tout outil
`WRITE`/`DANGEROUS` déclenche `ToolConfirmationRequired` (carte Apply in-app, `propose`/`apply`
côté MCP en phase E). **Toggle par vault** `aiDestructiveTools` (défaut : activés) pour désactiver
delete/rename/move/replace. Tests : `tests/test_tools_mutations.py` (40 tests).
- **#79 Phase C — Catalogue d'outils lecture & recherche** — 10 nouveaux outils IA exposés par la - **#79 Phase C — Catalogue d'outils lecture & recherche** — 10 nouveaux outils IA exposés par la
couche partagée (`backend/tools/service.py`) : `list_all_files`, `read_file_raw`, `get_backlinks`, couche partagée (`backend/tools/service.py`) : `list_all_files`, `read_file_raw`, `get_backlinks`,
`list_backups`, `diff_backup`, `get_graph`, `search_advanced`, `search_paths`, `suggest_tags`, `list_backups`, `diff_backup`, `get_graph`, `search_advanced`, `search_paths`, `suggest_tags`,
+202 -552
View File
@@ -36,7 +36,6 @@ from backend.history import (
) )
from backend.image_processor import preprocess_images from backend.image_processor import preprocess_images
from backend.indexer import ( from backend.indexer import (
SUPPORTED_EXTENSIONS,
_extract_tags, _extract_tags,
add_vault_to_index, add_vault_to_index,
build_index, build_index,
@@ -94,7 +93,6 @@ from backend.schemas import (
WebhookModel, WebhookModel,
) )
from backend.search import ( from backend.search import (
advanced_search,
init_inverted_index, init_inverted_index,
suggest_tags, suggest_tags,
suggest_titles, suggest_titles,
@@ -105,6 +103,36 @@ from backend.services.backups import list_backup_files as service_list_backup_fi
from backend.services.errors import ServiceError from backend.services.errors import ServiceError
from backend.services.files import read_raw_file from backend.services.files import read_raw_file
from backend.services.graph import get_graph as service_get_graph from backend.services.graph import get_graph as service_get_graph
from backend.services.mutations import (
create_directory as service_create_directory,
)
from backend.services.mutations import (
create_file as service_create_file,
)
from backend.services.mutations import (
delete_directory as service_delete_directory,
)
from backend.services.mutations import (
delete_file as service_delete_file,
)
from backend.services.mutations import (
edit_file as service_edit_file,
)
from backend.services.mutations import (
move_path as service_move_path,
)
from backend.services.mutations import (
rename_directory as service_rename_directory,
)
from backend.services.mutations import (
rename_file as service_rename_file,
)
from backend.services.mutations import (
replace_in_files as service_replace_in_files,
)
from backend.services.mutations import (
restore_backup as service_restore_backup,
)
from backend.services.recent import humanize_mtime, list_recent from backend.services.recent import humanize_mtime, list_recent
from backend.services.search import advanced_search_vaults, search_paths, search_vaults from backend.services.search import advanced_search_vaults, search_paths, search_vaults
from backend.services.search import list_tags as service_list_tags from backend.services.search import list_tags as service_list_tags
@@ -912,39 +940,14 @@ def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
def _backup_file(file_path: Path, vault_name: str, relative_path: str): def _backup_file(file_path: Path, vault_name: str, relative_path: str):
"""Create a timestamped backup of a file before modification. """Create a timestamped backup of a file before modification.
Backups are stored in {backup_root}/{vault}/{relative_path}.{timestamp}.bak Thin wrapper around :func:`backend.services.backups.create_backup`
Silently skips if the file doesn't exist or can't be read. (single implementation used by both routes and tools). Backups are stored
in ``{backup_root}/{vault}/{relative_path}.{timestamp}.bak``; the operation
is best-effort and never blocks the caller.
""" """
try: from backend.services.backups import create_backup
if not file_path.exists() or not file_path.is_file():
logger.debug(f"Backup skipped: file not found {file_path}")
return
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
# Resolve relative to vault root (inside vault, so it's always writable)
if not backup_root.is_absolute():
vault_data = get_vault_data(vault_name)
if vault_data:
backup_root = Path(vault_data["path"]) / backup_root
backup_dir = backup_root / vault_name / Path(relative_path).parent
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = int(time.time())
backup_name = f"{file_path.name}.{timestamp}.bak"
backup_path = backup_dir / backup_name
shutil.copy2(file_path, backup_path)
logger.info(f"Backup created: {relative_path} -> {backup_path}")
# Auto-cleanup: keep only the N most recent backups create_backup(file_path, vault_name, relative_path)
config = _load_config()
max_backups = config.get("max_backups_per_file", 10)
all_backups = sorted(
[f for f in backup_dir.iterdir() if f.is_file() and f.name.startswith(file_path.name + ".") and f.name.endswith(".bak")],
key=lambda f: f.stat().st_mtime, reverse=True
)
for old in all_backups[max_backups:]:
old.unlink()
logger.debug(f"Auto-cleanup: removed old backup {old.name}")
except Exception as e:
logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True)
def _check_vault_writable(vault_root: Path) -> bool: def _check_vault_writable(vault_root: Path) -> bool:
@@ -1642,36 +1645,15 @@ async def api_file_save(
""" """
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"]) content = body.get("content", "")
file_path = _resolve_safe_path(vault_root, path) result = service_edit_file(vault_name, path, content, backup=backup)
if not file_path.exists(): # Audit log
raise HTTPException(status_code=404, detail=f"File not found: {path}") client_ip = current_user.get("_request_ip", "unknown")
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
content = body.get('content', '') return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
try:
# Backup original content before overwriting (skip on auto-save)
if backup:
_backup_file(file_path, vault_name, path)
file_path.write_text(content, encoding="utf-8")
logger.info(f"File saved: {vault_name}/{path}")
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
return {"status": "ok", "vault": vault_name, "path": path, "size": len(content)}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
except Exception as e:
logger.error(f"Error saving file {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error saving file: {e!s}")
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse) @app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
@@ -1689,55 +1671,32 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
""" """
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"]) result = service_delete_file(vault_name, path)
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file(): # Audit log
raise HTTPException(status_code=404, detail=f"File not found: {path}") client_ip = current_user.get("_request_ip", "unknown")
log_file_delete(current_user["username"], vault_name, path, client_ip)
# Check if vault is writable # Update index
if not _check_vault_writable(vault_root): await remove_single_file(vault_name, path)
raise HTTPException(status_code=403, detail="Vault is read-only")
try: # Broadcast SSE event
# Backup original content before deletion await sse_manager.broadcast("file_deleted", {
_backup_file(file_path, vault_name, path) "vault": vault_name,
"path": path,
})
file_path.unlink() from backend.plugins import emit_file_deleted
logger.info(f"File deleted: {vault_name}/{path}") emit_file_deleted(vault_name, path)
# Audit log # Remove from recent files
client_ip = current_user.get("_request_ip", "unknown") remove_recent(current_user["username"], vault_name, path)
log_file_delete(current_user["username"], vault_name, path, client_ip)
# Update index # Dispatch webhooks
await remove_single_file(vault_name, path) await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
# Broadcast SSE event return {"status": "ok", "vault": result["vault"], "path": result["path"]}
await sse_manager.broadcast("file_deleted", {
"vault": vault_name,
"path": path,
})
from backend.plugins import emit_file_deleted
emit_file_deleted(vault_name, path)
# Remove from recent files
remove_recent(current_user["username"], vault_name, path)
# Dispatch webhooks
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
return {"status": "ok", "vault": vault_name, "path": path}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
except Exception as e:
logger.error(f"Error deleting file {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error deleting file: {e!s}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1762,60 +1721,35 @@ async def api_directory_create(
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) result = service_create_directory(vault_name, body.path)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"]) # Update path_index with the new directory
from backend.indexer import _index_lock
from backend.indexer import path_index as _path_idx
with _index_lock:
if vault_name not in _path_idx:
_path_idx[vault_name] = []
existing = {p["path"] for p in _path_idx[vault_name]}
# Build all parent segments
parts = body.path.split("/")
for i in range(1, len(parts) + 1):
seg_path = "/".join(parts[:i])
if seg_path and seg_path not in existing:
existing.add(seg_path)
_path_idx[vault_name].append({
"path": seg_path,
"name": parts[i - 1],
"type": "directory",
})
# Check if vault is writable # Broadcast SSE event
if not _check_vault_writable(vault_root): await sse_manager.broadcast("directory_created", {
raise HTTPException(status_code=403, detail="Vault is read-only") "vault": vault_name,
"path": result["path"],
})
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
# Resolve and validate path return {"success": True, "path": result["path"]}
dir_path = _resolve_safe_path(vault_root, body.path)
# Check if directory already exists
if dir_path.exists():
raise HTTPException(status_code=409, detail=f"Directory already exists: {body.path}")
try:
# Create directory with parents
dir_path.mkdir(parents=True, exist_ok=False)
logger.info(f"Directory created: {vault_name}/{body.path}")
# Update path_index with the new directory
from backend.indexer import _index_lock
from backend.indexer import path_index as _path_idx
with _index_lock:
if vault_name not in _path_idx:
_path_idx[vault_name] = []
existing = {p["path"] for p in _path_idx[vault_name]}
# Build all parent segments
parts = body.path.split("/")
for i in range(1, len(parts) + 1):
seg_path = "/".join(parts[:i])
if seg_path and seg_path not in existing:
existing.add(seg_path)
_path_idx[vault_name].append({
"path": seg_path,
"name": parts[i - 1],
"type": "directory",
})
# Broadcast SSE event
await sse_manager.broadcast("directory_created", {
"vault": vault_name,
"path": body.path,
})
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": body.path})
return {"success": True, "path": body.path}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot create directory")
except Exception as e:
logger.error(f"Error creating directory {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error creating directory: {e!s}")
@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse) @app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
@@ -1836,59 +1770,23 @@ async def api_directory_rename(
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) result = service_rename_directory(vault_name, body.path, body.new_name)
if not vault_data: old_path_str = result["old_path"]
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") new_path_str = result["new_path"]
vault_root = Path(vault_data["path"]) # Update index for all files in the directory
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Check if vault is writable # Broadcast SSE event
if not _check_vault_writable(vault_root): await sse_manager.broadcast("directory_renamed", {
raise HTTPException(status_code=403, detail="Vault is read-only") "vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
# Resolve old path return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
old_path = _resolve_safe_path(vault_root, body.path)
if not old_path.exists() or not old_path.is_dir():
raise HTTPException(status_code=404, detail=f"Directory not found: {body.path}")
# Construct new path (same parent, new name)
new_path = old_path.parent / body.new_name
# Validate new path is still within vault
new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root)))
# Check if destination already exists
if new_path.exists():
raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}")
try:
# Rename directory
old_path.rename(new_path)
old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/")
new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/")
logger.info(f"Directory renamed: {vault_name}/{old_path_str} -> {new_path_str}")
# Update index for all files in the directory
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Broadcast SSE event
await sse_manager.broadcast("directory_renamed", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot rename directory")
except Exception as e:
logger.error(f"Error renaming directory {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error renaming directory: {e!s}")
@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse) @app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
@@ -1909,48 +1807,22 @@ async def api_directory_delete(
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) result = service_delete_directory(vault_name, path, recursive=True)
if not vault_data: file_count = result["deleted_count"]
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"]) # Update index
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Check if vault is writable # Broadcast SSE event
if not _check_vault_writable(vault_root): await sse_manager.broadcast("directory_deleted", {
raise HTTPException(status_code=403, detail="Vault is read-only") "vault": vault_name,
"path": result["path"],
"deleted_count": file_count,
})
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
# Resolve and validate path return {"success": True, "deleted_count": file_count}
dir_path = _resolve_safe_path(vault_root, path)
if not dir_path.exists() or not dir_path.is_dir():
raise HTTPException(status_code=404, detail=f"Directory not found: {path}")
try:
# Count files before deletion
file_count = sum(1 for _ in dir_path.rglob('*') if _.is_file())
# Delete directory recursively
shutil.rmtree(dir_path)
logger.info(f"Directory deleted: {vault_name}/{path} ({file_count} files)")
# Update index
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Broadcast SSE event
await sse_manager.broadcast("directory_deleted", {
"vault": vault_name,
"path": path,
"deleted_count": file_count,
})
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": path})
return {"success": True, "deleted_count": file_count}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot delete directory")
except Exception as e:
logger.error(f"Error deleting directory {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error deleting directory: {e!s}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1975,59 +1847,21 @@ async def api_file_create(
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) result = service_create_file(vault_name, body.path, body.content)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"]) # Update index
await update_single_file(vault_name, result["path"])
# Check if vault is writable # Broadcast SSE event
if not _check_vault_writable(vault_root): await sse_manager.broadcast("file_created", {
raise HTTPException(status_code=403, detail="Vault is read-only") "vault": vault_name,
"path": result["path"],
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]})
from backend.plugins import emit_file_created
emit_file_created(vault_name, result["path"])
# Resolve and validate path return {"success": True, "path": result["path"]}
file_path = _resolve_safe_path(vault_root, body.path)
# Validate file extension
ext = file_path.suffix.lower()
if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"):
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
# Check if file already exists
if file_path.exists():
raise HTTPException(status_code=409, detail=f"File already exists: {body.path}")
try:
# Create parent directories if needed
file_path.parent.mkdir(parents=True, exist_ok=True)
# For .excalidraw files, inject skeleton JSON if content is empty
content = body.content
if ext == ".excalidraw" and not content.strip():
content = '{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
# Create file with initial content
file_path.write_text(content, encoding="utf-8")
logger.info(f"File created: {vault_name}/{body.path}")
# Update index
await update_single_file(vault_name, body.path)
# Broadcast SSE event
await sse_manager.broadcast("file_created", {
"vault": vault_name,
"path": body.path,
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": body.path})
from backend.plugins import emit_file_created
emit_file_created(vault_name, body.path)
return {"success": True, "path": body.path}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot create file")
except Exception as e:
logger.error(f"Error creating file {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error creating file: {e!s}")
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse) @app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
@@ -2048,68 +1882,27 @@ async def api_file_rename(
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) result = service_rename_file(vault_name, body.path, body.new_name)
if not vault_data: old_path_str = result["old_path"]
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") new_path_str = result["new_path"]
vault_root = Path(vault_data["path"]) # Update index
await handle_file_move(vault_name, old_path_str, new_path_str)
# Check if vault is writable # Update bookmarks, history, and shares
if not _check_vault_writable(vault_root): update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
raise HTTPException(status_code=403, detail="Vault is read-only") update_history_after_rename(vault_name, old_path_str, new_path_str)
update_shares_after_rename(vault_name, old_path_str, new_path_str)
# Resolve old path # Broadcast SSE event
old_path = _resolve_safe_path(vault_root, body.path) await sse_manager.broadcast("file_renamed", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
if not old_path.exists() or not old_path.is_file(): return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
raise HTTPException(status_code=404, detail=f"File not found: {body.path}")
# Construct new path (same parent, new name)
new_path = old_path.parent / body.new_name
# Validate new path is still within vault
new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root)))
# Validate file extension
ext = new_path.suffix.lower()
if ext not in SUPPORTED_EXTENSIONS and new_path.name.lower() not in ("dockerfile", "makefile"):
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
# Check if destination already exists
if new_path.exists():
raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}")
try:
# Rename file
old_path.rename(new_path)
old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/")
new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/")
logger.info(f"File renamed: {vault_name}/{old_path_str} -> {new_path_str}")
# Update index
await handle_file_move(vault_name, old_path_str, new_path_str)
# Update bookmarks, history, and shares
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
update_history_after_rename(vault_name, old_path_str, new_path_str)
update_shares_after_rename(vault_name, old_path_str, new_path_str)
# Broadcast SSE event
await sse_manager.broadcast("file_renamed", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot rename file")
except Exception as e:
logger.error(f"Error renaming file {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error renaming file: {e!s}")
@app.post("/api/move/{vault_name}", response_model=FileMoveResponse) @app.post("/api/move/{vault_name}", response_model=FileMoveResponse)
@@ -2133,95 +1926,28 @@ async def api_file_move(
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) result = service_move_path(vault_name, body.source_path, body.destination_dir)
if not vault_data: old_path_str = result["old_path"]
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") new_path_str = result["new_path"]
item_type = result["item_type"]
vault_root = Path(vault_data["path"]) # Update index
if item_type == "directory":
# Check if vault is writable from backend.indexer import reload_single_vault
if not _check_vault_writable(vault_root): await reload_single_vault(vault_name)
raise HTTPException(status_code=403, detail="Vault is read-only")
# Resolve source path
source = _resolve_safe_path(vault_root, body.source_path)
if not source.exists():
raise HTTPException(status_code=404, detail=f"Source not found: {body.source_path}")
is_directory = source.is_dir()
# Determine the item name (last segment)
item_name = source.name
# Construct destination path: destination_dir / item_name
dest_dir_clean = body.destination_dir.strip("/")
if dest_dir_clean:
dest_parent = _resolve_safe_path(vault_root, dest_dir_clean)
if not dest_parent.exists() or not dest_parent.is_dir():
raise HTTPException(status_code=404, detail=f"Destination directory not found: {body.destination_dir}")
else: else:
dest_parent = vault_root await handle_file_move(vault_name, old_path_str, new_path_str)
destination = dest_parent / item_name # Broadcast SSE event
await sse_manager.broadcast("item_moved", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
"item_type": item_type,
})
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
# Validate destination is within vault return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
destination = _resolve_safe_path(vault_root, str(destination.relative_to(vault_root)))
# Don't move to the same location
if source.resolve() == destination.resolve():
return {
"success": True,
"old_path": body.source_path,
"new_path": body.source_path,
"item_type": "directory" if is_directory else "file",
}
# Check if destination already exists
if destination.exists():
raise HTTPException(status_code=409, detail=f"A file or directory already exists at the destination: {destination.name}")
# For files, validate extension
if not is_directory:
ext = destination.suffix.lower()
if ext not in SUPPORTED_EXTENSIONS and destination.name.lower() not in ("dockerfile", "makefile"):
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
try:
# Move the file/directory
source.rename(destination)
old_path_str = str(source.relative_to(vault_root)).replace("\\", "/")
# After rename, source is now at destination — reconstruct the new relative path
new_path_str = body.destination_dir.strip("/")
new_path_str = (new_path_str + "/" if new_path_str else "") + item_name
item_type = "directory" if is_directory else "file"
logger.info(f"Item moved: {vault_name}/{old_path_str} -> {new_path_str}")
# Update index
if is_directory:
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
else:
await handle_file_move(vault_name, old_path_str, new_path_str)
# Broadcast SSE event
await sse_manager.broadcast("item_moved", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
"item_type": item_type,
})
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot move item")
except Exception as e:
logger.error(f"Error moving item {vault_name}/{body.source_path}: {e}")
raise HTTPException(status_code=500, detail=f"Error moving item: {e!s}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2331,64 +2057,28 @@ async def api_file_restore(
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) result = service_restore_backup(vault_name, path, body.version)
if not vault_data: current_backed_up = result["current_backed_up"]
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"]) # Update index
await update_single_file(vault_name, path)
# Check if vault is writable # Broadcast SSE event
if not _check_vault_writable(vault_root): await sse_manager.broadcast("file_restored", {
raise HTTPException(status_code=403, detail="Vault is read-only") "vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
})
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
file_path = _resolve_safe_path(vault_root, path) return {
original_name = Path(path).name "success": True,
"vault": vault_name,
backup_dir = _get_backup_dir(vault_name, path) "path": path,
backup_path = backup_dir / f"{original_name}.{body.version}.bak" "restored_from": body.version,
"current_backed_up": current_backed_up,
if not backup_path.exists(): }
raise HTTPException(status_code=404, detail=f"Backup version {body.version} not found for {path}")
try:
# Backup the current file before restoring
current_backed_up = None
if file_path.exists() and file_path.is_file():
_backup_file(file_path, vault_name, path)
current_backed_up = int(time.time())
# Read backup content
backup_content = backup_path.read_text(encoding="utf-8")
# Write restored content
file_path.write_text(backup_content, encoding="utf-8")
logger.info(f"File restored from backup: {vault_name}/{path} <- version {body.version}")
# Update index
await update_single_file(vault_name, path)
# Broadcast SSE event
await sse_manager.broadcast("file_restored", {
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
})
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
return {
"success": True,
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot restore file")
except Exception as e:
logger.error(f"Error restoring file {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error restoring file: {e!s}")
@app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse) @app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
@@ -2924,7 +2614,6 @@ async def api_search_replace(
current_user=Depends(require_auth), current_user=Depends(require_auth),
): ):
"""Find and replace across vault files.""" """Find and replace across vault files."""
import re as re_mod
query = body.get("query", "") query = body.get("query", "")
replacement = body.get("replacement", "") replacement = body.get("replacement", "")
vault_filter = body.get("vault", "all") vault_filter = body.get("vault", "all")
@@ -2939,71 +2628,32 @@ async def api_search_replace(
if not query: if not query:
raise HTTPException(400, "Query is required") raise HTTPException(400, "Query is required")
search_results = advanced_search( result = service_replace_in_files(
query, vault_filter=vault_filter, query,
case_sensitive=case_sensitive, whole_word=whole_word, replacement,
regex=regex_mode, include_paths=include_paths, exclude_paths=exclude_paths, vault=vault_filter,
limit=500, sort_by="relevance", case_sensitive=case_sensitive,
whole_word=whole_word,
regex=regex_mode,
include_paths=include_paths,
exclude_paths=exclude_paths,
replace_all=replace_all,
dry_run=dry_run,
is_vault_allowed=lambda v: check_vault_access(v, current_user),
) )
if not search_results["results"]:
return {"matches": [], "total_matches": 0}
flags = 0 if case_sensitive else re_mod.IGNORECASE
if regex_mode:
pattern = re_mod.compile(query, flags)
elif whole_word:
pattern = re_mod.compile(rf"\b{re_mod.escape(query)}\b", flags)
else:
pattern = re_mod.compile(re_mod.escape(query), flags)
matches = []
total_replacements = 0
for result in search_results["results"]:
if not check_vault_access(result["vault"], current_user):
continue
vault_data = get_vault_data(result["vault"])
if not vault_data:
continue
file_path = _resolve_safe_path(Path(vault_data["path"]), result["path"])
if not file_path.exists():
continue
try:
original = file_path.read_text(encoding="utf-8", errors="replace")
except Exception: # nosec B112 — fichier illisible, on passe au suivant
continue
occurrences = list(pattern.finditer(original))
if not occurrences:
continue
if dry_run:
previews = []
for m in occurrences[:3]:
start = max(0, m.start() - 40)
end = min(len(original), m.end() + 40)
previews.append(f"...{original[start:end]}...")
matches.append({
"vault": result["vault"], "path": result["path"],
"title": result["title"], "match_count": len(occurrences),
"preview": previews,
})
total_replacements += len(occurrences)
else:
new_content, count = pattern.subn(replacement, original)
if count > 0:
_backup_file(file_path, result["vault"], result["path"])
file_path.write_text(new_content, encoding="utf-8")
log_file_save(current_user["username"], result["vault"], result["path"], len(new_content))
matches.append({
"vault": result["vault"], "path": result["path"],
"title": result["title"], "replacements": count,
})
total_replacements += count
await update_single_file(result["vault"], str(file_path))
if dry_run: if dry_run:
return {"matches": matches, "total_matches": total_replacements, "dry_run": True} return result
return {"replaced": matches, "total_replacements": total_replacements}
# Side effects for applied replacements (audit + incremental index).
for match in result.get("replaced", []):
log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0))
vault_data = get_vault_data(match["vault"])
if vault_data:
abs_path = str(Path(vault_data["path"]) / match["path"])
await update_single_file(match["vault"], abs_path)
return result
@app.get("/api/suggest", response_model=SuggestResponse) @app.get("/api/suggest", response_model=SuggestResponse)
+68
View File
@@ -12,6 +12,8 @@ from __future__ import annotations
import difflib import difflib
import logging import logging
import os import os
import shutil
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -22,6 +24,19 @@ from backend.services.vaults import get_vault_root
logger = logging.getLogger("obsigate.services.backups") logger = logging.getLogger("obsigate.services.backups")
# Default number of backups kept per file (matches ``max_backups_per_file``).
DEFAULT_MAX_BACKUPS = 10
def _default_max_backups() -> int:
"""Read ``max_backups_per_file`` from app config (lazy, best-effort)."""
try:
from backend.main import _load_config
return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS))
except Exception: # pragma: no cover - config unavailable
return DEFAULT_MAX_BACKUPS
def get_backup_dir(vault_name: str, relative_path: str) -> Path: def get_backup_dir(vault_name: str, relative_path: str) -> Path:
"""Return the directory where backups for a specific file are stored. """Return the directory where backups for a specific file are stored.
@@ -39,6 +54,59 @@ def get_backup_dir(vault_name: str, relative_path: str) -> Path:
return backup_root / vault_name / Path(relative_path).parent return backup_root / vault_name / Path(relative_path).parent
def create_backup(
file_path: Path,
vault_name: str,
relative_path: str,
*,
max_backups: int | None = None,
) -> Path | None:
"""Create a timestamped backup of a file before it is modified.
Backups are stored as ``{backup_root}/{vault}/{relative_path}.{ts}.bak``.
Missing or unreadable files are skipped silently (returns ``None``) so a
backup failure never blocks the caller's mutation.
Args:
file_path: Absolute path of the file to back up.
vault_name: Name of the vault the file belongs to.
relative_path: Vault-relative path (used to mirror the tree).
max_backups: Number of backups to keep per file. ``None`` reads
``max_backups_per_file`` from the app config.
Returns:
The created backup path, or ``None`` when skipped.
"""
try:
if not file_path.exists() or not file_path.is_file():
logger.debug(f"Backup skipped: file not found {file_path}")
return None
backup_dir = get_backup_dir(vault_name, relative_path)
backup_dir.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir / f"{file_path.name}.{int(time.time())}.bak"
shutil.copy2(file_path, backup_path)
logger.info(f"Backup created: {relative_path} -> {backup_path}")
keep = max_backups if max_backups is not None else _default_max_backups()
all_backups = sorted(
[
f
for f in backup_dir.iterdir()
if f.is_file() and f.name.startswith(file_path.name + ".") and f.name.endswith(".bak")
],
key=lambda f: f.stat().st_mtime,
reverse=True,
)
for old in all_backups[keep:]:
old.unlink()
logger.debug(f"Auto-cleanup: removed old backup {old.name}")
return backup_path
except Exception as e:
logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True)
return None
def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any]]: def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any]]:
"""List all backup files for a vault file, sorted newest first. """List all backup files for a vault file, sorted newest first.
+675
View File
@@ -0,0 +1,675 @@
"""File and directory mutation services shared by REST routes and the AI tool layer.
Single source of truth for the write-side operations (create, edit, append,
rename, move, delete, restore, find/replace). Every function performs the
anti path-traversal check, the read-only guard and an automatic backup before
any destructive change, then returns a JSON-friendly result dict.
Index refresh, SSE broadcasts, webhooks and audit logging stay in the route /
agent layers: these services are synchronous and side-effect free apart from
the filesystem mutation (plus the backup).
"""
from __future__ import annotations
import logging
import os
import shutil
from collections.abc import Callable
from pathlib import Path
from typing import Any
from backend.services.backups import create_backup, get_backup_dir
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
from backend.services.vaults import get_vault_root
logger = logging.getLogger("obsigate.services.mutations")
# Skeleton injected into empty ``.excalidraw`` files (mirrors the route logic).
_EXCALIDRAW_SKELETON = (
'{"type":"excalidraw","version":2,"elements":[],'
'"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
)
def _rel(root: Path, path: Path) -> str:
"""Return *path* relative to *root* as a POSIX-style string."""
return str(path.relative_to(root)).replace("\\", "/")
def _ensure_writable(root: Path) -> None:
"""Raise ``read_only`` (403) when the vault root is not writable."""
if not os.access(root, os.W_OK):
raise ServiceError("Vault is read-only", code="read_only", status=403)
def _validate_extension(file_path: Path) -> None:
"""Reject unsupported file extensions (400)."""
from backend.indexer import SUPPORTED_EXTENSIONS
ext = file_path.suffix.lower()
if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"):
raise ServiceError(
f"Unsupported file extension: {ext}",
code="unsupported_extension",
status=400,
details={"extension": ext},
)
def _validate_new_name(name: str) -> str:
"""Validate a rename target (a plain name, not a path)."""
candidate = (name or "").strip()
if not candidate or candidate in (".", "..") or "/" in candidate or "\\" in candidate:
raise ServiceError(
f"Invalid name: {name!r}",
code="invalid_arguments",
status=400,
details={"new_name": name},
)
return candidate
# ── D1. Creation ───────────────────────────────────────────────────────────
def create_file(
vault_name: str,
path: str,
content: str = "",
*,
overwrite: bool = False,
) -> dict[str, Any]:
"""Create a text file in a vault.
Args:
vault_name: Name of the vault.
path: Vault-relative path of the new file.
content: Initial content (an Excalidraw skeleton is injected for empty
``.excalidraw`` files).
overwrite: When True, replace an existing file (with a backup) instead
of raising ``already_exists``.
Returns:
``{"success", "vault", "path", "size"}``.
Raises:
ServiceError: ``not_found`` (404) unknown vault, ``read_only`` (403),
``unsupported_extension`` (400) or ``already_exists`` (409).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
_validate_extension(file_path)
if file_path.exists():
if not overwrite:
raise ServiceError(
f"File already exists: {path}",
code="already_exists",
status=409,
details={"vault": vault_name, "path": path},
)
create_backup(file_path, vault_name, _rel(root, file_path))
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
if file_path.suffix.lower() == ".excalidraw" and not content.strip():
content = _EXCALIDRAW_SKELETON
file_path.write_text(content, encoding="utf-8")
except PermissionError as e:
raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e
rel_path = _rel(root, file_path)
logger.info(f"File created: {vault_name}/{rel_path}")
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
def create_directory(vault_name: str, path: str) -> dict[str, Any]:
"""Create a directory (and its parents) in a vault.
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403) or
``already_exists`` (409).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
dir_path = resolve_safe_path(root, path)
if dir_path.exists():
raise ServiceError(
f"Directory already exists: {path}",
code="already_exists",
status=409,
details={"vault": vault_name, "path": path},
)
try:
dir_path.mkdir(parents=True, exist_ok=False)
except PermissionError as e:
raise ServiceError("Permission denied: cannot create directory", code="permission_denied", status=403) from e
rel_path = _rel(root, dir_path)
logger.info(f"Directory created: {vault_name}/{rel_path}")
return {"success": True, "vault": vault_name, "path": rel_path}
# ── D2. Edition / rename / move ────────────────────────────────────────────
def edit_file(
vault_name: str,
path: str,
content: str,
*,
backup: bool = True,
) -> dict[str, Any]:
"""Overwrite an existing file's content (with a backup by default).
Raises:
ServiceError: ``not_found`` (404) or ``read_only`` (403).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
rel_path = _rel(root, file_path)
if backup:
create_backup(file_path, vault_name, rel_path)
try:
file_path.write_text(content, encoding="utf-8")
except PermissionError as e:
raise ServiceError("Permission denied: cannot save file", code="permission_denied", status=403) from e
logger.info(f"File saved: {vault_name}/{rel_path}")
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
def append_to_file(
vault_name: str,
path: str,
content: str,
*,
backup: bool = True,
) -> dict[str, Any]:
"""Append text to an existing file (a newline is inserted if needed).
Raises:
ServiceError: ``not_found`` (404) or ``read_only`` (403).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
rel_path = _rel(root, file_path)
if backup:
create_backup(file_path, vault_name, rel_path)
try:
existing = file_path.read_text(encoding="utf-8", errors="replace")
separator = "" if (not existing or existing.endswith("\n")) else "\n"
new_content = existing + separator + content
file_path.write_text(new_content, encoding="utf-8")
except PermissionError as e:
raise ServiceError("Permission denied: cannot append to file", code="permission_denied", status=403) from e
logger.info(f"File appended: {vault_name}/{rel_path} (+{len(content)} chars)")
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"appended": len(content),
"size": len(new_content),
}
def rename_file(vault_name: str, path: str, new_name: str) -> dict[str, Any]:
"""Rename a file in place (same parent directory).
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403),
``unsupported_extension`` (400) or ``already_exists`` (409).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
old_path = resolve_safe_path(root, path)
if not old_path.exists() or not old_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
new_path = old_path.parent / _validate_new_name(new_name)
new_path = resolve_safe_path(root, _rel(root, new_path))
_validate_extension(new_path)
if new_path.exists():
raise ServiceError(
f"Destination already exists: {new_name}",
code="already_exists",
status=409,
details={"vault": vault_name, "new_name": new_name},
)
old_rel = _rel(root, old_path)
try:
old_path.rename(new_path)
except PermissionError as e:
raise ServiceError("Permission denied: cannot rename file", code="permission_denied", status=403) from e
new_rel = _rel(root, new_path)
logger.info(f"File renamed: {vault_name}/{old_rel} -> {new_rel}")
return {"success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel}
def rename_directory(vault_name: str, path: str, new_name: str) -> dict[str, Any]:
"""Rename a directory in place (same parent directory).
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403) or
``already_exists`` (409).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
old_path = resolve_safe_path(root, path)
if not old_path.exists() or not old_path.is_dir():
raise ServiceError(
f"Directory not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
new_path = old_path.parent / _validate_new_name(new_name)
new_path = resolve_safe_path(root, _rel(root, new_path))
if new_path.exists():
raise ServiceError(
f"Destination already exists: {new_name}",
code="already_exists",
status=409,
details={"vault": vault_name, "new_name": new_name},
)
old_rel = _rel(root, old_path)
try:
old_path.rename(new_path)
except PermissionError as e:
raise ServiceError("Permission denied: cannot rename directory", code="permission_denied", status=403) from e
new_rel = _rel(root, new_path)
logger.info(f"Directory renamed: {vault_name}/{old_rel} -> {new_rel}")
return {"success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel}
def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> dict[str, Any]:
"""Move a file or directory to another directory within the same vault.
The item keeps its name; only its parent directory changes.
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403),
``unsupported_extension`` (400) or ``already_exists`` (409).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
source = resolve_safe_path(root, source_path)
if not source.exists():
raise ServiceError(
f"Source not found: {source_path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": source_path},
)
is_directory = source.is_dir()
item_name = source.name
dest_clean = (destination_dir or "").strip("/")
if dest_clean:
dest_parent = resolve_safe_path(root, dest_clean)
if not dest_parent.exists() or not dest_parent.is_dir():
raise ServiceError(
f"Destination directory not found: {destination_dir}",
code="not_found",
status=404,
details={"vault": vault_name, "path": destination_dir},
)
else:
dest_parent = root
destination = resolve_safe_path(root, _rel(root, dest_parent / item_name))
if source.resolve() == destination.resolve():
rel = _rel(root, source)
return {
"success": True,
"vault": vault_name,
"old_path": rel,
"new_path": rel,
"item_type": "directory" if is_directory else "file",
}
if destination.exists():
raise ServiceError(
f"A file or directory already exists at the destination: {destination.name}",
code="already_exists",
status=409,
details={"vault": vault_name, "new_path": _rel(root, destination)},
)
if not is_directory:
_validate_extension(destination)
old_rel = _rel(root, source)
try:
source.rename(destination)
except PermissionError as e:
raise ServiceError("Permission denied: cannot move item", code="permission_denied", status=403) from e
new_rel = _rel(root, destination)
item_type = "directory" if is_directory else "file"
logger.info(f"Item moved: {vault_name}/{old_rel} -> {new_rel}")
return {
"success": True,
"vault": vault_name,
"old_path": old_rel,
"new_path": new_rel,
"item_type": item_type,
}
# ── D3. Find & replace ─────────────────────────────────────────────────────
def replace_in_files(
find: str,
replacement: str,
*,
vault: str = "all",
case_sensitive: bool = False,
whole_word: bool = False,
regex: bool = False,
include_paths: str | None = None,
exclude_paths: str | None = None,
replace_all: bool = False,
dry_run: bool = True,
is_vault_allowed: Callable[[str], bool] | None = None,
) -> dict[str, Any]:
"""Find and replace text across vault files (dry-run by default).
A backup is created before every file is rewritten. When
``is_vault_allowed`` is provided, files from vaults it rejects are skipped
(used by the tool layer to enforce per-vault permissions and the
destructive-tools toggle).
Returns:
``{"matches", "total_matches"}`` in dry-run mode (with
``"dry_run": True``) or ``{"replaced", "total_replacements"}`` when
applied.
"""
import re as re_mod
from backend.services.search import advanced_search_vaults
if not find:
raise ServiceError("Query is required", code="invalid_arguments", status=400)
search_results = advanced_search_vaults(
find,
vault=vault,
case_sensitive=case_sensitive,
whole_word=whole_word,
regex=regex,
include_paths=include_paths,
exclude_paths=exclude_paths,
limit=500,
sort="relevance",
)
if not search_results["results"]:
return {"matches": [], "total_matches": 0, "dry_run": dry_run}
flags = 0 if case_sensitive else re_mod.IGNORECASE
if regex:
pattern = re_mod.compile(find, flags)
elif whole_word:
pattern = re_mod.compile(rf"\b{re_mod.escape(find)}\b", flags)
else:
pattern = re_mod.compile(re_mod.escape(find), flags)
matches: list[dict[str, Any]] = []
total = 0
for result in search_results["results"]:
result_vault = result["vault"]
if is_vault_allowed is not None and not is_vault_allowed(result_vault):
continue
try:
root = get_vault_root(result_vault)
file_path = resolve_safe_path(root, result["path"])
except ServiceError:
continue
if not file_path.exists() or not file_path.is_file():
continue
try:
original = file_path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
occurrences = list(pattern.finditer(original))
if not occurrences:
continue
if dry_run:
previews = []
for m in occurrences[:3]:
start = max(0, m.start() - 40)
end = min(len(original), m.end() + 40)
previews.append(f"...{original[start:end]}...")
matches.append({
"vault": result_vault,
"path": result["path"],
"title": result.get("title", result["path"]),
"match_count": len(occurrences),
"preview": previews,
})
total += len(occurrences)
continue
new_content, count = pattern.subn(replacement, original)
if count == 0:
continue
create_backup(file_path, result_vault, result["path"])
try:
file_path.write_text(new_content, encoding="utf-8")
except PermissionError as e:
raise ServiceError(
f"Permission denied writing {result['path']}",
code="permission_denied",
status=403,
) from e
matches.append({
"vault": result_vault,
"path": result["path"],
"title": result.get("title", result["path"]),
"replacements": count,
"size": len(new_content),
})
total += count
if dry_run:
return {"matches": matches, "total_matches": total, "dry_run": True}
return {"replaced": matches, "total_replacements": total, "dry_run": False}
# ── D4. Deletion / restore ─────────────────────────────────────────────────
def delete_file(vault_name: str, path: str, *, backup: bool = True) -> dict[str, Any]:
"""Delete a file (with a backup by default).
Raises:
ServiceError: ``not_found`` (404) or ``read_only`` (403).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
rel_path = _rel(root, file_path)
if backup:
create_backup(file_path, vault_name, rel_path)
try:
file_path.unlink()
except PermissionError as e:
raise ServiceError("Permission denied: cannot delete file", code="permission_denied", status=403) from e
logger.info(f"File deleted: {vault_name}/{rel_path}")
return {"success": True, "vault": vault_name, "path": rel_path}
def delete_directory(vault_name: str, path: str, *, recursive: bool = True) -> dict[str, Any]:
"""Delete a directory (recursively by default).
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403),
``not_empty`` (409) when non-recursive and not empty, or
``invalid_arguments`` (400) when targeting the vault root.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
dir_path = resolve_safe_path(root, path)
if not dir_path.exists() or not dir_path.is_dir():
raise ServiceError(
f"Directory not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
if dir_path.resolve() == root.resolve():
raise ServiceError(
"Refusing to delete the vault root",
code="invalid_arguments",
status=400,
details={"vault": vault_name},
)
file_count = sum(1 for p in dir_path.rglob("*") if p.is_file())
try:
if recursive:
shutil.rmtree(dir_path)
else:
if any(dir_path.iterdir()):
raise ServiceError(
f"Directory not empty: {path}",
code="not_empty",
status=409,
details={"vault": vault_name, "path": path},
)
dir_path.rmdir()
except PermissionError as e:
raise ServiceError("Permission denied: cannot delete directory", code="permission_denied", status=403) from e
rel_path = _rel(root, dir_path)
logger.info(f"Directory deleted: {vault_name}/{rel_path} ({file_count} files)")
return {"success": True, "vault": vault_name, "path": rel_path, "deleted_count": file_count}
def restore_backup(
vault_name: str,
path: str,
version: int,
*,
backup: bool = True,
) -> dict[str, Any]:
"""Restore a file from a backup version.
The current file is backed up first (when ``backup`` is True) so the
operation is reversible.
Raises:
ServiceError: ``not_found`` (404) when the file or backup is missing,
or ``read_only`` (403).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
backup_dir = get_backup_dir(vault_name, path)
backup_path = backup_dir / f"{Path(path).name}.{version}.bak"
if not backup_path.exists():
raise ServiceError(
f"Backup version {version} not found for {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path, "version": version},
)
# Read the target version *before* backing up the current file: both use a
# second-resolution timestamp, so a same-second backup could otherwise
# overwrite the version we are about to restore.
try:
content = backup_path.read_text(encoding="utf-8")
except OSError as e:
raise ServiceError(
f"Failed to read backup {version}: {e}",
code="read_error",
status=500,
details={"vault": vault_name, "path": path, "version": version},
) from e
current_backed_up: int | None = None
if backup and file_path.exists() and file_path.is_file():
import time as _time
create_backup(file_path, vault_name, path)
current_backed_up = int(_time.time())
try:
file_path.write_text(content, encoding="utf-8")
except PermissionError as e:
raise ServiceError("Permission denied: cannot restore file", code="permission_denied", status=403) from e
logger.info(f"File restored from backup: {vault_name}/{path} <- version {version}")
return {
"success": True,
"vault": vault_name,
"path": path,
"restored_from": version,
"current_backed_up": current_backed_up,
}
+21
View File
@@ -139,6 +139,27 @@ class ToolContext:
details={"vault": vault}, details={"vault": vault},
) )
def destructive_tools_enabled(self, vault: str) -> bool:
"""Return whether destructive tools are allowed for *vault*.
Controlled by the per-vault ``aiDestructiveTools`` setting (default:
enabled). Disabling it blocks delete/rename/move/find-replace tools
while keeping create/edit/append available.
"""
from backend.vault_settings import get_vault_setting
settings = get_vault_setting(vault) or {}
return bool(settings.get("aiDestructiveTools", True))
def require_destructive_allowed(self, vault: str) -> None:
"""Raise :class:`ToolPermissionError` if destructive tools are disabled."""
if not self.destructive_tools_enabled(vault):
raise ToolPermissionError(
f"Destructive tools are disabled for vault '{vault}'",
code="destructive_tools_disabled",
details={"vault": vault},
)
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path: def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a vault-relative path, rejecting traversal outside the vault. """Resolve a vault-relative path, rejecting traversal outside the vault.
+7
View File
@@ -194,6 +194,13 @@ def call_tool(
_audit(ctx, spec, arguments, ok=False, error=e.code) _audit(ctx, spec, arguments, ok=False, error=e.code)
raise raise
if spec.risk == ToolRisk.DANGEROUS and vault and vault != "all":
try:
ctx.require_destructive_allowed(vault)
except ToolPermissionError as e:
_audit(ctx, spec, arguments, ok=False, error=e.code)
raise
if spec.requires_confirmation and not (confirm or ctx.confirmed): if spec.requires_confirmation and not (confirm or ctx.confirmed):
raise ToolConfirmationRequired(spec.name, arguments) raise ToolConfirmationRequired(spec.name, arguments)
+99
View File
@@ -138,6 +138,105 @@ class ListRecentInput(BaseModel):
mode: str = Field("opened", description="'opened' (history) or 'modified'") mode: str = Field("opened", description="'opened' (history) or 'modified'")
# ── D. Mutations ───────────────────────────────────────────────────────────
class CreateFileInput(BaseModel):
"""Create a new text file in a vault."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative path of the new file")
content: str = Field("", description="Initial file content")
class CreateDirectoryInput(BaseModel):
"""Create a new directory in a vault."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative path of the new directory")
class EditFileInput(BaseModel):
"""Overwrite the full content of an existing file."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative file path")
content: str = Field(..., description="New full content of the file")
class AppendToFileInput(BaseModel):
"""Append text to the end of an existing file."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative file path")
content: str = Field(..., description="Text to append")
class RenameFileInput(BaseModel):
"""Rename a file in place (same parent directory)."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Current vault-relative file path")
new_name: str = Field(..., description="New file name (no directory separator)")
class RenameDirectoryInput(BaseModel):
"""Rename a directory in place (same parent directory)."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Current vault-relative directory path")
new_name: str = Field(..., description="New directory name (no directory separator)")
class MovePathInput(BaseModel):
"""Move a file or directory to another directory within the same vault."""
vault: str = Field(..., description="Vault name")
source_path: str = Field(..., description="Current vault-relative path of the file/directory")
destination_dir: str = Field("", description="Target directory path (empty = vault root)")
class ReplaceInFilesInput(BaseModel):
"""Find and replace text across vault files (destructive, dry-run by default)."""
find: str = Field(..., min_length=1, description="Text or pattern to search for")
replace: str = Field("", description="Replacement text")
vault: str = Field("all", description="Vault name or 'all'")
case_sensitive: bool = Field(False, description="Match case")
whole_word: bool = Field(False, description="Match whole words only")
regex: bool = Field(False, description="Treat 'find' as a regular expression")
include_paths: str | None = Field(None, description="Comma-separated glob patterns to include")
exclude_paths: str | None = Field(None, description="Comma-separated glob patterns to exclude")
replace_all: bool = Field(False, description="Apply the replacement (otherwise only preview)")
dry_run: bool | None = Field(
None,
description="Preview matches without writing. Defaults to the opposite of replace_all.",
)
class DeleteFileInput(BaseModel):
"""Delete a file from a vault (destructive)."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative file path")
class DeleteDirectoryInput(BaseModel):
"""Delete a directory from a vault (destructive)."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative directory path")
recursive: bool = Field(True, description="Delete non-empty directories recursively")
class RestoreBackupInput(BaseModel):
"""Restore a file from one of its backup versions."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative file path")
version: int = Field(..., description="Backup timestamp to restore")
class ToolResult(BaseModel): class ToolResult(BaseModel):
"""Uniform result returned by :func:`backend.tools.registry.call_tool`.""" """Uniform result returned by :func:`backend.tools.registry.call_tool`."""
+206 -2
View File
@@ -1,9 +1,11 @@
"""Built-in tool services (Phase 0 + Phase C — read/search catalogue). """Built-in tool services (Phase 0 + Phase C read/search + Phase D mutations).
These functions are the single source of truth consumed by both the in-app These functions are the single source of truth consumed by both the in-app
assistant and the MCP server. They delegate to the shared business-logic assistant and the MCP server. They delegate to the shared business-logic
services (``backend.services``) so routes and tools never diverge. services (``backend.services``) so routes and tools never diverge.
Mutating tools (create/edit/rename/move/delete) are added in later phases. Mutating tools (create/edit/rename/move/delete/restore) are registered with
``ToolRisk.WRITE`` or ``ToolRisk.DANGEROUS`` and gated by the registry's
confirmation mechanism (two-step propose/apply).
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,6 +19,39 @@ from backend.services.backups import diff_backup as _diff_backup
from backend.services.backups import list_backup_files as _list_backup_files from backend.services.backups import list_backup_files as _list_backup_files
from backend.services.files import read_file_text from backend.services.files import read_file_text
from backend.services.graph import get_graph as _get_graph from backend.services.graph import get_graph as _get_graph
from backend.services.mutations import (
append_to_file as _append_to_file,
)
from backend.services.mutations import (
create_directory as _create_directory,
)
from backend.services.mutations import (
create_file as _create_file,
)
from backend.services.mutations import (
delete_directory as _delete_directory,
)
from backend.services.mutations import (
delete_file as _delete_file,
)
from backend.services.mutations import (
edit_file as _edit_file,
)
from backend.services.mutations import (
move_path as _move_path,
)
from backend.services.mutations import (
rename_directory as _rename_directory,
)
from backend.services.mutations import (
rename_file as _rename_file,
)
from backend.services.mutations import (
replace_in_files as _replace_in_files,
)
from backend.services.mutations import (
restore_backup as _restore_backup,
)
from backend.services.recent import list_recent as _list_recent from backend.services.recent import list_recent as _list_recent
from backend.services.search import advanced_search_vaults, search_vaults 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 list_tags as _list_tags
@@ -25,7 +60,13 @@ from backend.services.vaults import browse_directory, list_accessible_vaults, li
from backend.tools.context import ToolContext, ToolRisk from backend.tools.context import ToolContext, ToolRisk
from backend.tools.registry import tool from backend.tools.registry import tool
from backend.tools.schemas import ( from backend.tools.schemas import (
AppendToFileInput,
CreateDirectoryInput,
CreateFileInput,
DeleteDirectoryInput,
DeleteFileInput,
DiffBackupInput, DiffBackupInput,
EditFileInput,
GetBacklinksInput, GetBacklinksInput,
GetGraphInput, GetGraphInput,
ListAllFilesInput, ListAllFilesInput,
@@ -34,8 +75,13 @@ from backend.tools.schemas import (
ListRecentInput, ListRecentInput,
ListTagsInput, ListTagsInput,
ListVaultsInput, ListVaultsInput,
MovePathInput,
ReadFileInput, ReadFileInput,
ReadFileRawInput, ReadFileRawInput,
RenameDirectoryInput,
RenameFileInput,
ReplaceInFilesInput,
RestoreBackupInput,
SearchAdvancedInput, SearchAdvancedInput,
SearchFulltextInput, SearchFulltextInput,
SearchPathsInput, SearchPathsInput,
@@ -300,3 +346,161 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
limit=params.limit, limit=params.limit,
mode=params.mode, mode=params.mode,
) )
# ── D. Mutations ───────────────────────────────────────────────────────────
@tool(
name="create_file",
description="Create a new text file in a vault with optional initial content.",
input_model=CreateFileInput,
risk=ToolRisk.WRITE,
requires_vault=True,
)
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)
@tool(
name="create_directory",
description="Create a new directory (and parents) in a vault.",
input_model=CreateDirectoryInput,
risk=ToolRisk.WRITE,
requires_vault=True,
)
def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str, Any]:
"""Create a vault directory."""
return _create_directory(params.vault, params.path)
@tool(
name="edit_file",
description="Overwrite the full content of an existing file (automatic backup).",
input_model=EditFileInput,
risk=ToolRisk.WRITE,
requires_vault=True,
)
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)
@tool(
name="append_to_file",
description="Append text to the end of an existing file (automatic backup).",
input_model=AppendToFileInput,
risk=ToolRisk.WRITE,
requires_vault=True,
)
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)
@tool(
name="rename_file",
description="Rename a file in place (same parent directory). Destructive: requires confirmation.",
input_model=RenameFileInput,
risk=ToolRisk.DANGEROUS,
requires_vault=True,
)
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
"""Rename a vault file."""
return _rename_file(params.vault, params.path, params.new_name)
@tool(
name="rename_directory",
description="Rename a directory in place (same parent directory). Destructive: requires confirmation.",
input_model=RenameDirectoryInput,
risk=ToolRisk.DANGEROUS,
requires_vault=True,
)
def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str, Any]:
"""Rename a vault directory."""
return _rename_directory(params.vault, params.path, params.new_name)
@tool(
name="move_path",
description="Move a file or directory to another directory in the same vault. Destructive: requires confirmation.",
input_model=MovePathInput,
risk=ToolRisk.DANGEROUS,
requires_vault=True,
)
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)
@tool(
name="replace_in_files",
description=(
"Find and replace text across vault files. Previews by default "
"(dry_run); set replace_all=true to apply. Destructive: requires confirmation."
),
input_model=ReplaceInFilesInput,
risk=ToolRisk.DANGEROUS,
)
def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str, Any]:
"""Preview or apply a find/replace, filtered to permitted vaults."""
if params.vault and params.vault != "all":
ctx.require_vault_access(params.vault)
ctx.require_destructive_allowed(params.vault)
dry_run = params.dry_run if params.dry_run is not None else not params.replace_all
def _allowed(vault: str) -> bool:
return ctx.has_vault_access(vault) and ctx.destructive_tools_enabled(vault)
return _replace_in_files(
params.find,
params.replace,
vault=params.vault,
case_sensitive=params.case_sensitive,
whole_word=params.whole_word,
regex=params.regex,
include_paths=params.include_paths,
exclude_paths=params.exclude_paths,
replace_all=params.replace_all,
dry_run=dry_run,
is_vault_allowed=_allowed,
)
@tool(
name="delete_file",
description="Delete a file from a vault (automatic backup). Destructive: requires confirmation.",
input_model=DeleteFileInput,
risk=ToolRisk.DANGEROUS,
requires_vault=True,
)
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
"""Delete a vault file."""
return _delete_file(params.vault, params.path)
@tool(
name="delete_directory",
description="Delete a directory (recursive by default) from a vault. Destructive: requires confirmation.",
input_model=DeleteDirectoryInput,
risk=ToolRisk.DANGEROUS,
requires_vault=True,
)
def delete_directory(ctx: ToolContext, params: DeleteDirectoryInput) -> dict[str, Any]:
"""Delete a vault directory."""
return _delete_directory(params.vault, params.path, recursive=params.recursive)
@tool(
name="restore_backup",
description="Restore a file from one of its backup versions (current version backed up first).",
input_model=RestoreBackupInput,
risk=ToolRisk.WRITE,
requires_vault=True,
)
def restore_backup(ctx: ToolContext, params: RestoreBackupInput) -> dict[str, Any]:
"""Restore a vault file from a backup."""
return _restore_backup(params.vault, params.path, params.version)
+11 -5
View File
@@ -81,7 +81,9 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même
### 2.5 Limites de l'existant ### 2.5 Limites de l'existant
1. **Pas de tool calling** : parsing regex fragile, pas de résultats structurés, pas de multi-étapes. 1. **Pas de tool calling** : parsing regex fragile, pas de résultats structurés, pas de multi-étapes.
→ **résolu** (phases B/C/D) : tool calling natif, catalogue lecture/recherche + mutations.
2. **2 actions seulement** (`create_file`, `create_directory`) ; ni lecture active, ni recherche, ni édition, ni suppression exposées au modèle. 2. **2 actions seulement** (`create_file`, `create_directory`) ; ni lecture active, ni recherche, ni édition, ni suppression exposées au modèle.
→ **résolu** (phases C/D) : catalogue complet (lecture, recherche, mutations avec confirmation).
3. **SSE** : ~~non réellement streaming~~ → **corrigé** (B4) : `ai_chat.stream_completion` alimente `/api/ai/bookslm/chat` token par token ; le tool-calling (`/agent`) reste non-streaming (les appels d'outils exigent la réponse complète). 3. **SSE** : ~~non réellement streaming~~ → **corrigé** (B4) : `ai_chat.stream_completion` alimente `/api/ai/bookslm/chat` token par token ; le tool-calling (`/agent`) reste non-streaming (les appels d'outils exigent la réponse complète).
4. **`PROVIDERS` chargé une seule fois** : les modèles par défaut ne sont modifiables que par variables d'environnement (pas de persistance UI). 4. **`PROVIDERS` chargé une seule fois** : les modèles par défaut ne sont modifiables que par variables d'environnement (pas de persistance UI).
@@ -93,7 +95,7 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même
Extraire la logique métier des routes de `backend/main.py` vers des fonctions réutilisables (services). Les routes REST, l'agent in-app et le serveur MCP appellent ces mêmes services. Extraire la logique métier des routes de `backend/main.py` vers des fonctions réutilisables (services). Les routes REST, l'agent in-app et le serveur MCP appellent ces mêmes services.
**Nouveau module `backend/services/` (A2 lecture/recherche + C) :** **Nouveau module `backend/services/` (A2 lecture/recherche + C + D mutations) :**
``` ```
backend/services/ backend/services/
@@ -101,17 +103,21 @@ backend/services/
├── paths.py # resolve_safe_path (anti path-traversal, source unique) ├── paths.py # resolve_safe_path (anti path-traversal, source unique)
├── vaults.py # list_accessible_vaults, browse_directory, get_vault_root, list_all_files ├── vaults.py # list_accessible_vaults, browse_directory, get_vault_root, list_all_files
├── files.py # read_raw_file, read_file_text (redaction + quota) ├── files.py # read_raw_file, read_file_text (redaction + quota)
├── mutations.py # create/edit/append/rename/move/delete/restore/replace (D)
├── search.py # search_vaults (pagination), list_tags, advanced_search_vaults, search_paths ├── search.py # search_vaults (pagination), list_tags, advanced_search_vaults, search_paths
├── backups.py # get_backup_dir, list_backup_files, diff_backup ├── backups.py # get_backup_dir, create_backup, list_backup_files, diff_backup
├── graph.py # get_graph (nodes/edges, wikilinks) ├── graph.py # get_graph (nodes/edges, wikilinks)
└── recent.py # list_recent, humanize_mtime └── recent.py # list_recent, humanize_mtime
``` ```
Les routes `/api/vaults`, `/api/browse/{vault}`, `/api/file/{vault}/raw`, `/api/search`, `/api/tags`, Les routes `/api/vaults`, `/api/browse/{vault}`, `/api/file/{vault}/raw`, `/api/search`, `/api/tags`,
`/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`, `/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`,
`/api/graph/{vault}`, `/api/tree-search` et `/api/search/advanced` en sont de simples wrappers, tout `/api/graph/{vault}`, `/api/tree-search`, `/api/search/advanced` (phase C) ainsi que
comme les outils correspondants du catalogue (phase C). Un `ServiceError` est traduit en `/api/file/{vault}` (POST/PATCH/DELETE), `/api/file/{vault}/save`, `/api/file/{vault}/restore`,
`HTTPException` (handler global de `backend/main.py`) ou en `ToolError` (`backend/tools/registry.py`). `/api/directory/{vault}` (POST/PATCH/DELETE), `/api/move/{vault}` et `/api/search/replace` (phase D)
en sont de simples wrappers, tout comme les outils correspondants du catalogue. Un `ServiceError`
est traduit en `HTTPException` (handler global de `backend/main.py`) ou en `ToolError`
(`backend/tools/registry.py`).
**Nouveau module `backend/tools/` :** **Nouveau module `backend/tools/` :**
+3 -3
View File
@@ -43,11 +43,11 @@
### 79. Assistant IA — Outils (function calling) & serveur MCP ### 79. Assistant IA — Outils (function calling) & serveur MCP
- **Effort :** 10-15 jours | **Impact :** 🟡 - **Effort :** 10-15 jours | **Impact :** 🟡
- **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) - **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md)
- **Description :** Transformer l'assistant BooksLM en agent (lire, chercher, lister, ouvrir, modifier) via **function calling natif**, puis exposer ObsiGate à des **clients MCP externes** (Claude Desktop, Cursor…). Les deux fronts consomment une **couche d'outils partagée**. - **Description :** Transformer l'assistant BooksLM en agent (lire, chercher, lister, ouvrir, modifier) via **function calling natif**, puis exposer ObsiGate à des **clients MCP externes** (Claude Desktop, Cursor…). Les deux fronts consomment une **couche d'outils partagée**.
- **Reste à faire :** - **Reste à faire :**
- [x] **C.** Catalogue lecture & recherche (vaults, read, backlinks, backups, search, tags) - [x] **C.** Catalogue lecture & recherche (vaults, read, backlinks, backups, search, tags)
- [ ] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step - [x] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step
- [ ] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault) - [ ] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault)
- [ ] **F.** Durcissement (rate limiting, redaction secrets, OpenAPI + guide MCP, E2E) - [ ] **F.** Durcissement (rate limiting, redaction secrets, OpenAPI + guide MCP, E2E)
- **Documentation :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) (architecture, catalogue d'outils, sécurité, phases). - **Documentation :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) (architecture, catalogue d'outils, sécurité, phases).
@@ -180,7 +180,7 @@
|---|---|---| |---|---|---|
| ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés | | ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours | | 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours |
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + C + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours | | ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + C + D + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours |
| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours | | ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours |
| **Total restant** | **6 items + finitions** | **~27-39 jours** | | **Total restant** | **6 items + finitions** | **~27-39 jours** |
+12 -6
View File
@@ -1,6 +1,6 @@
# #79 — Assistant IA — Outils (function calling) & serveur MCP # #79 — Assistant IA — Outils (function calling) & serveur MCP
> **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + G livrés (2026-09-11) > **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + G livrés (2026-09-11)
> **Effort :** 10-15 jours | **Impact :** 🟡 > **Effort :** 10-15 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md) > **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md)
@@ -29,11 +29,17 @@
- [x] **C3.** Recherche : `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` — extensions `backend/services/search.py` (`advanced_search_vaults`, `search_paths`) et `recent.py` ; filtrage systématique par permissions vault - [x] **C3.** Recherche : `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` — extensions `backend/services/search.py` (`advanced_search_vaults`, `search_paths`) et `recent.py` ; filtrage systématique par permissions vault
- Routes `/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`, `/api/graph/{vault}`, `/api/tree-search`, `/api/search/advanced` refactorées en wrappers des services ; tests `tests/test_tools.py` (+17) - Routes `/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`, `/api/graph/{vault}`, `/api/tree-search`, `/api/search/advanced` refactorées en wrappers des services ; tests `tests/test_tools.py` (+17)
## D. Catalogue d'outils — mutations (2 jours) ## D. Catalogue d'outils — mutations (2 jours) — ✅ livré (2026-09-11)
- [ ] **D1.** `create_file`, `create_directory` (migration du protocole texte existant) - [x] **D1.** `create_file`, `create_directory` (migration du protocole texte existant)
- [ ] **D2.** `edit_file`, `append_to_file`, `rename_file`, `rename_directory`, `move_path` - [x] **D2.** `edit_file`, `append_to_file`, `rename_file`, `rename_directory`, `move_path`
- [ ] **D3.** `replace_in_files` (⚠️ confirmation two-step + backup auto) - [x] **D3.** `replace_in_files` (⚠️ confirmation two-step + backup auto)
- [ ] **D4.** `delete_file`, `delete_directory`, `restore_backup` (⚠️ confirmation two-step + backup auto, toggle par vault) - [x] **D4.** `delete_file`, `delete_directory`, `restore_backup` (⚠️ confirmation two-step + backup auto, toggle par vault)
- **Détail :** service `backend/services/mutations.py` (source unique : anti path-traversal, garde
lecture seule, backup auto via `backend/services/backups.create_backup`) ; les routes REST
correspondantes délèguent à cette couche. Risques : `WRITE` (create/edit/append/restore) et
`DANGEROUS` (rename/move/replace/delete) → confirmation two-step automatique par le registry.
Toggle par vault `aiDestructiveTools` (défaut activé) pour désactiver les outils `DANGEROUS`.
Tests : `tests/test_tools_mutations.py` (40 tests).
## E. Serveur MCP (2-3 jours) ## E. Serveur MCP (2-3 jours)
- [ ] **E1.** `backend/mcp/server.py` (SDK MCP Python) enregistrant les outils depuis le registry - [ ] **E1.** `backend/mcp/server.py` (SDK MCP Python) enregistrant les outils depuis le registry
+12
View File
@@ -46,6 +46,18 @@ BUILTIN_TOOLS = {
"search_paths", "search_paths",
"suggest_tags", "suggest_tags",
"list_recent", "list_recent",
# Phase D — mutations
"create_file",
"create_directory",
"edit_file",
"append_to_file",
"rename_file",
"rename_directory",
"move_path",
"replace_in_files",
"delete_file",
"delete_directory",
"restore_backup",
} }
+396
View File
@@ -0,0 +1,396 @@
# tests/test_tools_mutations.py — Unit tests for the AI tool layer (Phase D)
"""Tests for the mutation tools: create/edit/append/rename/move/delete/restore.
These exercise the shared tool layer end-to-end against the ``TestVault``
fixture (``client``), including confirmation gating, backups and the
per-vault destructive-tools toggle.
"""
from pathlib import Path
import pytest
from backend.tools.api import (
ToolConfirmationRequired,
ToolContext,
ToolError,
ToolPermissionError,
ToolRisk,
call_tool,
get_tool,
)
MUTATION_TOOLS = {
"create_file",
"create_directory",
"edit_file",
"append_to_file",
"rename_file",
"rename_directory",
"move_path",
"replace_in_files",
"delete_file",
"delete_directory",
"restore_backup",
}
WRITE_TOOLS = {"create_file", "create_directory", "edit_file", "append_to_file", "restore_backup"}
DANGEROUS_TOOLS = {
"rename_file",
"rename_directory",
"move_path",
"replace_in_files",
"delete_file",
"delete_directory",
}
def _ctx(vaults=None, **kwargs) -> ToolContext:
user = {"username": "tester", "role": "admin", "vaults": vaults or ["*"]}
kwargs.setdefault("audit_enabled", False)
return ToolContext(user=user, **kwargs)
def _vault_path() -> Path:
from backend.indexer import get_vault_data
return Path(get_vault_data("TestVault")["path"])
# ═══════════════════════════════════════════════════════════════════
# Registry / risk levels
# ═══════════════════════════════════════════════════════════════════
class TestMutationRegistry:
def test_mutation_tools_registered(self):
for name in MUTATION_TOOLS:
assert get_tool(name) is not None, name
@pytest.mark.parametrize("name", sorted(WRITE_TOOLS))
def test_write_risk(self, name):
assert get_tool(name).risk is ToolRisk.WRITE
@pytest.mark.parametrize("name", sorted(DANGEROUS_TOOLS))
def test_dangerous_risk(self, name):
assert get_tool(name).risk is ToolRisk.DANGEROUS
def test_all_mutations_require_confirmation(self):
for name in MUTATION_TOOLS:
assert get_tool(name).requires_confirmation is True
# ═══════════════════════════════════════════════════════════════════
# Confirmation gating
# ═══════════════════════════════════════════════════════════════════
class TestMutationConfirmation:
def test_create_file_requires_confirmation(self, client):
with pytest.raises(ToolConfirmationRequired):
call_tool("create_file", _ctx(), {"vault": "TestVault", "path": "n.md", "content": "x"})
assert not (_vault_path() / "n.md").exists()
def test_delete_file_requires_confirmation(self, client):
with pytest.raises(ToolConfirmationRequired):
call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"})
assert (_vault_path() / "note1.md").exists()
# ═══════════════════════════════════════════════════════════════════
# D1. Creation
# ═══════════════════════════════════════════════════════════════════
class TestCreate:
def test_create_file(self, client):
result = call_tool(
"create_file",
_ctx(),
{"vault": "TestVault", "path": "new/note.md", "content": "# Hi\n"},
confirm=True,
)
assert result.ok
assert (_vault_path() / "new" / "note.md").read_text(encoding="utf-8") == "# Hi\n"
assert result.data["path"] == "new/note.md"
def test_create_file_already_exists(self, client):
with pytest.raises(ToolError) as exc:
call_tool("create_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True)
assert exc.value.code == "already_exists"
def test_create_file_unsupported_extension(self, client):
with pytest.raises(ToolError) as exc:
call_tool(
"create_file",
_ctx(),
{"vault": "TestVault", "path": "binary.exe", "content": "x"},
confirm=True,
)
assert exc.value.code == "unsupported_extension"
def test_create_file_traversal_rejected(self, client):
with pytest.raises(ToolPermissionError):
call_tool(
"create_file",
_ctx(),
{"vault": "TestVault", "path": "../evil.md", "content": "x"},
confirm=True,
)
def test_create_directory(self, client):
result = call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "A/B"}, confirm=True)
assert result.ok
assert (_vault_path() / "A" / "B").is_dir()
# ═══════════════════════════════════════════════════════════════════
# D2. Edit / append / rename / move
# ═══════════════════════════════════════════════════════════════════
class TestEditAppend:
def test_edit_file(self, client):
result = call_tool(
"edit_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "content": "# Replaced\n"},
confirm=True,
)
assert result.ok
assert (_vault_path() / "note1.md").read_text(encoding="utf-8") == "# Replaced\n"
def test_edit_file_creates_backup(self, client):
from backend.services.backups import get_backup_dir
call_tool(
"edit_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "content": "# Changed\n"},
confirm=True,
)
backups = list(get_backup_dir("TestVault", "note1.md").glob("note1.md.*.bak"))
assert backups
def test_edit_missing_file(self, client):
with pytest.raises(ToolError) as exc:
call_tool(
"edit_file",
_ctx(),
{"vault": "TestVault", "path": "missing.md", "content": "x"},
confirm=True,
)
assert exc.value.code == "not_found"
def test_append_to_file(self, client):
original = (_vault_path() / "note1.md").read_text(encoding="utf-8")
result = call_tool(
"append_to_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "content": "APPENDED"},
confirm=True,
)
assert result.ok
updated = (_vault_path() / "note1.md").read_text(encoding="utf-8")
assert updated.startswith(original)
assert updated.endswith("APPENDED")
assert result.data["appended"] == len("APPENDED")
class TestRenameMove:
def test_rename_file(self, client):
result = call_tool(
"rename_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "new_name": "note1_renamed.md"},
confirm=True,
)
assert result.ok
assert not (_vault_path() / "note1.md").exists()
assert (_vault_path() / "note1_renamed.md").exists()
assert result.data["new_path"] == "note1_renamed.md"
def test_rename_file_rejects_path_separator(self, client):
with pytest.raises(ToolError) as exc:
call_tool(
"rename_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "new_name": "sub/note1.md"},
confirm=True,
)
assert exc.value.code == "invalid_arguments"
def test_rename_directory(self, client):
call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "OldDir"}, confirm=True)
result = call_tool(
"rename_directory",
_ctx(),
{"vault": "TestVault", "path": "OldDir", "new_name": "NewDir"},
confirm=True,
)
assert result.ok
assert not (_vault_path() / "OldDir").exists()
assert (_vault_path() / "NewDir").is_dir()
def test_move_file(self, client):
result = call_tool(
"move_path",
_ctx(),
{"vault": "TestVault", "source_path": "note1.md", "destination_dir": "Projets"},
confirm=True,
)
assert result.ok
assert result.data["new_path"] == "Projets/note1.md"
assert (_vault_path() / "Projets" / "note1.md").exists()
def test_move_directory(self, client):
result = call_tool(
"move_path",
_ctx(),
{"vault": "TestVault", "source_path": "Projets", "destination_dir": ""},
confirm=True,
)
assert result.data["item_type"] == "directory"
# ═══════════════════════════════════════════════════════════════════
# D3. Find & replace
# ═══════════════════════════════════════════════════════════════════
class TestReplaceInFiles:
def test_dry_run_does_not_write(self, client):
before = (_vault_path() / "note1.md").read_text(encoding="utf-8")
result = call_tool(
"replace_in_files",
_ctx(),
{"find": "Python", "replace": "Rust", "vault": "TestVault"},
confirm=True,
)
assert result.data["dry_run"] is True
assert result.data["total_matches"] >= 1
assert (_vault_path() / "note1.md").read_text(encoding="utf-8") == before
def test_apply_replaces_and_backs_up(self, client):
result = call_tool(
"replace_in_files",
_ctx(),
{
"find": "Python",
"replace": "Rust",
"vault": "TestVault",
"replace_all": True,
"dry_run": False,
},
confirm=True,
)
assert result.data["dry_run"] is False
assert result.data["total_replacements"] >= 1
assert "Rust" in (_vault_path() / "note1.md").read_text(encoding="utf-8")
def test_filters_inaccessible_vaults(self, client):
ctx = ToolContext(user={"username": "limited", "vaults": ["OtherVault"]}, audit_enabled=False)
result = call_tool(
"replace_in_files",
ctx,
{"find": "Python", "replace": "Rust", "vault": "all"},
confirm=True,
)
assert result.data["total_matches"] == 0
# ═══════════════════════════════════════════════════════════════════
# D4. Delete / restore
# ═══════════════════════════════════════════════════════════════════
class TestDeleteRestore:
def test_delete_file_with_backup(self, client):
from backend.services.backups import get_backup_dir
result = call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True)
assert result.ok
assert not (_vault_path() / "note1.md").exists()
assert list(get_backup_dir("TestVault", "note1.md").glob("note1.md.*.bak"))
def test_delete_missing_file(self, client):
with pytest.raises(ToolError) as exc:
call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "missing.md"}, confirm=True)
assert exc.value.code == "not_found"
def test_delete_directory(self, client):
result = call_tool("delete_directory", _ctx(), {"vault": "TestVault", "path": "Projets"}, confirm=True)
assert result.ok
assert result.data["deleted_count"] >= 1
assert not (_vault_path() / "Projets").exists()
def test_restore_backup(self, client):
from backend.services.backups import create_backup
file_path = _vault_path() / "note1.md"
original = file_path.read_text(encoding="utf-8")
backup_path = create_backup(file_path, "TestVault", "note1.md")
assert backup_path is not None
# Change the file directly (no extra backup), then restore the version.
file_path.write_text("# modified\n", encoding="utf-8")
assert file_path.read_text(encoding="utf-8") == "# modified\n"
version = int(backup_path.name.split(".")[-2])
result = call_tool(
"restore_backup",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "version": version},
confirm=True,
)
assert result.ok
assert file_path.read_text(encoding="utf-8") == original
# ═══════════════════════════════════════════════════════════════════
# Per-vault destructive-tools toggle
# ═══════════════════════════════════════════════════════════════════
class TestDestructiveToggle:
def _disable(self, monkeypatch):
import backend.vault_settings as vs
monkeypatch.setattr(vs, "get_vault_setting", lambda name: {"aiDestructiveTools": False})
def test_delete_blocked_when_disabled(self, client, monkeypatch):
self._disable(monkeypatch)
with pytest.raises(ToolPermissionError) as exc:
call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True)
assert exc.value.code == "destructive_tools_disabled"
assert (_vault_path() / "note1.md").exists()
def test_rename_blocked_when_disabled(self, client, monkeypatch):
self._disable(monkeypatch)
with pytest.raises(ToolPermissionError):
call_tool(
"rename_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "new_name": "x.md"},
confirm=True,
)
def test_edit_still_allowed_when_disabled(self, client, monkeypatch):
self._disable(monkeypatch)
result = call_tool(
"edit_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "content": "# ok\n"},
confirm=True,
)
assert result.ok
def test_replace_filters_disabled_vault(self, client, monkeypatch):
self._disable(monkeypatch)
result = call_tool(
"replace_in_files",
_ctx(),
{"find": "Python", "replace": "Rust", "vault": "all", "replace_all": True, "dry_run": False},
confirm=True,
)
assert result.data["total_replacements"] == 0