idea135-crisisops-open-crisis/crisisops/replay.py

130 lines
4.1 KiB
Python

"""Deterministic snapshots and replay helpers for CrisisOps."""
from __future__ import annotations
import hashlib
import json
from typing import Any, Dict, Iterable, List
from .core import DualVariables, GraphOfContracts, Morphism, Object, PlanDelta
SCHEMA_VERSION = "1.0"
def canonical_json(payload: Any) -> str:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def _numeric(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _action_sort_key(action: Dict[str, Any]) -> tuple:
return (
str(action.get("morphism_id", "")),
str(action.get("action", "")),
str(action.get("resource_id", "")),
str(action.get("destination_id", "")),
)
def _object_to_dict(item: Object) -> Dict[str, Any]:
return {"id": item.id, "type": item.type, "attributes": dict(item.attributes)}
def _morphism_to_dict(item: Morphism) -> Dict[str, Any]:
return {
"id": item.id,
"source_id": item.source_id,
"target_id": item.target_id,
"type": item.type,
"signals": dict(item.signals),
}
def _dual_to_dict(item: DualVariables) -> Dict[str, Any]:
return {"name": item.name, "values": dict(item.values)}
def _plan_to_dict(plan: PlanDelta | None) -> Dict[str, Any] | None:
if plan is None:
return None
actions = [dict(action) for action in plan.actions]
actions.sort(key=_action_sort_key)
return {"timestamp": plan.timestamp, "actions": actions}
def graph_snapshot(graph: GraphOfContracts, plan: PlanDelta | None = None) -> Dict[str, Any]:
payload = {
"schema_version": SCHEMA_VERSION,
"objects": [_object_to_dict(graph.objects[key]) for key in sorted(graph.objects)],
"morphisms": [_morphism_to_dict(graph.morphisms[key]) for key in sorted(graph.morphisms)],
"duals": [_dual_to_dict(graph.duals[key]) for key in sorted(graph.duals)],
"plan_delta": _plan_to_dict(plan or graph.plan_delta),
}
payload["snapshot_hash"] = snapshot_hash(payload)
return payload
def snapshot_hash(snapshot: Dict[str, Any]) -> str:
normalized = dict(snapshot)
normalized.pop("snapshot_hash", None)
return hashlib.sha256(canonical_json(normalized).encode("utf-8")).hexdigest()
def restore_graph(snapshot: Dict[str, Any]) -> GraphOfContracts:
objects = {
item["id"]: Object(id=item["id"], type=item["type"], attributes=dict(item.get("attributes", {})))
for item in snapshot.get("objects", [])
}
morphisms = {
item["id"]: Morphism(
id=item["id"],
source_id=item["source_id"],
target_id=item["target_id"],
type=item["type"],
signals=dict(item.get("signals", {})),
)
for item in snapshot.get("morphisms", [])
}
duals = {
item["name"]: DualVariables(name=item["name"], values=dict(item.get("values", {})))
for item in snapshot.get("duals", [])
}
plan_data = snapshot.get("plan_delta")
plan_delta = None
if plan_data is not None:
plan_delta = PlanDelta(
timestamp=_numeric(plan_data.get("timestamp")),
actions=[dict(action) for action in plan_data.get("actions", [])],
)
graph = GraphOfContracts(objects=objects, morphisms=morphisms, plan_delta=plan_delta, duals=duals)
return graph
def replay_graphs(snapshots: Iterable[Dict[str, Any]]) -> List[GraphOfContracts]:
return [restore_graph(snapshot) for snapshot in snapshots]
class ReplayJournal:
"""A minimal append-only journal of canonical graph snapshots."""
def __init__(self) -> None:
self._snapshots: List[Dict[str, Any]] = []
def record(self, graph: GraphOfContracts, plan: PlanDelta | None = None) -> Dict[str, Any]:
snapshot = graph_snapshot(graph, plan=plan)
self._snapshots.append(snapshot)
return snapshot
def list_snapshots(self) -> List[Dict[str, Any]]:
return [dict(snapshot) for snapshot in self._snapshots]
def replay(self) -> List[GraphOfContracts]:
return replay_graphs(self._snapshots)