1
0
This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/infrastructure/world/hardening/backup.py

179 lines
5.8 KiB
Python

"""World-state backup strategy — timestamped files with rotation.
``WorldStateBackup`` writes each backup as a standalone JSON file and
maintains a ``MANIFEST.jsonl`` index for fast listing. Old backups
beyond the retention limit are rotated out automatically.
Usage::
backup = WorldStateBackup("var/backups/", max_backups=10)
record = backup.create(adapter, notes="pre-phase-8 checkpoint")
backup.restore(adapter, record.backup_id)
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from pathlib import Path
from infrastructure.world.adapters.mock import MockWorldAdapter
logger = logging.getLogger(__name__)
@dataclass
class BackupRecord:
"""Metadata entry written to the backup manifest."""
backup_id: str
timestamp: str
location: str
entity_count: int
event_count: int
size_bytes: int = 0
notes: str = ""
class WorldStateBackup:
"""Timestamped, rotating world-state backups.
Each backup is a JSON file named ``backup_<timestamp>.json`` inside
*backup_dir*. A ``MANIFEST.jsonl`` index tracks all backups for fast
listing and rotation.
Parameters
----------
backup_dir:
Directory where backup files and the manifest are stored.
max_backups:
Maximum number of backup files to retain.
"""
MANIFEST_NAME = "MANIFEST.jsonl"
def __init__(
self,
backup_dir: Path | str,
*,
max_backups: int = 10,
) -> None:
self._dir = Path(backup_dir)
self._dir.mkdir(parents=True, exist_ok=True)
self._max = max_backups
# -- create ------------------------------------------------------------
def create(
self,
adapter: MockWorldAdapter,
*,
notes: str = "",
) -> BackupRecord:
"""Snapshot *adapter* and write a new backup file.
Returns the ``BackupRecord`` describing the backup.
"""
perception = adapter.observe()
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%f")
backup_id = f"backup_{ts}"
payload = {
"backup_id": backup_id,
"timestamp": datetime.now(UTC).isoformat(),
"location": perception.location,
"entities": list(perception.entities),
"events": list(perception.events),
"raw": dict(perception.raw),
"notes": notes,
}
backup_path = self._dir / f"{backup_id}.json"
backup_path.write_text(json.dumps(payload, indent=2))
size = backup_path.stat().st_size
record = BackupRecord(
backup_id=backup_id,
timestamp=payload["timestamp"],
location=perception.location,
entity_count=len(perception.entities),
event_count=len(perception.events),
size_bytes=size,
notes=notes,
)
self._update_manifest(record)
self._rotate()
logger.info(
"WorldStateBackup: created %s (%d bytes)", backup_id, size
)
return record
# -- restore -----------------------------------------------------------
def restore(self, adapter: MockWorldAdapter, backup_id: str) -> bool:
"""Restore *adapter* state from backup *backup_id*.
Returns ``True`` on success, ``False`` if the backup file is missing.
"""
backup_path = self._dir / f"{backup_id}.json"
if not backup_path.exists():
logger.warning("WorldStateBackup: backup %s not found", backup_id)
return False
payload = json.loads(backup_path.read_text())
adapter._location = payload.get("location", "")
adapter._entities = list(payload.get("entities", []))
adapter._events = list(payload.get("events", []))
logger.info("WorldStateBackup: restored from %s", backup_id)
return True
# -- listing -----------------------------------------------------------
def list_backups(self) -> list[BackupRecord]:
"""Return all backup records, most recent first."""
manifest = self._dir / self.MANIFEST_NAME
if not manifest.exists():
return []
records: list[BackupRecord] = []
for line in manifest.read_text().strip().splitlines():
try:
data = json.loads(line)
records.append(BackupRecord(**data))
except (json.JSONDecodeError, TypeError):
continue
return list(reversed(records))
def latest(self) -> BackupRecord | None:
"""Return the most recent backup record, or ``None``."""
backups = self.list_backups()
return backups[0] if backups else None
# -- internal ----------------------------------------------------------
def _update_manifest(self, record: BackupRecord) -> None:
manifest = self._dir / self.MANIFEST_NAME
with manifest.open("a") as f:
f.write(json.dumps(asdict(record)) + "\n")
def _rotate(self) -> None:
"""Remove oldest backups when over the retention limit."""
backups = self.list_backups() # most recent first
if len(backups) <= self._max:
return
to_remove = backups[self._max :]
for rec in to_remove:
path = self._dir / f"{rec.backup_id}.json"
try:
path.unlink(missing_ok=True)
logger.debug("WorldStateBackup: rotated out %s", rec.backup_id)
except OSError as exc:
logger.warning(
"WorldStateBackup: could not remove %s: %s", path, exc
)
# Rewrite manifest with only the retained backups
keep = backups[: self._max]
manifest = self._dir / self.MANIFEST_NAME
manifest.write_text(
"\n".join(json.dumps(asdict(r)) for r in reversed(keep)) + "\n"
)