# retoor import asyncio import json import logging from abc import ABC, abstractmethod from collections import deque from datetime import datetime, timedelta, timezone from devplacepy.database import db, get_table, get_setting, get_int_setting logger = logging.getLogger(__name__) class ConfigField: def __init__( self, key, label, type="str", default="", help="", options=None, secret=False, minimum=None, maximum=None, group="General", ): self.key = key self.label = label self.type = type self.default = default self.help = help self.options = options or [] self.secret = secret self.minimum = minimum self.maximum = maximum self.group = group def coerce(self, raw): if self.type == "int": try: value = int(str(raw).strip()) except (TypeError, ValueError): raise ValueError(f"{self.label} must be a whole number") if self.minimum is not None and value < self.minimum: raise ValueError(f"{self.label} must be at least {self.minimum}") if self.maximum is not None and value > self.maximum: raise ValueError(f"{self.label} must be at most {self.maximum}") return value if self.type == "float": try: value = float(str(raw).strip()) except (TypeError, ValueError): raise ValueError(f"{self.label} must be a number") if self.minimum is not None and value < self.minimum: raise ValueError(f"{self.label} must be at least {self.minimum}") if self.maximum is not None and value > self.maximum: raise ValueError(f"{self.label} must be at most {self.maximum}") return value if self.type == "bool": text = str(raw).strip() if text in ("0", "1"): return text == "1" raise ValueError(f"{self.label} must be enabled or disabled") if self.type == "select": allowed = [option["value"] for option in self.options] if raw not in allowed: raise ValueError(f"{self.label} has an invalid selection") return raw if self.type == "url": text = str(raw).strip() if text and not (text.startswith("http://") or text.startswith("https://")): raise ValueError(f"{self.label} must be a http(s) URL") return text return str(raw) def to_storage(self, value): if isinstance(value, bool): return "1" if value else "0" return str(value) def read(self): raw = get_setting(self.key, "") if raw == "": return self.default try: return self.coerce(raw) except ValueError: return self.default def display_value(self): if self.secret: return "" raw = get_setting(self.key, "") if raw == "": return self.to_storage(self.default) return raw def spec(self): return { "key": self.key, "label": self.label, "type": self.type, "help": self.help, "options": self.options, "secret": self.secret, "minimum": self.minimum, "maximum": self.maximum, "value": self.display_value(), "is_set": bool(get_setting(self.key, "")) if self.secret else None, "group": self.group, } class BaseService(ABC): config_fields = [] interval_key = None min_interval = 1 default_enabled = True title = "" description = "" details = "" DEFAULT_LOG_SIZE = 20 TICK_SECONDS = 1 PERSIST_SECONDS = 8 METRICS_SECONDS = 15 STALE_SECONDS = 15 def __init__(self, name: str, interval_seconds: int = 3600): self.name = name self.interval_seconds = interval_seconds self.interval_key = self.interval_key or f"service_{name}_interval" self.enabled_key = f"service_{name}_enabled" self.command_key = f"service_{name}_command" self.log_size_key = f"service_{name}_log_size" self.log_buffer = deque(maxlen=self.DEFAULT_LOG_SIZE) self._task = None self._running = False self._shutdown = False self._started_at = None self._last_run = None self._next_run = None self._next_due = None self._run_pending = False self._last_command = None self._last_persist = None self._last_metrics = None self._metrics_snapshot = {} self._state_row_id = None self.enabled_field = ConfigField( self.enabled_key, "Enabled", type="bool", default=self.default_enabled, help="When enabled the service runs on its interval and starts on boot.", group="General", ) self.interval_field = ConfigField( self.interval_key, "Run interval (seconds)", type="int", default=interval_seconds, minimum=self.min_interval, help=f"Delay between runs. Minimum {self.min_interval} seconds.", group="General", ) self.log_size_field = ConfigField( self.log_size_key, "Log buffer size", type="int", default=self.DEFAULT_LOG_SIZE, minimum=1, maximum=200, help="Number of recent log lines to keep.", group="Advanced", ) def all_fields(self) -> list: return [ self.enabled_field, self.interval_field, *self.config_fields, self.log_size_field, ] def _grouped_fields(self) -> list: groups: dict = {} order: list = [] for field in self.all_fields(): spec = field.spec() group = spec["group"] if group not in groups: groups[group] = [] order.append(group) groups[group].append(spec) return [{"name": name, "fields": groups[name]} for name in order] @property def status(self) -> str: return "running" if self._running else "stopped" def is_enabled(self) -> bool: return ( get_setting(self.enabled_key, "1" if self.default_enabled else "0") == "1" ) def current_interval(self) -> int: return max( self.min_interval, get_int_setting(self.interval_key, self.interval_seconds) ) def get_config(self) -> dict: return {field.key: field.read() for field in self.all_fields()} def log(self, message: str) -> None: stamp = datetime.now(timezone.utc).strftime("%H:%M:%S") self.log_buffer.append(f"[{stamp}] {message}") logger.info(f"[{self.name}] {message}") @abstractmethod async def run_once(self) -> None: pass async def on_enable(self) -> None: pass async def on_disable(self) -> None: pass def collect_metrics(self) -> dict: return {} def _safe_metrics(self) -> dict: try: return self.collect_metrics() except Exception as e: logger.warning(f"Could not collect metrics for {self.name}: {e}") return {} def _current_metrics(self, now, force: bool = False) -> dict: if not force and self._last_metrics is not None: if (now - self._last_metrics).total_seconds() < self.METRICS_SECONDS: return self._metrics_snapshot self._last_metrics = now self._metrics_snapshot = self._safe_metrics() return self._metrics_snapshot def start_supervisor(self) -> None: if self._task is not None: return self._shutdown = False self._task = asyncio.create_task(self._run_loop()) def request_shutdown(self) -> None: self._shutdown = True async def _run_loop(self) -> None: self._last_command = get_setting(self.command_key, "") self.log("Supervisor started") self._persist_state(force=True) while not self._shutdown: try: await self._tick() except asyncio.CancelledError: break except Exception as e: self.log(f"Loop error: {e}") try: await asyncio.sleep(self.TICK_SECONDS) except asyncio.CancelledError: break if self._started_at is not None: await self._safe_disable() self._running = False self._started_at = None self._persist_state(force=True) self.log("Supervisor stopped") async def _tick(self) -> None: self._sync_log_size() self._handle_commands() if self.is_enabled(): if self._started_at is None: self._started_at = datetime.now(timezone.utc) self._running = True self.log("Service enabled") await self.on_enable() due = self._next_due is None or datetime.now(timezone.utc) >= self._next_due if self._run_pending or due: self._run_pending = False await self._execute_run() elif self._started_at is not None: await self._safe_disable() self._started_at = None self._running = False self._next_due = None self._next_run = None self.log("Service disabled") self._persist_state() async def _safe_disable(self) -> None: try: await self.on_disable() except Exception as e: self.log(f"on_disable error: {e}") async def _execute_run(self) -> None: self.interval_seconds = self.current_interval() self._last_run = datetime.now(timezone.utc).isoformat() self._next_run = None self._persist_state(force=True) try: await self.run_once() except asyncio.CancelledError: raise except Exception as e: self.log(f"Error in run_once: {e}") self.interval_seconds = self.current_interval() self._next_due = datetime.now(timezone.utc) + timedelta( seconds=self.interval_seconds ) self._next_run = self._next_due.isoformat() self.log(f"Next run in {self.interval_seconds}s") self._persist_state(force=True) def _handle_commands(self) -> None: raw = get_setting(self.command_key, "") if not raw or raw == self._last_command: return self._last_command = raw verb = raw.split(":", 1)[0] if verb == "run": self._run_pending = True self.log("Run requested") elif verb == "clear": self.log_buffer.clear() self.log("Logs cleared") def _sync_log_size(self) -> None: size = max(1, get_int_setting(self.log_size_key, self.DEFAULT_LOG_SIZE)) if size != self.log_buffer.maxlen: self.log_buffer = deque(self.log_buffer, maxlen=size) def _persist_state(self, force: bool = False) -> None: now = datetime.now(timezone.utc) if not force and self._last_persist is not None: if (now - self._last_persist).total_seconds() < self.PERSIST_SECONDS: return self._last_persist = now record = { "name": self.name, "status": self.status, "last_run": self._last_run or "", "next_run": self._next_run or "", "started_at": self._started_at.isoformat() if self._started_at else "", "heartbeat": now.isoformat(), "logs": json.dumps(list(self.log_buffer)), "metrics": json.dumps(self._current_metrics(now, force=force)), "updated_at": now.isoformat(), } try: table = get_table("service_state") if self._state_row_id is None: existing = table.find_one(name=self.name) self._state_row_id = existing["id"] if existing else None if self._state_row_id is not None: table.update({**record, "id": self._state_row_id}, ["id"]) else: self._state_row_id = table.insert(record) except Exception as e: logger.warning(f"Could not persist state for {self.name}: {e}") def _read_state(self) -> dict: if "service_state" not in db.tables: return {} row = db["service_state"].find_one(name=self.name) return row or {} def _display_status(self, enabled: bool, state: dict) -> str: if not enabled: return "stopped" heartbeat = state.get("heartbeat") if not heartbeat: return "stalled" try: beat = datetime.fromisoformat(heartbeat) except ValueError: return "stalled" if (datetime.now(timezone.utc) - beat).total_seconds() > self.STALE_SECONDS: return "stalled" return "running" def _uptime(self, state: dict, status: str) -> str | None: if status != "running": return None started = state.get("started_at") if not started: return None try: since = datetime.fromisoformat(started) except ValueError: return None return str(datetime.now(timezone.utc) - since).split(".")[0] def _logs(self, state: dict) -> list: raw = state.get("logs") if not raw: return [] try: return json.loads(raw) except (ValueError, TypeError): return [] def _metrics(self, state: dict) -> dict: raw = state.get("metrics") if not raw: return self._safe_metrics() try: return json.loads(raw) except (ValueError, TypeError): return self._safe_metrics() def describe(self) -> dict: state = self._read_state() enabled = self.is_enabled() status = self._display_status(enabled, state) return { "name": self.name, "title": self.title or self.name.capitalize(), "description": self.description, "details": self.details, "enabled": enabled, "status": status, "interval_seconds": self.current_interval(), "last_run": state.get("last_run") or None, "next_run": state.get("next_run") or None, "heartbeat": state.get("heartbeat") or None, "uptime": self._uptime(state, status), "log_buffer": self._logs(state), "metrics": self._metrics(state), "fields": [field.spec() for field in self.all_fields()], "field_groups": self._grouped_fields(), }