import hashlib import json import math import os from dataclasses import asdict, dataclass, field from pathlib import Path from urllib.parse import urlsplit import tomllib @dataclass class Config: state_dir: Path = field( default_factory=lambda: Path( os.environ.get( "COORDINATION_STATE_DIR", str( Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) / "coordination-engine" ), ) ) ) socket: Path = field( default_factory=lambda: Path( os.environ.get( "COORDINATION_SOCKET", str( Path( os.environ.get( "XDG_RUNTIME_DIR", f"/tmp/coordination-{os.getuid()}" ) ) / "coordination-engine.sock" ), ) ) ) tamq_socket: Path = field( default_factory=lambda: Path( os.environ.get( "TAMQ_SOCKET", str(Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "tamq.sock"), ) ) ) api_base: str = field( default_factory=lambda: os.environ.get( "STATEHUB_API_BASE", "http://127.0.0.1:8000" ) ) repos: list[str] = field(default_factory=list) endpoints: dict[str, str] = field(default_factory=dict) poll_interval: float = 15 lease_seconds: float = 30 renew_interval: float = 10 timeout: float = 5 busy_timeout: float = 5 max_attempts: int = 4 retry_backoff: list[float] = field(default_factory=lambda: [5, 15, 60, 300]) policy_profile: str = "default" # Only these classes may be requested automatically. Unknown classes stop. allow: list[str] = field( default_factory=lambda: [ "repo_inspect", "repo_edit", "local_checks", "state_updates", "tmux_wake", ] ) def validate(self): for key in ( "poll_interval", "lease_seconds", "renew_interval", "timeout", "busy_timeout", ): value = getattr(self, key) if ( isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(value) or value <= 0 ): raise ValueError(f"invalid {key}") if self.renew_interval >= self.lease_seconds: raise ValueError("renew_interval must be shorter than lease_seconds") if type(self.max_attempts) is not int or not 0 <= self.max_attempts <= 9: raise ValueError("max_attempts must be 0..9") if not self.retry_backoff or any( type(v) not in (int, float) or not math.isfinite(v) or v <= 0 for v in self.retry_backoff ): raise ValueError("retry_backoff must contain positive finite delays") if self.policy_profile != "default" or not set(self.allow) <= { "repo_inspect", "repo_edit", "local_checks", "state_updates", "tmux_wake", }: raise ValueError("unsupported policy profile or action grant") if not isinstance(self.repos, list) or any( not isinstance(r, str) or not r or any( c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._" for c in r ) for r in self.repos ): raise ValueError("repos must contain exact repository slugs") if not isinstance(self.endpoints, dict) or any( k not in self.repos or not isinstance(v, str) or not v for k, v in self.endpoints.items() ): raise ValueError("endpoints must map configured repos to endpoint IDs") url = urlsplit(self.api_base) if ( url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password or url.query or url.fragment ): raise ValueError("invalid State Hub URL; credentials are not configuration") for name in ("state_dir", "socket", "tamq_socket"): setattr(self, name, Path(getattr(self, name)).expanduser().absolute()) return self @property def version(self): return hashlib.sha256( json.dumps(asdict(self), default=str, sort_keys=True).encode() ).hexdigest()[:16] @classmethod def load(cls, path=None): path = Path( path or os.environ.get( "COORDINATION_CONFIG", str( Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "coordination-engine/config.toml" ), ) ) data = ( tomllib.loads(path.read_text()).get("coordination", {}) if path.exists() else {} ) for env, key in [ ("COORDINATION_STATE_DIR", "state_dir"), ("COORDINATION_SOCKET", "socket"), ("TAMQ_SOCKET", "tamq_socket"), ("STATEHUB_API_BASE", "api_base"), ]: if env in os.environ: data[key] = os.environ[env] return cls(**data).validate()