738 lines
25 KiB
Python
738 lines
25 KiB
Python
|
|
"""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
|