Files
leocrm/app/plugins/builtins/automation/services.py
T

738 lines
25 KiB
Python
Raw Normal View History

2026-07-23 20:00:37 +02:00
"""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