""" Typed configuration scaffolding for the Nilor-Nodes ComfyUI sidecar. This module defines the dataclasses and public loader API contract. The actual implementation of precedence, parsing, and validation is added in a subsequent commit. For now, only type definitions and the public `Config.load` signature are provided to enable incremental integration without behavior changes. """ from __future__ import annotations import os import socket from dataclasses import dataclass from typing import Dict, Optional from urllib.parse import urlparse from pathlib import Path from ..logger import logger # Ensure we cache config globally _CONFIG: Optional["NilorNodesConfig"] = None try: # json5 is declared in nilor-nodes/requirements.txt import json5 # type: ignore except Exception as _e: # pragma: no cover json5 = None # lazy failure in loader class BaseConfig: # type: ignore @classmethod def get_instance(cls): # Minimal fallback: load JSON5 directly when BaseConfig is unavailable path = os.path.join(os.path.dirname(__file__), "config.json5") with open(path, "r", encoding="utf-8") as f: data = json5.load(f) if json5 is not None else {} return cls.from_dict(data) # type: ignore[attr-defined] @dataclass(frozen=True) class ComfyApiConfig: """Configuration for the ComfyUI client. Args: api_url: Base HTTP URL for the ComfyUI REST API (e.g., "http://127.0.0.1:8188"). ws_url: Base WebSocket URL for ComfyUI events (e.g., "ws://127.0.0.1:8188"). timeout_s: Request timeout in seconds for ComfyUI HTTP calls. retry_base_seconds: Base backoff seconds for idempotent retries. retry_multiplier: Exponential backoff multiplier. retry_jitter_seconds: Jitter range (±seconds) added to backoff. retry_max_sleep_seconds: Maximum sleep per backoff step. retry_max_attempts: Maximum retry attempts for idempotent routes. ws_max_reconnect_attempts: Maximum websocket reconnect attempts. ws_max_total_backoff_seconds: Cap on total backoff time during WS reconnects. """ api_url: str ws_url: str timeout_s: int retry_base_seconds: float retry_multiplier: float retry_jitter_seconds: float retry_max_sleep_seconds: float retry_max_attempts: int ws_max_reconnect_attempts: int ws_max_total_backoff_seconds: float @dataclass(frozen=True) class WorkerConfig: """Configuration for the worker and its SQS integration. Args: sqs_endpoint_url: URL of the SQS-compatible endpoint (e.g., ElasticMQ). jobs_queue: Name of the queue from which to pull new jobs. status_queue: Name of the queue to which job status updates are published. poll_wait_s: Long poll wait time in seconds (expected to be within [0, 20]). max_messages: Max number of messages pulled per poll. aws_access_key_id: Access key id for the SQS client (non-secret default acceptable for local dev). aws_secret_access_key: Secret access key for the SQS client (must be overridden via environment for real deployments). aws_region: AWS region name used by the SQS client. worker_client_id: Stable identifier for routing websocket events to this worker. workflow_os_normalization_enabled: When true, normalize OS-specific path formatting inside ComfyUI prompt graphs before submission. """ sqs_endpoint_url: str jobs_queue: str status_queue: str poll_wait_s: int max_messages: int aws_access_key_id: str aws_secret_access_key: str aws_region: str worker_client_id: str workflow_os_normalization_enabled: bool @dataclass(frozen=True) class MemoryHygieneConfig: """Configuration for Memory Guardian hygiene between jobs. Args: enabled: Feature flag to enable/disable hygiene. idle_poll_seconds: How often to check while idle. vram_usage_pct_max: If used VRAM exceeds this percent, trigger remediation. ram_usage_pct_max: If used RAM exceeds this percent, trigger remediation. vram_min_free_mb: Absolute minimum free VRAM (MB) threshold. ram_min_free_mb: Absolute minimum free RAM (MB) threshold. action_policy: One of {free, unload, both, auto}. max_retries: Max remediation attempts per cycle. cooldown_seconds: Cooldown between remediation cycles. sleep_between_attempts_seconds: Wait time after /free before re-checking. max_cycle_duration_seconds: Hard cap for a single remediation cycle. """ enabled: bool idle_poll_seconds: int vram_usage_pct_max: int ram_usage_pct_max: int vram_min_free_mb: int ram_min_free_mb: int action_policy: str max_retries: int cooldown_seconds: int sleep_between_attempts_seconds: int max_cycle_duration_seconds: int @dataclass class NilorNodesConfig(BaseConfig): """Aggregate configuration for the Nilor-Nodes sidecar. Args: comfy: Configuration for the ComfyUI HTTP/WS client. worker: Configuration for SQS and worker identity. allow_env_override: When true, environment variables may override file values. """ comfy: ComfyApiConfig worker: WorkerConfig allow_env_override: bool sqs_enabled: bool hygiene: MemoryHygieneConfig @classmethod def _get_config_path(cls) -> str: return os.path.join(os.path.dirname(__file__), "config.json5") @classmethod def from_dict(cls, config_dict: Dict[str, object]) -> "NilorNodesConfig": allow_env_override = bool(config_dict.get("allow_env_override", True)) sqs_enabled = _coerce_bool(config_dict.get("NILOR_SQS_ENABLED", False)) # Build nested from flat NILOR_* keys present in JSON5 comfy_cfg = ComfyApiConfig( api_url=str(config_dict.get("NILOR_COMFYUI_API_URL", "")).strip(), ws_url=str(config_dict.get("NILOR_COMFYUI_WS_URL", "")).strip(), timeout_s=int(config_dict.get("NILOR_COMFY_API_TIMEOUT_SECONDS", 30)), retry_base_seconds=float( config_dict.get("NILOR_COMFY_RETRY_BASE_SECONDS", 0.25) ), retry_multiplier=float( config_dict.get("NILOR_COMFY_RETRY_MULTIPLIER", 2.0) ), retry_jitter_seconds=float( config_dict.get("NILOR_COMFY_RETRY_JITTER_SECONDS", 0.25) ), retry_max_sleep_seconds=float( config_dict.get("NILOR_COMFY_RETRY_MAX_SLEEP_SECONDS", 4.0) ), retry_max_attempts=int( config_dict.get("NILOR_COMFY_RETRY_MAX_ATTEMPTS", 3) ), ws_max_reconnect_attempts=int( config_dict.get("NILOR_COMFY_WS_MAX_RECONNECT_ATTEMPTS", 5) ), ws_max_total_backoff_seconds=float( config_dict.get("NILOR_COMFY_WS_MAX_TOTAL_BACKOFF_SECONDS", 30.0) ), ) worker_client_id = ( str(config_dict.get("NILOR_WORKER_CLIENT_ID", "")).strip() or _generate_worker_client_id() ) worker_cfg = WorkerConfig( sqs_endpoint_url=str(config_dict.get("NILOR_SQS_ENDPOINT_URL", "")).strip(), jobs_queue=str( config_dict.get("NILOR_SQS_JOBS_TO_PROCESS_QUEUE_NAME", "") ).strip(), status_queue=str( config_dict.get("NILOR_SQS_JOB_STATUS_UPDATES_QUEUE_NAME", "") ).strip(), poll_wait_s=int(config_dict.get("NILOR_SQS_POLL_WAIT_TIME", 10)), max_messages=int(config_dict.get("NILOR_SQS_MAX_MESSAGES", 1)), aws_access_key_id=str( config_dict.get("NILOR_AWS_ACCESS_KEY_ID", "") ).strip(), aws_secret_access_key=str( config_dict.get("NILOR_AWS_SECRET_ACCESS_KEY", "") ).strip(), aws_region=str(config_dict.get("NILOR_AWS_DEFAULT_REGION", "")).strip(), worker_client_id=worker_client_id, workflow_os_normalization_enabled=_coerce_bool( config_dict.get("NILOR_WORKFLOW_OS_NORMALIZATION_ENABLED", True) ), ) # Memory hygiene hygiene_cfg = MemoryHygieneConfig( enabled=_coerce_bool(config_dict.get("NILOR_MEMORY_HYGIENE_ENABLED", True)), idle_poll_seconds=int( config_dict.get("NILOR_MEMORY_HYGIENE_IDLE_POLL_SECONDS", 5) ), vram_usage_pct_max=int( config_dict.get("NILOR_MEMORY_HYGIENE_VRAM_USAGE_PCT_MAX", 88) ), ram_usage_pct_max=int( config_dict.get("NILOR_MEMORY_HYGIENE_RAM_USAGE_PCT_MAX", 90) ), vram_min_free_mb=int( config_dict.get("NILOR_MEMORY_HYGIENE_VRAM_MIN_FREE_MB", 2048) ), ram_min_free_mb=int( config_dict.get("NILOR_MEMORY_HYGIENE_RAM_MIN_FREE_MB", 4096) ), action_policy=str( config_dict.get("NILOR_MEMORY_HYGIENE_ACTION_POLICY", "auto") ).strip(), max_retries=int(config_dict.get("NILOR_MEMORY_HYGIENE_MAX_RETRIES", 2)), cooldown_seconds=int( config_dict.get("NILOR_MEMORY_HYGIENE_COOLDOWN_SECONDS", 60) ), sleep_between_attempts_seconds=int( config_dict.get( "NILOR_MEMORY_HYGIENE_SLEEP_BETWEEN_ATTEMPTS_SECONDS", 4 ) ), max_cycle_duration_seconds=int( config_dict.get("NILOR_MEMORY_HYGIENE_MAX_CYCLE_DURATION_SECONDS", 15) ), ) _validate_hygiene_config(hygiene_cfg) cfg = cls( comfy=comfy_cfg, worker=worker_cfg, allow_env_override=allow_env_override, sqs_enabled=sqs_enabled, hygiene=hygiene_cfg, ) _validate_comfy_config(cfg.comfy) _validate_worker_config(cfg.worker) return cfg # ---- Internal helpers ---- def _apply_env_overrides(cfg: NilorNodesConfig) -> None: if not cfg.allow_env_override: return # Feature flags sqs_enabled = os.getenv("NILOR_SQS_ENABLED", cfg.sqs_enabled) cfg.sqs_enabled = _coerce_bool(sqs_enabled) # Comfy (rebuild frozen dataclass) comfy_api_url = os.getenv("NILOR_COMFYUI_API_URL", cfg.comfy.api_url) comfy_ws_url = os.getenv("NILOR_COMFYUI_WS_URL", cfg.comfy.ws_url) comfy_timeout_s = int( os.getenv("NILOR_COMFY_API_TIMEOUT_SECONDS", cfg.comfy.timeout_s) ) comfy_retry_base = float( os.getenv("NILOR_COMFY_RETRY_BASE_SECONDS", cfg.comfy.retry_base_seconds) ) comfy_retry_multiplier = float( os.getenv("NILOR_COMFY_RETRY_MULTIPLIER", cfg.comfy.retry_multiplier) ) comfy_retry_jitter = float( os.getenv("NILOR_COMFY_RETRY_JITTER_SECONDS", cfg.comfy.retry_jitter_seconds) ) comfy_retry_max_sleep = float( os.getenv( "NILOR_COMFY_RETRY_MAX_SLEEP_SECONDS", cfg.comfy.retry_max_sleep_seconds ) ) comfy_retry_max_attempts = int( os.getenv("NILOR_COMFY_RETRY_MAX_ATTEMPTS", cfg.comfy.retry_max_attempts) ) comfy_ws_max_reconnect = int( os.getenv( "NILOR_COMFY_WS_MAX_RECONNECT_ATTEMPTS", cfg.comfy.ws_max_reconnect_attempts, ) ) comfy_ws_max_total_backoff = float( os.getenv( "NILOR_COMFY_WS_MAX_TOTAL_BACKOFF_SECONDS", cfg.comfy.ws_max_total_backoff_seconds, ) ) cfg.comfy = ComfyApiConfig( api_url=str(comfy_api_url), ws_url=str(comfy_ws_url), timeout_s=comfy_timeout_s, retry_base_seconds=comfy_retry_base, retry_multiplier=comfy_retry_multiplier, retry_jitter_seconds=comfy_retry_jitter, retry_max_sleep_seconds=comfy_retry_max_sleep, retry_max_attempts=comfy_retry_max_attempts, ws_max_reconnect_attempts=comfy_ws_max_reconnect, ws_max_total_backoff_seconds=comfy_ws_max_total_backoff, ) # Worker (rebuild frozen dataclass) worker_sqs_endpoint_url = os.getenv( "NILOR_SQS_ENDPOINT_URL", cfg.worker.sqs_endpoint_url ) worker_jobs_queue = os.getenv( "NILOR_SQS_JOBS_TO_PROCESS_QUEUE_NAME", cfg.worker.jobs_queue ) worker_status_queue = os.getenv( "NILOR_SQS_JOB_STATUS_UPDATES_QUEUE_NAME", cfg.worker.status_queue ) worker_poll_wait_s = int( os.getenv("NILOR_SQS_POLL_WAIT_TIME", cfg.worker.poll_wait_s) ) worker_max_messages = int( os.getenv("NILOR_SQS_MAX_MESSAGES", cfg.worker.max_messages) ) worker_access_key = os.getenv( "NILOR_AWS_ACCESS_KEY_ID", cfg.worker.aws_access_key_id ) worker_secret_key = os.getenv( "NILOR_AWS_SECRET_ACCESS_KEY", cfg.worker.aws_secret_access_key ) worker_region = os.getenv("NILOR_AWS_DEFAULT_REGION", cfg.worker.aws_region) worker_client_id = os.getenv("NILOR_WORKER_CLIENT_ID", cfg.worker.worker_client_id) worker_workflow_os_norm = os.getenv( "NILOR_WORKFLOW_OS_NORMALIZATION_ENABLED", cfg.worker.workflow_os_normalization_enabled, ) cfg.worker = WorkerConfig( sqs_endpoint_url=str(worker_sqs_endpoint_url), jobs_queue=str(worker_jobs_queue), status_queue=str(worker_status_queue), poll_wait_s=worker_poll_wait_s, max_messages=worker_max_messages, aws_access_key_id=str(worker_access_key), aws_secret_access_key=str(worker_secret_key), aws_region=str(worker_region), worker_client_id=str(worker_client_id), workflow_os_normalization_enabled=_coerce_bool(worker_workflow_os_norm), ) # Re-validate after overrides _validate_comfy_config(cfg.comfy) _validate_worker_config(cfg.worker) # Hygiene (rebuild frozen dataclass) hygiene_enabled = _coerce_bool( os.getenv("NILOR_MEMORY_HYGIENE_ENABLED", cfg.hygiene.enabled) ) hygiene_idle_poll = int( os.getenv( "NILOR_MEMORY_HYGIENE_IDLE_POLL_SECONDS", cfg.hygiene.idle_poll_seconds ) ) hygiene_vram_pct = int( os.getenv( "NILOR_MEMORY_HYGIENE_VRAM_USAGE_PCT_MAX", cfg.hygiene.vram_usage_pct_max ) ) hygiene_ram_pct = int( os.getenv( "NILOR_MEMORY_HYGIENE_RAM_USAGE_PCT_MAX", cfg.hygiene.ram_usage_pct_max ) ) hygiene_vram_min = int( os.getenv("NILOR_MEMORY_HYGIENE_VRAM_MIN_FREE_MB", cfg.hygiene.vram_min_free_mb) ) hygiene_ram_min = int( os.getenv("NILOR_MEMORY_HYGIENE_RAM_MIN_FREE_MB", cfg.hygiene.ram_min_free_mb) ) hygiene_policy = str( os.getenv("NILOR_MEMORY_HYGIENE_ACTION_POLICY", cfg.hygiene.action_policy) ).strip() hygiene_max_retries = int( os.getenv("NILOR_MEMORY_HYGIENE_MAX_RETRIES", cfg.hygiene.max_retries) ) hygiene_cooldown = int( os.getenv("NILOR_MEMORY_HYGIENE_COOLDOWN_SECONDS", cfg.hygiene.cooldown_seconds) ) hygiene_sleep_between = int( os.getenv( "NILOR_MEMORY_HYGIENE_SLEEP_BETWEEN_ATTEMPTS_SECONDS", cfg.hygiene.sleep_between_attempts_seconds, ) ) hygiene_max_cycle = int( os.getenv( "NILOR_MEMORY_HYGIENE_MAX_CYCLE_DURATION_SECONDS", cfg.hygiene.max_cycle_duration_seconds, ) ) cfg.hygiene = MemoryHygieneConfig( enabled=hygiene_enabled, idle_poll_seconds=hygiene_idle_poll, vram_usage_pct_max=hygiene_vram_pct, ram_usage_pct_max=hygiene_ram_pct, vram_min_free_mb=hygiene_vram_min, ram_min_free_mb=hygiene_ram_min, action_policy=hygiene_policy, max_retries=hygiene_max_retries, cooldown_seconds=hygiene_cooldown, sleep_between_attempts_seconds=hygiene_sleep_between, max_cycle_duration_seconds=hygiene_max_cycle, ) _validate_hygiene_config(cfg.hygiene) def _validate_comfy_config(cfg: ComfyApiConfig) -> None: _require_url_scheme(cfg.api_url, {"http", "https"}, "NILOR_COMFYUI_API_URL") _require_url_scheme(cfg.ws_url, {"ws", "wss"}, "NILOR_COMFYUI_WS_URL") if cfg.timeout_s <= 0: raise ValueError( f"NILOR_COMFY_API_TIMEOUT_SECONDS must be a positive integer; got {cfg.timeout_s}" ) if ( cfg.retry_base_seconds < 0 or cfg.retry_multiplier <= 0 or cfg.retry_max_sleep_seconds <= 0 ): raise ValueError("Invalid retry backoff parameters in Comfy client config") if cfg.retry_max_attempts <= 0: raise ValueError("NILOR_COMFY_RETRY_MAX_ATTEMPTS must be a positive integer") if cfg.ws_max_reconnect_attempts < 0 or cfg.ws_max_total_backoff_seconds < 0: raise ValueError( "Invalid websocket reconnect parameters in Comfy client config" ) def _validate_worker_config(cfg: WorkerConfig) -> None: if cfg.poll_wait_s < 0 or cfg.poll_wait_s > 20: raise ValueError( f"NILOR_SQS_POLL_WAIT_TIME must be within [0, 20]; got {cfg.poll_wait_s}" ) if cfg.max_messages <= 0: raise ValueError( f"NILOR_SQS_MAX_MESSAGES must be a positive integer; got {cfg.max_messages}" ) def _validate_hygiene_config(cfg: MemoryHygieneConfig) -> None: if cfg.idle_poll_seconds < 0: raise ValueError("NILOR_MEMORY_HYGIENE_IDLE_POLL_SECONDS must be >= 0") if not 0 <= cfg.vram_usage_pct_max <= 100: raise ValueError( "NILOR_MEMORY_HYGIENE_VRAM_USAGE_PCT_MAX must be within [0, 100]" ) if not 0 <= cfg.ram_usage_pct_max <= 100: raise ValueError( "NILOR_MEMORY_HYGIENE_RAM_USAGE_PCT_MAX must be within [0, 100]" ) if cfg.vram_min_free_mb < 0 or cfg.ram_min_free_mb < 0: raise ValueError("Memory hygiene min free MB must be >= 0") if cfg.max_retries < 0: raise ValueError("NILOR_MEMORY_HYGIENE_MAX_RETRIES must be >= 0") if cfg.cooldown_seconds < 0 or cfg.sleep_between_attempts_seconds < 0: raise ValueError("Memory hygiene cooldown/sleep must be >= 0") if cfg.max_cycle_duration_seconds < 0: raise ValueError("Memory hygiene max cycle duration must be >= 0") allowed = {"free", "unload", "both", "auto"} if cfg.action_policy not in allowed: allowed_str = ", ".join(sorted(allowed)) raise ValueError( f"NILOR_MEMORY_HYGIENE_ACTION_POLICY must be one of {{{allowed_str}}}" ) def _coerce_bool(value: object) -> bool: if isinstance(value, bool): return value if value is None: return False text = str(value).strip().lower() if text in {"1", "true", "yes", "on"}: return True if text in {"0", "false", "no", "off"}: return False return bool(text) def _require_url_scheme(url: str, allowed: set[str], key_name: str) -> None: parsed = urlparse(url) if not parsed.scheme or parsed.scheme.lower() not in allowed: allowed_str = ", ".join(sorted(allowed)) raise ValueError( f"{key_name} must start with one of [{allowed_str}]; got: {url!r}" ) def _generate_worker_client_id() -> str: host = socket.gethostname().strip() or "worker" suffix = _random_base36_suffix(5) return f"nilor-worker-{host}-{suffix}" def _random_base36_suffix(length: int = 5) -> str: import random n = random.getrandbits(32) base36 = _to_base36(n) return base36[-length:] def _to_base36(n: int) -> str: if n == 0: return "0" digits = "0123456789abcdefghijklmnopqrstuvwxyz" sign = "-" if n < 0 else "" n = abs(n) res = [] while n: n, r = divmod(n, 36) res.append(digits[r]) return sign + "".join(reversed(res)) def load_nilor_nodes_config() -> NilorNodesConfig: """Convenience loader aligning with brain_rnd config pattern. - Loads environment variables via python-dotenv if available - Reads JSON5 defaults and applies env overrides when enabled - Returns a typed `NilorNodesConfig` """ global _CONFIG if _CONFIG is not None: return _CONFIG try: from dotenv import load_dotenv # optional dependency present in sidecar try: # Load .env next to nilor-nodes (ComfyUI/custom_nodes/nilor-nodes/.env) first here = Path(__file__).resolve().parent.parent # .../nilor-nodes/ dotenv_path = here / ".env" loaded = load_dotenv(dotenv_path=dotenv_path) try: if loaded: logger.info( f"✅ Nilor-Nodes: Loaded environment variables from {dotenv_path}" ) else: logger.info( "⚠️\u2009 Nilor-Nodes: No .env file found, relying on shell environment variables." ) except Exception: pass except Exception: pass try: # Also allow default search (repo root / current working dir) load_dotenv() except Exception: pass except Exception: pass # Use BaseConfig-backed singleton load if json5 is None: raise RuntimeError( "json5 module is required to load configuration from JSON5 file" ) cfg = NilorNodesConfig.get_instance() # type: ignore[attr-defined] _apply_env_overrides(cfg) _CONFIG = cfg return _CONFIG def refresh_nilor_nodes_config() -> NilorNodesConfig: """Clear the cached config and reload it. Intended for explicit hot-reload.""" global _CONFIG _CONFIG = None return load_nilor_nodes_config()