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
+68
View File
@@ -12,6 +12,8 @@ from __future__ import annotations
import difflib
import logging
import os
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -22,6 +24,19 @@ from backend.services.vaults import get_vault_root
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:
"""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
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]]:
"""List all backup files for a vault file, sorted newest first.