"""Reset only the Hermes conversation ID used by Alfred. Admin-protected, CSRF-protected, and concurrency-safe with ask_hermes(). Does NOT delete the actual Hermes conversation history or change MCP/skills. """ from __future__ import annotations import fcntl import os from pathlib import Path import secrets from datetime import datetime, timezone from flask import Blueprint, jsonify from services import admin_console, hermes_client class HermesSessionBusy(RuntimeError): """Hermes is currently processing a message.""" def archive_current_session() -> bool: """Return True when an existing session was archived. The exact same thread lock and filesystem lock are used by ask_hermes. Both locks are non-blocking here to keep HTTP responsive. """ if os.getenv('HERMES_SESSION_MODE', 'persistent').strip().lower() == 'legacy': raise ValueError('Hermes est en mode legacy : les conversations sont déjà indépendantes.') request_lock = hermes_client._REQUEST_LOCK if not request_lock.acquire(blocking=False): raise HermesSessionBusy('Hermes traite une demande. Réessaie lorsqu’il a terminé.') try: path = hermes_client._session_file() if path.is_symlink(): raise ValueError('Fichier de session symbolique non autorisé.') path.parent.mkdir(parents=True, exist_ok=True) lock_path = path.with_name(path.name + '.lock') flags = os.O_CREAT | os.O_RDWR if hasattr(os, 'O_NOFOLLOW'): flags |= os.O_NOFOLLOW fd = os.open(lock_path, flags, 0o600) with os.fdopen(fd, 'r+') as lock_file: try: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError as exc: raise HermesSessionBusy('Une conversation Hermes est en cours. Réessaie plus tard.') from exc try: if not path.exists(): return False if not path.is_file(): raise ValueError('État de session Hermes inattendu.') # Archive the file even if its JSON is invalid: this action is also a recovery path. archive_dir = path.parent / 'hermes_session_archives' if archive_dir.is_symlink(): raise ValueError('Répertoire d’archives symbolique non autorisé.') archive_dir.mkdir(mode=0o700, parents=True, exist_ok=True) stem = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') name = f'{path.stem}-{stem}-{secrets.token_hex(4)}.json' os.replace(path, archive_dir / name) print('[ALFRED] Nouvelle session Hermes demandée depuis l’interface administrateur.', flush=True) return True finally: fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) finally: request_lock.release() def register_hermes_session_page(app): bp = Blueprint('alfred_hermes_session_button_v1', __name__) @bp.post('/api/control/hermes/new-session') @admin_console._require_admin def new_session(): try: archived = archive_current_session() except HermesSessionBusy as exc: return jsonify(ok=False, error=str(exc)), 409 except (OSError, ValueError, RuntimeError) as exc: app.logger.warning('Nouvelle session Hermes impossible : %s', exc) return jsonify(ok=False, error='Impossible de réinitialiser la session : '+str(exc)), 503 response = jsonify(ok=True, archived=archived, message=('Session précédente sauvegardée. La prochaine demande ouvrira une nouvelle session Hermes.' if archived else 'Aucune session précédente : Hermes en créera une à la prochaine demande.')) response.headers['Cache-Control'] = 'no-store, private' return response app.register_blueprint(bp)