90 lines
3.9 KiB
Python
90 lines
3.9 KiB
Python
"""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)
|