Phase 1: Cluster-Fundament – Nachrichten, Registry, Paarung, Discovery

- hms_cluster: ClusterMessage (§6.5: Pflichtfelder, Sequenzen, Revisionen,
  execute_at, Trace-ID), CommandTracker (idempotent, vorwärts-only:
  accepted->armed->executed/failed)
- NodeRegistry: online/degraded/stale/offline über Schwellen, UI-Kategorien
  discovered/paired/unknown/incompatible/offline, Doppel-Node-ID blockiert,
  IP-Wechsel erhaelt node_id (§6.3, §6.5)
- Pairing: PIN (TTL 120s, Versuchslimit), Fingerprint (SHA-256 gruppiert),
  Token nur als Hash, Scopes read/control/content_sync/admin, Ablauf +
  sofortiger Widerruf (§6.3, §27.1, ADR-0010)
- Discovery-Modell: _hmsmedia._tcp.local. TXT ohne Secrets, Capability-
  Digest, persistente manuelle Fallback-Liste (ADR-0009)
- ADR-0009 (mDNS + Fallback) und ADR-0010 (Paarung) dokumentiert
- 25 Unit-Tests; Gesamtsuite 183 Tests gruen, Ruff gruen
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 01:06:26 +02:00
parent 80dc10de22
commit c4574927fd
14 changed files with 1190 additions and 14 deletions
+168
View File
@@ -0,0 +1,168 @@
"""Node-Registry mit Heartbeat-Zuständen (PLAN.md §6.3, §6.5).
- Zustände je Node: online / degraded / stale / offline mit konfigurierbaren
Schwellen (§6.5)
- doppelte node_id wird als Fehler blockiert, nie still übernommen (§6.3)
- Kategorien für die UI: discovered / paired / unknown / incompatible /
offline werden getrennt geführt (§6.3)
- persistente node_id bleibt identisch bei IP-Wechsel; Endpunkte werden
als „zuletzt bekannt" aktualisiert (§10.1 Node)
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import StrEnum
class NodeHealth(StrEnum):
ONLINE = "online"
DEGRADED = "degraded"
STALE = "stale"
OFFLINE = "offline"
class NodeCategory(StrEnum):
"""UI-Kategorien gemäß §6.3: gefunden, gepaart, unbekannt, inkompatibel,
offline werden getrennt aufgeführt."""
DISCOVERED = "discovered"
PAIRED = "paired"
UNKNOWN = "unknown"
INCOMPATIBLE = "incompatible"
OFFLINE = "offline"
class DuplicateNodeError(Exception):
"""Doppelte node_id wird als Fehler blockiert (§6.3)."""
@dataclass
class NodeEntry:
"""Registry-Eintrag: Identität stabil, Endpunkte „zuletzt bekannt" (§10.1)."""
node_id: str
display_name: str
roles: tuple[str, ...] = ()
api_port: int = 0
protocol_version: int = 1
capability_digest: str = ""
last_known_endpoints: list[str] = field(default_factory=list)
last_heartbeat_ns: int = 0
health: NodeHealth = NodeHealth.OFFLINE
category: NodeCategory = NodeCategory.DISCOVERED
clock_offset_ns: int = 0
def record_endpoint(self, endpoint: str) -> None:
"""IP-Wechsel: node_id bleibt, Endpunkt wird aktualisiert (§6.3)."""
if endpoint in self.last_known_endpoints:
self.last_known_endpoints.remove(endpoint)
self.last_known_endpoints.insert(0, endpoint)
del self.last_known_endpoints[4:] # die letzten 5 genügen
@dataclass
class HealthThresholds:
"""Konfigurierbare Schwellen je Zustand (§6.5).
Heartbeat pünktlich < degraded_after_ns; verspätet, aber vorhanden
< stale_after_ns; danach offline. Standard-Heartbeat 500 ms (§6.5).
"""
heartbeat_interval_ns: int = 500_000_000
degraded_after_ns: int = 2_000_000_000 # 2 s ohne Heartbeat
stale_after_ns: int = 5_000_000_000 # 5 s ohne Heartbeat
class NodeRegistry:
"""Autoritative Liste bekannter Nodes (Coordinator-seitig)."""
def __init__(
self,
thresholds: HealthThresholds | None = None,
protocol_version: int = 1,
) -> None:
self._nodes: dict[str, NodeEntry] = {}
self._thresholds = thresholds or HealthThresholds()
self._protocol_version = protocol_version
def register(
self,
node_id: str,
display_name: str,
roles: tuple[str, ...] = (),
api_port: int = 0,
protocol_version: int = 1,
capability_digest: str = "",
endpoint: str = "",
) -> NodeEntry:
"""Neue Node oder Update bekannter Node; Doppel-ID mit
widersprüchlicher Identität ist ein Fehler (§6.3)."""
existing = self._nodes.get(node_id)
if existing is not None and existing.display_name != display_name:
raise DuplicateNodeError(
f"node_id {node_id} bereits als {existing.display_name!r} registriert"
)
if existing is None:
entry = NodeEntry(
node_id=node_id,
display_name=display_name,
roles=tuple(roles),
api_port=api_port,
protocol_version=protocol_version,
capability_digest=capability_digest,
)
self._nodes[node_id] = entry
else:
entry = existing
entry.roles = tuple(roles)
entry.api_port = api_port
entry.capability_digest = capability_digest
if endpoint:
entry.record_endpoint(endpoint)
# Inkompatible Protokollversion sichtbar kategorisieren (§6.3)
if entry.protocol_version != self._protocol_version:
entry.category = NodeCategory.INCOMPATIBLE
return entry
def record_heartbeat(self, node_id: str, clock_offset_ns: int = 0) -> None:
entry = self._nodes.get(node_id)
if entry is None:
raise KeyError(f"unknown node {node_id}")
entry.last_heartbeat_ns = time.monotonic_ns()
entry.clock_offset_ns = clock_offset_ns
def evaluate_health(self, node_id: str) -> NodeHealth:
"""Berechnet den Zustand aus letztem Heartbeat + Schwellen (§6.5)."""
entry = self._nodes[node_id]
if entry.last_heartbeat_ns == 0:
entry.health = NodeHealth.OFFLINE
if entry.category not in (NodeCategory.INCOMPATIBLE, NodeCategory.PAIRED):
entry.category = NodeCategory.DISCOVERED
return entry.health
elapsed = time.monotonic_ns() - entry.last_heartbeat_ns
if elapsed < self._thresholds.degraded_after_ns:
entry.health = NodeHealth.ONLINE
elif elapsed < self._thresholds.stale_after_ns:
entry.health = NodeHealth.DEGRADED
else:
entry.health = NodeHealth.OFFLINE
if entry.category is NodeCategory.PAIRED:
entry.category = NodeCategory.OFFLINE # Vertrauen bleibt, nur weg
return entry.health
def mark_paired(self, node_id: str) -> None:
self._nodes[node_id].category = NodeCategory.PAIRED
def mark_unknown(self, node_id: str) -> None:
self._nodes[node_id].category = NodeCategory.UNKNOWN
def get(self, node_id: str) -> NodeEntry | None:
return self._nodes.get(node_id)
def by_category(self, category: NodeCategory) -> list[NodeEntry]:
return [n for n in self._nodes.values() if n.category is category]
def all(self) -> list[NodeEntry]:
return list(self._nodes.values())