fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
362
agent-core/sessions/checkpoint.py
Normal file
362
agent-core/sessions/checkpoint.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Checkpoint Management for Breakpilot Agents
|
||||
|
||||
Provides checkpoint-based recovery with:
|
||||
- Named checkpoints for semantic recovery points
|
||||
- Automatic checkpoint compression
|
||||
- Recovery from specific checkpoints
|
||||
- Checkpoint analytics
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Checkpoint:
|
||||
"""Represents a recovery checkpoint"""
|
||||
id: str
|
||||
name: str
|
||||
timestamp: datetime
|
||||
data: Dict[str, Any]
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"data": self.data,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Checkpoint":
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
timestamp=datetime.fromisoformat(data["timestamp"]),
|
||||
data=data["data"],
|
||||
metadata=data.get("metadata", {})
|
||||
)
|
||||
|
||||
|
||||
class CheckpointManager:
|
||||
"""
|
||||
Manages checkpoints for agent sessions.
|
||||
|
||||
Provides:
|
||||
- Named checkpoints for semantic recovery
|
||||
- Automatic compression of old checkpoints
|
||||
- Recovery to specific checkpoint states
|
||||
- Analytics on checkpoint patterns
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
max_checkpoints: int = 100,
|
||||
compress_after: int = 50
|
||||
):
|
||||
"""
|
||||
Initialize the checkpoint manager.
|
||||
|
||||
Args:
|
||||
session_id: The session ID this manager belongs to
|
||||
max_checkpoints: Maximum number of checkpoints to retain
|
||||
compress_after: Compress checkpoints after this count
|
||||
"""
|
||||
self.session_id = session_id
|
||||
self.max_checkpoints = max_checkpoints
|
||||
self.compress_after = compress_after
|
||||
self._checkpoints: List[Checkpoint] = []
|
||||
self._checkpoint_count = 0
|
||||
self._on_checkpoint: Optional[Callable[[Checkpoint], None]] = None
|
||||
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
data: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Checkpoint:
|
||||
"""
|
||||
Creates a new checkpoint.
|
||||
|
||||
Args:
|
||||
name: Semantic name for the checkpoint (e.g., "task_started")
|
||||
data: Checkpoint data to store
|
||||
metadata: Optional additional metadata
|
||||
|
||||
Returns:
|
||||
The created checkpoint
|
||||
"""
|
||||
self._checkpoint_count += 1
|
||||
|
||||
checkpoint = Checkpoint(
|
||||
id=f"{self.session_id}:{self._checkpoint_count}",
|
||||
name=name,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
data=data,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
self._checkpoints.append(checkpoint)
|
||||
|
||||
# Compress if needed
|
||||
if len(self._checkpoints) > self.compress_after:
|
||||
self._compress_checkpoints()
|
||||
|
||||
# Trigger callback
|
||||
if self._on_checkpoint:
|
||||
self._on_checkpoint(checkpoint)
|
||||
|
||||
logger.debug(
|
||||
f"Session {self.session_id}: Created checkpoint '{name}' "
|
||||
f"(#{self._checkpoint_count})"
|
||||
)
|
||||
|
||||
return checkpoint
|
||||
|
||||
def get(self, checkpoint_id: str) -> Optional[Checkpoint]:
|
||||
"""
|
||||
Gets a checkpoint by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The checkpoint ID
|
||||
|
||||
Returns:
|
||||
The checkpoint or None if not found
|
||||
"""
|
||||
for cp in self._checkpoints:
|
||||
if cp.id == checkpoint_id:
|
||||
return cp
|
||||
return None
|
||||
|
||||
def get_by_name(self, name: str) -> List[Checkpoint]:
|
||||
"""
|
||||
Gets all checkpoints with a given name.
|
||||
|
||||
Args:
|
||||
name: The checkpoint name
|
||||
|
||||
Returns:
|
||||
List of matching checkpoints (newest first)
|
||||
"""
|
||||
return [
|
||||
cp for cp in reversed(self._checkpoints)
|
||||
if cp.name == name
|
||||
]
|
||||
|
||||
def get_latest(self, name: Optional[str] = None) -> Optional[Checkpoint]:
|
||||
"""
|
||||
Gets the latest checkpoint, optionally filtered by name.
|
||||
|
||||
Args:
|
||||
name: Optional name filter
|
||||
|
||||
Returns:
|
||||
The latest matching checkpoint or None
|
||||
"""
|
||||
if not self._checkpoints:
|
||||
return None
|
||||
|
||||
if name:
|
||||
matching = self.get_by_name(name)
|
||||
return matching[0] if matching else None
|
||||
|
||||
return self._checkpoints[-1]
|
||||
|
||||
def get_all(self) -> List[Checkpoint]:
|
||||
"""Returns all checkpoints"""
|
||||
return list(self._checkpoints)
|
||||
|
||||
def get_since(self, timestamp: datetime) -> List[Checkpoint]:
|
||||
"""
|
||||
Gets all checkpoints since a given timestamp.
|
||||
|
||||
Args:
|
||||
timestamp: The starting timestamp
|
||||
|
||||
Returns:
|
||||
List of checkpoints after the timestamp
|
||||
"""
|
||||
return [
|
||||
cp for cp in self._checkpoints
|
||||
if cp.timestamp > timestamp
|
||||
]
|
||||
|
||||
def get_between(
|
||||
self,
|
||||
start: datetime,
|
||||
end: datetime
|
||||
) -> List[Checkpoint]:
|
||||
"""
|
||||
Gets checkpoints between two timestamps.
|
||||
|
||||
Args:
|
||||
start: Start timestamp
|
||||
end: End timestamp
|
||||
|
||||
Returns:
|
||||
List of checkpoints in the range
|
||||
"""
|
||||
return [
|
||||
cp for cp in self._checkpoints
|
||||
if start <= cp.timestamp <= end
|
||||
]
|
||||
|
||||
def rollback_to(self, checkpoint_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Gets data needed to rollback to a checkpoint.
|
||||
|
||||
Note: This doesn't actually rollback - it returns the checkpoint
|
||||
data for the caller to use for recovery.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The checkpoint to rollback to
|
||||
|
||||
Returns:
|
||||
The checkpoint data or None if not found
|
||||
"""
|
||||
checkpoint = self.get(checkpoint_id)
|
||||
if checkpoint:
|
||||
logger.info(
|
||||
f"Session {self.session_id}: Rollback to checkpoint "
|
||||
f"'{checkpoint.name}' ({checkpoint_id})"
|
||||
)
|
||||
return checkpoint.data
|
||||
return None
|
||||
|
||||
def clear(self) -> int:
|
||||
"""
|
||||
Clears all checkpoints.
|
||||
|
||||
Returns:
|
||||
Number of checkpoints cleared
|
||||
"""
|
||||
count = len(self._checkpoints)
|
||||
self._checkpoints.clear()
|
||||
logger.info(f"Session {self.session_id}: Cleared {count} checkpoints")
|
||||
return count
|
||||
|
||||
def _compress_checkpoints(self) -> None:
|
||||
"""
|
||||
Compresses old checkpoints to save memory.
|
||||
|
||||
Keeps:
|
||||
- First checkpoint (session start)
|
||||
- Last N checkpoints (recent history)
|
||||
- One checkpoint per unique name (latest)
|
||||
"""
|
||||
if len(self._checkpoints) <= self.compress_after:
|
||||
return
|
||||
|
||||
# Keep first checkpoint
|
||||
first = self._checkpoints[0]
|
||||
|
||||
# Keep last 20 checkpoints
|
||||
recent = self._checkpoints[-20:]
|
||||
|
||||
# Keep one of each unique name from the middle
|
||||
middle = self._checkpoints[1:-20]
|
||||
by_name: Dict[str, Checkpoint] = {}
|
||||
for cp in middle:
|
||||
# Keep the latest of each name
|
||||
if cp.name not in by_name or cp.timestamp > by_name[cp.name].timestamp:
|
||||
by_name[cp.name] = cp
|
||||
|
||||
# Combine and sort
|
||||
compressed = [first] + list(by_name.values()) + recent
|
||||
compressed.sort(key=lambda cp: cp.timestamp)
|
||||
|
||||
old_count = len(self._checkpoints)
|
||||
self._checkpoints = compressed
|
||||
|
||||
logger.debug(
|
||||
f"Session {self.session_id}: Compressed checkpoints "
|
||||
f"from {old_count} to {len(self._checkpoints)}"
|
||||
)
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Gets a summary of checkpoint activity.
|
||||
|
||||
Returns:
|
||||
Summary dict with counts and timing info
|
||||
"""
|
||||
if not self._checkpoints:
|
||||
return {
|
||||
"total_count": 0,
|
||||
"unique_names": 0,
|
||||
"names": {},
|
||||
"first_checkpoint": None,
|
||||
"last_checkpoint": None,
|
||||
"duration_seconds": 0
|
||||
}
|
||||
|
||||
name_counts: Dict[str, int] = {}
|
||||
for cp in self._checkpoints:
|
||||
name_counts[cp.name] = name_counts.get(cp.name, 0) + 1
|
||||
|
||||
first = self._checkpoints[0]
|
||||
last = self._checkpoints[-1]
|
||||
|
||||
return {
|
||||
"total_count": len(self._checkpoints),
|
||||
"unique_names": len(name_counts),
|
||||
"names": name_counts,
|
||||
"first_checkpoint": first.to_dict(),
|
||||
"last_checkpoint": last.to_dict(),
|
||||
"duration_seconds": (last.timestamp - first.timestamp).total_seconds()
|
||||
}
|
||||
|
||||
def on_checkpoint(self, callback: Callable[[Checkpoint], None]) -> None:
|
||||
"""
|
||||
Sets a callback to be called on each checkpoint.
|
||||
|
||||
Args:
|
||||
callback: Function to call with each checkpoint
|
||||
"""
|
||||
self._on_checkpoint = callback
|
||||
|
||||
def export(self) -> str:
|
||||
"""
|
||||
Exports all checkpoints to JSON.
|
||||
|
||||
Returns:
|
||||
JSON string of all checkpoints
|
||||
"""
|
||||
return json.dumps(
|
||||
[cp.to_dict() for cp in self._checkpoints],
|
||||
indent=2
|
||||
)
|
||||
|
||||
def import_checkpoints(self, json_data: str) -> int:
|
||||
"""
|
||||
Imports checkpoints from JSON.
|
||||
|
||||
Args:
|
||||
json_data: JSON string of checkpoints
|
||||
|
||||
Returns:
|
||||
Number of checkpoints imported
|
||||
"""
|
||||
data = json.loads(json_data)
|
||||
imported = [Checkpoint.from_dict(cp) for cp in data]
|
||||
self._checkpoints.extend(imported)
|
||||
self._checkpoint_count = max(
|
||||
self._checkpoint_count,
|
||||
len(self._checkpoints)
|
||||
)
|
||||
return len(imported)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._checkpoints)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._checkpoints)
|
||||
Reference in New Issue
Block a user