"""Debug mode and log cleanup service for VoIdea.""" import json import logging import os from datetime import datetime, timedelta, timezone from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.agent import AgentConfig from app.models.log import LogEntry logger = logging.getLogger("voidea.debug") _DEBUG_KEY = "debug_config" DEFAULT_DEBUG_CONFIG: dict[str, Any] = { "debug_mode": False, "debug_mode_expires_at": None, "cleanup_interval_hours": 24, "log_retention_days": 14, "log_max_bytes": 524288000, "notify_at_percent": 80, } class DebugService: def __init__(self, db: AsyncSession): self.db = db async def _get_config_raw(self) -> dict[str, Any]: result = await self.db.execute( select(AgentConfig).where(AgentConfig.agent_name == "system") ) cfg = result.scalar_one_or_none() if not cfg or not cfg.config: return dict(DEFAULT_DEBUG_CONFIG) try: data = json.loads(cfg.config) return data.get(_DEBUG_KEY, dict(DEFAULT_DEBUG_CONFIG)) except (json.JSONDecodeError, TypeError): return dict(DEFAULT_DEBUG_CONFIG) async def _save_config_raw(self, data: dict[str, Any]) -> None: result = await self.db.execute( select(AgentConfig).where(AgentConfig.agent_name == "system") ) cfg = result.scalar_one_or_none() full = {} if cfg and cfg.config: try: full = json.loads(cfg.config) except (json.JSONDecodeError, TypeError): full = {} full[_DEBUG_KEY] = data if not cfg: cfg = AgentConfig( agent_name="system", description="System-wide configuration", is_enabled=True, version="1.0.0", config=json.dumps(full, ensure_ascii=False), ) self.db.add(cfg) else: cfg.config = json.dumps(full, ensure_ascii=False) await self.db.commit() async def get_config(self) -> dict[str, Any]: return await self._get_config_raw() async def update_config(self, updates: dict[str, Any]) -> dict[str, Any]: current = await self._get_config_raw() current.update(updates) await self._save_config_raw(current) return current async def is_debug_mode(self) -> bool: cfg = await self._get_config_raw() if not cfg.get("debug_mode"): return False expires_at = cfg.get("debug_mode_expires_at") if expires_at: try: exp = datetime.fromisoformat(expires_at) if exp < datetime.now(timezone.utc): return False except (ValueError, TypeError): pass return True async def enable_debug_mode(self, ttl_hours: int = 48) -> dict[str, Any]: expires_at = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat() return await self.update_config({ "debug_mode": True, "debug_mode_expires_at": expires_at, }) async def disable_debug_mode(self) -> dict[str, Any]: return await self.update_config({ "debug_mode": False, "debug_mode_expires_at": None, }) async def get_logs_size(self) -> int: result = await self.db.execute( select(func.sum(func.length(LogEntry.message)).cast(int)) ) size = result.scalar() return size or 0 async def get_logs_count(self) -> int: result = await self.db.execute(select(func.count(LogEntry.id))) return result.scalar() or 0 async def get_status(self) -> dict[str, Any]: cfg = await self._get_config_raw() size = await self.get_logs_size() count = await self.get_logs_count() max_bytes = cfg.get("log_max_bytes", DEFAULT_DEBUG_CONFIG["log_max_bytes"]) percent = round((size / max_bytes) * 100, 1) if max_bytes > 0 else 0 debug_active = await self.is_debug_mode() return { "debug_mode": debug_active, "debug_config": cfg, "logs_size_bytes": size, "logs_count": count, "logs_max_bytes": max_bytes, "usage_percent": min(percent, 100), "needs_cleanup": percent >= cfg.get("notify_at_percent", 80), } async def cleanup_logs(self, force: bool = False) -> dict[str, Any]: cfg = await self._get_config_raw() retention_days = cfg.get("log_retention_days", 14) min_retention = max(retention_days, 7) cutoff = datetime.now(timezone.utc) - timedelta(days=min_retention) if force: cutoff = datetime.now(timezone.utc) - timedelta(days=min_retention) stmt = select(func.count(LogEntry.id)).where(LogEntry.created_at < cutoff) count_result = await self.db.execute(stmt) to_delete = count_result.scalar() or 0 del_stmt = type(LogEntry).__table__.delete().where(LogEntry.created_at < cutoff) await self.db.execute(del_stmt) await self.db.commit() return { "deleted": to_delete, "retention_days": min_retention, "cutoff": cutoff.isoformat(), } async def auto_cleanup_if_needed(self) -> dict[str, Any] | None: status = await self.get_status() if status["needs_cleanup"]: result = await self.cleanup_logs() logger.info("Auto-cleanup performed: %d logs deleted", result["deleted"]) return result return None @staticmethod async def check_last_deploy(): """Enable debug mode for 48h if this is a fresh deploy.""" deploy_stamp_file = "/opt/voidea/.last_deploy" if not os.path.exists(deploy_stamp_file): return False try: with open(deploy_stamp_file) as f: timestamp = int(f.read().strip()) elapsed = datetime.now().timestamp() - timestamp if elapsed < 48 * 3600: return True except (ValueError, OSError): pass return False