Files
voidea/app/services/debug_service.py

180 lines
6.1 KiB
Python

"""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