"""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)