AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""hms_cluster – Clusterprotokoll, Registry, Paarung, Discovery, Gruppen,
|
||||
Clock-Sync, zeitgestempelte Aktivierung (§6.3–§6.5; ADR-0009/0010)."""
|
||||
|
||||
from hms_cluster.activation import (
|
||||
ActivationCoordinator,
|
||||
ArmState,
|
||||
PlannedActivation,
|
||||
)
|
||||
from hms_cluster.clock import ClockEstimator, ClockSample, now_monotonic_ns
|
||||
from hms_cluster.discovery import (
|
||||
DISCOVERY_PROTOCOL_VERSION,
|
||||
SERVICE_TYPE,
|
||||
ManualNodeList,
|
||||
ServiceInfo,
|
||||
capability_digest,
|
||||
)
|
||||
from hms_cluster.groups import GroupRouter, GroupRule, ServerGroup, TargetKind
|
||||
from hms_cluster.message import ClusterMessage, CommandStatus, CommandTracker
|
||||
from hms_cluster.pairing import (
|
||||
PairingPin,
|
||||
PairingStore,
|
||||
Scope,
|
||||
generate_pin,
|
||||
hash_token,
|
||||
identity_fingerprint,
|
||||
new_token,
|
||||
pin_valid,
|
||||
)
|
||||
from hms_cluster.registry import (
|
||||
DuplicateNodeError,
|
||||
HealthThresholds,
|
||||
NodeCategory,
|
||||
NodeEntry,
|
||||
NodeHealth,
|
||||
NodeRegistry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SERVICE_TYPE",
|
||||
"DISCOVERY_PROTOCOL_VERSION",
|
||||
"ServiceInfo",
|
||||
"ManualNodeList",
|
||||
"capability_digest",
|
||||
"ClusterMessage",
|
||||
"CommandStatus",
|
||||
"CommandTracker",
|
||||
"PairingPin",
|
||||
"PairingStore",
|
||||
"Scope",
|
||||
"generate_pin",
|
||||
"pin_valid",
|
||||
"identity_fingerprint",
|
||||
"new_token",
|
||||
"hash_token",
|
||||
"DuplicateNodeError",
|
||||
"HealthThresholds",
|
||||
"NodeCategory",
|
||||
"NodeEntry",
|
||||
"NodeHealth",
|
||||
"NodeRegistry",
|
||||
"GroupRouter",
|
||||
"GroupRule",
|
||||
"ServerGroup",
|
||||
"TargetKind",
|
||||
"ClockEstimator",
|
||||
"ClockSample",
|
||||
"now_monotonic_ns",
|
||||
"ActivationCoordinator",
|
||||
"ArmState",
|
||||
"PlannedActivation",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Zeitgestempelte Preset-Aktivierung über Gruppen (§6.5, §6.4).
|
||||
|
||||
Koordinierter Show-Modus (§6.3): Der Coordinator verteilt zeitgestempelte
|
||||
Preset-/Parameterkommandos mit Preload/Arm/Ack und execute_at typischerweise
|
||||
100–300 ms im Voraus. Zwei-Phasen-Aktivierung (§6.5): vollständig ins
|
||||
Staging übertragen und validieren, danach atomar auf dieselbe Revision
|
||||
schalten.
|
||||
|
||||
execute_at ist eine Showzeit (Coordinator-Zeitbasis); jeder Node bildet sie
|
||||
über seinen Clock-Offset auf die lokale monotone Zeit ab (§6.4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
from hms_cluster.clock import now_monotonic_ns
|
||||
from hms_cluster.groups import GroupRouter, TargetKind
|
||||
|
||||
DEFAULT_LEAD_NS = 200_000_000 # 200 ms Vorlauf (§6.4: typisch 100–300 ms)
|
||||
|
||||
|
||||
class ArmState(StrEnum):
|
||||
"""Phasen eines geplanten Preset-Starts (§6.5)."""
|
||||
|
||||
CREATED = "created"
|
||||
ARMED = "armed"
|
||||
EXECUTED = "executed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlannedActivation:
|
||||
"""Ein zeitgestempelter Gruppen-Command (§6.5)."""
|
||||
|
||||
command_id: str
|
||||
scene_id: str
|
||||
target_kind: TargetKind
|
||||
target_id: str | None
|
||||
execute_at_show_ns: int # Coordinator-Showzeit
|
||||
created_ns: int
|
||||
state: ArmState = ArmState.CREATED
|
||||
armed_nodes: frozenset[str] = field(default_factory=frozenset)
|
||||
executed_nodes: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
@classmethod
|
||||
def plan(
|
||||
cls,
|
||||
scene_id: str,
|
||||
target_kind: TargetKind,
|
||||
target_id: str | None,
|
||||
now_show_ns: int,
|
||||
lead_ns: int = DEFAULT_LEAD_NS,
|
||||
) -> PlannedActivation:
|
||||
"""Plant die Ausführung `lead_ns` im Voraus (§6.4)."""
|
||||
return cls(
|
||||
command_id=str(uuid.uuid4()),
|
||||
scene_id=scene_id,
|
||||
target_kind=target_kind,
|
||||
target_id=target_id,
|
||||
execute_at_show_ns=now_show_ns + lead_ns,
|
||||
created_ns=now_monotonic_ns(),
|
||||
)
|
||||
|
||||
|
||||
class ActivationCoordinator:
|
||||
"""Koordiniert Preload/Arm/Execute über eine Node-Gruppe (§6.5).
|
||||
|
||||
- schedule(): plant die Aktivierung mit Vorlauf
|
||||
- acknowledge_arm(): Node bestätigt arm (Preflight grün)
|
||||
- acknowledge_execute(): Node hat ausgeführt (mit Ist-Zeit)
|
||||
- due(): Aktivierungen, deren Showzeit erreicht ist (pro Tick)
|
||||
|
||||
Fehlerfälle (§6.5): fehlt eine Arm-Bestätigung, blockiert die
|
||||
Ausführung standardmäßig; das wird als FAILED sichtbar dokumentiert
|
||||
und nicht still überbrückt.
|
||||
"""
|
||||
|
||||
def __init__(self, router: GroupRouter) -> None:
|
||||
self._router = router
|
||||
self._planned: dict[str, PlannedActivation] = {}
|
||||
|
||||
def schedule(
|
||||
self,
|
||||
scene_id: str,
|
||||
target_kind: TargetKind = TargetKind.ALL,
|
||||
target_id: str | None = None,
|
||||
now_show_ns: int | None = None,
|
||||
lead_ns: int = DEFAULT_LEAD_NS,
|
||||
) -> PlannedActivation:
|
||||
"""Plant eine Aktivierung; Ziel-Nodes werden sofort aufgelöst."""
|
||||
now = now_show_ns if now_show_ns is not None else now_monotonic_ns()
|
||||
planned = PlannedActivation.plan(
|
||||
scene_id=scene_id,
|
||||
target_kind=target_kind,
|
||||
target_id=target_id,
|
||||
now_show_ns=now,
|
||||
lead_ns=lead_ns,
|
||||
)
|
||||
self._planned[planned.command_id] = planned
|
||||
return planned
|
||||
|
||||
def expected_nodes(self, planned: PlannedActivation) -> frozenset[str]:
|
||||
"""Ziel-Nodes dieser Aktivierung (§17.5: vor Commit sichtbar)."""
|
||||
return self._router.preview_targets(planned.target_kind, planned.target_id)
|
||||
|
||||
def acknowledge_arm(self, command_id: str, node_id: str) -> ArmState:
|
||||
"""Node hat geprüft und armed (§6.5)."""
|
||||
planned = self._planned.get(command_id)
|
||||
if planned is None:
|
||||
raise KeyError(f"unbekannter Command {command_id}")
|
||||
planned.armed_nodes = frozenset(set(planned.armed_nodes) | {node_id})
|
||||
return planned.state
|
||||
|
||||
def acknowledge_execute(self, command_id: str, node_id: str) -> ArmState:
|
||||
"""Node hat ausgeführt; Ist-Zeit wird vom Node gemeldet (§6.5)."""
|
||||
planned = self._planned.get(command_id)
|
||||
if planned is None:
|
||||
raise KeyError(f"unbekannter Command {command_id}")
|
||||
planned.executed_nodes = frozenset(set(planned.executed_nodes) | {node_id})
|
||||
if planned.executed_nodes >= self.expected_nodes(planned):
|
||||
planned.state = ArmState.EXECUTED
|
||||
return planned.state
|
||||
|
||||
def due(self, now_show_ns: int | None = None) -> list[PlannedActivation]:
|
||||
"""Aktivierungen, deren Showzeit gekommen ist – nur für armed.
|
||||
|
||||
Nicht voll armbare Aktivierungen werden FAILED, nicht still
|
||||
ausgeführt (§6.5: fehlende Bestätigung blockiert standardmäßig).
|
||||
"""
|
||||
now = now_show_ns if now_show_ns is not None else now_monotonic_ns()
|
||||
due_list: list[PlannedActivation] = []
|
||||
for planned in list(self._planned.values()):
|
||||
if planned.state is not ArmState.CREATED:
|
||||
continue
|
||||
if now >= planned.execute_at_show_ns:
|
||||
expected = self.expected_nodes(planned)
|
||||
if expected and planned.armed_nodes >= expected:
|
||||
planned.state = ArmState.ARMED
|
||||
due_list.append(planned)
|
||||
else:
|
||||
planned.state = ArmState.FAILED # §6.5: Blockade sichtbar
|
||||
return due_list
|
||||
|
||||
def get(self, command_id: str) -> PlannedActivation | None:
|
||||
return self._planned.get(command_id)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Clock-Offset- und Drift-Messung (PLAN.md §6.4 Clock Sync).
|
||||
|
||||
V1-Verfahren: PTP, wenn verfügbar; sonst gemessene Offset-/Drift-
|
||||
Schätzung gegen den Coordinator (§6.4). Das hier ist die softwareseitige
|
||||
Messung: Round-Trip-basierte Offset-Schätzung mit Min-Filterung (NTP-artig)
|
||||
und linearer Drift-Schätzung über Probenpaare.
|
||||
|
||||
Grenzen (§6.4): messbare Softwarezeit, kein Genlock; p95 ≤ 10 ms für
|
||||
vorgepufferte Preset-/Command-Starts im verkabelten Referenz-LAN ist ein
|
||||
Abnahmeziel von Gate 2, keine Zusage für beliebige Netze.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClockSample:
|
||||
"""Eine RTT-Messprobe zwischen Coordinator und Node.
|
||||
|
||||
t0/t1 in lokaler monotoner Zeit des Coordinators; node_time ist die
|
||||
vom Node zurückgemeldete eigene monotone Zeit (normiert).
|
||||
"""
|
||||
|
||||
t0_ns: int
|
||||
t1_ns: int
|
||||
node_time_ns: int
|
||||
|
||||
@property
|
||||
def rtt_ns(self) -> int:
|
||||
return self.t1_ns - self.t0_ns
|
||||
|
||||
@property
|
||||
def offset_ns(self) -> int:
|
||||
"""Min-RTT-Näherung: Offset = node_time - (t0 + rtt/2)."""
|
||||
return self.node_time_ns - (self.t0_ns + self.rtt_ns // 2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClockEstimator:
|
||||
"""Schätzt Offset und Drift aus RTT-Proben (§6.4).
|
||||
|
||||
- feed(): neue Probe; behält die Proben mit kleinster RTT (Min-Filter,
|
||||
weil geringe RTT ≈ geringe Warteschlangen-Verzögerung)
|
||||
- offset: geglätteter Offset gegen den Coordinator
|
||||
- drift_ppm: Änderung des Offsets über die Zeit (μs/s)
|
||||
- Window begrenzt (kein unbeschränkter Zustand, §33)
|
||||
"""
|
||||
|
||||
max_samples: int = 64
|
||||
_samples: list[ClockSample] = field(default_factory=list)
|
||||
_best: list[ClockSample] = field(default_factory=list) # kleinste RTTs
|
||||
|
||||
def feed(self, sample: ClockSample) -> None:
|
||||
if sample.rtt_ns < 0:
|
||||
raise ValueError("negative RTT unmöglich")
|
||||
self._samples.append(sample)
|
||||
if len(self._samples) > self.max_samples:
|
||||
self._samples.pop(0)
|
||||
# Min-RTT-Filter: nur Proben mit RTT ≤ 2× Minimum sind belastbar;
|
||||
# hohe RTT bedeutet Warteschlangen-Jitter, der den Offset verfälscht
|
||||
min_rtt = min(s.rtt_ns for s in self._samples)
|
||||
ranked = sorted(
|
||||
(s for s in self._samples if s.rtt_ns <= 2 * min_rtt),
|
||||
key=lambda s: s.rtt_ns,
|
||||
)[:8]
|
||||
self._best = ranked
|
||||
|
||||
@property
|
||||
def offset_ns(self) -> int | None:
|
||||
"""Aktueller Offset-Schätzer (Mittel über Best-Proben)."""
|
||||
if not self._best:
|
||||
return None
|
||||
return sum(s.offset_ns for s in self._best) // len(self._best)
|
||||
|
||||
@property
|
||||
def rtt_ns(self) -> int | None:
|
||||
"""Beste (kleinste) gemessene RTT."""
|
||||
if not self._best:
|
||||
return None
|
||||
return min(s.rtt_ns for s in self._best)
|
||||
|
||||
@property
|
||||
def drift_ppm(self) -> float | None:
|
||||
"""Lineare Drift-Schätzung über die Best-Proben (μs/s).
|
||||
|
||||
Offset-Änderung geteilt durch verstrichene RTT-Mittezeit; None bei
|
||||
weniger als zwei Best-Proben oder zu kurzem Fenster (< 1 s).
|
||||
"""
|
||||
if len(self._best) < 2:
|
||||
return None
|
||||
ordered = sorted(self._best, key=lambda s: s.t0_ns)
|
||||
first, last = ordered[0], ordered[-1]
|
||||
dt_ns = last.t0_ns - first.t0_ns
|
||||
if dt_ns < 1_000_000_000: # < 1 s: Drift nicht belastbar
|
||||
return None
|
||||
d_offset = last.offset_ns - first.offset_ns
|
||||
return (d_offset / dt_ns) * 1_000_000.0
|
||||
|
||||
def map_show_time(self, show_time_ns: int) -> int | None:
|
||||
"""Bildet Coordinator-Showzeit auf lokale Node-Zeit ab (§6.4:
|
||||
Showzeit → lokale Monotonic).
|
||||
|
||||
Voraussetzung: dieser Estimator läuft Node-seitig mit Proben,
|
||||
deren node_time die eigene Uhr ist.
|
||||
"""
|
||||
offset = self.offset_ns
|
||||
if offset is None:
|
||||
return None
|
||||
return show_time_ns + offset
|
||||
|
||||
|
||||
def now_monotonic_ns() -> int:
|
||||
"""Gemeinsame monotone Zeitbasis (§12.2: Audio/Video gemeinsame Basis)."""
|
||||
return time.monotonic_ns()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Discovery: mDNS-Service-Modell + manuelle Fallback-Liste (PLAN.md §6.3;
|
||||
ADR-0009).
|
||||
|
||||
- Service-Typ: _hmsmedia._tcp.local.
|
||||
- TXT nur kleine, nicht vertrauliche Daten: proto, node, roles, port, caps
|
||||
- manuelle Node-Liste für VLANs/geroutete Netze (§6.3)
|
||||
- IP-Wechsel ändert die node_id nicht (§6.3)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
SERVICE_TYPE = "_hmsmedia._tcp.local."
|
||||
DISCOVERY_PROTOCOL_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceInfo:
|
||||
"""mDNS-Ankündigung eines Nodes (TXT-Inhalt, ADR-0009)."""
|
||||
|
||||
node_id: str
|
||||
display_name: str
|
||||
port: int
|
||||
roles: tuple[str, ...]
|
||||
protocol_version: int = DISCOVERY_PROTOCOL_VERSION
|
||||
capability_digest: str = ""
|
||||
|
||||
@property
|
||||
def instance_name(self) -> str:
|
||||
"""Eindeutiger Instanzname: bereinigter Anzeigename."""
|
||||
safe = "".join(c for c in self.display_name if c.isalnum() or c in " -_")
|
||||
return safe[:63] or self.node_id[:8]
|
||||
|
||||
def txt(self) -> dict[str, str]:
|
||||
"""TXT-Record: klein, nicht vertraulich (ADR-0009, §27.1)."""
|
||||
return {
|
||||
"proto": str(self.protocol_version),
|
||||
"node": self.node_id,
|
||||
"roles": ",".join(self.roles),
|
||||
"port": str(self.port),
|
||||
"caps": self.capability_digest[:16],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_txt(cls, instance_name: str, port: int, txt: dict[str, str]) -> ServiceInfo:
|
||||
"""Parst eine fremde Ankündigung; wirft bei unvollständigen Daten."""
|
||||
required = ("proto", "node", "port")
|
||||
missing = [k for k in required if k not in txt]
|
||||
if missing:
|
||||
raise ValueError(f"TXT unvollstaendig, fehlt: {missing}")
|
||||
node_id = txt["node"]
|
||||
if len(node_id) < 8:
|
||||
raise ValueError("node-Eintrag ungueltig")
|
||||
roles = tuple(r for r in txt.get("roles", "").split(",") if r)
|
||||
return cls(
|
||||
node_id=node_id,
|
||||
display_name=instance_name,
|
||||
port=int(txt["port"]) or port,
|
||||
roles=roles,
|
||||
protocol_version=int(txt["proto"]),
|
||||
capability_digest=txt.get("caps", ""),
|
||||
)
|
||||
|
||||
|
||||
def capability_digest(capabilities: dict) -> str:
|
||||
"""Kurzer, stabiler Digest über Capabilities (ADR-0009 TXT 'caps')."""
|
||||
canonical = json.dumps(capabilities, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualNodeList:
|
||||
"""Persistente manuelle Node-Liste (Fallback ohne mDNS, §6.3).
|
||||
|
||||
JSON-Format: Liste von {"host", "port", "node_id"}; ungeprüfte Hosts
|
||||
bleiben Kategorie `unknown`, bis sich die Node legitim identifiziert.
|
||||
"""
|
||||
|
||||
path: Path
|
||||
|
||||
def save(self, entries: list[dict]) -> None:
|
||||
clean = [
|
||||
{
|
||||
"host": str(e.get("host", "")),
|
||||
"port": int(e.get("port", 0)),
|
||||
"node_id": str(e.get("node_id", "")),
|
||||
}
|
||||
for e in entries
|
||||
]
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(
|
||||
json.dumps(clean, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
def load(self) -> list[dict]:
|
||||
if not self.path.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
entries = []
|
||||
for item in data if isinstance(data, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
host = str(item.get("host", ""))
|
||||
if not host:
|
||||
continue
|
||||
try:
|
||||
port = int(item.get("port", 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
entries.append(
|
||||
{"host": host, "port": port, "node_id": str(item.get("node_id", ""))}
|
||||
)
|
||||
return entries
|
||||
|
||||
def add(self, host: str, port: int, node_id: str = "") -> None:
|
||||
entries = self.load()
|
||||
if any(e["host"] == host and e["port"] == port for e in entries):
|
||||
return
|
||||
entries.append({"host": host, "port": port, "node_id": node_id})
|
||||
self.save(entries)
|
||||
|
||||
def remove(self, host: str, port: int) -> None:
|
||||
entries = [e for e in self.load() if not (e["host"] == host and e["port"] == port)]
|
||||
self.save(entries)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Servergruppen und Zielrouting (PLAN.md §6.3 Bedienmodelle, §10.1 ServerGroup).
|
||||
|
||||
- Coordinator routet Commands an `All`, eine `ServerGroup`, einen einzelnen
|
||||
`Node` oder einen `Output` (§6.3 Control-Center-Modell)
|
||||
- Zielregel je Gruppe: all | selected | tag_query | feste Node-Liste
|
||||
- Zielauflösung ist deterministisch und testbar; Gruppenänderungen zeigen
|
||||
vor dem Commit, welche Nodes sie erhalten (§17.5)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class TargetKind(StrEnum):
|
||||
"""Command-Ziele (§6.3): All, ServerGroup, Node oder Output."""
|
||||
|
||||
ALL = "all"
|
||||
SERVER_GROUP = "server_group"
|
||||
NODE = "node"
|
||||
OUTPUT = "output"
|
||||
|
||||
|
||||
class GroupRule(StrEnum):
|
||||
"""Zielregel je ServerGroup (§10.1)."""
|
||||
|
||||
ALL = "all"
|
||||
SELECTED = "selected"
|
||||
TAG_QUERY = "tag_query"
|
||||
NODE_LIST = "node_list"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerGroup:
|
||||
"""Servergruppe (§10.1): Node-Auswahl mit Zielregel.
|
||||
|
||||
- node_ids: feste Mitglieder bei SELECTED/NODE_LIST
|
||||
- tags: Node-Tags für TAG_QUERY (Node-Einträge tragen passende Tags)
|
||||
- output_map: optionaler Layer-/Output-Zuordnungshinweis (§10.1)
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
rule: GroupRule = GroupRule.SELECTED
|
||||
node_ids: frozenset[str] = field(default_factory=frozenset)
|
||||
tags: frozenset[str] = field(default_factory=frozenset)
|
||||
output_map: dict[str, str] = field(default_factory=dict) # layer_id → output_id
|
||||
|
||||
|
||||
class GroupRouter:
|
||||
"""Löst Command-Ziele auf Node-Mengen auf (§6.3).
|
||||
|
||||
Der Coordinator nutzt resolve() vor jedem Senden; die UI kann
|
||||
resolve() für die Commit-Vorschau verwenden (§17.5: „welche Nodes
|
||||
erhalten dies?").
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._groups: dict[str, ServerGroup] = {}
|
||||
self._node_tags: dict[str, frozenset[str]] = {} # node_id → tags
|
||||
self._node_outputs: dict[str, list[str]] = {} # node_id → output_ids
|
||||
|
||||
# ---------- Verwaltung ----------
|
||||
|
||||
def upsert_group(self, group: ServerGroup) -> None:
|
||||
self._groups[group.id] = group
|
||||
|
||||
def remove_group(self, group_id: str) -> None:
|
||||
self._groups.pop(group_id, None)
|
||||
|
||||
def get_group(self, group_id: str) -> ServerGroup | None:
|
||||
return self._groups.get(group_id)
|
||||
|
||||
def set_node_tags(self, node_id: str, tags: frozenset[str]) -> None:
|
||||
self._node_tags[node_id] = frozenset(tags)
|
||||
|
||||
def set_node_outputs(self, node_id: str, output_ids: list[str]) -> None:
|
||||
self._node_outputs[node_id] = list(output_ids)
|
||||
|
||||
def known_nodes(self) -> frozenset[str]:
|
||||
return frozenset(self._node_tags)
|
||||
|
||||
# ---------- Zielauflösung (§6.3) ----------
|
||||
|
||||
def resolve(self, kind: TargetKind, target_id: str | None = None) -> frozenset[str]:
|
||||
"""Löst ein Ziel auf eine Node-Menge auf; leer bei unbekanntem Ziel."""
|
||||
if kind is TargetKind.ALL:
|
||||
return self.known_nodes()
|
||||
if kind is TargetKind.NODE:
|
||||
return frozenset({target_id}) if target_id in self._node_tags else frozenset()
|
||||
if kind is TargetKind.OUTPUT:
|
||||
return frozenset(
|
||||
node_id
|
||||
for node_id, outputs in self._node_outputs.items()
|
||||
if target_id in outputs
|
||||
)
|
||||
if kind is TargetKind.SERVER_GROUP:
|
||||
group = self._groups.get(target_id or "")
|
||||
if group is None:
|
||||
return frozenset()
|
||||
if group.rule is GroupRule.ALL:
|
||||
return self.known_nodes()
|
||||
if group.rule is GroupRule.SELECTED or group.rule is GroupRule.NODE_LIST:
|
||||
return group.node_ids & self.known_nodes()
|
||||
if group.rule is GroupRule.TAG_QUERY:
|
||||
return frozenset(
|
||||
node_id
|
||||
for node_id, tags in self._node_tags.items()
|
||||
if group.tags & tags
|
||||
)
|
||||
return frozenset()
|
||||
|
||||
def preview_targets(self, kind: TargetKind, target_id: str | None = None) -> frozenset[str]:
|
||||
"""Commit-Vorschau: identisch zu resolve (§17.5)."""
|
||||
return self.resolve(kind, target_id)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Cluster-Nachrichtenhülle (PLAN.md §6.5).
|
||||
|
||||
Jede Cluster-Nachricht enthält mindestens: cluster_id, node_id, command_id,
|
||||
Sequenz, Projekt-Revision, Absenderzeit, optionale execute_at-Showzeit und
|
||||
Trace-ID. Zustandsändernde Commands sind idempotent und werden mit
|
||||
accepted/armed/executed/failed bestätigt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class CommandStatus(StrEnum):
|
||||
"""Bestätigungsstufen zustandsändernder Commands (§6.5)."""
|
||||
|
||||
ACCEPTED = "accepted"
|
||||
ARMED = "armed"
|
||||
EXECUTED = "executed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class ClusterMessage:
|
||||
"""Versionierte Cluster-Nachricht mit Pflichtfeldern (§6.5).
|
||||
|
||||
- sequence: je Absender monoton; Lücken signalisieren Paketverlust
|
||||
- project_revision: Zustandsrevision, auf die sich der Command bezieht
|
||||
- sender_time_ns: monotone Absenderzeit (nicht Wanduhr)
|
||||
- execute_at_show_time_ns: optional; 100–300 ms Vorlauf für Sync-Starts
|
||||
- trace_id: Korrelations-ID über Log-Grenzen (§28.1)
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"cluster_id",
|
||||
"node_id",
|
||||
"command_id",
|
||||
"sequence",
|
||||
"project_revision",
|
||||
"sender_time_ns",
|
||||
"execute_at_show_time_ns",
|
||||
"trace_id",
|
||||
"status",
|
||||
"payload",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cluster_id: str,
|
||||
node_id: str,
|
||||
command_id: str,
|
||||
sequence: int,
|
||||
project_revision: int,
|
||||
sender_time_ns: int | None = None,
|
||||
execute_at_show_time_ns: int | None = None,
|
||||
trace_id: str | None = None,
|
||||
status: CommandStatus | None = None,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
for field_name, value in (
|
||||
("cluster_id", cluster_id),
|
||||
("node_id", node_id),
|
||||
("command_id", command_id),
|
||||
):
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise ValueError(f"{field_name} must be a UUID") from exc
|
||||
if sequence < 0:
|
||||
raise ValueError("sequence must be >= 0")
|
||||
if project_revision < 0:
|
||||
raise ValueError("project_revision must be >= 0")
|
||||
self.cluster_id = cluster_id
|
||||
self.node_id = node_id
|
||||
self.command_id = command_id
|
||||
self.sequence = sequence
|
||||
self.project_revision = project_revision
|
||||
self.sender_time_ns = (
|
||||
sender_time_ns if sender_time_ns is not None else time.monotonic_ns()
|
||||
)
|
||||
self.execute_at_show_time_ns = execute_at_show_time_ns
|
||||
self.trace_id = trace_id or str(uuid.uuid4())
|
||||
self.status = status
|
||||
self.payload = payload or {}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"cluster_id": self.cluster_id,
|
||||
"node_id": self.node_id,
|
||||
"command_id": self.command_id,
|
||||
"sequence": self.sequence,
|
||||
"project_revision": self.project_revision,
|
||||
"sender_time_ns": self.sender_time_ns,
|
||||
"execute_at_show_time_ns": self.execute_at_show_time_ns,
|
||||
"trace_id": self.trace_id,
|
||||
"status": self.status.value if self.status else None,
|
||||
"payload": self.payload,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> ClusterMessage:
|
||||
status_raw = data.get("status")
|
||||
status = CommandStatus(status_raw) if status_raw else None
|
||||
return cls(
|
||||
cluster_id=data["cluster_id"],
|
||||
node_id=data["node_id"],
|
||||
command_id=data["command_id"],
|
||||
sequence=int(data["sequence"]),
|
||||
project_revision=int(data["project_revision"]),
|
||||
sender_time_ns=data.get("sender_time_ns"),
|
||||
execute_at_show_time_ns=data.get("execute_at_show_time_ns"),
|
||||
trace_id=data.get("trace_id"),
|
||||
status=status,
|
||||
payload=data.get("payload", {}),
|
||||
)
|
||||
|
||||
|
||||
class CommandTracker:
|
||||
"""Idempotenz je (node_id, command_id) mit Statusübergängen (§6.5).
|
||||
|
||||
- register: neuer Command → True; Duplikat → False (gleiches Ack)
|
||||
- advance: nur vorwärts accepted → armed → executed/failed
|
||||
- veraltete Einträge werden nach Kapazität begrenzt (kein unbeschränkter
|
||||
Cache, §33)
|
||||
"""
|
||||
|
||||
_ORDER = {
|
||||
CommandStatus.ACCEPTED: 1,
|
||||
CommandStatus.ARMED: 2,
|
||||
CommandStatus.EXECUTED: 3,
|
||||
CommandStatus.FAILED: 3,
|
||||
}
|
||||
|
||||
def __init__(self, capacity: int = 4096) -> None:
|
||||
if capacity <= 0:
|
||||
raise ValueError("capacity must be positive")
|
||||
self._capacity = capacity
|
||||
self._states: dict[str, CommandStatus] = {}
|
||||
|
||||
def register(self, node_id: str, command_id: str) -> bool:
|
||||
"""True, wenn der Command neu ist; False bei Duplikat."""
|
||||
key = f"{node_id}:{command_id}"
|
||||
if key in self._states:
|
||||
return False
|
||||
self._states[key] = CommandStatus.ACCEPTED
|
||||
if len(self._states) > self._capacity:
|
||||
oldest = next(iter(self._states))
|
||||
del self._states[oldest]
|
||||
return True
|
||||
|
||||
def advance(self, node_id: str, command_id: str, new_status: CommandStatus) -> bool:
|
||||
"""Nur vorwärts; False bei unbekanntem Command oder Rückschritt."""
|
||||
key = f"{node_id}:{command_id}"
|
||||
current = self._states.get(key)
|
||||
if current is None:
|
||||
return False
|
||||
if self._ORDER[new_status] <= self._ORDER[current]:
|
||||
return False
|
||||
self._states[key] = new_status
|
||||
return True
|
||||
|
||||
def status(self, node_id: str, command_id: str) -> CommandStatus | None:
|
||||
return self._states.get(f"{node_id}:{command_id}")
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Node-Paarung: PIN, Fingerprint, Token-Scopes, Widerruf (§6.3, §27.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
PIN_TTL_S = 120.0
|
||||
|
||||
|
||||
class Scope(StrEnum):
|
||||
"""Getrennte Berechtigungsscopes (§27.1)."""
|
||||
|
||||
READ = "read"
|
||||
CONTROL = "control"
|
||||
CONTENT_SYNC = "content_sync"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
_ALL_SCOPES = frozenset(s.value for s in Scope)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairingPin:
|
||||
"""Kurzlebige Paarungs-PIN (§6.3)."""
|
||||
|
||||
value: str
|
||||
created_ns: int
|
||||
|
||||
@property
|
||||
def expires_ns(self) -> int:
|
||||
return self.created_ns + int(PIN_TTL_S * 1_000_000_000)
|
||||
|
||||
|
||||
def generate_pin() -> PairingPin:
|
||||
"""6-stellige PIN, kryptographisch erzeugt (§6.3)."""
|
||||
return PairingPin(
|
||||
value=f"{secrets.randbelow(1_000_000):06d}",
|
||||
created_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
|
||||
def pin_valid(pin: PairingPin, now_ns: int | None = None) -> bool:
|
||||
now = now_ns if now_ns is not None else time.monotonic_ns()
|
||||
return now <= pin.expires_ns
|
||||
|
||||
|
||||
def identity_fingerprint(
|
||||
node_id: str,
|
||||
display_name: str,
|
||||
public_key_pem: str | None = None,
|
||||
) -> str:
|
||||
"""Sichtbarer Fingerprint über öffentliche Identitätsdaten (§6.3).
|
||||
|
||||
Format: 8 Gruppen à 4 Hex-Zeichen (128 Bits des SHA-256), für Menschen
|
||||
vergleichbar.
|
||||
"""
|
||||
material = f"{node_id}|{display_name}".encode()
|
||||
if public_key_pem:
|
||||
material += b"|" + public_key_pem.encode("ascii", errors="replace")
|
||||
digest = hashlib.sha256(material).hexdigest()
|
||||
groups = [digest[i : i + 4] for i in range(0, 32, 4)]
|
||||
return ":".join(groups)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairedToken:
|
||||
"""Ausgestelltes Token; nur der Hash wird gespeichert (§27.1)."""
|
||||
|
||||
token_hash: str
|
||||
scopes: frozenset[str]
|
||||
expires_ns: int | None
|
||||
created_ns: int
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Token-Hash; Klartext existiert nur beim Besitzer."""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def new_token(scopes: frozenset[str], ttl_s: float | None = None) -> tuple[str, PairedToken]:
|
||||
"""Erzeugt Token + gespeicherte Repräsentation; Klartext genau einmal."""
|
||||
unknown = scopes - _ALL_SCOPES
|
||||
if unknown:
|
||||
raise ValueError(f"unknown scopes: {sorted(unknown)}")
|
||||
if not scopes:
|
||||
raise ValueError("scopes must not be empty")
|
||||
now = time.monotonic_ns()
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires = now + int(ttl_s * 1_000_000_000) if ttl_s is not None else None
|
||||
return token, PairedToken(
|
||||
token_hash=hash_token(token),
|
||||
scopes=scopes,
|
||||
expires_ns=expires,
|
||||
created_ns=now,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingStore:
|
||||
"""PINs, Paarungszustand und Tokens je Node (§6.3, §27.1, ADR-0010)."""
|
||||
|
||||
_pins: dict[str, PairingPin] = field(default_factory=dict)
|
||||
_tokens: dict[str, PairedToken] = field(default_factory=dict)
|
||||
_failed_attempts: dict[str, int] = field(default_factory=dict)
|
||||
max_pin_attempts: int = 3
|
||||
|
||||
def issue_pin(self, node_id: str) -> PairingPin:
|
||||
pin = generate_pin()
|
||||
self._pins[node_id] = pin
|
||||
self._failed_attempts.pop(node_id, None)
|
||||
return pin
|
||||
|
||||
def complete_pairing(
|
||||
self,
|
||||
node_id: str,
|
||||
entered_pin: str,
|
||||
fingerprint_seen: str,
|
||||
expected_fingerprint: str,
|
||||
scopes: frozenset[str],
|
||||
token_ttl_s: float | None = None,
|
||||
now_ns: int | None = None,
|
||||
) -> str:
|
||||
"""Prüft PIN + Fingerprint, stellt Token aus; gibt Klartext zurück.
|
||||
|
||||
Fehlversuche erhöhen den Zähler; nach max_pin_attempts wird die PIN
|
||||
gesperrt (Neuausstellung nötig). Vergleiche konstantzeit über
|
||||
hmac.compare_digest.
|
||||
"""
|
||||
pin = self._pins.get(node_id)
|
||||
now = now_ns if now_ns is not None else time.monotonic_ns()
|
||||
if pin is None:
|
||||
raise PermissionError("keine PIN ausgestellt")
|
||||
if self._failed_attempts.get(node_id, 0) >= self.max_pin_attempts:
|
||||
raise PermissionError("PIN gesperrt; neu ausstellen")
|
||||
if not pin_valid(pin, now_ns=now) or not hmac.compare_digest(pin.value, entered_pin):
|
||||
self._failed_attempts[node_id] = self._failed_attempts.get(node_id, 0) + 1
|
||||
raise PermissionError("PIN falsch oder abgelaufen")
|
||||
if not hmac.compare_digest(
|
||||
fingerprint_seen.strip().lower(), expected_fingerprint.strip().lower()
|
||||
):
|
||||
self._failed_attempts[node_id] = self._failed_attempts.get(node_id, 0) + 1
|
||||
raise PermissionError("Fingerprint stimmt nicht ueberein")
|
||||
token, stored = new_token(scopes, ttl_s=token_ttl_s)
|
||||
self._tokens[node_id] = stored
|
||||
self._pins.pop(node_id, None) # PIN nur einmal verwendbar
|
||||
self._failed_attempts.pop(node_id, None)
|
||||
return token
|
||||
|
||||
def revoke(self, node_id: str) -> None:
|
||||
"""Sofortiger Widerruf (§27.1)."""
|
||||
self._tokens.pop(node_id, None)
|
||||
self._pins.pop(node_id, None)
|
||||
|
||||
def verify(
|
||||
self,
|
||||
node_id: str,
|
||||
token: str,
|
||||
required_scope: Scope,
|
||||
now_ns: int | None = None,
|
||||
) -> bool:
|
||||
"""Token- und Scope-Prüfung; Hash-Vergleich konstantzeit."""
|
||||
stored = self._tokens.get(node_id)
|
||||
if stored is None:
|
||||
return False
|
||||
now = now_ns if now_ns is not None else time.monotonic_ns()
|
||||
if stored.expires_ns is not None and now > stored.expires_ns:
|
||||
return False
|
||||
if not hmac.compare_digest(stored.token_hash, hash_token(token)):
|
||||
return False
|
||||
return required_scope.value in stored.scopes
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user