"""File watcher for hot-reloading skills and config changes. Monitors the config directory for file changes and triggers agent rebuild + Agent Card update broadcast. """ import asyncio import hashlib import logging import os from pathlib import Path import httpx logger = logging.getLogger(__name__) DEBOUNCE_SECONDS = 2.0 POLL_INTERVAL = 3.0 # seconds between filesystem checks class ConfigWatcher: """Watches the config directory for changes and triggers reload callbacks.""" def __init__( self, config_path: str, platform_url: str, workspace_id: str, on_reload=None, ): self.config_path = config_path self.platform_url = platform_url self.workspace_id = workspace_id self.on_reload = on_reload self._file_hashes: dict[str, str] = {} self._running = False def _hash_file(self, path: str) -> str: try: # H1: SHA-256 replaces MD5 for file-integrity change detection. # MD5 is collision-prone; using SHA-256 prevents a crafted config # file from producing the same hash as a benign one, which would # silently suppress the hot-reload callback. return hashlib.sha256(Path(path).read_bytes()).hexdigest() except (OSError, IOError): return "" def _scan_hashes(self) -> dict[str, str]: """Scan all files in config directory and return hash map.""" hashes = {} for root, _, files in os.walk(self.config_path): for fname in files: if fname.startswith("."): continue fpath = os.path.join(root, fname) rel = os.path.relpath(fpath, self.config_path) hashes[rel] = self._hash_file(fpath) return hashes def _detect_changes(self) -> list[str]: """Compare current state with cached hashes, return changed files.""" current = self._scan_hashes() changed = [] for path, h in current.items(): if path not in self._file_hashes or self._file_hashes[path] != h: changed.append(path) for path in self._file_hashes: if path not in current: changed.append(path) self._file_hashes = current return changed async def _notify_platform(self, agent_card: dict): """Push updated Agent Card to the platform.""" try: async with httpx.AsyncClient(timeout=10.0) as client: await client.post( f"{self.platform_url}/registry/update-card", json={ "workspace_id": self.workspace_id, "agent_card": agent_card, }, ) logger.info("Agent Card updated via platform") except Exception as e: logger.warning("Failed to update Agent Card: %s", e) async def start(self): """Start watching for changes in a background loop.""" self._running = True self._file_hashes = self._scan_hashes() logger.info("Config watcher started for %s", self.config_path) while self._running: await asyncio.sleep(POLL_INTERVAL) changed = self._detect_changes() if not changed: continue logger.info("Config changes detected: %s", changed) # Debounce — wait for writes to settle await asyncio.sleep(DEBOUNCE_SECONDS) # Re-scan after debounce (more changes may have occurred) self._detect_changes() # Trigger reload callback if self.on_reload: try: await self.on_reload() except Exception as e: logger.error("Reload callback failed: %s", e) def stop(self): self._running = False