feat: add read-only session recovery audit

This commit is contained in:
ai-ag2026 committed 2026-05-11 02:06:43 +02:00
1 parent 663817570c
commit 7b6d91d490
2 files changed
+232

No files matched your search

+132
View File
@@ -25,6 +25,7 @@ Three integration points:
"""
from __future__ import annotations
import argparse
import json
import logging
import shutil
@@ -174,6 +175,120 @@ def _orphaned_backup_live_paths(
return paths
def _new_audit_item(
session_id: str,
kind: str,
category: str,
recommendation: str,
live_messages: int = -1,
bak_messages: int = -1,
) -> dict:
return {
"session_id": session_id,
"kind": kind,
"category": category,
"recommendation": recommendation,
"live_messages": live_messages,
"bak_messages": bak_messages,
}
def _read_index_session_ids(index_path: Path) -> set[str]:
try:
data = json.loads(index_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError, ValueError):
return set()
if not isinstance(data, list):
return set()
ids: set[str] = set()
for entry in data:
if isinstance(entry, dict) and isinstance(entry.get('session_id'), str):
ids.add(entry['session_id'])
return ids
def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None) -> dict:
"""Read-only audit of session recovery state.
The audit intentionally does not mutate files. It classifies only the safe
recovery primitives this module knows how to perform: backup restores and
derived index rebuilds. Call ``recover_all_sessions_on_startup`` separately
for safe repairs.
"""
if not session_dir.exists():
return {
"status": "ok",
"summary": {"ok": 0, "repairable": 0, "unsafe_to_repair": 0},
"items": [],
}
items: list[dict] = []
live_paths = sorted(p for p in session_dir.glob('*.json') if not p.name.startswith('_'))
live_ids = {p.stem for p in live_paths}
for live_path in live_paths:
status = inspect_session_recovery_status(live_path)
if status.get('recommend') == 'restore':
items.append(_new_audit_item(
status['session_id'],
"shrunken_live",
"repairable",
"restore_from_bak",
status.get('live_messages', -1),
status.get('bak_messages', -1),
))
for bak_path in sorted(session_dir.glob('*.json.bak')):
live_path = bak_path.with_suffix('')
if live_path.exists() or live_path.name.startswith('_'):
continue
bak_messages = _msg_count(bak_path)
session_id = live_path.stem
if bak_messages < 0:
items.append(_new_audit_item(
session_id, "malformed_orphan_backup", "unsafe_to_repair", "manual_review", -1, bak_messages
))
elif _state_db_has_session(session_id, state_db_path):
items.append(_new_audit_item(
session_id, "orphan_backup", "repairable", "restore_from_bak", -1, bak_messages
))
else:
items.append(_new_audit_item(
session_id,
"orphan_backup_without_state_row",
"unsafe_to_repair",
"manual_review",
-1,
bak_messages,
))
index_path = session_dir / '_index.json'
if index_path.exists():
index_ids = _read_index_session_ids(index_path)
for session_id in sorted(index_ids - live_ids):
items.append(_new_audit_item(
session_id, "index_missing_file", "repairable", "rebuild_index"
))
for session_id in sorted(live_ids - index_ids):
items.append(_new_audit_item(
session_id, "index_missing_entry", "repairable", "rebuild_index",
_msg_count(session_dir / f"{session_id}.json"), -1,
))
summary = {"ok": len(live_paths), "repairable": 0, "unsafe_to_repair": 0}
for item in items:
category = item.get('category')
if category in summary:
summary[category] += 1
if summary["unsafe_to_repair"]:
overall = "needs_manual_review"
elif summary["repairable"]:
overall = "warn"
else:
overall = "ok"
return {"status": overall, "summary": summary, "items": items}
def recover_all_sessions_on_startup(
session_dir: Path,
rebuild_index: bool = False,
@@ -228,3 +343,20 @@ def recover_all_sessions_on_startup(
"orphaned_backups": len(orphan_paths),
"details": details,
}
def _main() -> int:
parser = argparse.ArgumentParser(description="Audit Hermes WebUI session recovery state")
parser.add_argument("--audit", action="store_true", help="run a read-only recovery audit")
parser.add_argument("--session-dir", type=Path, required=True, help="path to WebUI sessions directory")
parser.add_argument("--state-db", type=Path, default=None, help="optional Hermes state.db path")
args = parser.parse_args()
if not args.audit:
parser.error("currently only --audit is supported")
report = audit_session_recovery(args.session_dir, state_db_path=args.state_db)
print(json.dumps(report, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(_main())
+100
View File
@@ -0,0 +1,100 @@
import json
import sqlite3
import subprocess
import sys
from pathlib import Path
from api.session_recovery import audit_session_recovery
REPO_ROOT = Path(__file__).resolve().parents[1]
def _write_session(session_dir, sid, messages=1):
path = session_dir / f"{sid}.json"
path.write_text(
json.dumps({"id": sid, "session_id": sid, "title": sid, "messages": [{"role": "user", "content": str(i)} for i in range(messages)]}),
encoding="utf-8",
)
return path
def _state_db(session_dir, *session_ids):
db = session_dir / "state.db"
with sqlite3.connect(db) as conn:
conn.execute("create table sessions (id text primary key)")
conn.executemany("insert into sessions (id) values (?)", [(sid,) for sid in session_ids])
return db
def test_audit_reports_repairable_orphan_backup_when_state_db_has_session(tmp_path):
sid = "abc123"
live = _write_session(tmp_path, sid, messages=3)
bak = tmp_path / f"{sid}.json.bak"
bak.write_text(live.read_text(encoding="utf-8"), encoding="utf-8")
live.unlink()
db = _state_db(tmp_path, sid)
report = audit_session_recovery(tmp_path, state_db_path=db)
assert report["status"] == "warn"
assert report["summary"]["repairable"] == 1
assert report["items"] == [
{
"session_id": sid,
"kind": "orphan_backup",
"category": "repairable",
"recommendation": "restore_from_bak",
"live_messages": -1,
"bak_messages": 3,
}
]
def test_audit_marks_orphan_backup_without_state_row_unsafe(tmp_path):
sid = "abc123"
live = _write_session(tmp_path, sid, messages=2)
bak = tmp_path / f"{sid}.json.bak"
bak.write_text(live.read_text(encoding="utf-8"), encoding="utf-8")
live.unlink()
db = _state_db(tmp_path, "different")
report = audit_session_recovery(tmp_path, state_db_path=db)
assert report["status"] == "needs_manual_review"
assert report["summary"]["unsafe_to_repair"] == 1
assert report["items"][0]["kind"] == "orphan_backup_without_state_row"
assert report["items"][0]["recommendation"] == "manual_review"
def test_audit_reports_index_drift(tmp_path):
sid = "abc123"
_write_session(tmp_path, sid, messages=1)
(tmp_path / "_index.json").write_text(
json.dumps([{"session_id": "missing", "message_count": 1}]),
encoding="utf-8",
)
report = audit_session_recovery(tmp_path)
kinds = {item["kind"] for item in report["items"]}
assert "index_missing_file" in kinds
assert "index_missing_entry" in kinds
assert report["summary"]["repairable"] == 2
def test_session_recovery_module_audit_cli_outputs_json(tmp_path):
sid = "abc123"
_write_session(tmp_path, sid, messages=1)
result = subprocess.run(
[sys.executable, "-m", "api.session_recovery", "--audit", "--session-dir", str(tmp_path)],
cwd=str(REPO_ROOT),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
payload = json.loads(result.stdout)
assert payload["status"] == "ok"
assert payload["summary"]["ok"] == 1