130 lines
4.1 KiB
Python
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)
|