"""Round-robin monitoring scheduler.""" from __future__ import annotations import logging import time from typing import Optional from monitor.browser import BrowserManager from monitor.models import AppConfig, WebsiteConfig from monitor.processor import handle_processing_result, process_with_openai from notifications.base import NotificationService from notifications.email import EmailNotificationService from notifications.sms import SmsNotificationService logger = logging.getLogger(__name__) class MonitorScheduler: """Runs continuous round-robin website monitoring.""" def __init__( self, config: AppConfig, browser_manager: BrowserManager, notification_service: Optional[NotificationService] = None, ) -> None: self.config = config self.browser_manager = browser_manager self.notification_service = notification_service or _build_notification_service() self._running = False self._current_index = 0 @property def enabled_websites(self) -> list[WebsiteConfig]: return [site for site in self.config.websites if site.enabled] def start(self) -> None: """Start the round-robin monitoring loop.""" sites = self.enabled_websites if not sites: logger.error("No enabled websites configured. Exiting.") return self._running = True logger.info("Starting monitor with %d website(s)", len(sites)) try: self.browser_manager.start() except Exception as exc: logger.error("Failed to start browser: %s", exc) return while self._running: website = sites[self._current_index % len(sites)] self._current_index += 1 if not self._running: break self._process_website(website) if not self._running: break delay = self.config.monitor.round_robin_delay_seconds if delay > 0: self._interruptible_sleep(delay) def stop(self) -> None: """Signal the scheduler to stop.""" logger.info("Stopping monitor...") self._running = False def _interruptible_sleep(self, seconds: float) -> None: """Sleep in small increments so shutdown is responsive.""" end = time.monotonic() + seconds while self._running and time.monotonic() < end: time.sleep(min(0.5, end - time.monotonic())) def _process_website(self, website: WebsiteConfig) -> None: """Visit, snapshot, process, and notify for a single website.""" retries = 0 max_retries = self.config.monitor.browser_restart_max_retries backoff = self.config.monitor.browser_restart_backoff_seconds snapshot = None while retries <= max_retries: try: snapshot = self.browser_manager.visit_and_snapshot(website) break except Exception as exc: retries += 1 logger.error( "Browser error on %s (attempt %d/%d): %s", website.id, retries, max_retries, exc, ) if retries > max_retries: logger.error("Max browser restart retries reached for %s", website.id) return try: self.browser_manager.restart() except Exception as restart_exc: logger.error("Browser restart failed: %s", restart_exc) time.sleep(backoff * retries) continue time.sleep(backoff) if snapshot is None: return try: result = process_with_openai(snapshot, website) except Exception as exc: logger.error( "OpenAI processing failed for %s: %s", website.id, exc, ) return try: handle_processing_result( result, self.notification_service, self.config.notifications, ) except Exception as exc: logger.error( "Notification failed for %s: %s", website.id, exc, ) class CompositeNotificationService(NotificationService): """Delegates to SMS and email notification services.""" def __init__(self) -> None: self._sms = SmsNotificationService() self._email = EmailNotificationService() def send_sms(self, message: str) -> bool: return self._sms.send_sms(message) def send_email(self, subject: str, body: str) -> bool: return self._email.send_email(subject, body) def _build_notification_service() -> NotificationService: return CompositeNotificationService()