Phase 2: State-Sync-Fundament – State-Store, Gruppen, Clock, Aktivierung
- ProjectStateStore (§6.4, §24.2): monotone State-/Projekt-Revisionen, Snapshot nach Verbindung, Delta mit neu/geaendert/geloescht, Szenenabruf als Zielzustand (Uebergang macht der Renderer, §18.1), Projekt-/Livezustand strikt getrennt - GroupRouter (§6.3, §10.1): Ziele All/Node/Output/ServerGroup, Regeln selected/tag_query/all, Commit-Vorschau (§17.5) - ClockEstimator (§6.4): RTT-Min-Filter (<=2x Min gegen Jitter), Offset-/Drift-Schaetzung (Drift erst ab 1 s Fenster belastbar), Showzeit -> lokale Node-Zeit - ActivationCoordinator (§6.5): zeitgestempelte Preset-Aktivierung, 200 ms Vorlauf, Arm/Execute/Ack-Kette, FAILED bei fehlender Arm-Bestaetigung (kein stilles Ueberbruecken) - 35 neue Unit-Tests; Gesamtsuite 293 gruen, Ruff gruen
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user