"""Service classes for the Automation & Agents plugin. AgentService, AutomationService, CronJobService, RunLogService. """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession from app.plugins.builtins.automation.models import ( AgentDefinition, AgentRun, AgentVersion, AutomationCronJob, AutomationDefinition, AutomationRun, AutomationVersion, ) logger = logging.getLogger(__name__) # ─── Agent Service ─── class AgentService: """CRUD and version management for AI agent definitions.""" @staticmethod async def list( db: AsyncSession, tenant_id: uuid.UUID, is_active: bool | None = None, mode: str | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[AgentDefinition], int]: """List agent definitions with optional filters.""" query = select(AgentDefinition).where(AgentDefinition.tenant_id == tenant_id) count_query = select(func.count()).select_from(AgentDefinition).where( AgentDefinition.tenant_id == tenant_id ) if is_active is not None: query = query.where(AgentDefinition.is_active == is_active) count_query = count_query.where(AgentDefinition.is_active == is_active) if mode is not None: query = query.where(AgentDefinition.mode == mode) count_query = count_query.where(AgentDefinition.mode == mode) count_result = await db.execute(count_query) total = count_result.scalar() or 0 result = await db.execute( query.order_by(AgentDefinition.created_at.desc()) .limit(limit) .offset(offset) ) items = list(result.scalars().all()) return items, total @staticmethod async def get_by_id( db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID ) -> AgentDefinition | None: """Get a single agent definition by ID.""" result = await db.execute( select(AgentDefinition) .where(AgentDefinition.id == agent_id) .where(AgentDefinition.tenant_id == tenant_id) .limit(1) ) return result.scalar_one_or_none() @staticmethod async def get_by_name( db: AsyncSession, name: str, tenant_id: uuid.UUID | None = None ) -> AgentDefinition | None: """Get a single agent definition by name (optionally scoped to tenant).""" query = select(AgentDefinition).where(AgentDefinition.name == name) if tenant_id is not None: query = query.where(AgentDefinition.tenant_id == tenant_id) result = await db.execute(query.limit(1)) return result.scalar_one_or_none() @staticmethod async def create( db: AsyncSession, tenant_id: uuid.UUID, data: dict[str, Any], user_id: uuid.UUID | None = None ) -> AgentDefinition: """Create a new agent definition and its initial version snapshot.""" agent = AgentDefinition( tenant_id=tenant_id, name=data["name"], description=data.get("description", ""), llm_model=data.get("llm_model", "ollama/deepseek-v4-flash"), system_prompt=data.get("system_prompt", ""), tool_ids=data.get("tool_ids", []), heartbeat_interval_seconds=data.get("heartbeat_interval_seconds", 0), mode=data.get("mode", "reactive"), is_active=data.get("is_active", True), max_executions_per_hour=data.get("max_executions_per_hour", 10), max_duration_seconds=data.get("max_duration_seconds", 300), budget_limit_usd=data.get("budget_limit_usd", 1.0), created_by=user_id, ) db.add(agent) await db.flush() # Create initial version snapshot version = AgentVersion( tenant_id=tenant_id, agent_id=agent.id, version_number=1, snapshot={ "name": agent.name, "description": agent.description, "llm_model": agent.llm_model, "system_prompt": agent.system_prompt, "tool_ids": agent.tool_ids, "heartbeat_interval_seconds": agent.heartbeat_interval_seconds, "mode": agent.mode, "is_active": agent.is_active, "max_executions_per_hour": agent.max_executions_per_hour, "max_duration_seconds": agent.max_duration_seconds, "budget_limit_usd": agent.budget_limit_usd, }, changed_by=user_id, ) db.add(version) await db.flush() return agent @staticmethod async def update( db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID, data: dict[str, Any], user_id: uuid.UUID | None = None, ) -> AgentDefinition | None: """Update an agent definition and create a new version snapshot if changed.""" agent = await AgentService.get_by_id(db, tenant_id, agent_id) if agent is None: return None changed = False for key, value in data.items(): if hasattr(agent, key) and value is not None: setattr(agent, key, value) changed = True if changed: # Get latest version number result = await db.execute( select(func.coalesce(func.max(AgentVersion.version_number), 0)) .where(AgentVersion.agent_id == agent_id) .where(AgentVersion.tenant_id == tenant_id) ) max_version = result.scalar() or 0 version = AgentVersion( tenant_id=tenant_id, agent_id=agent.id, version_number=max_version + 1, snapshot={ "name": agent.name, "description": agent.description, "llm_model": agent.llm_model, "system_prompt": agent.system_prompt, "tool_ids": agent.tool_ids, "heartbeat_interval_seconds": agent.heartbeat_interval_seconds, "mode": agent.mode, "is_active": agent.is_active, "max_executions_per_hour": agent.max_executions_per_hour, "max_duration_seconds": agent.max_duration_seconds, "budget_limit_usd": agent.budget_limit_usd, }, changed_by=user_id, ) db.add(version) await db.flush() return agent @staticmethod async def delete( db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID ) -> bool: """Delete an agent definition (cascades to versions and runs).""" agent = await AgentService.get_by_id(db, tenant_id, agent_id) if agent is None: return False await db.delete(agent) await db.flush() return True @staticmethod async def get_versions( db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID, limit: int = 50, offset: int = 0 ) -> tuple[list[AgentVersion], int]: """Get version history for an agent definition.""" query = ( select(AgentVersion) .where(AgentVersion.agent_id == agent_id) .where(AgentVersion.tenant_id == tenant_id) ) count_query = ( select(func.count()) .select_from(AgentVersion) .where(AgentVersion.agent_id == agent_id) .where(AgentVersion.tenant_id == tenant_id) ) count_result = await db.execute(count_query) total = count_result.scalar() or 0 result = await db.execute( query.order_by(AgentVersion.version_number.desc()) .limit(limit) .offset(offset) ) return list(result.scalars().all()), total @staticmethod async def restore_version( db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID, version_id: uuid.UUID, user_id: uuid.UUID | None = None, ) -> AgentDefinition | None: """Restore an agent definition from a specific version snapshot.""" agent = await AgentService.get_by_id(db, tenant_id, agent_id) if agent is None: return None result = await db.execute( select(AgentVersion) .where(AgentVersion.id == version_id) .where(AgentVersion.agent_id == agent_id) .where(AgentVersion.tenant_id == tenant_id) .limit(1) ) version = result.scalar_one_or_none() if version is None: return None snapshot = version.snapshot for key, value in snapshot.items(): if hasattr(agent, key): setattr(agent, key, value) # Create new version reflecting the restore max_ver_result = await db.execute( select(func.coalesce(func.max(AgentVersion.version_number), 0)) .where(AgentVersion.agent_id == agent_id) .where(AgentVersion.tenant_id == tenant_id) ) max_version = max_ver_result.scalar() or 0 new_version = AgentVersion( tenant_id=tenant_id, agent_id=agent.id, version_number=max_version + 1, snapshot=snapshot, changed_by=user_id, ) db.add(new_version) await db.flush() return agent # ─── Automation Service ─── class AutomationService: """CRUD and version management for automation definitions.""" @staticmethod async def list( db: AsyncSession, tenant_id: uuid.UUID, trigger_type: str | None = None, is_active: bool | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[AutomationDefinition], int]: """List automation definitions with optional filters.""" query = select(AutomationDefinition).where( AutomationDefinition.tenant_id == tenant_id ) count_query = ( select(func.count()) .select_from(AutomationDefinition) .where(AutomationDefinition.tenant_id == tenant_id) ) if trigger_type is not None: query = query.where(AutomationDefinition.trigger_type == trigger_type) count_query = count_query.where( AutomationDefinition.trigger_type == trigger_type ) if is_active is not None: query = query.where(AutomationDefinition.is_active == is_active) count_query = count_query.where( AutomationDefinition.is_active == is_active ) count_result = await db.execute(count_query) total = count_result.scalar() or 0 result = await db.execute( query.order_by(AutomationDefinition.created_at.desc()) .limit(limit) .offset(offset) ) return list(result.scalars().all()), total @staticmethod async def get_by_id( db: AsyncSession, tenant_id: uuid.UUID, automation_id: uuid.UUID ) -> AutomationDefinition | None: """Get a single automation definition by ID.""" result = await db.execute( select(AutomationDefinition) .where(AutomationDefinition.id == automation_id) .where(AutomationDefinition.tenant_id == tenant_id) .limit(1) ) return result.scalar_one_or_none() @staticmethod async def get_by_name( db: AsyncSession, name: str, tenant_id: uuid.UUID | None = None ) -> AutomationDefinition | None: """Get a single automation definition by name (optionally scoped to tenant).""" query = select(AutomationDefinition).where(AutomationDefinition.name == name) if tenant_id is not None: query = query.where(AutomationDefinition.tenant_id == tenant_id) result = await db.execute(query.limit(1)) return result.scalar_one_or_none() @staticmethod async def create( db: AsyncSession, tenant_id: uuid.UUID, data: dict[str, Any], user_id: uuid.UUID | None = None, ) -> AutomationDefinition: """Create a new automation definition and its initial version snapshot.""" automation = AutomationDefinition( tenant_id=tenant_id, name=data["name"], description=data.get("description", ""), trigger_type=data.get("trigger_type", "manual"), trigger_config=data.get("trigger_config", {}), conditions=data.get("conditions", []), actions=data.get("actions", []), is_active=data.get("is_active", True), dry_run=data.get("dry_run", False), created_by=user_id, ) db.add(automation) await db.flush() # Create initial version snapshot version = AutomationVersion( tenant_id=tenant_id, automation_id=automation.id, version_number=1, snapshot={ "name": automation.name, "description": automation.description, "trigger_type": automation.trigger_type, "trigger_config": automation.trigger_config, "conditions": automation.conditions, "actions": automation.actions, "is_active": automation.is_active, "dry_run": automation.dry_run, }, changed_by=user_id, ) db.add(version) await db.flush() return automation @staticmethod async def update( db: AsyncSession, tenant_id: uuid.UUID, automation_id: uuid.UUID, data: dict[str, Any], user_id: uuid.UUID | None = None, ) -> AutomationDefinition | None: """Update an automation definition and create a new version snapshot if changed.""" automation = await AutomationService.get_by_id(db, tenant_id, automation_id) if automation is None: return None changed = False for key, value in data.items(): if hasattr(automation, key) and value is not None: setattr(automation, key, value) changed = True if changed: result = await db.execute( select(func.coalesce(func.max(AutomationVersion.version_number), 0)) .where(AutomationVersion.automation_id == automation_id) .where(AutomationVersion.tenant_id == tenant_id) ) max_version = result.scalar() or 0 version = AutomationVersion( tenant_id=tenant_id, automation_id=automation.id, version_number=max_version + 1, snapshot={ "name": automation.name, "description": automation.description, "trigger_type": automation.trigger_type, "trigger_config": automation.trigger_config, "conditions": automation.conditions, "actions": automation.actions, "is_active": automation.is_active, "dry_run": automation.dry_run, }, changed_by=user_id, ) db.add(version) await db.flush() return automation @staticmethod async def delete( db: AsyncSession, tenant_id: uuid.UUID, automation_id: uuid.UUID ) -> bool: """Delete an automation definition (cascades to versions and runs).""" automation = await AutomationService.get_by_id(db, tenant_id, automation_id) if automation is None: return False await db.delete(automation) await db.flush() return True @staticmethod async def get_versions( db: AsyncSession, tenant_id: uuid.UUID, automation_id: uuid.UUID, limit: int = 50, offset: int = 0, ) -> tuple[list[AutomationVersion], int]: """Get version history for an automation definition.""" query = ( select(AutomationVersion) .where(AutomationVersion.automation_id == automation_id) .where(AutomationVersion.tenant_id == tenant_id) ) count_query = ( select(func.count()) .select_from(AutomationVersion) .where(AutomationVersion.automation_id == automation_id) .where(AutomationVersion.tenant_id == tenant_id) ) count_result = await db.execute(count_query) total = count_result.scalar() or 0 result = await db.execute( query.order_by(AutomationVersion.version_number.desc()) .limit(limit) .offset(offset) ) return list(result.scalars().all()), total @staticmethod async def restore_version( db: AsyncSession, tenant_id: uuid.UUID, automation_id: uuid.UUID, version_id: uuid.UUID, user_id: uuid.UUID | None = None, ) -> AutomationDefinition | None: """Restore an automation definition from a specific version snapshot.""" automation = await AutomationService.get_by_id(db, tenant_id, automation_id) if automation is None: return None result = await db.execute( select(AutomationVersion) .where(AutomationVersion.id == version_id) .where(AutomationVersion.automation_id == automation_id) .where(AutomationVersion.tenant_id == tenant_id) .limit(1) ) version = result.scalar_one_or_none() if version is None: return None snapshot = version.snapshot for key, value in snapshot.items(): if hasattr(automation, key): setattr(automation, key, value) max_ver_result = await db.execute( select(func.coalesce(func.max(AutomationVersion.version_number), 0)) .where(AutomationVersion.automation_id == automation_id) .where(AutomationVersion.tenant_id == tenant_id) ) max_version = max_ver_result.scalar() or 0 new_version = AutomationVersion( tenant_id=tenant_id, automation_id=automation.id, version_number=max_version + 1, snapshot=snapshot, changed_by=user_id, ) db.add(new_version) await db.flush() return automation # ─── Cron Job Service ─── class CronJobService: """CRUD and next_run calculation for cron job schedules.""" @staticmethod async def list( db: AsyncSession, tenant_id: uuid.UUID, job_type: str | None = None, is_active: bool | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[AutomationCronJob], int]: """List cron jobs with optional filters.""" query = select(AutomationCronJob).where( AutomationCronJob.tenant_id == tenant_id ) count_query = ( select(func.count()) .select_from(AutomationCronJob) .where(AutomationCronJob.tenant_id == tenant_id) ) if job_type is not None: query = query.where(AutomationCronJob.job_type == job_type) count_query = count_query.where(AutomationCronJob.job_type == job_type) if is_active is not None: query = query.where(AutomationCronJob.is_active == is_active) count_query = count_query.where(AutomationCronJob.is_active == is_active) count_result = await db.execute(count_query) total = count_result.scalar() or 0 result = await db.execute( query.order_by(AutomationCronJob.created_at.desc()) .limit(limit) .offset(offset) ) return list(result.scalars().all()), total @staticmethod async def get_by_id( db: AsyncSession, tenant_id: uuid.UUID, job_id: uuid.UUID ) -> AutomationCronJob | None: """Get a single cron job by ID.""" result = await db.execute( select(AutomationCronJob) .where(AutomationCronJob.id == job_id) .where(AutomationCronJob.tenant_id == tenant_id) .limit(1) ) return result.scalar_one_or_none() @staticmethod async def get_by_name( db: AsyncSession, name: str, tenant_id: uuid.UUID | None = None ) -> AutomationCronJob | None: """Get a single cron job by name (optionally scoped to tenant).""" query = select(AutomationCronJob).where(AutomationCronJob.name == name) if tenant_id is not None: query = query.where(AutomationCronJob.tenant_id == tenant_id) result = await db.execute(query.limit(1)) return result.scalar_one_or_none() @staticmethod async def create( db: AsyncSession, tenant_id: uuid.UUID, data: dict[str, Any] ) -> AutomationCronJob: """Create a new cron job entry.""" job = AutomationCronJob( tenant_id=tenant_id, name=data["name"], cron_expression=data["cron_expression"], job_type=data.get("job_type", "automation_trigger"), target_id=data.get("target_id"), plugin_name=data.get("plugin_name", ""), is_active=data.get("is_active", True), ) db.add(job) await db.flush() return job @staticmethod async def update( db: AsyncSession, tenant_id: uuid.UUID, job_id: uuid.UUID, data: dict[str, Any], ) -> AutomationCronJob | None: """Update a cron job entry.""" job = await CronJobService.get_by_id(db, tenant_id, job_id) if job is None: return None for key, value in data.items(): if hasattr(job, key) and value is not None: setattr(job, key, value) await db.flush() return job @staticmethod async def delete( db: AsyncSession, tenant_id: uuid.UUID, job_id: uuid.UUID ) -> bool: """Delete a cron job entry.""" job = await CronJobService.get_by_id(db, tenant_id, job_id) if job is None: return False await db.delete(job) await db.flush() return True @staticmethod async def get_due_jobs( db: AsyncSession, limit: int = 100 ) -> list[AutomationCronJob]: """Get cron jobs that are due for execution (next_run_at <= now).""" now = datetime.now(UTC) result = await db.execute( select(AutomationCronJob) .where(AutomationCronJob.is_active == True) .where(AutomationCronJob.next_run_at <= now) .limit(limit) ) return list(result.scalars().all()) # ─── Run Log Service ─── class RunLogService: """Query execution logs for agents and automations.""" @staticmethod async def list_agent_runs( db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID | None = None, status: str | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[AgentRun], int]: """List agent run logs with optional filters.""" query = select(AgentRun).where(AgentRun.tenant_id == tenant_id) count_query = ( select(func.count()) .select_from(AgentRun) .where(AgentRun.tenant_id == tenant_id) ) if agent_id is not None: query = query.where(AgentRun.agent_id == agent_id) count_query = count_query.where(AgentRun.agent_id == agent_id) if status is not None: query = query.where(AgentRun.status == status) count_query = count_query.where(AgentRun.status == status) count_result = await db.execute(count_query) total = count_result.scalar() or 0 result = await db.execute( query.order_by(AgentRun.started_at.desc()) .limit(limit) .offset(offset) ) return list(result.scalars().all()), total @staticmethod async def list_automation_runs( db: AsyncSession, tenant_id: uuid.UUID, automation_id: uuid.UUID | None = None, status: str | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[AutomationRun], int]: """List automation run logs with optional filters.""" query = select(AutomationRun).where(AutomationRun.tenant_id == tenant_id) count_query = ( select(func.count()) .select_from(AutomationRun) .where(AutomationRun.tenant_id == tenant_id) ) if automation_id is not None: query = query.where(AutomationRun.automation_id == automation_id) count_query = count_query.where( AutomationRun.automation_id == automation_id ) if status is not None: query = query.where(AutomationRun.status == status) count_query = count_query.where(AutomationRun.status == status) count_result = await db.execute(count_query) total = count_result.scalar() or 0 result = await db.execute( query.order_by(AutomationRun.started_at.desc()) .limit(limit) .offset(offset) ) return list(result.scalars().all()), total