191 lines
6.3 KiB
Python
191 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Session and state backup automation for Ezra.
|
|
Backs up critical files: sessions, memory, config, state.db.
|
|
|
|
Epic: EZRA-SELF-001 / Phase 4 - Session Management
|
|
Author: Ezra (self-improvement)
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tarfile
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
|
|
class SessionBackup:
|
|
"""Automated backup of Ezra's state and sessions."""
|
|
|
|
def __init__(
|
|
self,
|
|
home_dir: str = None,
|
|
backup_dir: str = None,
|
|
max_backups: int = 10,
|
|
):
|
|
self.home_dir = Path(home_dir or "/root/wizards/ezra/home")
|
|
self.backup_dir = Path(backup_dir or "/root/wizards/ezra/backups")
|
|
self.max_backups = max_backups
|
|
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Files/patterns to back up
|
|
CRITICAL_FILES = [
|
|
"config.yaml",
|
|
"memories/MEMORY.md",
|
|
"memories/USER.md",
|
|
"state.db",
|
|
"channel_directory.json",
|
|
"gateway_state.json",
|
|
"cron/jobs.json",
|
|
]
|
|
|
|
CRITICAL_DIRS = [
|
|
"sessions",
|
|
]
|
|
|
|
def create_backup(self, label: str = None) -> dict:
|
|
"""Create a compressed backup of critical state."""
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
label = label or "auto"
|
|
filename = f"ezra-backup-{timestamp}-{label}.tar.gz"
|
|
filepath = self.backup_dir / filename
|
|
|
|
files_included = []
|
|
files_missing = []
|
|
total_size = 0
|
|
|
|
with tarfile.open(filepath, "w:gz") as tar:
|
|
# Individual critical files
|
|
for rel_path in self.CRITICAL_FILES:
|
|
full_path = self.home_dir / rel_path
|
|
if full_path.exists():
|
|
tar.add(full_path, arcname=rel_path)
|
|
size = full_path.stat().st_size
|
|
files_included.append({"path": rel_path, "size": size})
|
|
total_size += size
|
|
else:
|
|
files_missing.append(rel_path)
|
|
|
|
# Session files (only metadata, not full JSONL)
|
|
sessions_dir = self.home_dir / "sessions"
|
|
if sessions_dir.exists():
|
|
# Include session index
|
|
sessions_json = sessions_dir / "sessions.json"
|
|
if sessions_json.exists():
|
|
tar.add(sessions_json, arcname="sessions/sessions.json")
|
|
files_included.append({"path": "sessions/sessions.json", "size": sessions_json.stat().st_size})
|
|
|
|
# Include session metadata (small files)
|
|
for f in sessions_dir.glob("session_*.json"):
|
|
if f.stat().st_size < 100_000: # Skip huge session files
|
|
tar.add(f, arcname=f"sessions/{f.name}")
|
|
files_included.append({"path": f"sessions/{f.name}", "size": f.stat().st_size})
|
|
total_size += f.stat().st_size
|
|
|
|
backup_size = filepath.stat().st_size
|
|
|
|
result = {
|
|
"filename": filename,
|
|
"path": str(filepath),
|
|
"backup_size": backup_size,
|
|
"backup_size_human": self._human_size(backup_size),
|
|
"source_size": total_size,
|
|
"files_included": len(files_included),
|
|
"files_missing": files_missing,
|
|
"timestamp": timestamp,
|
|
}
|
|
|
|
# Rotate old backups
|
|
self._rotate_backups()
|
|
|
|
return result
|
|
|
|
def _rotate_backups(self):
|
|
"""Remove old backups beyond max_backups."""
|
|
backups = sorted(
|
|
self.backup_dir.glob("ezra-backup-*.tar.gz"),
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
for old in backups[self.max_backups:]:
|
|
old.unlink()
|
|
|
|
def list_backups(self) -> list[dict]:
|
|
"""List existing backups."""
|
|
backups = []
|
|
for f in sorted(self.backup_dir.glob("ezra-backup-*.tar.gz"), reverse=True):
|
|
stat = f.stat()
|
|
backups.append({
|
|
"filename": f.name,
|
|
"size": self._human_size(stat.st_size),
|
|
"created": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
"age_hours": round((time.time() - stat.st_mtime) / 3600, 1),
|
|
})
|
|
return backups
|
|
|
|
def restore_backup(self, filename: str, dry_run: bool = True) -> dict:
|
|
"""Restore from a backup. Use dry_run=True to preview."""
|
|
filepath = self.backup_dir / filename
|
|
if not filepath.exists():
|
|
return {"error": f"Backup not found: {filename}"}
|
|
|
|
with tarfile.open(filepath, "r:gz") as tar:
|
|
members = tar.getmembers()
|
|
|
|
if dry_run:
|
|
return {
|
|
"mode": "dry_run",
|
|
"filename": filename,
|
|
"files": [m.name for m in members],
|
|
"total_files": len(members),
|
|
}
|
|
|
|
# Actual restore
|
|
tar.extractall(path=str(self.home_dir))
|
|
return {
|
|
"mode": "restored",
|
|
"filename": filename,
|
|
"files_restored": len(members),
|
|
}
|
|
|
|
def check_freshness(self) -> dict:
|
|
"""Check if backups are fresh enough."""
|
|
backups = self.list_backups()
|
|
if not backups:
|
|
return {"fresh": False, "reason": "No backups exist", "latest": None}
|
|
|
|
latest = backups[0]
|
|
age = latest["age_hours"]
|
|
return {
|
|
"fresh": age < 24,
|
|
"latest": latest["filename"],
|
|
"age_hours": age,
|
|
"total_backups": len(backups),
|
|
}
|
|
|
|
@staticmethod
|
|
def _human_size(size: int) -> str:
|
|
for unit in ["B", "KB", "MB", "GB"]:
|
|
if size < 1024:
|
|
return f"{size:.1f}{unit}"
|
|
size /= 1024
|
|
return f"{size:.1f}TB"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
backup = SessionBackup()
|
|
|
|
# Create a backup
|
|
result = backup.create_backup("manual")
|
|
print(f"Created: {result['filename']} ({result['backup_size_human']})")
|
|
print(f"Files: {result['files_included']} included, {len(result['files_missing'])} missing")
|
|
if result["files_missing"]:
|
|
print(f"Missing: {', '.join(result['files_missing'])}")
|
|
|
|
# List backups
|
|
print("\nExisting backups:")
|
|
for b in backup.list_backups():
|
|
print(f" {b['filename']} - {b['size']} ({b['age_hours']}h ago)")
|