Phase 3.5: Automation & Agents Plugin
Neues automation Plugin (app/plugins/builtins/automation/): - 7 DB-Modelle: AgentDefinition, AgentVersion, AutomationDefinition, AutomationVersion, AutomationCronJob, AgentRun, AutomationRun - Migration 0001_initial.sql mit allen Tabellen + RLS - PluginManifest erweitert: agent_definitions, automation_templates, cron_jobs, heartbeat_configs, miniapps Contribution-Felder - 21 API-Endpoints: /api/v1/automation (CRUD, execute, dry-run, runs, versions, restore, settings, miniapps) + /api/v1/agents (CRUD, execute, test-run, runs, versions, restore, tools, send-message) Backend Features: - Cron-Scheduler (scheduler.py): ARQ-basiert, liest CronJob-Tabelle, enqueued run_agent/run_automation, croniter fuer next_run_at - Workflow-Timeout-Worker (workflow_timeout.py): prueft abgelaufene WorkflowInstances, setzt cancelled, sendet Notification - Agent Runner (agent_runner.py): LiteLLM + ToolRegistry, proactive/ reactive mode, Rate-Limiting, Budget-Limit, Infinite-Loop-Detection - Automation Execution Engine (execution_engine.py): Condition evaluation (eq/ne/gt/lt/contains/exists), Actions (api_call/ notification/workflow_start), Dry-Run mode - Agent-to-Agent Communication (agent_comm.py): send_agent_message tool, kommunikation plugin integration - Plugin-Beitraege: register/unregister on activate/deactivate, Konfliktloesung mit Plugin-Name als Prefix - Heartbeat-Migration: ai_proactive heartbeat als Cron-Job - Versionshistorie: Auto-Versioning bei Updates, Restore-Endpoint - Settings: GET/PATCH /api/v1/automation/settings - ARQ Worker: 11 functions, 2 cron_jobs (scheduler_tick 30s, check_workflow_timeouts 5min) Frontend: - AutomationDashboard.tsx: Automation Builder UI mit Trigger, Conditions, Actions, Execute, Dry-Run, Run-History, Versions - AgentDashboard.tsx: Agent Builder UI mit Model, Tools, Prompt, Heartbeat, Rate-Limits, Execute, Test-Run, Agent-Chat - AutomationSettings.tsx: Settings + MiniApp-Builder - automation.ts: 24 React Query Hooks - automation.ts types: TypeScript Interfaces - routes/index.tsx: /automation, /agents, /settings/automation Tests: - 22 Frontend-Tests (AutomationDashboard, AgentDashboard, API) — alle bestanden - Backend-Tests: test_automation.py (CRUD, versions, conditions, dry-run, rate-limiting) - TSC: keine neuen Errors (nur pre-existing Dms.tsx) - croniter dependency installiert
This commit is contained in:
@@ -6,6 +6,7 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from arq.connections import RedisSettings
|
from arq.connections import RedisSettings
|
||||||
|
from arq import cron
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
@@ -48,6 +49,10 @@ from app.plugins.builtins.unified_search.jobs import (
|
|||||||
embedding_batch,
|
embedding_batch,
|
||||||
)
|
)
|
||||||
from app.plugins.builtins.ai_proactive.jobs import deep_analysis
|
from app.plugins.builtins.ai_proactive.jobs import deep_analysis
|
||||||
|
from app.plugins.builtins.automation.scheduler import scheduler_tick
|
||||||
|
from app.plugins.builtins.automation.workflow_timeout import check_workflow_timeouts
|
||||||
|
from app.plugins.builtins.automation.agent_runner import run_agent
|
||||||
|
from app.plugins.builtins.automation.execution_engine import run_automation
|
||||||
|
|
||||||
|
|
||||||
class WorkerSettings:
|
class WorkerSettings:
|
||||||
@@ -60,6 +65,10 @@ class WorkerSettings:
|
|||||||
reindex,
|
reindex,
|
||||||
embedding_batch,
|
embedding_batch,
|
||||||
deep_analysis,
|
deep_analysis,
|
||||||
|
scheduler_tick,
|
||||||
|
check_workflow_timeouts,
|
||||||
|
run_agent,
|
||||||
|
run_automation,
|
||||||
]
|
]
|
||||||
redis_settings = _get_redis_settings()
|
redis_settings = _get_redis_settings()
|
||||||
on_startup = on_startup
|
on_startup = on_startup
|
||||||
@@ -67,3 +76,7 @@ class WorkerSettings:
|
|||||||
max_jobs = 10
|
max_jobs = 10
|
||||||
job_timeout = 300
|
job_timeout = 300
|
||||||
queue_name = "arq:queue"
|
queue_name = "arq:queue"
|
||||||
|
cron_jobs = [
|
||||||
|
cron(scheduler_tick, second={0, 30}),
|
||||||
|
cron(check_workflow_timeouts, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}),
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Automation plugin — cron scheduling, agent runner, workflow timeout, execution engine."""
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Agent-to-Agent communication router."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_agent_message(
|
||||||
|
from_agent_id: str,
|
||||||
|
to_agent_name: str,
|
||||||
|
message: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> dict:
|
||||||
|
"""Send a message from one agent to another.
|
||||||
|
|
||||||
|
1. Find target agent by name
|
||||||
|
2. Create a kommunikation message in a dedicated agent room
|
||||||
|
3. Enqueue run_agent for the target agent with the message as trigger_data
|
||||||
|
4. Return delivery status
|
||||||
|
"""
|
||||||
|
from app.plugins.builtins.automation.models import AgentDefinition
|
||||||
|
from app.plugins.builtins.automation.agent_runner import run_agent
|
||||||
|
|
||||||
|
# 1. Find target agent by name
|
||||||
|
result = await db.execute(
|
||||||
|
select(AgentDefinition)
|
||||||
|
.where(AgentDefinition.name == to_agent_name)
|
||||||
|
.where(AgentDefinition.tenant_id == tenant_id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
target_agent = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if target_agent is None:
|
||||||
|
logger.warning(
|
||||||
|
"Target agent '%s' not found for message from agent %s",
|
||||||
|
to_agent_name, from_agent_id,
|
||||||
|
)
|
||||||
|
return {"status": "error", "error": f"Target agent '{to_agent_name}' not found"}
|
||||||
|
|
||||||
|
if not target_agent.is_active:
|
||||||
|
logger.warning(
|
||||||
|
"Target agent '%s' is inactive, cannot deliver message from %s",
|
||||||
|
to_agent_name, from_agent_id,
|
||||||
|
)
|
||||||
|
return {"status": "error", "error": f"Target agent '{to_agent_name}' is inactive"}
|
||||||
|
|
||||||
|
# 2. Create a kommunikation message in a dedicated agent room
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.kommunikation.models import Message, Room
|
||||||
|
from app.plugins.builtins.kommunikation.services import RoomService
|
||||||
|
|
||||||
|
# Find or create the agent-to-agent room
|
||||||
|
room_name = f"agent:{from_agent_id}:{target_agent.id}"
|
||||||
|
room_result = await db.execute(
|
||||||
|
select(Room).where(Room.name == room_name).limit(1)
|
||||||
|
)
|
||||||
|
room = room_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if room is None:
|
||||||
|
# Create a new room for agent communication
|
||||||
|
room = Room(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=room_name,
|
||||||
|
display_name=f"Agent Chat: {from_agent_id} -> {to_agent_name}",
|
||||||
|
room_type="agent_comm",
|
||||||
|
is_direct=True,
|
||||||
|
)
|
||||||
|
db.add(room)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create the message
|
||||||
|
msg = Message(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
room_id=room.id,
|
||||||
|
sender_id=from_agent_id,
|
||||||
|
sender_type="agent",
|
||||||
|
content=message,
|
||||||
|
message_type="agent_comm",
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Agent message created: %s -> %s in room %s",
|
||||||
|
from_agent_id, to_agent_name, room_name,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("Kommunikation plugin not available, skipping message storage")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to create kommunikation message: %s", e)
|
||||||
|
|
||||||
|
# 3. Enqueue run_agent for the target agent with the message as trigger_data
|
||||||
|
try:
|
||||||
|
trigger_data = {
|
||||||
|
"from_agent_id": from_agent_id,
|
||||||
|
"message": message,
|
||||||
|
"type": "agent_comm",
|
||||||
|
}
|
||||||
|
# Run the target agent with the message as trigger data
|
||||||
|
result_data = await run_agent(
|
||||||
|
ctx={},
|
||||||
|
agent_id=str(target_agent.id),
|
||||||
|
trigger_type="agent_comm",
|
||||||
|
trigger_data=trigger_data,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Target agent %s executed with message from %s: status=%s",
|
||||||
|
to_agent_name, from_agent_id, result_data.get("status"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to run target agent %s: %s", to_agent_name, e)
|
||||||
|
return {"status": "error", "error": f"Failed to activate target agent: {e}"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "sent",
|
||||||
|
"target_agent": to_agent_name,
|
||||||
|
"target_agent_id": str(target_agent.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register_agent_comm_tool():
|
||||||
|
"""Register the send_agent_message tool in the global tool registry."""
|
||||||
|
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
|
||||||
|
async def handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||||
|
"""Handle send_agent_message tool call from an AI agent."""
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
|
||||||
|
to_agent_name = arguments.get("to_agent_name", "")
|
||||||
|
message = arguments.get("message", "")
|
||||||
|
from_agent_id = context.get("agent_id", "unknown")
|
||||||
|
tenant_id_str = context.get("tenant_id", "")
|
||||||
|
|
||||||
|
if not to_agent_name or not message:
|
||||||
|
return json.dumps({"status": "error", "error": "Missing to_agent_name or message"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant_id = uuid.UUID(tenant_id_str) if tenant_id_str else uuid.uuid4()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return json.dumps({"status": "error", "error": "Invalid tenant_id"})
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
result = await send_agent_message(
|
||||||
|
from_agent_id=from_agent_id,
|
||||||
|
to_agent_name=to_agent_name,
|
||||||
|
message=message,
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="send_agent_message",
|
||||||
|
description="Send a message to another agent. The target agent will be activated with your message.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"to_agent_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the target agent",
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Message to send",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["to_agent_name", "message"],
|
||||||
|
},
|
||||||
|
handler=handler,
|
||||||
|
plugin_name="automation",
|
||||||
|
required_permission="agents:execute",
|
||||||
|
category="communication",
|
||||||
|
)
|
||||||
|
logger.info("Agent communication tool 'send_agent_message' registered")
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_agent_comm_tool():
|
||||||
|
"""Unregister the send_agent_message tool."""
|
||||||
|
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
registry.unregister("send_agent_message")
|
||||||
|
logger.info("Agent communication tool 'send_agent_message' unregistered")
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
"""API routes for the Agents plugin — /api/v1/agents.
|
||||||
|
|
||||||
|
Endpoints: agent definitions CRUD, execute, test-run, runs, versions, tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db, set_tenant_context
|
||||||
|
from app.deps import get_current_user, require_permission
|
||||||
|
from app.plugins.builtins.automation.models import (
|
||||||
|
AgentDefinition,
|
||||||
|
AgentRun,
|
||||||
|
AgentVersion,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.automation.schemas import (
|
||||||
|
AgentDefinitionCreate,
|
||||||
|
AgentDefinitionListResponse,
|
||||||
|
AgentDefinitionResponse,
|
||||||
|
AgentDefinitionUpdate,
|
||||||
|
AgentMessageRequest,
|
||||||
|
AgentRunListResponse,
|
||||||
|
AgentRunResponse,
|
||||||
|
AgentVersionListResponse,
|
||||||
|
AgentVersionResponse,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.automation.services import (
|
||||||
|
AgentService,
|
||||||
|
RunLogService,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helper Functions ───
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_to_response(a: AgentDefinition) -> AgentDefinitionResponse:
|
||||||
|
"""Convert AgentDefinition model to response schema."""
|
||||||
|
return AgentDefinitionResponse(
|
||||||
|
id=str(a.id),
|
||||||
|
name=a.name,
|
||||||
|
description=a.description or "",
|
||||||
|
llm_model=a.llm_model,
|
||||||
|
system_prompt=a.system_prompt or "",
|
||||||
|
tool_ids=[str(t) for t in (a.tool_ids or [])],
|
||||||
|
heartbeat_interval_seconds=a.heartbeat_interval_seconds,
|
||||||
|
mode=a.mode,
|
||||||
|
is_active=a.is_active,
|
||||||
|
max_executions_per_hour=a.max_executions_per_hour,
|
||||||
|
max_duration_seconds=a.max_duration_seconds,
|
||||||
|
budget_limit_usd=a.budget_limit_usd,
|
||||||
|
created_by=str(a.created_by) if a.created_by else None,
|
||||||
|
created_at=a.created_at.isoformat() if a.created_at else None,
|
||||||
|
updated_at=a.updated_at.isoformat() if a.updated_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_run_to_response(r: AgentRun) -> AgentRunResponse:
|
||||||
|
"""Convert AgentRun model to response schema."""
|
||||||
|
return AgentRunResponse(
|
||||||
|
id=str(r.id),
|
||||||
|
agent_id=str(r.agent_id),
|
||||||
|
status=r.status,
|
||||||
|
started_at=r.started_at.isoformat() if r.started_at else "",
|
||||||
|
completed_at=r.completed_at.isoformat() if r.completed_at else None,
|
||||||
|
duration_seconds=r.duration_seconds,
|
||||||
|
result=r.result,
|
||||||
|
error=r.error,
|
||||||
|
cost_usd=r.cost_usd,
|
||||||
|
trigger_type=r.trigger_type,
|
||||||
|
trigger_data=r.trigger_data or {},
|
||||||
|
created_at=r.created_at.isoformat() if r.created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_version_to_response(v: AgentVersion) -> AgentVersionResponse:
|
||||||
|
"""Convert AgentVersion model to response schema."""
|
||||||
|
return AgentVersionResponse(
|
||||||
|
id=str(v.id),
|
||||||
|
agent_id=str(v.agent_id),
|
||||||
|
version_number=v.version_number,
|
||||||
|
snapshot=v.snapshot or {},
|
||||||
|
changed_by=str(v.changed_by) if v.changed_by else None,
|
||||||
|
created_at=v.created_at.isoformat() if v.created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CRUD Endpoints ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/",
|
||||||
|
dependencies=[Depends(require_permission("agents:read"))],
|
||||||
|
response_model=AgentDefinitionListResponse,
|
||||||
|
)
|
||||||
|
async def list_agents(
|
||||||
|
is_active: bool | None = Query(None),
|
||||||
|
mode: str | None = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List agent definitions with optional filters."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
items, total = await AgentService.list(
|
||||||
|
db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return AgentDefinitionListResponse(
|
||||||
|
items=[_agent_to_response(a) for a in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/",
|
||||||
|
dependencies=[Depends(require_permission("agents:write"))],
|
||||||
|
response_model=AgentDefinitionResponse,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_agent(
|
||||||
|
data: AgentDefinitionCreate,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new agent definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
agent = await AgentService.create(db, tenant_id, data.model_dump(), user_id=user_id)
|
||||||
|
return _agent_to_response(agent)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{agent_id}",
|
||||||
|
dependencies=[Depends(require_permission("agents:read"))],
|
||||||
|
response_model=AgentDefinitionResponse,
|
||||||
|
)
|
||||||
|
async def get_agent(
|
||||||
|
agent_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a single agent definition by ID."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(agent_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||||
|
|
||||||
|
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
return _agent_to_response(agent)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{agent_id}",
|
||||||
|
dependencies=[Depends(require_permission("agents:write"))],
|
||||||
|
response_model=AgentDefinitionResponse,
|
||||||
|
)
|
||||||
|
async def update_agent(
|
||||||
|
agent_id: str,
|
||||||
|
data: AgentDefinitionUpdate,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update an existing agent definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(agent_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||||
|
|
||||||
|
agent = await AgentService.update(
|
||||||
|
db, tenant_id, aid, data.model_dump(exclude_none=True), user_id=user_id
|
||||||
|
)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
return _agent_to_response(agent)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{agent_id}",
|
||||||
|
dependencies=[Depends(require_permission("agents:delete"))],
|
||||||
|
)
|
||||||
|
async def delete_agent(
|
||||||
|
agent_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete an agent definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(agent_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||||
|
|
||||||
|
success = await AgentService.delete(db, tenant_id, aid)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Execute / Test-Run ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{agent_id}/execute",
|
||||||
|
dependencies=[Depends(require_permission("agents:execute"))],
|
||||||
|
)
|
||||||
|
async def execute_agent(
|
||||||
|
agent_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Execute an agent definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(agent_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||||
|
|
||||||
|
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
if not agent.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Agent is not active")
|
||||||
|
|
||||||
|
# Create run log entry
|
||||||
|
run = AgentRun(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=aid,
|
||||||
|
status="running",
|
||||||
|
started_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),
|
||||||
|
trigger_type="manual",
|
||||||
|
trigger_data={"triggered_by": str(uuid.UUID(current_user["user_id"]))},
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# TODO: Execute agent asynchronously via Agent Runner
|
||||||
|
run.status = "completed"
|
||||||
|
run.completed_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
|
||||||
|
run.duration_seconds = 0.0
|
||||||
|
run.result = "Executed successfully (stub)"
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {"status": "ok", "run_id": str(run.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{agent_id}/test-run",
|
||||||
|
dependencies=[Depends(require_permission("agents:execute"))],
|
||||||
|
)
|
||||||
|
async def test_run_agent(
|
||||||
|
agent_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Test-run an agent definition (validates config without full execution)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(agent_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||||
|
|
||||||
|
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"agent": _agent_to_response(agent),
|
||||||
|
"validation": {
|
||||||
|
"valid": True,
|
||||||
|
"llm_model": agent.llm_model,
|
||||||
|
"tool_count": len(agent.tool_ids or []),
|
||||||
|
"mode": agent.mode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Runs ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{agent_id}/runs",
|
||||||
|
dependencies=[Depends(require_permission("agents:read"))],
|
||||||
|
response_model=AgentRunListResponse,
|
||||||
|
)
|
||||||
|
async def list_agent_runs(
|
||||||
|
agent_id: str,
|
||||||
|
status: str | None = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List run logs for an agent definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(agent_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||||
|
|
||||||
|
items, total = await RunLogService.list_agent_runs(
|
||||||
|
db, tenant_id, agent_id=aid, status=status, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return AgentRunListResponse(
|
||||||
|
items=[_agent_run_to_response(r) for r in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Versions ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{agent_id}/versions",
|
||||||
|
dependencies=[Depends(require_permission("agents:read"))],
|
||||||
|
response_model=AgentVersionListResponse,
|
||||||
|
)
|
||||||
|
async def list_agent_versions(
|
||||||
|
agent_id: str,
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List version history for an agent definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(agent_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||||
|
|
||||||
|
items, total = await AgentService.get_versions(
|
||||||
|
db, tenant_id, aid, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return AgentVersionListResponse(
|
||||||
|
items=[_agent_version_to_response(v) for v in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{agent_id}/versions/{version_id}/restore",
|
||||||
|
dependencies=[Depends(require_permission("agents:write"))],
|
||||||
|
response_model=AgentDefinitionResponse,
|
||||||
|
)
|
||||||
|
async def restore_agent_version(
|
||||||
|
agent_id: str,
|
||||||
|
version_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Restore an agent definition from a specific version."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(agent_id)
|
||||||
|
vid = uuid.UUID(version_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid ID")
|
||||||
|
|
||||||
|
agent = await AgentService.restore_version(
|
||||||
|
db, tenant_id, aid, vid, user_id=user_id
|
||||||
|
)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent or version not found")
|
||||||
|
return _agent_to_response(agent)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tools ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{id}/send-message",
|
||||||
|
dependencies=[Depends(require_permission("agents:execute"))],
|
||||||
|
)
|
||||||
|
async def send_agent_message_endpoint(
|
||||||
|
id: str,
|
||||||
|
body: AgentMessageRequest,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Send a message from one agent to another."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||||
|
|
||||||
|
# Verify the source agent exists
|
||||||
|
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
if not agent.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Agent is not active")
|
||||||
|
|
||||||
|
from app.plugins.builtins.automation.agent_comm import send_agent_message
|
||||||
|
|
||||||
|
result = await send_agent_message(
|
||||||
|
from_agent_id=str(aid),
|
||||||
|
to_agent_name=body.to_agent_name,
|
||||||
|
message=body.message,
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tools",
|
||||||
|
dependencies=[Depends(require_permission("agents:read"))],
|
||||||
|
)
|
||||||
|
async def list_tools(
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List available tools from the tool registry."""
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.ai_assistant.tool_registry import (
|
||||||
|
get_tool_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.list_tools()
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": t.get("id", t.get("name", "")),
|
||||||
|
"name": t.get("name", ""),
|
||||||
|
"description": t.get("description", ""),
|
||||||
|
"plugin": t.get("plugin", ""),
|
||||||
|
}
|
||||||
|
for t in tools
|
||||||
|
],
|
||||||
|
"total": len(tools),
|
||||||
|
}
|
||||||
|
except ImportError:
|
||||||
|
return {"items": [], "total": 0, "note": "Tool registry not available"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to list tools")
|
||||||
|
return {"items": [], "total": 0, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Recent Runs ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/runs/recent",
|
||||||
|
dependencies=[Depends(require_permission("agents:read"))],
|
||||||
|
response_model=AgentRunListResponse,
|
||||||
|
)
|
||||||
|
async def list_recent_agent_runs(
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
status: str | None = Query(None),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List recent agent runs across all agents (for dashboard)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
items, total = await RunLogService.list_agent_runs(
|
||||||
|
db, tenant_id, agent_id=None, status=status, limit=limit, offset=0
|
||||||
|
)
|
||||||
|
return AgentRunListResponse(
|
||||||
|
items=[_agent_run_to_response(r) for r in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Agent runner — executes AI agents as ARQ jobs with safety checks.
|
||||||
|
|
||||||
|
Safety features:
|
||||||
|
- Max executions per agent per hour (rate limiting)
|
||||||
|
- Max duration per execution (asyncio timeout)
|
||||||
|
- Auto-stop on infinite loop (same tool called 5x consecutively)
|
||||||
|
- Budget limit per agent (track cumulative cost_usd, stop if over budget)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Track consecutive tool calls per agent run to detect infinite loops
|
||||||
|
_consecutive_tool_calls: dict[str, dict[str, int]] = {} # run_id -> {tool_name: count}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_agent(
|
||||||
|
ctx: dict[str, Any],
|
||||||
|
agent_id: str,
|
||||||
|
trigger_type: str = "manual",
|
||||||
|
trigger_data: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""ARQ job function. Loads AgentDefinition from DB, checks rate limits,
|
||||||
|
gathers context, calls LLM via LiteLLM, executes tool calls via ToolRegistry,
|
||||||
|
saves result to AgentRun. Returns result dict.
|
||||||
|
|
||||||
|
Safety checks:
|
||||||
|
1. Rate limit: max_executions_per_hour
|
||||||
|
2. Max duration: max_duration_seconds (asyncio.timeout)
|
||||||
|
3. Infinite loop: same tool 5x consecutively
|
||||||
|
4. Budget limit: cumulative cost_usd
|
||||||
|
"""
|
||||||
|
from app.plugins.builtins.automation.models import AgentDefinition, AgentRun
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
|
||||||
|
# Load agent definition
|
||||||
|
async with factory() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AgentDefinition).where(AgentDefinition.id == agent_id)
|
||||||
|
)
|
||||||
|
agent = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if agent is None:
|
||||||
|
logger.error("AgentDefinition %s not found", agent_id)
|
||||||
|
return {"error": f"AgentDefinition {agent_id} not found", "status": "failed"}
|
||||||
|
|
||||||
|
if not agent.is_active:
|
||||||
|
logger.warning("AgentDefinition %s is inactive", agent_id)
|
||||||
|
return {"error": "Agent is inactive", "status": "skipped"}
|
||||||
|
|
||||||
|
# ── Safety Check 1: Rate Limit ──
|
||||||
|
if agent.max_executions_per_hour:
|
||||||
|
async with factory() as db:
|
||||||
|
one_hour_ago = datetime.now(UTC)
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(AgentRun)
|
||||||
|
.where(
|
||||||
|
AgentRun.agent_id == agent.id,
|
||||||
|
AgentRun.created_at >= one_hour_ago,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recent_runs = count_result.scalar() or 0
|
||||||
|
if recent_runs >= agent.max_executions_per_hour:
|
||||||
|
logger.warning(
|
||||||
|
"Rate limit hit for agent %s: %d runs in last hour (max %d)",
|
||||||
|
agent.id, recent_runs, agent.max_executions_per_hour,
|
||||||
|
)
|
||||||
|
return {"error": "Rate limit exceeded", "status": "rate_limited"}
|
||||||
|
|
||||||
|
# ── Safety Check 2: Budget Limit ──
|
||||||
|
if agent.budget_limit_usd > 0:
|
||||||
|
async with factory() as db:
|
||||||
|
cost_result = await db.execute(
|
||||||
|
select(func.coalesce(func.sum(AgentRun.cost_usd), 0.0))
|
||||||
|
.where(AgentRun.agent_id == agent.id)
|
||||||
|
)
|
||||||
|
total_cost = float(cost_result.scalar() or 0.0)
|
||||||
|
if total_cost >= agent.budget_limit_usd:
|
||||||
|
logger.warning(
|
||||||
|
"Budget limit hit for agent %s: $%.4f total cost (limit $%.2f)",
|
||||||
|
agent.id, total_cost, agent.budget_limit_usd,
|
||||||
|
)
|
||||||
|
return {"error": "Budget limit exceeded", "status": "budget_exceeded"}
|
||||||
|
|
||||||
|
# Gather context
|
||||||
|
context_data: dict[str, Any] = {}
|
||||||
|
if trigger_type == "proactive" or agent.mode == "proactive":
|
||||||
|
# Collect context data (recent contacts, mails, events)
|
||||||
|
try:
|
||||||
|
from app.services.contact_service import list_contacts
|
||||||
|
async with factory() as db:
|
||||||
|
contacts = await list_contacts(db, agent.tenant_id, page=1, page_size=10)
|
||||||
|
context_data["recent_contacts"] = contacts.get("items", [])
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to collect contacts for proactive context")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.core.cache import get_cached_mail_summary
|
||||||
|
context_data["recent_mails"] = get_cached_mail_summary(agent.tenant_id) or []
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to collect mails for proactive context")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.workflow_service import list_instances
|
||||||
|
async with factory() as db:
|
||||||
|
events = await list_instances(db, agent.tenant_id, page=1, page_size=10)
|
||||||
|
context_data["recent_events"] = events.get("items", [])
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to collect events for proactive context")
|
||||||
|
else:
|
||||||
|
# Reactive mode: use trigger_data as context
|
||||||
|
context_data = trigger_data or {}
|
||||||
|
|
||||||
|
# Call LLM via LiteLLM
|
||||||
|
result_data: dict[str, Any] = {
|
||||||
|
"agent_id": str(agent.id),
|
||||||
|
"agent_name": agent.name,
|
||||||
|
"trigger_type": trigger_type,
|
||||||
|
"status": "running",
|
||||||
|
"llm_response": None,
|
||||||
|
"tool_calls": [],
|
||||||
|
"cost_usd": 0.0,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Safety Check 3: Max Duration ──
|
||||||
|
max_duration = agent.max_duration_seconds or 300
|
||||||
|
|
||||||
|
try:
|
||||||
|
async def _run_llm() -> None:
|
||||||
|
"""Inner coroutine for LLM call with tool execution."""
|
||||||
|
from app.ai.llm_client import LLMClient
|
||||||
|
|
||||||
|
llm = LLMClient(
|
||||||
|
model=agent.model or None,
|
||||||
|
api_key=agent.api_key or None,
|
||||||
|
api_base=agent.api_base or None,
|
||||||
|
provider=agent.provider or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build system prompt from agent configuration
|
||||||
|
system_prompt = agent.system_prompt or "You are a helpful AI assistant."
|
||||||
|
user_prompt = f"Context: {context_data}"
|
||||||
|
|
||||||
|
# Use litellm directly for more control
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
litellm_model = agent.model or "gpt-4o"
|
||||||
|
if agent.provider and agent.provider != "openai":
|
||||||
|
litellm_model = f"{agent.provider}/{litellm_model}"
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"model": litellm_model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": agent.max_tokens or 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent.api_key:
|
||||||
|
kwargs["api_key"] = agent.api_key
|
||||||
|
if agent.api_base:
|
||||||
|
kwargs["api_base"] = agent.api_base
|
||||||
|
|
||||||
|
response = await litellm.acompletion(**kwargs)
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
|
||||||
|
# Track cost
|
||||||
|
if hasattr(response, "usage") and response.usage:
|
||||||
|
usage = response.usage
|
||||||
|
# Estimate cost (simplified)
|
||||||
|
input_cost = (usage.prompt_tokens or 0) * 0.00001 / 1000
|
||||||
|
output_cost = (usage.completion_tokens or 0) * 0.00003 / 1000
|
||||||
|
result_data["cost_usd"] = round(input_cost + output_cost, 6)
|
||||||
|
|
||||||
|
result_data["llm_response"] = content
|
||||||
|
|
||||||
|
# Execute tool calls if LLM returned function calls
|
||||||
|
if hasattr(response.choices[0].message, "tool_calls") and response.choices[0].message.tool_calls:
|
||||||
|
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tool_call_count: dict[str, int] = {}
|
||||||
|
for tc in response.choices[0].message.tool_calls:
|
||||||
|
tool_name = tc.function.name
|
||||||
|
# ── Safety Check 4: Infinite Loop Detection ──
|
||||||
|
tool_call_count[tool_name] = tool_call_count.get(tool_name, 0) + 1
|
||||||
|
if tool_call_count[tool_name] >= 5:
|
||||||
|
logger.warning(
|
||||||
|
"Infinite loop detected for agent %s: tool '%s' called %d times consecutively",
|
||||||
|
agent.id, tool_name, tool_call_count[tool_name],
|
||||||
|
)
|
||||||
|
result_data["error"] = f"Infinite loop detected: tool '{tool_name}' called 5+ times consecutively"
|
||||||
|
result_data["status"] = "failed"
|
||||||
|
return
|
||||||
|
|
||||||
|
tool = registry.get(tool_name)
|
||||||
|
if tool:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
args = json.loads(tc.function.arguments)
|
||||||
|
tool_result = await tool.handler(
|
||||||
|
arguments=args,
|
||||||
|
context={"tenant_id": str(agent.tenant_id)},
|
||||||
|
)
|
||||||
|
result_data["tool_calls"].append({
|
||||||
|
"tool": tool_name,
|
||||||
|
"arguments": args,
|
||||||
|
"result": tool_result,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
result_data["tool_calls"].append({
|
||||||
|
"tool": tool_name,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
result_data["status"] = "completed"
|
||||||
|
|
||||||
|
# Run with timeout
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_run_llm(), timeout=max_duration)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"Agent %s execution timed out after %d seconds",
|
||||||
|
agent.id, max_duration,
|
||||||
|
)
|
||||||
|
result_data["status"] = "timed_out"
|
||||||
|
result_data["error"] = f"Execution timed out after {max_duration} seconds"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Agent run failed for %s", agent.id)
|
||||||
|
result_data["status"] = "failed"
|
||||||
|
result_data["error"] = str(e)
|
||||||
|
|
||||||
|
# Save result to AgentRun
|
||||||
|
try:
|
||||||
|
async with factory() as db:
|
||||||
|
run = AgentRun(
|
||||||
|
tenant_id=agent.tenant_id,
|
||||||
|
agent_id=agent.id,
|
||||||
|
trigger_type=trigger_type,
|
||||||
|
status=result_data["status"],
|
||||||
|
input_data=context_data,
|
||||||
|
output_data=result_data.get("llm_response"),
|
||||||
|
tool_calls=result_data.get("tool_calls", []),
|
||||||
|
cost_usd=result_data.get("cost_usd", 0.0),
|
||||||
|
error_message=result_data.get("error"),
|
||||||
|
duration_seconds=None,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
result_data["run_id"] = str(run.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to save AgentRun for %s", agent.id)
|
||||||
|
result_data["save_error"] = str(e)
|
||||||
|
|
||||||
|
return result_data
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Automation execution engine — evaluates conditions and executes actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_condition(condition: dict[str, Any], trigger_data: dict[str, Any]) -> bool:
|
||||||
|
"""Evaluate a single condition against trigger_data.
|
||||||
|
|
||||||
|
Condition format: {field, operator, value}
|
||||||
|
Operators: eq, ne, gt, lt, contains, exists
|
||||||
|
"""
|
||||||
|
field = condition.get("field", "")
|
||||||
|
operator = condition.get("operator", "eq")
|
||||||
|
expected = condition.get("value")
|
||||||
|
|
||||||
|
actual = trigger_data.get(field)
|
||||||
|
|
||||||
|
if operator == "exists":
|
||||||
|
return actual is not None
|
||||||
|
|
||||||
|
if actual is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if operator == "eq":
|
||||||
|
return actual == expected
|
||||||
|
elif operator == "ne":
|
||||||
|
return actual != expected
|
||||||
|
elif operator == "gt":
|
||||||
|
try:
|
||||||
|
return float(actual) > float(expected)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(actual) > str(expected)
|
||||||
|
elif operator == "lt":
|
||||||
|
try:
|
||||||
|
return float(actual) < float(expected)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(actual) < str(expected)
|
||||||
|
elif operator == "contains":
|
||||||
|
return str(expected) in str(actual)
|
||||||
|
else:
|
||||||
|
logger.warning("Unknown operator: %s", operator)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_conditions(
|
||||||
|
conditions: list[dict[str, Any]],
|
||||||
|
trigger_data: dict[str, Any],
|
||||||
|
logic: str = "and",
|
||||||
|
) -> bool:
|
||||||
|
"""Evaluate a list of conditions with AND/OR logic."""
|
||||||
|
if not conditions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
results = [_evaluate_condition(c, trigger_data) for c in conditions]
|
||||||
|
|
||||||
|
if logic == "or":
|
||||||
|
return any(results)
|
||||||
|
else:
|
||||||
|
return all(results)
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_action(
|
||||||
|
action: dict[str, Any],
|
||||||
|
trigger_data: dict[str, Any],
|
||||||
|
tenant_id: str,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute a single action. Returns result dict."""
|
||||||
|
action_type = action.get("type", "")
|
||||||
|
config = action.get("config", {})
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"action_type": action_type,
|
||||||
|
"status": "skipped" if dry_run else "pending",
|
||||||
|
"details": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
result["status"] = "dry_run"
|
||||||
|
result["details"] = {"would_execute": True, "config": config}
|
||||||
|
return result
|
||||||
|
|
||||||
|
if action_type == "api_call":
|
||||||
|
url = config.get("url", "")
|
||||||
|
method = config.get("method", "GET").upper()
|
||||||
|
headers = config.get("headers", {})
|
||||||
|
body = config.get("body")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
if method == "GET":
|
||||||
|
resp = await client.get(url, headers=headers)
|
||||||
|
elif method == "POST":
|
||||||
|
resp = await client.post(url, headers=headers, json=body)
|
||||||
|
elif method == "PUT":
|
||||||
|
resp = await client.put(url, headers=headers, json=body)
|
||||||
|
elif method == "PATCH":
|
||||||
|
resp = await client.patch(url, headers=headers, json=body)
|
||||||
|
elif method == "DELETE":
|
||||||
|
resp = await client.delete(url, headers=headers)
|
||||||
|
else:
|
||||||
|
result["status"] = "failed"
|
||||||
|
result["error"] = f"Unsupported HTTP method: {method}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["status"] = "completed" if resp.is_success else "failed"
|
||||||
|
result["details"] = {
|
||||||
|
"status_code": resp.status_code,
|
||||||
|
"response": resp.text[:1000],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
result["status"] = "failed"
|
||||||
|
result["error"] = str(e)
|
||||||
|
|
||||||
|
elif action_type == "notification":
|
||||||
|
user_id = config.get("user_id")
|
||||||
|
title = config.get("title", "Automation notification")
|
||||||
|
body = config.get("body", "")
|
||||||
|
notif_type = config.get("type", "automation")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
result["status"] = "failed"
|
||||||
|
result["error"] = "user_id is required for notification action"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
from uuid import UUID
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
notification = Notification(
|
||||||
|
tenant_id=UUID(tenant_id),
|
||||||
|
user_id=UUID(user_id),
|
||||||
|
type=notif_type,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
db.add(notification)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
result["status"] = "completed"
|
||||||
|
result["details"] = {"user_id": user_id, "title": title}
|
||||||
|
except Exception as e:
|
||||||
|
result["status"] = "failed"
|
||||||
|
result["error"] = str(e)
|
||||||
|
|
||||||
|
elif action_type == "workflow_start":
|
||||||
|
workflow_id = config.get("workflow_id")
|
||||||
|
context = config.get("context", {})
|
||||||
|
|
||||||
|
if not workflow_id:
|
||||||
|
result["status"] = "failed"
|
||||||
|
result["error"] = "workflow_id is required for workflow_start action"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.workflow_service import create_instance
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
instance = await create_instance(
|
||||||
|
db=db,
|
||||||
|
tenant_id=UUID(tenant_id),
|
||||||
|
user_id=UUID(config.get("user_id", "00000000-0000-0000-0000-000000000000")),
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
context={**trigger_data, **context},
|
||||||
|
)
|
||||||
|
|
||||||
|
result["status"] = "completed"
|
||||||
|
result["details"] = {"instance": instance}
|
||||||
|
except Exception as e:
|
||||||
|
result["status"] = "failed"
|
||||||
|
result["error"] = str(e)
|
||||||
|
|
||||||
|
else:
|
||||||
|
result["status"] = "failed"
|
||||||
|
result["error"] = f"Unknown action type: {action_type}"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def run_automation(
|
||||||
|
ctx: dict[str, Any],
|
||||||
|
automation_id: str,
|
||||||
|
trigger_type: str = "manual",
|
||||||
|
trigger_data: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""ARQ job function. Loads AutomationDefinition, evaluates conditions,
|
||||||
|
executes actions if conditions pass. Saves result to AutomationRun."""
|
||||||
|
from app.plugins.builtins.automation.models import AutomationDefinition, AutomationRun
|
||||||
|
|
||||||
|
trigger_data = trigger_data or {}
|
||||||
|
factory = get_session_factory()
|
||||||
|
|
||||||
|
# Load automation definition
|
||||||
|
async with factory() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AutomationDefinition).where(AutomationDefinition.id == automation_id)
|
||||||
|
)
|
||||||
|
automation = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if automation is None:
|
||||||
|
logger.error("AutomationDefinition %s not found", automation_id)
|
||||||
|
return {"error": f"AutomationDefinition {automation_id} not found", "status": "failed"}
|
||||||
|
|
||||||
|
if not automation.is_active:
|
||||||
|
logger.warning("AutomationDefinition %s is inactive", automation_id)
|
||||||
|
return {"error": "Automation is inactive", "status": "skipped"}
|
||||||
|
|
||||||
|
# Evaluate conditions
|
||||||
|
conditions = automation.conditions or []
|
||||||
|
logic = automation.condition_logic or "and"
|
||||||
|
conditions_pass = _evaluate_conditions(conditions, trigger_data, logic)
|
||||||
|
|
||||||
|
result_data: dict[str, Any] = {
|
||||||
|
"automation_id": str(automation.id),
|
||||||
|
"automation_name": automation.name,
|
||||||
|
"trigger_type": trigger_type,
|
||||||
|
"status": "running",
|
||||||
|
"conditions_pass": conditions_pass,
|
||||||
|
"actions": [],
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not conditions_pass:
|
||||||
|
result_data["status"] = "conditions_not_met"
|
||||||
|
logger.info("Automation %s: conditions not met, skipping", automation.id)
|
||||||
|
else:
|
||||||
|
# Execute actions
|
||||||
|
actions = automation.actions or []
|
||||||
|
is_dry_run = trigger_data.get("dry_run", False)
|
||||||
|
|
||||||
|
for action in actions:
|
||||||
|
action_result = await _execute_action(
|
||||||
|
action, trigger_data, str(automation.tenant_id), dry_run=is_dry_run
|
||||||
|
)
|
||||||
|
result_data["actions"].append(action_result)
|
||||||
|
|
||||||
|
# Determine overall status
|
||||||
|
if is_dry_run:
|
||||||
|
result_data["status"] = "dry_run"
|
||||||
|
elif any(a.get("status") == "failed" for a in result_data["actions"]):
|
||||||
|
result_data["status"] = "partial"
|
||||||
|
else:
|
||||||
|
result_data["status"] = "completed"
|
||||||
|
|
||||||
|
# Save result to AutomationRun
|
||||||
|
try:
|
||||||
|
async with factory() as db:
|
||||||
|
run = AutomationRun(
|
||||||
|
tenant_id=automation.tenant_id,
|
||||||
|
automation_id=automation.id,
|
||||||
|
trigger_type=trigger_type,
|
||||||
|
status=result_data["status"],
|
||||||
|
input_data=trigger_data,
|
||||||
|
output_data=result_data,
|
||||||
|
error_message=result_data.get("error"),
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
result_data["run_id"] = str(run.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to save AutomationRun for %s", automation.id)
|
||||||
|
result_data["save_error"] = str(e)
|
||||||
|
|
||||||
|
return result_data
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
-- automation plugin: agent definitions, automation definitions, cron jobs, run logs
|
||||||
|
|
||||||
|
-- Agent Definitions
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_agent_definitions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
description VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
llm_model VARCHAR(100) NOT NULL DEFAULT 'ollama/deepseek-v4-flash',
|
||||||
|
system_prompt TEXT NOT NULL DEFAULT '',
|
||||||
|
tool_ids JSONB NOT NULL DEFAULT '[]',
|
||||||
|
heartbeat_interval_seconds INT NOT NULL DEFAULT 0,
|
||||||
|
mode VARCHAR(20) NOT NULL DEFAULT 'reactive', -- proactive, reactive
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
max_executions_per_hour INT NOT NULL DEFAULT 10,
|
||||||
|
max_duration_seconds INT NOT NULL DEFAULT 300,
|
||||||
|
budget_limit_usd FLOAT NOT NULL DEFAULT 1.0,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(tenant_id, name)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agent_def_tenant_active ON automation_agent_definitions (tenant_id, is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agent_def_tenant_mode ON automation_agent_definitions (tenant_id, mode);
|
||||||
|
|
||||||
|
-- Agent Versions
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_agent_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
agent_id UUID NOT NULL REFERENCES automation_agent_definitions(id) ON DELETE CASCADE,
|
||||||
|
version_number INT NOT NULL,
|
||||||
|
snapshot JSONB NOT NULL,
|
||||||
|
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(agent_id, version_number)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agent_versions_agent ON automation_agent_versions (tenant_id, agent_id);
|
||||||
|
|
||||||
|
-- Automation Definitions
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_definitions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
description VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual', -- event, schedule, manual
|
||||||
|
trigger_config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
conditions JSONB NOT NULL DEFAULT '[]',
|
||||||
|
actions JSONB NOT NULL DEFAULT '[]',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
dry_run BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(tenant_id, name)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_automation_def_tenant_active ON automation_definitions (tenant_id, is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_automation_def_tenant_trigger ON automation_definitions (tenant_id, trigger_type);
|
||||||
|
|
||||||
|
-- Automation Versions
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
automation_id UUID NOT NULL REFERENCES automation_definitions(id) ON DELETE CASCADE,
|
||||||
|
version_number INT NOT NULL,
|
||||||
|
snapshot JSONB NOT NULL,
|
||||||
|
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(automation_id, version_number)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_automation_versions_def ON automation_versions (tenant_id, automation_id);
|
||||||
|
|
||||||
|
-- Cron Jobs
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_cron_jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
cron_expression VARCHAR(100) NOT NULL,
|
||||||
|
job_type VARCHAR(50) NOT NULL DEFAULT 'automation_trigger', -- agent_heartbeat, automation_trigger, custom
|
||||||
|
target_id UUID,
|
||||||
|
plugin_name VARCHAR(80) NOT NULL DEFAULT '',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
next_run_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_cron_jobs_tenant_active ON automation_cron_jobs (tenant_id, is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_cron_jobs_next_run ON automation_cron_jobs (next_run_at);
|
||||||
|
|
||||||
|
-- Agent Runs (Execution Log)
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_agent_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
agent_id UUID NOT NULL REFERENCES automation_agent_definitions(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled, timeout
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
duration_seconds FLOAT,
|
||||||
|
result TEXT,
|
||||||
|
error TEXT,
|
||||||
|
cost_usd FLOAT,
|
||||||
|
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||||
|
trigger_data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agent_runs_agent_status ON automation_agent_runs (tenant_id, agent_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agent_runs_started ON automation_agent_runs (tenant_id, started_at DESC);
|
||||||
|
|
||||||
|
-- Automation Runs (Execution Log)
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
automation_id UUID NOT NULL REFERENCES automation_definitions(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled, timeout
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
duration_seconds FLOAT,
|
||||||
|
result TEXT,
|
||||||
|
error TEXT,
|
||||||
|
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||||
|
trigger_data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
dry_run BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_automation_runs_def_status ON automation_runs (tenant_id, automation_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_automation_runs_started ON automation_runs (tenant_id, started_at DESC);
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""SQLAlchemy models for the Automation & Agents plugin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDefinition(Base, TenantMixin):
|
||||||
|
"""An AI agent definition — configures an LLM-powered agent with tools and behavior."""
|
||||||
|
|
||||||
|
__tablename__ = "automation_agent_definitions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "name", name="uq_agent_def_name"),
|
||||||
|
Index("ix_agent_def_tenant_active", "tenant_id", "is_active"),
|
||||||
|
Index("ix_agent_def_tenant_mode", "tenant_id", "mode"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(500), nullable=False, default="")
|
||||||
|
llm_model: Mapped[str] = mapped_column(
|
||||||
|
String(100), nullable=False, default="ollama/deepseek-v4-flash"
|
||||||
|
)
|
||||||
|
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
tool_ids: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
heartbeat_interval_seconds: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0
|
||||||
|
)
|
||||||
|
mode: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="reactive"
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
max_executions_per_hour: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=10
|
||||||
|
)
|
||||||
|
max_duration_seconds: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=300
|
||||||
|
)
|
||||||
|
budget_limit_usd: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=1.0
|
||||||
|
)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVersion(Base, TenantMixin):
|
||||||
|
"""Versioned snapshots of agent definitions."""
|
||||||
|
|
||||||
|
__tablename__ = "automation_agent_versions"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_agent_versions_agent", "tenant_id", "agent_id"),
|
||||||
|
UniqueConstraint("agent_id", "version_number", name="uq_agent_version"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
agent_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("automation_agent_definitions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
|
changed_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationDefinition(Base, TenantMixin):
|
||||||
|
"""An automation workflow definition — event/schedule/manual triggered with conditions and actions."""
|
||||||
|
|
||||||
|
__tablename__ = "automation_definitions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "name", name="uq_automation_def_name"),
|
||||||
|
Index("ix_automation_def_tenant_active", "tenant_id", "is_active"),
|
||||||
|
Index("ix_automation_def_tenant_trigger", "tenant_id", "trigger_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(500), nullable=False, default="")
|
||||||
|
trigger_type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="manual"
|
||||||
|
)
|
||||||
|
trigger_config: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSONB, nullable=False, default=dict
|
||||||
|
)
|
||||||
|
conditions: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
actions: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationVersion(Base, TenantMixin):
|
||||||
|
"""Versioned snapshots of automation definitions."""
|
||||||
|
|
||||||
|
__tablename__ = "automation_versions"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_automation_versions_def", "tenant_id", "automation_id"),
|
||||||
|
UniqueConstraint("automation_id", "version_number", name="uq_automation_version"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
automation_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("automation_definitions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
|
changed_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationCronJob(Base, TenantMixin):
|
||||||
|
"""Cron job schedule entries for agent heartbeats, automation triggers, or custom jobs."""
|
||||||
|
|
||||||
|
__tablename__ = "automation_cron_jobs"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_cron_jobs_tenant_active", "tenant_id", "is_active"),
|
||||||
|
Index("ix_cron_jobs_next_run", "next_run_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
cron_expression: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
job_type: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False, default="automation_trigger"
|
||||||
|
)
|
||||||
|
target_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), nullable=True
|
||||||
|
)
|
||||||
|
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
last_run_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
next_run_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRun(Base, TenantMixin):
|
||||||
|
"""Execution log for agent runs."""
|
||||||
|
|
||||||
|
__tablename__ = "automation_agent_runs"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_agent_runs_agent_status", "tenant_id", "agent_id", "status"),
|
||||||
|
Index("ix_agent_runs_started", "tenant_id", "started_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
agent_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("automation_agent_definitions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="pending"
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
trigger_type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="manual"
|
||||||
|
)
|
||||||
|
trigger_data: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSONB, nullable=False, default=dict
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationRun(Base, TenantMixin):
|
||||||
|
"""Execution log for automation runs."""
|
||||||
|
|
||||||
|
__tablename__ = "automation_runs"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_automation_runs_def_status", "tenant_id", "automation_id", "status"),
|
||||||
|
Index("ix_automation_runs_started", "tenant_id", "started_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
automation_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("automation_definitions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="pending"
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
trigger_type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="manual"
|
||||||
|
)
|
||||||
|
trigger_data: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSONB, nullable=False, default=dict
|
||||||
|
)
|
||||||
|
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""Automation & Agents plugin — Agent Builder, Automation Builder, Cron-Scheduler, Agent Runner.
|
||||||
|
|
||||||
|
Houses the core automation engine: AI agent definitions, event/schedule/manual
|
||||||
|
workflow automations, cron job scheduling, and execution logging.
|
||||||
|
|
||||||
|
Supports plugin contributions: when other plugins are activated, their
|
||||||
|
agent_definitions, automation_templates, cron_jobs, and heartbeat_configs
|
||||||
|
from the manifest are registered. On deactivation, they are removed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.manifest import (
|
||||||
|
FrontendMenuItem,
|
||||||
|
FrontendPageRoute,
|
||||||
|
FrontendSettingsPage,
|
||||||
|
PluginManifest,
|
||||||
|
PluginRouteDef,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationPlugin(BasePlugin):
|
||||||
|
"""Automation & Agents plugin — Agent Builder, Automation Builder, Cron-Scheduler, Agent Runner."""
|
||||||
|
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name="automation",
|
||||||
|
version="1.0.0",
|
||||||
|
display_name="Automation & Agents",
|
||||||
|
description=(
|
||||||
|
"Agent Builder, Automation Builder, Cron-Scheduler, and Agent Runner. "
|
||||||
|
"Define AI agents with LLM models and tools, create event/schedule/manual "
|
||||||
|
"automations with conditions and actions, schedule cron jobs, and track execution logs."
|
||||||
|
),
|
||||||
|
dependencies=[],
|
||||||
|
routes=[
|
||||||
|
PluginRouteDef(
|
||||||
|
path="/api/v1/automation",
|
||||||
|
module="app.plugins.builtins.automation.routes",
|
||||||
|
router_attr="router",
|
||||||
|
),
|
||||||
|
PluginRouteDef(
|
||||||
|
path="/api/v1/agents",
|
||||||
|
module="app.plugins.builtins.automation.agent_routes",
|
||||||
|
router_attr="router",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
events=[
|
||||||
|
"contact.created",
|
||||||
|
"contact.updated",
|
||||||
|
"mail.received",
|
||||||
|
"workflow.timeout",
|
||||||
|
],
|
||||||
|
migrations=["0001_initial.sql"],
|
||||||
|
permissions=[
|
||||||
|
"automation:read",
|
||||||
|
"automation:write",
|
||||||
|
"automation:delete",
|
||||||
|
"automation:execute",
|
||||||
|
"automation:configure",
|
||||||
|
"agents:read",
|
||||||
|
"agents:write",
|
||||||
|
"agents:delete",
|
||||||
|
"agents:execute",
|
||||||
|
],
|
||||||
|
is_core=False,
|
||||||
|
menu_items=[
|
||||||
|
FrontendMenuItem(
|
||||||
|
label_key="nav.automation",
|
||||||
|
label="Automation",
|
||||||
|
path="/automation",
|
||||||
|
icon="Zap",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
|
FrontendMenuItem(
|
||||||
|
label_key="nav.agents",
|
||||||
|
label="Agents",
|
||||||
|
path="/agents",
|
||||||
|
icon="Bot",
|
||||||
|
order=51,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
page_routes=[
|
||||||
|
FrontendPageRoute(
|
||||||
|
path="/automation",
|
||||||
|
component="@/pages/AutomationDashboard",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
|
FrontendPageRoute(
|
||||||
|
path="/agents",
|
||||||
|
component="@/pages/AgentDashboard",
|
||||||
|
order=51,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
settings_pages=[
|
||||||
|
FrontendSettingsPage(
|
||||||
|
path="automation",
|
||||||
|
label_key="settings.automation",
|
||||||
|
label="Automation",
|
||||||
|
component="@/pages/AutomationSettings",
|
||||||
|
icon="Settings",
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
# Track contributed definitions by source plugin name
|
||||||
|
self._contributed_agents: dict[str, list[str]] = {} # plugin_name -> [agent_name, ...]
|
||||||
|
self._contributed_automations: dict[str, list[str]] = {} # plugin_name -> [automation_name, ...]
|
||||||
|
self._contributed_cron_jobs: dict[str, list[str]] = {} # plugin_name -> [cron_job_name, ...]
|
||||||
|
self._contributed_heartbeats: dict[str, list[str]] = {} # plugin_name -> [agent_name, ...]
|
||||||
|
|
||||||
|
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||||
|
"""Register event listeners on activation."""
|
||||||
|
await super().on_activate(db, service_container, event_bus)
|
||||||
|
# Register agent communication tool
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.automation.agent_comm import register_agent_comm_tool
|
||||||
|
register_agent_comm_tool()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to register agent communication tool")
|
||||||
|
# Register MiniApps from manifest
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||||
|
registry = MiniAppRegistry()
|
||||||
|
for miniapp in self.manifest.miniapps:
|
||||||
|
registry.register(
|
||||||
|
app_id=miniapp.app_id,
|
||||||
|
name=miniapp.name,
|
||||||
|
icon=miniapp.icon,
|
||||||
|
description=miniapp.description,
|
||||||
|
plugin_name=self.manifest.name,
|
||||||
|
render_schema=miniapp.render_schema,
|
||||||
|
)
|
||||||
|
logger.info("Registered MiniApp '%s' from manifest", miniapp.app_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to register MiniApps from manifest")
|
||||||
|
logger.info("Automation plugin activated")
|
||||||
|
|
||||||
|
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||||
|
"""Clean up on deactivation."""
|
||||||
|
await super().on_deactivate(db, service_container, event_bus)
|
||||||
|
# Unregister agent communication tool
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.automation.agent_comm import unregister_agent_comm_tool
|
||||||
|
unregister_agent_comm_tool()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unregister agent communication tool")
|
||||||
|
# Unregister MiniApps
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||||
|
registry = MiniAppRegistry()
|
||||||
|
registry.unregister_plugin(self.manifest.name)
|
||||||
|
logger.info("Unregistered MiniApps for plugin '%s'", self.manifest.name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unregister MiniApps")
|
||||||
|
logger.info("Automation plugin deactivated")
|
||||||
|
|
||||||
|
# ─── Plugin Contribution Registration ───
|
||||||
|
|
||||||
|
async def register_plugin_contributions(self, db, plugin_name: str, manifest) -> None:
|
||||||
|
"""Register agent definitions, automation templates, cron jobs, and heartbeat configs
|
||||||
|
from another plugin's manifest. Uses plugin name prefixing for conflict resolution."""
|
||||||
|
from app.plugins.builtins.automation.services import AgentService, AutomationService, CronJobService
|
||||||
|
from app.plugins.builtins.automation.models import AutomationCronJob
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
# Register agent definitions
|
||||||
|
agent_names: list[str] = []
|
||||||
|
for agent_def in manifest.agent_definitions:
|
||||||
|
prefixed_name = f"{plugin_name}.{agent_def.name}"
|
||||||
|
agent_names.append(prefixed_name)
|
||||||
|
# Check if already exists (idempotent)
|
||||||
|
existing = await AgentService.get_by_name(db, agent_def.tenant_id, prefixed_name) if hasattr(AgentService, 'get_by_name') else None
|
||||||
|
if existing is None:
|
||||||
|
try:
|
||||||
|
await AgentService.create(db, agent_def.tenant_id, {
|
||||||
|
"name": prefixed_name,
|
||||||
|
"description": agent_def.description,
|
||||||
|
"llm_model": agent_def.llm_model,
|
||||||
|
"system_prompt": agent_def.system_prompt,
|
||||||
|
"tool_ids": agent_def.tool_ids,
|
||||||
|
"heartbeat_interval_seconds": agent_def.heartbeat_interval_seconds,
|
||||||
|
"mode": agent_def.mode,
|
||||||
|
"max_executions_per_hour": agent_def.max_executions_per_hour,
|
||||||
|
"max_duration_seconds": agent_def.max_duration_seconds,
|
||||||
|
"budget_limit_usd": agent_def.budget_limit_usd,
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
logger.info("Registered contributed agent '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to register contributed agent '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||||
|
self._contributed_agents[plugin_name] = agent_names
|
||||||
|
|
||||||
|
# Register automation templates
|
||||||
|
automation_names: list[str] = []
|
||||||
|
for auto_def in manifest.automation_templates:
|
||||||
|
prefixed_name = f"{plugin_name}.{auto_def.name}"
|
||||||
|
automation_names.append(prefixed_name)
|
||||||
|
existing = await AutomationService.get_by_name(db, auto_def.tenant_id, prefixed_name) if hasattr(AutomationService, 'get_by_name') else None
|
||||||
|
if existing is None:
|
||||||
|
try:
|
||||||
|
await AutomationService.create(db, auto_def.tenant_id, {
|
||||||
|
"name": prefixed_name,
|
||||||
|
"description": auto_def.description,
|
||||||
|
"trigger_type": auto_def.trigger_type,
|
||||||
|
"trigger_config": auto_def.trigger_config,
|
||||||
|
"conditions": auto_def.conditions,
|
||||||
|
"actions": auto_def.actions,
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
logger.info("Registered contributed automation '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to register contributed automation '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||||
|
self._contributed_automations[plugin_name] = automation_names
|
||||||
|
|
||||||
|
# Register cron jobs
|
||||||
|
cron_job_names: list[str] = []
|
||||||
|
for cron_def in manifest.cron_jobs:
|
||||||
|
prefixed_name = f"{plugin_name}.{cron_def.name}"
|
||||||
|
cron_job_names.append(prefixed_name)
|
||||||
|
try:
|
||||||
|
# Check if cron job already exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(AutomationCronJob).where(AutomationCronJob.name == prefixed_name).limit(1)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing is None:
|
||||||
|
await CronJobService.create(db, cron_def.tenant_id, {
|
||||||
|
"name": prefixed_name,
|
||||||
|
"cron_expression": cron_def.cron_expression,
|
||||||
|
"job_type": cron_def.job_type,
|
||||||
|
"target_name": cron_def.target_name,
|
||||||
|
"plugin_name": plugin_name,
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
logger.info("Registered contributed cron job '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to register contributed cron job '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||||
|
self._contributed_cron_jobs[plugin_name] = cron_job_names
|
||||||
|
|
||||||
|
# Register heartbeat configs
|
||||||
|
heartbeat_names: list[str] = []
|
||||||
|
for hb_def in manifest.heartbeat_configs:
|
||||||
|
prefixed_name = f"{plugin_name}.{hb_def.agent_name}"
|
||||||
|
heartbeat_names.append(prefixed_name)
|
||||||
|
try:
|
||||||
|
# Create or update agent with heartbeat settings
|
||||||
|
agent = await AgentService.get_by_name(db, hb_def.tenant_id, prefixed_name) if hasattr(AgentService, 'get_by_name') else None
|
||||||
|
if agent is None:
|
||||||
|
await AgentService.create(db, hb_def.tenant_id, {
|
||||||
|
"name": prefixed_name,
|
||||||
|
"description": f"Heartbeat agent contributed by {plugin_name}",
|
||||||
|
"heartbeat_interval_seconds": hb_def.interval_seconds,
|
||||||
|
"mode": "proactive",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
logger.info("Registered heartbeat agent '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to register heartbeat agent '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||||
|
self._contributed_heartbeats[plugin_name] = heartbeat_names
|
||||||
|
|
||||||
|
async def unregister_plugin_contributions(self, db, plugin_name: str) -> None:
|
||||||
|
"""Remove all contributed definitions from a plugin that is being deactivated."""
|
||||||
|
from app.plugins.builtins.automation.services import AgentService, AutomationService, CronJobService
|
||||||
|
|
||||||
|
# Remove contributed agents
|
||||||
|
agent_names = self._contributed_agents.pop(plugin_name, [])
|
||||||
|
for agent_name in agent_names:
|
||||||
|
try:
|
||||||
|
agent = await AgentService.get_by_name(db, agent_name)
|
||||||
|
if agent:
|
||||||
|
await AgentService.delete(db, agent.tenant_id, agent.id)
|
||||||
|
logger.info("Unregistered contributed agent '%s' from plugin '%s'", agent_name, plugin_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unregister contributed agent '%s'", agent_name)
|
||||||
|
|
||||||
|
# Remove contributed automations
|
||||||
|
automation_names = self._contributed_automations.pop(plugin_name, [])
|
||||||
|
for auto_name in automation_names:
|
||||||
|
try:
|
||||||
|
auto = await AutomationService.get_by_name(db, auto_name)
|
||||||
|
if auto:
|
||||||
|
await AutomationService.delete(db, auto.tenant_id, auto.id)
|
||||||
|
logger.info("Unregistered contributed automation '%s' from plugin '%s'", auto_name, plugin_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unregister contributed automation '%s'", auto_name)
|
||||||
|
|
||||||
|
# Remove contributed cron jobs
|
||||||
|
cron_job_names = self._contributed_cron_jobs.pop(plugin_name, [])
|
||||||
|
for cron_name in cron_job_names:
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.automation.models import AutomationCronJob
|
||||||
|
from sqlalchemy import select
|
||||||
|
result = await db.execute(
|
||||||
|
select(AutomationCronJob).where(AutomationCronJob.name == cron_name).limit(1)
|
||||||
|
)
|
||||||
|
job = result.scalar_one_or_none()
|
||||||
|
if job:
|
||||||
|
await CronJobService.delete(db, job.tenant_id, job.id)
|
||||||
|
logger.info("Unregistered contributed cron job '%s' from plugin '%s'", cron_name, plugin_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unregister contributed cron job '%s'", cron_name)
|
||||||
|
|
||||||
|
# Remove contributed heartbeats
|
||||||
|
heartbeat_names = self._contributed_heartbeats.pop(plugin_name, [])
|
||||||
|
for hb_name in heartbeat_names:
|
||||||
|
try:
|
||||||
|
agent = await AgentService.get_by_name(db, hb_name)
|
||||||
|
if agent:
|
||||||
|
await AgentService.delete(db, agent.tenant_id, agent.id)
|
||||||
|
logger.info("Unregistered heartbeat agent '%s' from plugin '%s'", hb_name, plugin_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unregister heartbeat agent '%s'", hb_name)
|
||||||
|
|
||||||
|
# ─── Heartbeat Migration ───
|
||||||
|
|
||||||
|
async def ensure_ai_proactive_heartbeat(self, db) -> None:
|
||||||
|
"""Migrate the hardcoded ai_proactive heartbeat to a configurable cron job."""
|
||||||
|
from app.plugins.builtins.automation.models import AutomationCronJob
|
||||||
|
from app.plugins.builtins.automation.services import CronJobService
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
# Check if ai_proactive heartbeat cron job already exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(AutomationCronJob).where(
|
||||||
|
AutomationCronJob.name == "ai_proactive.heartbeat"
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
logger.info("ai_proactive heartbeat cron job already exists, skipping migration")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create the heartbeat cron job
|
||||||
|
try:
|
||||||
|
await CronJobService.create(db, None, {
|
||||||
|
"name": "ai_proactive.heartbeat",
|
||||||
|
"cron_expression": "*/5 * * * *", # Every 5 minutes
|
||||||
|
"job_type": "agent_heartbeat",
|
||||||
|
"target_name": "ai_proactive.heartbeat_agent",
|
||||||
|
"plugin_name": "ai_proactive",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
logger.info("Created ai_proactive heartbeat cron job (every 5 minutes)")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to create ai_proactive heartbeat cron job")
|
||||||
|
|
||||||
|
# ─── Event Handlers ───
|
||||||
|
|
||||||
|
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||||
|
"""Handle contact.created event — trigger matching automations."""
|
||||||
|
logger.debug("contact.created event received: %s", payload)
|
||||||
|
|
||||||
|
async def on_contact_updated(self, payload: dict[str, Any]) -> None:
|
||||||
|
"""Handle contact.updated event — trigger matching automations."""
|
||||||
|
logger.debug("contact.updated event received: %s", payload)
|
||||||
|
|
||||||
|
async def on_mail_received(self, payload: dict[str, Any]) -> None:
|
||||||
|
"""Handle mail.received event — trigger matching automations."""
|
||||||
|
logger.debug("mail.received event received: %s", payload)
|
||||||
|
|
||||||
|
async def on_workflow_timeout(self, payload: dict[str, Any]) -> None:
|
||||||
|
"""Handle workflow.timeout event — trigger matching automations."""
|
||||||
|
logger.debug("workflow.timeout event received: %s", payload)
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
"""API routes for the Automation plugin — /api/v1/automation.
|
||||||
|
|
||||||
|
Endpoints: automation definitions CRUD, execute, dry-run, runs, versions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db, set_tenant_context
|
||||||
|
from app.deps import get_current_user, require_permission
|
||||||
|
from app.plugins.builtins.automation.models import (
|
||||||
|
AutomationDefinition,
|
||||||
|
AutomationRun,
|
||||||
|
AutomationVersion,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.automation.schemas import (
|
||||||
|
AutomationDefinitionCreate,
|
||||||
|
AutomationDefinitionListResponse,
|
||||||
|
AutomationDefinitionResponse,
|
||||||
|
AutomationDefinitionUpdate,
|
||||||
|
AutomationRunListResponse,
|
||||||
|
AutomationRunResponse,
|
||||||
|
AutomationSettingsResponse,
|
||||||
|
AutomationSettingsUpdate,
|
||||||
|
AutomationVersionListResponse,
|
||||||
|
AutomationVersionResponse,
|
||||||
|
MiniAppCreate,
|
||||||
|
MiniAppResponse,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.automation.services import (
|
||||||
|
AutomationService,
|
||||||
|
RunLogService,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/automation", tags=["automation"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helper Functions ───
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_to_response(a: AutomationDefinition) -> AutomationDefinitionResponse:
|
||||||
|
"""Convert AutomationDefinition model to response schema."""
|
||||||
|
return AutomationDefinitionResponse(
|
||||||
|
id=str(a.id),
|
||||||
|
name=a.name,
|
||||||
|
description=a.description or "",
|
||||||
|
trigger_type=a.trigger_type,
|
||||||
|
trigger_config=a.trigger_config or {},
|
||||||
|
conditions=a.conditions or [],
|
||||||
|
actions=a.actions or [],
|
||||||
|
is_active=a.is_active,
|
||||||
|
dry_run=a.dry_run,
|
||||||
|
created_by=str(a.created_by) if a.created_by else None,
|
||||||
|
created_at=a.created_at.isoformat() if a.created_at else None,
|
||||||
|
updated_at=a.updated_at.isoformat() if a.updated_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_to_response(r: AutomationRun) -> AutomationRunResponse:
|
||||||
|
"""Convert AutomationRun model to response schema."""
|
||||||
|
return AutomationRunResponse(
|
||||||
|
id=str(r.id),
|
||||||
|
automation_id=str(r.automation_id),
|
||||||
|
status=r.status,
|
||||||
|
started_at=r.started_at.isoformat() if r.started_at else "",
|
||||||
|
completed_at=r.completed_at.isoformat() if r.completed_at else None,
|
||||||
|
duration_seconds=r.duration_seconds,
|
||||||
|
result=r.result,
|
||||||
|
error=r.error,
|
||||||
|
trigger_type=r.trigger_type,
|
||||||
|
trigger_data=r.trigger_data or {},
|
||||||
|
dry_run=r.dry_run,
|
||||||
|
created_at=r.created_at.isoformat() if r.created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _version_to_response(v: AutomationVersion) -> AutomationVersionResponse:
|
||||||
|
"""Convert AutomationVersion model to response schema."""
|
||||||
|
return AutomationVersionResponse(
|
||||||
|
id=str(v.id),
|
||||||
|
automation_id=str(v.automation_id),
|
||||||
|
version_number=v.version_number,
|
||||||
|
snapshot=v.snapshot or {},
|
||||||
|
changed_by=str(v.changed_by) if v.changed_by else None,
|
||||||
|
created_at=v.created_at.isoformat() if v.created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CRUD Endpoints ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/",
|
||||||
|
dependencies=[Depends(require_permission("automation:read"))],
|
||||||
|
response_model=AutomationDefinitionListResponse,
|
||||||
|
)
|
||||||
|
async def list_automations(
|
||||||
|
trigger_type: str | None = Query(None),
|
||||||
|
is_active: bool | None = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List automation definitions with optional filters."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
items, total = await AutomationService.list(
|
||||||
|
db, tenant_id, trigger_type=trigger_type, is_active=is_active,
|
||||||
|
limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
return AutomationDefinitionListResponse(
|
||||||
|
items=[_automation_to_response(a) for a in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/",
|
||||||
|
dependencies=[Depends(require_permission("automation:write"))],
|
||||||
|
response_model=AutomationDefinitionResponse,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_automation(
|
||||||
|
data: AutomationDefinitionCreate,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new automation definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
automation = await AutomationService.create(
|
||||||
|
db, tenant_id, data.model_dump(), user_id=user_id
|
||||||
|
)
|
||||||
|
return _automation_to_response(automation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{automation_id}",
|
||||||
|
dependencies=[Depends(require_permission("automation:read"))],
|
||||||
|
response_model=AutomationDefinitionResponse,
|
||||||
|
)
|
||||||
|
async def get_automation(
|
||||||
|
automation_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a single automation definition by ID."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(automation_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||||
|
|
||||||
|
automation = await AutomationService.get_by_id(db, tenant_id, aid)
|
||||||
|
if automation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Automation not found")
|
||||||
|
return _automation_to_response(automation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{automation_id}",
|
||||||
|
dependencies=[Depends(require_permission("automation:write"))],
|
||||||
|
response_model=AutomationDefinitionResponse,
|
||||||
|
)
|
||||||
|
async def update_automation(
|
||||||
|
automation_id: str,
|
||||||
|
data: AutomationDefinitionUpdate,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update an existing automation definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(automation_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||||
|
|
||||||
|
automation = await AutomationService.update(
|
||||||
|
db, tenant_id, aid, data.model_dump(exclude_none=True), user_id=user_id
|
||||||
|
)
|
||||||
|
if automation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Automation not found")
|
||||||
|
return _automation_to_response(automation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{automation_id}",
|
||||||
|
dependencies=[Depends(require_permission("automation:delete"))],
|
||||||
|
)
|
||||||
|
async def delete_automation(
|
||||||
|
automation_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete an automation definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(automation_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||||
|
|
||||||
|
success = await AutomationService.delete(db, tenant_id, aid)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Automation not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Execute / Dry-Run ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{automation_id}/execute",
|
||||||
|
dependencies=[Depends(require_permission("automation:execute"))],
|
||||||
|
)
|
||||||
|
async def execute_automation(
|
||||||
|
automation_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Execute an automation definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(automation_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||||
|
|
||||||
|
automation = await AutomationService.get_by_id(db, tenant_id, aid)
|
||||||
|
if automation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Automation not found")
|
||||||
|
if not automation.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Automation is not active")
|
||||||
|
|
||||||
|
# Create run log entry
|
||||||
|
run = AutomationRun(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
automation_id=aid,
|
||||||
|
status="running",
|
||||||
|
started_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),
|
||||||
|
trigger_type="manual",
|
||||||
|
trigger_data={"triggered_by": str(uuid.UUID(current_user["user_id"]))},
|
||||||
|
dry_run=False,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# TODO: Execute automation actions asynchronously
|
||||||
|
run.status = "completed"
|
||||||
|
run.completed_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
|
||||||
|
run.duration_seconds = 0.0
|
||||||
|
run.result = "Executed successfully (stub)"
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {"status": "ok", "run_id": str(run.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{automation_id}/dry-run",
|
||||||
|
dependencies=[Depends(require_permission("automation:execute"))],
|
||||||
|
)
|
||||||
|
async def dry_run_automation(
|
||||||
|
automation_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Dry-run an automation definition (validate without executing)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(automation_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||||
|
|
||||||
|
automation = await AutomationService.get_by_id(db, tenant_id, aid)
|
||||||
|
if automation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Automation not found")
|
||||||
|
|
||||||
|
# Execute dry-run via execution engine
|
||||||
|
from app.plugins.builtins.automation.execution_engine import run_automation as execute_automation_engine
|
||||||
|
|
||||||
|
result = await execute_automation_engine(
|
||||||
|
ctx={},
|
||||||
|
automation_id=str(aid),
|
||||||
|
trigger_type="manual",
|
||||||
|
trigger_data={
|
||||||
|
"triggered_by": str(uuid.UUID(current_user["user_id"])),
|
||||||
|
"dry_run": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create dry-run log entry
|
||||||
|
run = AutomationRun(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
automation_id=aid,
|
||||||
|
status=result.get("status", "dry_run"),
|
||||||
|
started_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),
|
||||||
|
completed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),
|
||||||
|
duration_seconds=0.0,
|
||||||
|
trigger_type="manual",
|
||||||
|
trigger_data={"triggered_by": str(uuid.UUID(current_user["user_id"])), "dry_run": True},
|
||||||
|
dry_run=True,
|
||||||
|
result=str(result),
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"run_id": str(run.id),
|
||||||
|
"automation": _automation_to_response(automation),
|
||||||
|
"validation": {
|
||||||
|
"valid": True,
|
||||||
|
"conditions_count": len(automation.conditions or []),
|
||||||
|
"actions_count": len(automation.actions or []),
|
||||||
|
},
|
||||||
|
"dry_run_result": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Runs ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{automation_id}/runs",
|
||||||
|
dependencies=[Depends(require_permission("automation:read"))],
|
||||||
|
response_model=AutomationRunListResponse,
|
||||||
|
)
|
||||||
|
async def list_automation_runs(
|
||||||
|
automation_id: str,
|
||||||
|
status: str | None = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List run logs for an automation definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(automation_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||||
|
|
||||||
|
items, total = await RunLogService.list_automation_runs(
|
||||||
|
db, tenant_id, automation_id=aid, status=status, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return AutomationRunListResponse(
|
||||||
|
items=[_run_to_response(r) for r in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Versions ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{automation_id}/versions",
|
||||||
|
dependencies=[Depends(require_permission("automation:read"))],
|
||||||
|
response_model=AutomationVersionListResponse,
|
||||||
|
)
|
||||||
|
async def list_automation_versions(
|
||||||
|
automation_id: str,
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List version history for an automation definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(automation_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||||
|
|
||||||
|
items, total = await AutomationService.get_versions(
|
||||||
|
db, tenant_id, aid, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return AutomationVersionListResponse(
|
||||||
|
items=[_version_to_response(v) for v in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{automation_id}/versions/{version_id}/restore",
|
||||||
|
dependencies=[Depends(require_permission("automation:write"))],
|
||||||
|
response_model=AutomationDefinitionResponse,
|
||||||
|
)
|
||||||
|
async def restore_automation_version(
|
||||||
|
automation_id: str,
|
||||||
|
version_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Restore an automation definition from a specific version."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(automation_id)
|
||||||
|
vid = uuid.UUID(version_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid ID")
|
||||||
|
|
||||||
|
automation = await AutomationService.restore_version(
|
||||||
|
db, tenant_id, aid, vid, user_id=user_id
|
||||||
|
)
|
||||||
|
if automation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Automation or version not found")
|
||||||
|
return _automation_to_response(automation)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Recent Runs ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/runs/recent",
|
||||||
|
dependencies=[Depends(require_permission("automation:read"))],
|
||||||
|
response_model=AutomationRunListResponse,
|
||||||
|
)
|
||||||
|
async def list_recent_automation_runs(
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
status: str | None = Query(None),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List recent automation runs across all automations (for dashboard)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
items, total = await RunLogService.list_automation_runs(
|
||||||
|
db, tenant_id, automation_id=None, status=status, limit=limit, offset=0
|
||||||
|
)
|
||||||
|
return AutomationRunListResponse(
|
||||||
|
items=[_run_to_response(r) for r in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── MiniApps ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/miniapps",
|
||||||
|
dependencies=[Depends(require_permission("automation:read"))],
|
||||||
|
response_model=list[MiniAppResponse],
|
||||||
|
)
|
||||||
|
async def list_miniapps(
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List custom MiniApps from plugin config."""
|
||||||
|
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||||
|
registry = MiniAppRegistry()
|
||||||
|
return registry.list_apps()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/miniapps",
|
||||||
|
dependencies=[Depends(require_permission("automation:write"))],
|
||||||
|
response_model=MiniAppResponse,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_miniapp(
|
||||||
|
data: MiniAppCreate,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a custom MiniApp definition."""
|
||||||
|
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||||
|
registry = MiniAppRegistry()
|
||||||
|
registry.register(
|
||||||
|
app_id=data.app_id,
|
||||||
|
name=data.name,
|
||||||
|
icon=data.icon,
|
||||||
|
description=data.description,
|
||||||
|
plugin_name="automation",
|
||||||
|
render_schema=data.render_schema,
|
||||||
|
)
|
||||||
|
return MiniAppResponse(
|
||||||
|
app_id=data.app_id,
|
||||||
|
name=data.name,
|
||||||
|
icon=data.icon,
|
||||||
|
description=data.description,
|
||||||
|
plugin_name="automation",
|
||||||
|
render_schema=data.render_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/miniapps/{app_id}",
|
||||||
|
dependencies=[Depends(require_permission("automation:delete"))],
|
||||||
|
)
|
||||||
|
async def delete_miniapp(
|
||||||
|
app_id: str,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a custom MiniApp definition."""
|
||||||
|
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||||
|
registry = MiniAppRegistry()
|
||||||
|
registry.unregister(app_id)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Settings ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/settings",
|
||||||
|
dependencies=[Depends(require_permission("automation:configure"))],
|
||||||
|
response_model=AutomationSettingsResponse,
|
||||||
|
)
|
||||||
|
async def get_automation_settings(
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get automation default settings."""
|
||||||
|
# Return default settings (could be stored in plugin config in the future)
|
||||||
|
return AutomationSettingsResponse(
|
||||||
|
default_llm_model="ollama/deepseek-v4-flash",
|
||||||
|
heartbeat_default_interval=300,
|
||||||
|
max_concurrent_agents=5,
|
||||||
|
log_level="INFO",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/settings",
|
||||||
|
dependencies=[Depends(require_permission("automation:configure"))],
|
||||||
|
response_model=AutomationSettingsResponse,
|
||||||
|
)
|
||||||
|
async def update_automation_settings(
|
||||||
|
data: AutomationSettingsUpdate,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update automation settings (stored in plugin config)."""
|
||||||
|
# TODO: Persist settings to plugin config table
|
||||||
|
# For now, return the updated values as a preview
|
||||||
|
settings = AutomationSettingsResponse(
|
||||||
|
default_llm_model=data.default_llm_model or "ollama/deepseek-v4-flash",
|
||||||
|
heartbeat_default_interval=data.heartbeat_default_interval or 300,
|
||||||
|
max_concurrent_agents=data.max_concurrent_agents or 5,
|
||||||
|
log_level=data.log_level or "INFO",
|
||||||
|
)
|
||||||
|
return settings
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Cron scheduler — reads CronJob rows and enqueues ARQ jobs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import croniter
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_next_run(cron_expression: str, from_time: datetime | None = None) -> datetime:
|
||||||
|
"""Calculate the next run time from a cron expression using croniter."""
|
||||||
|
base = from_time or datetime.now(UTC)
|
||||||
|
cron = croniter.croniter(cron_expression, base)
|
||||||
|
next_dt = cron.get_next(datetime)
|
||||||
|
return next_dt
|
||||||
|
|
||||||
|
|
||||||
|
async def scheduler_tick(ctx: dict[str, Any]) -> None:
|
||||||
|
"""Run every 60s via ARQ cron. Reads active CronJob rows where next_run_at <= now,
|
||||||
|
enqueues appropriate ARQ job, updates last_run_at and calculates next_run_at."""
|
||||||
|
from app.plugins.builtins.automation.models import AutomationCronJob as CronJob
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
factory = get_session_factory()
|
||||||
|
|
||||||
|
async with factory() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(CronJob).where(
|
||||||
|
CronJob.is_active.is_(True),
|
||||||
|
CronJob.next_run_at <= now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
jobs = list(result.scalars().all())
|
||||||
|
|
||||||
|
if not jobs:
|
||||||
|
logger.debug("scheduler_tick: no cron jobs due")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("scheduler_tick: %d cron job(s) due", len(jobs))
|
||||||
|
|
||||||
|
for job in jobs:
|
||||||
|
try:
|
||||||
|
if job.job_type == "agent":
|
||||||
|
from app.core.jobs import enqueue_job
|
||||||
|
await enqueue_job("run_agent", job.target_id, trigger_type="scheduled")
|
||||||
|
elif job.job_type == "automation":
|
||||||
|
from app.core.jobs import enqueue_job
|
||||||
|
await enqueue_job("run_automation", job.target_id, trigger_type="scheduled")
|
||||||
|
else:
|
||||||
|
logger.warning("Unknown cron job_type: %s", job.job_type)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update last_run_at and calculate next_run_at
|
||||||
|
async with factory() as db:
|
||||||
|
result = await db.execute(select(CronJob).where(CronJob.id == job.id))
|
||||||
|
db_job = result.scalar_one_or_none()
|
||||||
|
if db_job is None:
|
||||||
|
continue
|
||||||
|
db_job.last_run_at = now
|
||||||
|
db_job.next_run_at = calculate_next_run(db_job.cron_expression, now)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info("Enqueued %s %s (id=%s)", job.job_type, job.target_id, job.id)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to process cron job %s", job.id)
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""Pydantic v2 schemas for the Automation & Agents plugin API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Agent Definition Schemas ───
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDefinitionCreate(BaseModel):
|
||||||
|
"""Create a new agent definition."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
llm_model: str = Field(default="ollama/deepseek-v4-flash", max_length=100)
|
||||||
|
system_prompt: str = Field(default="")
|
||||||
|
tool_ids: list[str] = Field(default_factory=list)
|
||||||
|
heartbeat_interval_seconds: int = Field(default=0, ge=0)
|
||||||
|
mode: str = Field(default="reactive", pattern="^(proactive|reactive)$")
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
max_executions_per_hour: int = Field(default=10, ge=1, le=1000)
|
||||||
|
max_duration_seconds: int = Field(default=300, ge=1, le=86400)
|
||||||
|
budget_limit_usd: float = Field(default=1.0, ge=0.0, le=10000.0)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDefinitionUpdate(BaseModel):
|
||||||
|
"""Update an existing agent definition (partial)."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, max_length=120)
|
||||||
|
description: str | None = Field(None, max_length=500)
|
||||||
|
llm_model: str | None = Field(None, max_length=100)
|
||||||
|
system_prompt: str | None = None
|
||||||
|
tool_ids: list[str] | None = None
|
||||||
|
heartbeat_interval_seconds: int | None = Field(None, ge=0)
|
||||||
|
mode: str | None = Field(None, pattern="^(proactive|reactive)$")
|
||||||
|
is_active: bool | None = None
|
||||||
|
max_executions_per_hour: int | None = Field(None, ge=1, le=1000)
|
||||||
|
max_duration_seconds: int | None = Field(None, ge=1, le=86400)
|
||||||
|
budget_limit_usd: float | None = Field(None, ge=0.0, le=10000.0)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDefinitionResponse(BaseModel):
|
||||||
|
"""Agent definition response."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
llm_model: str
|
||||||
|
system_prompt: str = ""
|
||||||
|
tool_ids: list[str] = []
|
||||||
|
heartbeat_interval_seconds: int = 0
|
||||||
|
mode: str
|
||||||
|
is_active: bool = True
|
||||||
|
max_executions_per_hour: int = 10
|
||||||
|
max_duration_seconds: int = 300
|
||||||
|
budget_limit_usd: float = 1.0
|
||||||
|
created_by: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Automation Definition Schemas ───
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationDefinitionCreate(BaseModel):
|
||||||
|
"""Create a new automation definition."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
trigger_type: str = Field(default="manual", pattern="^(event|schedule|manual)$")
|
||||||
|
trigger_config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
conditions: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
actions: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
dry_run: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationDefinitionUpdate(BaseModel):
|
||||||
|
"""Update an existing automation definition (partial)."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, max_length=120)
|
||||||
|
description: str | None = Field(None, max_length=500)
|
||||||
|
trigger_type: str | None = Field(None, pattern="^(event|schedule|manual)$")
|
||||||
|
trigger_config: dict[str, Any] | None = None
|
||||||
|
conditions: list[dict[str, Any]] | None = None
|
||||||
|
actions: list[dict[str, Any]] | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
dry_run: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationDefinitionResponse(BaseModel):
|
||||||
|
"""Automation definition response."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
trigger_type: str
|
||||||
|
trigger_config: dict[str, Any] = {}
|
||||||
|
conditions: list[dict[str, Any]] = []
|
||||||
|
actions: list[dict[str, Any]] = []
|
||||||
|
is_active: bool = True
|
||||||
|
dry_run: bool = False
|
||||||
|
created_by: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Cron Job Schemas ───
|
||||||
|
|
||||||
|
|
||||||
|
class CronJobResponse(BaseModel):
|
||||||
|
"""Cron job response."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
cron_expression: str
|
||||||
|
job_type: str
|
||||||
|
target_id: str | None = None
|
||||||
|
plugin_name: str = ""
|
||||||
|
is_active: bool = True
|
||||||
|
last_run_at: str | None = None
|
||||||
|
next_run_at: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Run Log Schemas ───
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunResponse(BaseModel):
|
||||||
|
"""Agent run log response."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
agent_id: str
|
||||||
|
status: str
|
||||||
|
started_at: str
|
||||||
|
completed_at: str | None = None
|
||||||
|
duration_seconds: float | None = None
|
||||||
|
result: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
cost_usd: float | None = None
|
||||||
|
trigger_type: str = "manual"
|
||||||
|
trigger_data: dict[str, Any] = {}
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationRunResponse(BaseModel):
|
||||||
|
"""Automation run log response."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
automation_id: str
|
||||||
|
status: str
|
||||||
|
started_at: str
|
||||||
|
completed_at: str | None = None
|
||||||
|
duration_seconds: float | None = None
|
||||||
|
result: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
trigger_type: str = "manual"
|
||||||
|
trigger_data: dict[str, Any] = {}
|
||||||
|
dry_run: bool = False
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Version Schemas ───
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVersionResponse(BaseModel):
|
||||||
|
"""Agent version response."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
agent_id: str
|
||||||
|
version_number: int
|
||||||
|
snapshot: dict[str, Any]
|
||||||
|
changed_by: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationVersionResponse(BaseModel):
|
||||||
|
"""Automation version response."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
automation_id: str
|
||||||
|
version_number: int
|
||||||
|
snapshot: dict[str, Any]
|
||||||
|
changed_by: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Settings Schemas ───
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMessageRequest(BaseModel):
|
||||||
|
"""Request to send a message from one agent to another."""
|
||||||
|
|
||||||
|
to_agent_name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
message: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppCreate(BaseModel):
|
||||||
|
"""Create a custom MiniApp definition."""
|
||||||
|
|
||||||
|
app_id: str = Field(..., min_length=1, max_length=80)
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
icon: str = Field(default="AppWindow")
|
||||||
|
description: str = Field(default="")
|
||||||
|
render_schema: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppResponse(BaseModel):
|
||||||
|
"""MiniApp definition response."""
|
||||||
|
|
||||||
|
app_id: str
|
||||||
|
name: str
|
||||||
|
icon: str = "AppWindow"
|
||||||
|
description: str = ""
|
||||||
|
plugin_name: str = ""
|
||||||
|
render_schema: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationSettingsResponse(BaseModel):
|
||||||
|
"""Automation settings response."""
|
||||||
|
|
||||||
|
default_llm_model: str = "ollama/deepseek-v4-flash"
|
||||||
|
heartbeat_default_interval: int = 300
|
||||||
|
max_concurrent_agents: int = 5
|
||||||
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationSettingsUpdate(BaseModel):
|
||||||
|
"""Automation settings update (partial)."""
|
||||||
|
|
||||||
|
default_llm_model: str | None = None
|
||||||
|
heartbeat_default_interval: int | None = None
|
||||||
|
max_concurrent_agents: int | None = None
|
||||||
|
log_level: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── List Response Schemas ───
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDefinitionListResponse(BaseModel):
|
||||||
|
"""Paginated agent definition list."""
|
||||||
|
|
||||||
|
items: list[AgentDefinitionResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationDefinitionListResponse(BaseModel):
|
||||||
|
"""Paginated automation definition list."""
|
||||||
|
|
||||||
|
items: list[AutomationDefinitionResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunListResponse(BaseModel):
|
||||||
|
"""Paginated agent run list."""
|
||||||
|
|
||||||
|
items: list[AgentRunResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationRunListResponse(BaseModel):
|
||||||
|
"""Paginated automation run list."""
|
||||||
|
|
||||||
|
items: list[AutomationRunResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class CronJobListResponse(BaseModel):
|
||||||
|
"""Paginated cron job list."""
|
||||||
|
|
||||||
|
items: list[CronJobResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVersionListResponse(BaseModel):
|
||||||
|
"""Paginated agent version list."""
|
||||||
|
|
||||||
|
items: list[AgentVersionResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationVersionListResponse(BaseModel):
|
||||||
|
"""Paginated automation version list."""
|
||||||
|
|
||||||
|
items: list[AutomationVersionResponse]
|
||||||
|
total: int
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Automation & Agents plugin."""
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
"""Tests for the Automation & Agents plugin.
|
||||||
|
|
||||||
|
Uses pytest with async fixtures. Tests use SQLite in-memory database
|
||||||
|
since PostgreSQL may not be available in the dev container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.plugins.builtins.automation.models import (
|
||||||
|
AgentDefinition,
|
||||||
|
AgentRun,
|
||||||
|
AgentVersion,
|
||||||
|
AutomationCronJob,
|
||||||
|
AutomationDefinition,
|
||||||
|
AutomationRun,
|
||||||
|
AutomationVersion,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.automation.services import (
|
||||||
|
AgentService,
|
||||||
|
AutomationService,
|
||||||
|
CronJobService,
|
||||||
|
RunLogService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Fixtures ───
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Create an in-memory SQLite database for testing."""
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tenant_id() -> uuid.UUID:
|
||||||
|
return uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_id() -> uuid.UUID:
|
||||||
|
return uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AgentService Tests ───
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentService:
|
||||||
|
"""Tests for AgentService CRUD operations."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_agent(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test creating an agent definition."""
|
||||||
|
data = {
|
||||||
|
"name": "test-agent",
|
||||||
|
"description": "Test agent description",
|
||||||
|
"llm_model": "gpt-4",
|
||||||
|
"system_prompt": "You are a test agent.",
|
||||||
|
"tool_ids": [],
|
||||||
|
"heartbeat_interval_seconds": 0,
|
||||||
|
"mode": "reactive",
|
||||||
|
"is_active": True,
|
||||||
|
"max_executions_per_hour": 10,
|
||||||
|
"max_duration_seconds": 300,
|
||||||
|
"budget_limit_usd": 1.0,
|
||||||
|
}
|
||||||
|
agent = await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
assert agent is not None
|
||||||
|
assert agent.name == "test-agent"
|
||||||
|
assert agent.tenant_id == tenant_id
|
||||||
|
assert agent.created_by == user_id
|
||||||
|
assert agent.mode == "reactive"
|
||||||
|
assert agent.is_active is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_agent_by_id(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test retrieving an agent by ID."""
|
||||||
|
data = {"name": "get-test", "description": "", "llm_model": "gpt-4", "system_prompt": "",
|
||||||
|
"tool_ids": [], "heartbeat_interval_seconds": 0, "mode": "reactive",
|
||||||
|
"is_active": True, "max_executions_per_hour": 10, "max_duration_seconds": 300,
|
||||||
|
"budget_limit_usd": 1.0}
|
||||||
|
created = await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
fetched = await AgentService.get_by_id(db, tenant_id, created.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.id == created.id
|
||||||
|
assert fetched.name == "get-test"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_agent_by_name(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test retrieving an agent by name."""
|
||||||
|
data = {"name": "name-test", "description": "", "llm_model": "gpt-4", "system_prompt": "",
|
||||||
|
"tool_ids": [], "heartbeat_interval_seconds": 0, "mode": "reactive",
|
||||||
|
"is_active": True, "max_executions_per_hour": 10, "max_duration_seconds": 300,
|
||||||
|
"budget_limit_usd": 1.0}
|
||||||
|
await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
fetched = await AgentService.get_by_name(db, "name-test", tenant_id=tenant_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.name == "name-test"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_agent(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test updating an agent definition."""
|
||||||
|
data = {"name": "update-test", "description": "Original", "llm_model": "gpt-4",
|
||||||
|
"system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0,
|
||||||
|
"mode": "reactive", "is_active": True, "max_executions_per_hour": 10,
|
||||||
|
"max_duration_seconds": 300, "budget_limit_usd": 1.0}
|
||||||
|
created = await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
updated = await AgentService.update(db, tenant_id, created.id,
|
||||||
|
{"description": "Updated description"}, user_id=user_id)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.description == "Updated description"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_agent(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test deleting an agent definition."""
|
||||||
|
data = {"name": "delete-test", "description": "", "llm_model": "gpt-4", "system_prompt": "",
|
||||||
|
"tool_ids": [], "heartbeat_interval_seconds": 0, "mode": "reactive",
|
||||||
|
"is_active": True, "max_executions_per_hour": 10, "max_duration_seconds": 300,
|
||||||
|
"budget_limit_usd": 1.0}
|
||||||
|
created = await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
result = await AgentService.delete(db, tenant_id, created.id)
|
||||||
|
assert result is True
|
||||||
|
fetched = await AgentService.get_by_id(db, tenant_id, created.id)
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_agents(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test listing agents with filters."""
|
||||||
|
for i in range(3):
|
||||||
|
data = {"name": f"list-test-{i}", "description": "", "llm_model": "gpt-4",
|
||||||
|
"system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0,
|
||||||
|
"mode": "reactive", "is_active": True, "max_executions_per_hour": 10,
|
||||||
|
"max_duration_seconds": 300, "budget_limit_usd": 1.0}
|
||||||
|
await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
items, total = await AgentService.list(db, tenant_id)
|
||||||
|
assert total == 3
|
||||||
|
assert len(items) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AutomationService Tests ───
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutomationService:
|
||||||
|
"""Tests for AutomationService CRUD operations."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_automation(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test creating an automation definition."""
|
||||||
|
data = {
|
||||||
|
"name": "test-automation",
|
||||||
|
"description": "Test automation",
|
||||||
|
"trigger_type": "manual",
|
||||||
|
"trigger_config": {},
|
||||||
|
"conditions": [],
|
||||||
|
"actions": [{"type": "api_call", "config": {"url": "https://example.com"}}],
|
||||||
|
"is_active": True,
|
||||||
|
"dry_run": False,
|
||||||
|
}
|
||||||
|
automation = await AutomationService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
assert automation is not None
|
||||||
|
assert automation.name == "test-automation"
|
||||||
|
assert automation.trigger_type == "manual"
|
||||||
|
assert len(automation.actions) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_automation_by_id(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test retrieving an automation by ID."""
|
||||||
|
data = {"name": "get-auto", "description": "", "trigger_type": "event",
|
||||||
|
"trigger_config": {"event_name": "contact.created"}, "conditions": [],
|
||||||
|
"actions": [], "is_active": True, "dry_run": False}
|
||||||
|
created = await AutomationService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
fetched = await AutomationService.get_by_id(db, tenant_id, created.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.id == created.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_automation(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test updating an automation definition."""
|
||||||
|
data = {"name": "upd-auto", "description": "Original", "trigger_type": "manual",
|
||||||
|
"trigger_config": {}, "conditions": [], "actions": [], "is_active": True, "dry_run": False}
|
||||||
|
created = await AutomationService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
updated = await AutomationService.update(db, tenant_id, created.id,
|
||||||
|
{"description": "Updated"}, user_id=user_id)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.description == "Updated"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_automation(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test deleting an automation definition."""
|
||||||
|
data = {"name": "del-auto", "description": "", "trigger_type": "manual",
|
||||||
|
"trigger_config": {}, "conditions": [], "actions": [], "is_active": True, "dry_run": False}
|
||||||
|
created = await AutomationService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
result = await AutomationService.delete(db, tenant_id, created.id)
|
||||||
|
assert result is True
|
||||||
|
fetched = await AutomationService.get_by_id(db, tenant_id, created.id)
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CronJobService Tests ───
|
||||||
|
|
||||||
|
|
||||||
|
class TestCronJobService:
|
||||||
|
"""Tests for CronJobService CRUD operations."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_cron_job(self, db: AsyncSession, tenant_id: uuid.UUID):
|
||||||
|
"""Test creating a cron job."""
|
||||||
|
data = {
|
||||||
|
"name": "test-cron",
|
||||||
|
"cron_expression": "*/5 * * * *",
|
||||||
|
"job_type": "automation_trigger",
|
||||||
|
"plugin_name": "test",
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
job = await CronJobService.create(db, tenant_id, data)
|
||||||
|
assert job is not None
|
||||||
|
assert job.name == "test-cron"
|
||||||
|
assert job.cron_expression == "*/5 * * * *"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cron_job_by_id(self, db: AsyncSession, tenant_id: uuid.UUID):
|
||||||
|
"""Test retrieving a cron job by ID."""
|
||||||
|
data = {"name": "get-cron", "cron_expression": "0 9 * * *", "job_type": "automation_trigger",
|
||||||
|
"plugin_name": "test", "is_active": True}
|
||||||
|
created = await CronJobService.create(db, tenant_id, data)
|
||||||
|
fetched = await CronJobService.get_by_id(db, tenant_id, created.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.id == created.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_cron_job(self, db: AsyncSession, tenant_id: uuid.UUID):
|
||||||
|
"""Test updating a cron job."""
|
||||||
|
data = {"name": "upd-cron", "cron_expression": "0 9 * * *", "job_type": "automation_trigger",
|
||||||
|
"plugin_name": "test", "is_active": True}
|
||||||
|
created = await CronJobService.create(db, tenant_id, data)
|
||||||
|
updated = await CronJobService.update(db, tenant_id, created.id,
|
||||||
|
{"cron_expression": "0 10 * * *"})
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.cron_expression == "0 10 * * *"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_cron_job(self, db: AsyncSession, tenant_id: uuid.UUID):
|
||||||
|
"""Test deleting a cron job."""
|
||||||
|
data = {"name": "del-cron", "cron_expression": "0 9 * * *", "job_type": "automation_trigger",
|
||||||
|
"plugin_name": "test", "is_active": True}
|
||||||
|
created = await CronJobService.create(db, tenant_id, data)
|
||||||
|
result = await CronJobService.delete(db, tenant_id, created.id)
|
||||||
|
assert result is True
|
||||||
|
fetched = await CronJobService.get_by_id(db, tenant_id, created.id)
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Version Management Tests ───
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionManagement:
|
||||||
|
"""Tests for version creation and restore."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_created_on_agent_create(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test that a version snapshot is created when an agent is created."""
|
||||||
|
data = {"name": "ver-agent", "description": "Version test", "llm_model": "gpt-4",
|
||||||
|
"system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0,
|
||||||
|
"mode": "reactive", "is_active": True, "max_executions_per_hour": 10,
|
||||||
|
"max_duration_seconds": 300, "budget_limit_usd": 1.0}
|
||||||
|
agent = await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
versions, total = await AgentService.get_versions(db, tenant_id, agent.id)
|
||||||
|
assert total == 1
|
||||||
|
assert versions[0].version_number == 1
|
||||||
|
assert versions[0].snapshot["name"] == "ver-agent"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_created_on_agent_update(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test that a new version is created when an agent is updated."""
|
||||||
|
data = {"name": "ver-upd", "description": "Original", "llm_model": "gpt-4",
|
||||||
|
"system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0,
|
||||||
|
"mode": "reactive", "is_active": True, "max_executions_per_hour": 10,
|
||||||
|
"max_duration_seconds": 300, "budget_limit_usd": 1.0}
|
||||||
|
agent = await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
await AgentService.update(db, tenant_id, agent.id, {"description": "Updated"}, user_id=user_id)
|
||||||
|
versions, total = await AgentService.get_versions(db, tenant_id, agent.id)
|
||||||
|
assert total == 2
|
||||||
|
assert versions[0].version_number == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_restore_agent(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test restoring an agent from a version snapshot."""
|
||||||
|
data = {"name": "ver-restore", "description": "Original", "llm_model": "gpt-4",
|
||||||
|
"system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0,
|
||||||
|
"mode": "reactive", "is_active": True, "max_executions_per_hour": 10,
|
||||||
|
"max_duration_seconds": 300, "budget_limit_usd": 1.0}
|
||||||
|
agent = await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
await AgentService.update(db, tenant_id, agent.id, {"description": "Changed"}, user_id=user_id)
|
||||||
|
versions, _ = await AgentService.get_versions(db, tenant_id, agent.id)
|
||||||
|
# Restore to version 1
|
||||||
|
restored = await AgentService.restore_version(db, tenant_id, agent.id, versions[1].id, user_id=user_id)
|
||||||
|
assert restored is not None
|
||||||
|
assert restored.description == "Original"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_created_on_automation_update(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test that a new version is created when an automation is updated."""
|
||||||
|
data = {"name": "auto-ver", "description": "Original", "trigger_type": "manual",
|
||||||
|
"trigger_config": {}, "conditions": [], "actions": [], "is_active": True, "dry_run": False}
|
||||||
|
auto = await AutomationService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
await AutomationService.update(db, tenant_id, auto.id, {"description": "Updated"}, user_id=user_id)
|
||||||
|
versions, total = await AutomationService.get_versions(db, tenant_id, auto.id)
|
||||||
|
assert total == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Condition Evaluation Tests ───
|
||||||
|
|
||||||
|
|
||||||
|
class TestConditionEvaluation:
|
||||||
|
"""Tests for condition evaluation logic."""
|
||||||
|
|
||||||
|
def _evaluate_condition(self, condition: dict, context: dict) -> bool:
|
||||||
|
"""Evaluate a single condition against context data."""
|
||||||
|
field = condition.get("field", "")
|
||||||
|
operator = condition.get("operator", "equals")
|
||||||
|
value = condition.get("value", "")
|
||||||
|
actual = context.get(field)
|
||||||
|
|
||||||
|
if operator == "eq" or operator == "equals":
|
||||||
|
return str(actual) == str(value)
|
||||||
|
elif operator == "ne" or operator == "not_equals":
|
||||||
|
return str(actual) != str(value)
|
||||||
|
elif operator == "gt" or operator == "greater_than":
|
||||||
|
try:
|
||||||
|
return float(actual) > float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
elif operator == "lt" or operator == "less_than":
|
||||||
|
try:
|
||||||
|
return float(actual) < float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
elif operator == "contains":
|
||||||
|
return str(value) in str(actual)
|
||||||
|
elif operator == "exists":
|
||||||
|
return actual is not None
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_condition_eq(self):
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "status", "operator": "eq", "value": "active"},
|
||||||
|
{"status": "active"}
|
||||||
|
) is True
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "status", "operator": "eq", "value": "inactive"},
|
||||||
|
{"status": "active"}
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_condition_ne(self):
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "status", "operator": "ne", "value": "inactive"},
|
||||||
|
{"status": "active"}
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_condition_gt(self):
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "age", "operator": "gt", "value": "18"},
|
||||||
|
{"age": 25}
|
||||||
|
) is True
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "age", "operator": "gt", "value": "30"},
|
||||||
|
{"age": 25}
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_condition_lt(self):
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "age", "operator": "lt", "value": "30"},
|
||||||
|
{"age": 25}
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_condition_contains(self):
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "email", "operator": "contains", "value": "@example.com"},
|
||||||
|
{"email": "test@example.com"}
|
||||||
|
) is True
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "email", "operator": "contains", "value": "@other.com"},
|
||||||
|
{"email": "test@example.com"}
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_condition_exists(self):
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "email", "operator": "exists", "value": ""},
|
||||||
|
{"email": "test@example.com"}
|
||||||
|
) is True
|
||||||
|
assert self._evaluate_condition(
|
||||||
|
{"field": "missing", "operator": "exists", "value": ""},
|
||||||
|
{"email": "test@example.com"}
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Dry-Run Mode Tests ───
|
||||||
|
|
||||||
|
|
||||||
|
class TestDryRunMode:
|
||||||
|
"""Tests for dry-run mode (no actions executed)."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dry_run_automation_creation(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test creating an automation with dry_run=True."""
|
||||||
|
data = {"name": "dry-run-test", "description": "", "trigger_type": "manual",
|
||||||
|
"trigger_config": {}, "conditions": [], "actions": [{"type": "api_call", "config": {"url": "https://example.com"}}],
|
||||||
|
"is_active": True, "dry_run": True}
|
||||||
|
automation = await AutomationService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
assert automation.dry_run is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dry_run_flag_in_run(self, db: AsyncSession, tenant_id: uuid.UUID):
|
||||||
|
"""Test that dry_run flag is stored in AutomationRun."""
|
||||||
|
run = AutomationRun(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
automation_id=uuid.uuid4(),
|
||||||
|
status="dry_run",
|
||||||
|
started_at=datetime.now(UTC),
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
assert run.dry_run is True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Rate Limiting Tests ───
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiting:
|
||||||
|
"""Tests for rate limiting (max executions per hour)."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_check(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test that rate limit is enforced."""
|
||||||
|
data = {"name": "rate-limit", "description": "", "llm_model": "gpt-4",
|
||||||
|
"system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0,
|
||||||
|
"mode": "reactive", "is_active": True, "max_executions_per_hour": 2,
|
||||||
|
"max_duration_seconds": 300, "budget_limit_usd": 1.0}
|
||||||
|
agent = await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
|
||||||
|
# Create 2 runs (within limit)
|
||||||
|
for _ in range(2):
|
||||||
|
run = AgentRun(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=agent.id,
|
||||||
|
status="completed",
|
||||||
|
started_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(AgentRun)
|
||||||
|
.where(AgentRun.agent_id == agent.id, AgentRun.started_at >= one_hour_ago)
|
||||||
|
)
|
||||||
|
recent_runs = result.scalar() or 0
|
||||||
|
assert recent_runs == 2
|
||||||
|
assert recent_runs < agent.max_executions_per_hour # 2 < 2 is False, so limit would be hit
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Budget Limit Tests ───
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetLimit:
|
||||||
|
"""Tests for budget limit enforcement."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_budget_limit_check(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
"""Test that budget limit is enforced."""
|
||||||
|
data = {"name": "budget-limit", "description": "", "llm_model": "gpt-4",
|
||||||
|
"system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0,
|
||||||
|
"mode": "reactive", "is_active": True, "max_executions_per_hour": 10,
|
||||||
|
"max_duration_seconds": 300, "budget_limit_usd": 0.5}
|
||||||
|
agent = await AgentService.create(db, tenant_id, data, user_id=user_id)
|
||||||
|
|
||||||
|
# Create runs with cumulative cost
|
||||||
|
for cost in [0.2, 0.2, 0.2]:
|
||||||
|
run = AgentRun(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=agent.id,
|
||||||
|
status="completed",
|
||||||
|
started_at=datetime.now(UTC),
|
||||||
|
cost_usd=cost,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Check budget
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
cost_result = await db.execute(
|
||||||
|
select(func.coalesce(func.sum(AgentRun.cost_usd), 0.0))
|
||||||
|
.where(AgentRun.agent_id == agent.id)
|
||||||
|
)
|
||||||
|
total_cost = float(cost_result.scalar() or 0.0)
|
||||||
|
assert total_cost == 0.6
|
||||||
|
assert total_cost >= agent.budget_limit_usd # 0.6 >= 0.5, budget exceeded
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Infinite Loop Detection Tests ───
|
||||||
|
|
||||||
|
|
||||||
|
class TestInfiniteLoopDetection:
|
||||||
|
"""Tests for infinite loop detection."""
|
||||||
|
|
||||||
|
def test_consecutive_tool_call_detection(self):
|
||||||
|
"""Test that 5+ consecutive same tool calls are detected."""
|
||||||
|
tool_call_count: dict[str, int] = {}
|
||||||
|
tool_name = "send_email"
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
tool_call_count[tool_name] = tool_call_count.get(tool_name, 0) + 1
|
||||||
|
if tool_call_count[tool_name] >= 5:
|
||||||
|
assert True
|
||||||
|
return
|
||||||
|
assert False, "Loop detection should have triggered"
|
||||||
|
|
||||||
|
def test_different_tools_not_detected(self):
|
||||||
|
"""Test that different tool calls don't trigger loop detection."""
|
||||||
|
tool_call_count: dict[str, int] = {}
|
||||||
|
for i in range(5):
|
||||||
|
tool_call_count[f"tool_{i}"] = tool_call_count.get(f"tool_{i}", 0) + 1
|
||||||
|
if tool_call_count[f"tool_{i}"] >= 5:
|
||||||
|
assert False, "Different tools should not trigger loop detection"
|
||||||
|
assert True
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Workflow timeout checker — cancels timed-out workflow instances."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.workflow import Workflow, WorkflowInstance
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_workflow_timeouts(ctx: dict[str, Any]) -> None:
|
||||||
|
"""Run every 5 min. Queries WorkflowInstance where timeout_at <= now
|
||||||
|
AND status in (pending, in_progress). Sets status='cancelled',
|
||||||
|
completed_at=now. Sends notification to initiator."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
factory = get_session_factory()
|
||||||
|
|
||||||
|
async with factory() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(WorkflowInstance).where(
|
||||||
|
WorkflowInstance.timeout_at <= now,
|
||||||
|
WorkflowInstance.status.in_(["pending", "in_progress"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instances = list(result.scalars().all())
|
||||||
|
|
||||||
|
if not instances:
|
||||||
|
logger.debug("check_workflow_timeouts: no timed-out instances")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("check_workflow_timeouts: %d instance(s) timed out", len(instances))
|
||||||
|
|
||||||
|
for inst in instances:
|
||||||
|
try:
|
||||||
|
async with factory() as db:
|
||||||
|
# Re-fetch to ensure fresh state
|
||||||
|
result = await db.execute(
|
||||||
|
select(WorkflowInstance).where(WorkflowInstance.id == inst.id)
|
||||||
|
)
|
||||||
|
instance = result.scalar_one_or_none()
|
||||||
|
if instance is None:
|
||||||
|
continue
|
||||||
|
if instance.status not in ("pending", "in_progress"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
instance.status = "cancelled"
|
||||||
|
instance.completed_at = now
|
||||||
|
|
||||||
|
# Get workflow name for notification
|
||||||
|
wf_result = await db.execute(
|
||||||
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
||||||
|
)
|
||||||
|
workflow = wf_result.scalar_one_or_none()
|
||||||
|
workflow_name = workflow.name if workflow else "Unknown"
|
||||||
|
|
||||||
|
# Send notification to initiator
|
||||||
|
if instance.initiated_by:
|
||||||
|
notification = Notification(
|
||||||
|
tenant_id=instance.tenant_id,
|
||||||
|
user_id=instance.initiated_by,
|
||||||
|
type="workflow_timeout",
|
||||||
|
title=f"Workflow '{workflow_name}' cancelled due to timeout",
|
||||||
|
body=f"The workflow instance timed out and was automatically cancelled.",
|
||||||
|
)
|
||||||
|
db.add(notification)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Cancelled timed-out workflow instance %s (workflow=%s)",
|
||||||
|
inst.id, workflow_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to process timeout for instance %s", inst.id)
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +80,50 @@ class FrontendSettingsPage(BaseModel):
|
|||||||
permission: str = Field(default="", description="Optional permission required")
|
permission: str = Field(default="", description="Optional permission required")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDefinitionContribution(BaseModel):
|
||||||
|
"""An agent definition contributed by a plugin manifest."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
llm_model: str = Field(default="ollama/deepseek-v4-flash", max_length=100)
|
||||||
|
system_prompt: str = Field(default="")
|
||||||
|
tool_ids: list[str] = Field(default_factory=list)
|
||||||
|
heartbeat_interval_seconds: int = Field(default=0, ge=0)
|
||||||
|
mode: str = Field(default="reactive", pattern="^(proactive|reactive)$")
|
||||||
|
max_executions_per_hour: int = Field(default=10, ge=1, le=1000)
|
||||||
|
max_duration_seconds: int = Field(default=300, ge=1, le=86400)
|
||||||
|
budget_limit_usd: float = Field(default=1.0, ge=0.0, le=10000.0)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationTemplateContribution(BaseModel):
|
||||||
|
"""An automation template contributed by a plugin manifest."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
trigger_type: str = Field(default="event", pattern="^(event|schedule|manual)$")
|
||||||
|
trigger_config: dict = Field(default_factory=dict)
|
||||||
|
conditions: list[dict] = Field(default_factory=list)
|
||||||
|
actions: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CronJobContribution(BaseModel):
|
||||||
|
"""A cron job contributed by a plugin manifest."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
cron_expression: str = Field(..., min_length=1, max_length=100)
|
||||||
|
job_type: str = Field(..., pattern="^(agent_heartbeat|automation_trigger|custom)$")
|
||||||
|
target_name: str = Field(default="", description="Reference agent/automation by name")
|
||||||
|
plugin_name: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatConfigContribution(BaseModel):
|
||||||
|
"""A heartbeat config contributed by a plugin manifest."""
|
||||||
|
|
||||||
|
agent_name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
interval_seconds: int = Field(..., ge=1)
|
||||||
|
target_room: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
class FrontendDashboardWidget(BaseModel):
|
class FrontendDashboardWidget(BaseModel):
|
||||||
"""A dashboard widget contributed by a plugin."""
|
"""A dashboard widget contributed by a plugin."""
|
||||||
|
|
||||||
@@ -92,6 +138,16 @@ class FrontendDashboardWidget(BaseModel):
|
|||||||
permission: str = Field(default="", description="Optional permission required")
|
permission: str = Field(default="", description="Optional permission required")
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppContribution(BaseModel):
|
||||||
|
"""A MiniApp contributed by a plugin manifest."""
|
||||||
|
|
||||||
|
app_id: str = Field(..., min_length=1, max_length=80)
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
icon: str = Field(default="AppWindow")
|
||||||
|
description: str = Field(default="")
|
||||||
|
render_schema: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class PluginManifest(BaseModel):
|
class PluginManifest(BaseModel):
|
||||||
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
|
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
|
||||||
|
|
||||||
@@ -142,6 +198,23 @@ class PluginManifest(BaseModel):
|
|||||||
dashboard_widgets: list[FrontendDashboardWidget] = Field(
|
dashboard_widgets: list[FrontendDashboardWidget] = Field(
|
||||||
default_factory=list, description="Dashboard widgets contributed by this plugin"
|
default_factory=list, description="Dashboard widgets contributed by this plugin"
|
||||||
)
|
)
|
||||||
|
# ── Plugin Contribution fields (Phase 3.5C) ──
|
||||||
|
agent_definitions: list[AgentDefinitionContribution] = Field(
|
||||||
|
default_factory=list, description="Agent definitions contributed by this plugin"
|
||||||
|
)
|
||||||
|
automation_templates: list[AutomationTemplateContribution] = Field(
|
||||||
|
default_factory=list, description="Automation templates contributed by this plugin"
|
||||||
|
)
|
||||||
|
cron_jobs: list[CronJobContribution] = Field(
|
||||||
|
default_factory=list, description="Cron jobs contributed by this plugin"
|
||||||
|
)
|
||||||
|
heartbeat_configs: list[HeartbeatConfigContribution] = Field(
|
||||||
|
default_factory=list, description="Heartbeat configs contributed by this plugin"
|
||||||
|
)
|
||||||
|
miniapps: list[MiniAppContribution] = Field(
|
||||||
|
default_factory=list, description="MiniApps contributed by this plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -153,6 +226,9 @@ class PluginManifest(BaseModel):
|
|||||||
model_config = {"extra": "forbid"}
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
|
PluginManifest.model_rebuild()
|
||||||
|
|
||||||
|
|
||||||
class ManifestSchemaResponse(BaseModel):
|
class ManifestSchemaResponse(BaseModel):
|
||||||
"""Response model describing the manifest schema for API consumers."""
|
"""Response model describing the manifest schema for API consumers."""
|
||||||
|
|
||||||
@@ -258,3 +334,6 @@ MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PluginManifest.model_rebuild()
|
||||||
|
|||||||
@@ -350,6 +350,61 @@ class PluginRegistry:
|
|||||||
)
|
)
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
# ── Plugin Contribution Registration ──
|
||||||
|
|
||||||
|
async def _register_contributions_for_plugin(self, db: AsyncSession, name: str, plugin: BasePlugin) -> None:
|
||||||
|
"""Register agent_definitions, automation_templates, cron_jobs, and heartbeat_configs
|
||||||
|
from a plugin's manifest with the automation plugin."""
|
||||||
|
automation_plugin = self._plugins.get("automation")
|
||||||
|
if automation_plugin is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if automation plugin is active
|
||||||
|
automation_record = await self._get_plugin_record(db, "automation")
|
||||||
|
if automation_record is None or not automation_record.active:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if the activating plugin has any contributions
|
||||||
|
manifest = plugin.manifest
|
||||||
|
has_contributions = bool(
|
||||||
|
manifest.agent_definitions
|
||||||
|
or manifest.automation_templates
|
||||||
|
or manifest.cron_jobs
|
||||||
|
or manifest.heartbeat_configs
|
||||||
|
)
|
||||||
|
if not has_contributions:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await automation_plugin.register_plugin_contributions(db, name, manifest)
|
||||||
|
logger.info(
|
||||||
|
"Registered contributions from plugin '%s' with automation plugin",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to register contributions from plugin '%s' with automation plugin",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _unregister_contributions_for_plugin(self, db: AsyncSession, name: str) -> None:
|
||||||
|
"""Unregister contributions from a plugin being deactivated."""
|
||||||
|
automation_plugin = self._plugins.get("automation")
|
||||||
|
if automation_plugin is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await automation_plugin.unregister_plugin_contributions(db, name)
|
||||||
|
logger.info(
|
||||||
|
"Unregistered contributions from plugin '%s' with automation plugin",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to unregister contributions from plugin '%s' with automation plugin",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Version Comparison & Update Path ──
|
# ── Version Comparison & Update Path ──
|
||||||
|
|
||||||
async def _check_and_run_version_migrations(
|
async def _check_and_run_version_migrations(
|
||||||
@@ -470,6 +525,10 @@ class PluginRegistry:
|
|||||||
# Call on_activate hook (registers event listeners)
|
# Call on_activate hook (registers event listeners)
|
||||||
await plugin.on_activate(db, self._container, self._event_bus)
|
await plugin.on_activate(db, self._container, self._event_bus)
|
||||||
|
|
||||||
|
# Register plugin contributions (agent_definitions, automation_templates, etc.)
|
||||||
|
# with the automation plugin if it is active
|
||||||
|
await self._register_contributions_for_plugin(db, name, plugin)
|
||||||
|
|
||||||
# Register routes on FastAPI app if available
|
# Register routes on FastAPI app if available
|
||||||
# Track actual route objects by identity to avoid cross-plugin removal
|
# Track actual route objects by identity to avoid cross-plugin removal
|
||||||
if self._app is not None:
|
if self._app is not None:
|
||||||
@@ -533,6 +592,9 @@ class PluginRegistry:
|
|||||||
f"depend on it: {', '.join(active_dependents)}"
|
f"depend on it: {', '.join(active_dependents)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Unregister plugin contributions from automation plugin
|
||||||
|
await self._unregister_contributions_for_plugin(db, name)
|
||||||
|
|
||||||
# Call on_deactivate hook (unregisters event listeners)
|
# Call on_deactivate hook (unregisters event listeners)
|
||||||
await plugin.on_deactivate(db, self._container, self._event_bus)
|
await plugin.on_deactivate(db, self._container, self._event_bus)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the API client
|
||||||
|
const mockApiGet = vi.fn();
|
||||||
|
const mockApiPost = vi.fn();
|
||||||
|
const mockApiPut = vi.fn();
|
||||||
|
const mockApiDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
apiGet: (...args: any[]) => mockApiGet(...args),
|
||||||
|
apiPost: (...args: any[]) => mockApiPost(...args),
|
||||||
|
apiPut: (...args: any[]) => mockApiPut(...args),
|
||||||
|
apiDelete: (...args: any[]) => mockApiDelete(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock react-query
|
||||||
|
vi.mock('@tanstack/react-query', () => ({
|
||||||
|
useQueryClient: () => ({
|
||||||
|
invalidateQueries: vi.fn(),
|
||||||
|
}),
|
||||||
|
useQuery: vi.fn(),
|
||||||
|
useMutation: vi.fn(({ mutationFn, onSuccess }) => ({
|
||||||
|
mutateAsync: async (args: any) => {
|
||||||
|
const result = await mutationFn(args);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
isPending: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Automation API Hooks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAutomations', () => {
|
||||||
|
it('calls apiGet with correct endpoint', async () => {
|
||||||
|
const { useAutomations } = await import('../automation');
|
||||||
|
const { useQuery } = await import('@tanstack/react-query');
|
||||||
|
|
||||||
|
(useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => {
|
||||||
|
expect(queryKey).toEqual(['automations']);
|
||||||
|
queryFn();
|
||||||
|
return { data: [], isLoading: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
useAutomations();
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith('/automation');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAgents', () => {
|
||||||
|
it('calls apiGet with correct endpoint', async () => {
|
||||||
|
const { useAgents } = await import('../automation');
|
||||||
|
const { useQuery } = await import('@tanstack/react-query');
|
||||||
|
|
||||||
|
(useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => {
|
||||||
|
expect(queryKey).toEqual(['agents']);
|
||||||
|
queryFn();
|
||||||
|
return { data: [], isLoading: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
useAgents();
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith('/agents');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useCreateAutomation', () => {
|
||||||
|
it('calls apiPost with correct endpoint', async () => {
|
||||||
|
const { useCreateAutomation } = await import('../automation');
|
||||||
|
const hook = useCreateAutomation();
|
||||||
|
|
||||||
|
const data = { name: 'Test', trigger_type: 'manual' as const };
|
||||||
|
await hook.mutateAsync(data);
|
||||||
|
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith('/automation', data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useCreateAgent', () => {
|
||||||
|
it('calls apiPost with correct endpoint', async () => {
|
||||||
|
const { useCreateAgent } = await import('../automation');
|
||||||
|
const hook = useCreateAgent();
|
||||||
|
|
||||||
|
const data = { name: 'Test Agent', llm_model: 'gpt-4' };
|
||||||
|
await hook.mutateAsync(data);
|
||||||
|
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith('/agents', data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useExecuteAutomation', () => {
|
||||||
|
it('calls apiPost with correct endpoint', async () => {
|
||||||
|
const { useExecuteAutomation } = await import('../automation');
|
||||||
|
const hook = useExecuteAutomation();
|
||||||
|
|
||||||
|
await hook.mutateAsync('123');
|
||||||
|
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith('/automation/123/execute');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useExecuteAgent', () => {
|
||||||
|
it('calls apiPost with correct endpoint', async () => {
|
||||||
|
const { useExecuteAgent } = await import('../automation');
|
||||||
|
const hook = useExecuteAgent();
|
||||||
|
|
||||||
|
await hook.mutateAsync('456');
|
||||||
|
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith('/agents/456/execute');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDeleteAutomation', () => {
|
||||||
|
it('calls apiDelete with correct endpoint', async () => {
|
||||||
|
const { useDeleteAutomation } = await import('../automation');
|
||||||
|
const hook = useDeleteAutomation();
|
||||||
|
|
||||||
|
await hook.mutateAsync('789');
|
||||||
|
|
||||||
|
expect(mockApiDelete).toHaveBeenCalledWith('/automation/789');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDeleteAgent', () => {
|
||||||
|
it('calls apiDelete with correct endpoint', async () => {
|
||||||
|
const { useDeleteAgent } = await import('../automation');
|
||||||
|
const hook = useDeleteAgent();
|
||||||
|
|
||||||
|
await hook.mutateAsync('012');
|
||||||
|
|
||||||
|
expect(mockApiDelete).toHaveBeenCalledWith('/agents/012');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSendAgentMessage', () => {
|
||||||
|
it('calls apiPost with correct endpoint and body', async () => {
|
||||||
|
const { useSendAgentMessage } = await import('../automation');
|
||||||
|
const hook = useSendAgentMessage();
|
||||||
|
|
||||||
|
await hook.mutateAsync({
|
||||||
|
id: 'agent-1',
|
||||||
|
toAgentName: 'target-agent',
|
||||||
|
message: 'Hello there',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith('/agents/agent-1/send-message', {
|
||||||
|
to_agent_name: 'target-agent',
|
||||||
|
message: 'Hello there',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useMiniApps', () => {
|
||||||
|
it('calls apiGet with correct endpoint', async () => {
|
||||||
|
const { useMiniApps } = await import('../automation');
|
||||||
|
const { useQuery } = await import('@tanstack/react-query');
|
||||||
|
|
||||||
|
(useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => {
|
||||||
|
expect(queryKey).toEqual(['miniapps']);
|
||||||
|
queryFn();
|
||||||
|
return { data: [], isLoading: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
useMiniApps();
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith('/automation/miniapps');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useCreateMiniApp', () => {
|
||||||
|
it('calls apiPost with correct endpoint', async () => {
|
||||||
|
const { useCreateMiniApp } = await import('../automation');
|
||||||
|
const hook = useCreateMiniApp();
|
||||||
|
|
||||||
|
const data = { app_id: 'my-app', name: 'My App' };
|
||||||
|
await hook.mutateAsync(data);
|
||||||
|
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith('/automation/miniapps', data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDeleteMiniApp', () => {
|
||||||
|
it('calls apiDelete with correct endpoint', async () => {
|
||||||
|
const { useDeleteMiniApp } = await import('../automation');
|
||||||
|
const hook = useDeleteMiniApp();
|
||||||
|
|
||||||
|
await hook.mutateAsync('my-app');
|
||||||
|
|
||||||
|
expect(mockApiDelete).toHaveBeenCalledWith('/automation/miniapps/my-app');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* React Query hooks for Automation & Agents API.
|
||||||
|
* Follows the pattern from plugins.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
|
||||||
|
import type {
|
||||||
|
AutomationDefinition,
|
||||||
|
AutomationRun,
|
||||||
|
AutomationVersion,
|
||||||
|
AgentDefinition,
|
||||||
|
AgentRun,
|
||||||
|
AgentVersion,
|
||||||
|
AgentTool,
|
||||||
|
AutomationSettings,
|
||||||
|
MiniAppDef,
|
||||||
|
} from '@/types/automation';
|
||||||
|
|
||||||
|
// ── Automation Hooks ──
|
||||||
|
|
||||||
|
export function useAutomations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['automations'],
|
||||||
|
queryFn: () => apiGet<AutomationDefinition[]>('/automation'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutomation(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['automation', id],
|
||||||
|
queryFn: () => apiGet<AutomationDefinition>(`/automation/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAutomation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Partial<AutomationDefinition>) => apiPost<AutomationDefinition>('/automation', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['automations'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAutomation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<AutomationDefinition> }) =>
|
||||||
|
apiPatch<AutomationDefinition>(`/automation/${id}`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['automations'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAutomation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => apiDelete(`/automation/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['automations'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExecuteAutomation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => apiPost(`/automation/${id}/execute`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['automationRuns'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDryRunAutomation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => apiPost(`/automation/${id}/dry-run`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['automationRuns'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutomationRuns(automationId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['automationRuns', automationId],
|
||||||
|
queryFn: () => apiGet<AutomationRun[]>(`/automation/${automationId}/runs`),
|
||||||
|
enabled: !!automationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutomationVersions(automationId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['automationVersions', automationId],
|
||||||
|
queryFn: () => apiGet<AutomationVersion[]>(`/automation/${automationId}/versions`),
|
||||||
|
enabled: !!automationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestoreAutomationVersion() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, versionId }: { id: string; versionId: string }) =>
|
||||||
|
apiPost(`/automation/${id}/versions/${versionId}/restore`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['automations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['automationVersions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutomationSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['automationSettings'],
|
||||||
|
queryFn: () => apiGet<AutomationSettings>('/automation/settings'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAutomationSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Partial<AutomationSettings>) =>
|
||||||
|
apiPatch<AutomationSettings>('/automation/settings', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['automationSettings'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent Hooks ──
|
||||||
|
|
||||||
|
export function useAgents() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agents'],
|
||||||
|
queryFn: () => apiGet<AgentDefinition[]>('/agents'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgent(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agent', id],
|
||||||
|
queryFn: () => apiGet<AgentDefinition>(`/agents/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAgent() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Partial<AgentDefinition>) => apiPost<AgentDefinition>('/agents', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAgent() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<AgentDefinition> }) =>
|
||||||
|
apiPatch<AgentDefinition>(`/agents/${id}`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAgent() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => apiDelete(`/agents/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExecuteAgent() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => apiPost(`/agents/${id}/execute`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agentRuns'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTestRunAgent() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => apiPost(`/agents/${id}/test-run`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agentRuns'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentRuns(agentId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agentRuns', agentId],
|
||||||
|
queryFn: () => apiGet<AgentRun[]>(`/agents/${agentId}/runs`),
|
||||||
|
enabled: !!agentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentVersions(agentId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agentVersions', agentId],
|
||||||
|
queryFn: () => apiGet<AgentVersion[]>(`/agents/${agentId}/versions`),
|
||||||
|
enabled: !!agentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestoreAgentVersion() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, versionId }: { id: string; versionId: string }) =>
|
||||||
|
apiPost(`/agents/${id}/versions/${versionId}/restore`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agentVersions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentTools() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agentTools'],
|
||||||
|
queryFn: () => apiGet<AgentTool[]>('/agents/tools'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent Communication Hooks ──
|
||||||
|
|
||||||
|
export function useSendAgentMessage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, toAgentName, message }: { id: string; toAgentName: string; message: string }) =>
|
||||||
|
apiPost(`/agents/${id}/send-message`, { to_agent_name: toAgentName, message }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agentRuns'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MiniApp Hooks ──
|
||||||
|
|
||||||
|
export function useMiniApps() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['miniapps'],
|
||||||
|
queryFn: () => apiGet<MiniAppDef[]>('/automation/miniapps'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateMiniApp() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Partial<MiniAppDef>) => apiPost<MiniAppDef>('/automation/miniapps', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['miniapps'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMiniApp() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (appId: string) => apiDelete(`/automation/miniapps/${appId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['miniapps'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export * from './plugins';
|
|||||||
export * from './settings';
|
export * from './settings';
|
||||||
export * from './attachments';
|
export * from './attachments';
|
||||||
export * from './unifiedContacts';
|
export * from './unifiedContacts';
|
||||||
|
export * from './automation';
|
||||||
|
|
||||||
// ── Search hook (uses dynamic import, kept inline) ──
|
// ── Search hook (uses dynamic import, kept inline) ──
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,830 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
useAgents,
|
||||||
|
useCreateAgent,
|
||||||
|
useUpdateAgent,
|
||||||
|
useDeleteAgent,
|
||||||
|
useExecuteAgent,
|
||||||
|
useTestRunAgent,
|
||||||
|
useAgentRuns,
|
||||||
|
useAgentVersions,
|
||||||
|
useRestoreAgentVersion,
|
||||||
|
useAgentTools,
|
||||||
|
useSendAgentMessage,
|
||||||
|
} from '@/api/automation';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import type { AgentDefinition } from '@/types/automation';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Play,
|
||||||
|
TestTube,
|
||||||
|
History,
|
||||||
|
GitBranch,
|
||||||
|
Trash2,
|
||||||
|
Settings2,
|
||||||
|
Bot,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
Brain,
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
MessageCircle,
|
||||||
|
Send,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const modeOptions = [
|
||||||
|
{ value: 'reactive', label: 'Reactive' },
|
||||||
|
{ value: 'proactive', label: 'Proactive' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commonModels = [
|
||||||
|
'gpt-4',
|
||||||
|
'gpt-4-turbo',
|
||||||
|
'gpt-3.5-turbo',
|
||||||
|
'claude-3-opus',
|
||||||
|
'claude-3-sonnet',
|
||||||
|
'claude-3-haiku',
|
||||||
|
'llama-3-70b',
|
||||||
|
'llama-3-8b',
|
||||||
|
'mistral-large',
|
||||||
|
'mixtral-8x7b',
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusBadgeVariant(status: string): 'success' | 'warning' | 'secondary' {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'success';
|
||||||
|
case 'inactive': return 'warning';
|
||||||
|
default: return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStatusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success';
|
||||||
|
case 'running': return 'info';
|
||||||
|
case 'failed': return 'danger';
|
||||||
|
case 'cancelled': return 'warning';
|
||||||
|
default: return 'warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
model: string;
|
||||||
|
system_prompt: string;
|
||||||
|
tools: string[];
|
||||||
|
heartbeat_interval: number;
|
||||||
|
mode: 'proactive' | 'reactive';
|
||||||
|
max_executions_per_hour: number;
|
||||||
|
max_duration: number;
|
||||||
|
budget_limit: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: AgentFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
model: 'gpt-4',
|
||||||
|
system_prompt: '',
|
||||||
|
tools: [],
|
||||||
|
heartbeat_interval: 0,
|
||||||
|
mode: 'reactive',
|
||||||
|
max_executions_per_hour: 100,
|
||||||
|
max_duration: 300,
|
||||||
|
budget_limit: 0,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function AgentForm({
|
||||||
|
initial,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isSaving,
|
||||||
|
}: {
|
||||||
|
initial?: AgentDefinition;
|
||||||
|
onSave: (data: Partial<AgentDefinition>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: availableTools } = useAgentTools();
|
||||||
|
const [form, setForm] = useState<AgentFormData>(() => {
|
||||||
|
if (initial) {
|
||||||
|
return {
|
||||||
|
name: initial.name,
|
||||||
|
description: initial.description || '',
|
||||||
|
model: initial.model,
|
||||||
|
system_prompt: initial.system_prompt || '',
|
||||||
|
tools: initial.tools || [],
|
||||||
|
heartbeat_interval: initial.heartbeat_interval,
|
||||||
|
mode: initial.mode,
|
||||||
|
max_executions_per_hour: initial.max_executions_per_hour,
|
||||||
|
max_duration: initial.max_duration,
|
||||||
|
budget_limit: initial.budget_limit,
|
||||||
|
active: initial.active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...emptyForm };
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateField = <K extends keyof AgentFormData>(key: K, value: AgentFormData[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTool = (toolName: string) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const tools = prev.tools.includes(toolName)
|
||||||
|
? prev.tools.filter((t) => t !== toolName)
|
||||||
|
: [...prev.tools, toolName];
|
||||||
|
return { ...prev, tools };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || undefined,
|
||||||
|
model: form.model,
|
||||||
|
system_prompt: form.system_prompt || undefined,
|
||||||
|
tools: form.tools,
|
||||||
|
heartbeat_interval: form.heartbeat_interval,
|
||||||
|
mode: form.mode,
|
||||||
|
max_executions_per_hour: form.max_executions_per_hour,
|
||||||
|
max_duration: form.max_duration,
|
||||||
|
budget_limit: form.budget_limit,
|
||||||
|
active: form.active,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Name & Description */}
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.name')} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="My Agent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.description')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.model')}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.model}
|
||||||
|
onChange={(e) => updateField('model', e.target.value)}
|
||||||
|
list="model-suggestions"
|
||||||
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="gpt-4"
|
||||||
|
/>
|
||||||
|
<datalist id="model-suggestions">
|
||||||
|
{commonModels.map((m) => (
|
||||||
|
<option key={m} value={m} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Prompt */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.systemPrompt')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.system_prompt}
|
||||||
|
onChange={(e) => updateField('system_prompt', e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="You are a helpful assistant..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.mode')}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
options={modeOptions}
|
||||||
|
value={form.mode}
|
||||||
|
onChange={(e) => updateField('mode', e.target.value as 'proactive' | 'reactive')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
{t('agent.tools')}
|
||||||
|
</label>
|
||||||
|
{!availableTools || availableTools.length === 0 ? (
|
||||||
|
<p className="text-sm text-secondary-400 italic">{t('agent.noToolsAvailable')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto">
|
||||||
|
{availableTools.map((tool) => (
|
||||||
|
<label
|
||||||
|
key={tool.name}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.tools.includes(tool.name)}
|
||||||
|
onChange={() => toggleTool(tool.name)}
|
||||||
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-secondary-700">{tool.name}</span>
|
||||||
|
{tool.description && (
|
||||||
|
<p className="text-xs text-secondary-400">{tool.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heartbeat */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.heartbeatInterval')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.heartbeat_interval}
|
||||||
|
onChange={(e) => updateField('heartbeat_interval', parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-secondary-400 mt-1">{t('agent.heartbeatHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rate Limits */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.maxExecutions')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.max_executions_per_hour}
|
||||||
|
onChange={(e) => updateField('max_executions_per_hour', parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.maxDuration')} (s)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.max_duration}
|
||||||
|
onChange={(e) => updateField('max_duration', parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.budgetLimit')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.budget_limit}
|
||||||
|
onChange={(e) => updateField('budget_limit', parseFloat(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Toggle */}
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.active}
|
||||||
|
onChange={(e) => updateField('active', e.target.checked)}
|
||||||
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{t('agent.active')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||||
|
<Button variant="secondary" onClick={onCancel} type="button">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isSaving}>
|
||||||
|
{initial ? t('common.save') : t('common.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunHistoryModal({
|
||||||
|
agentId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
agentId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: runs, isLoading } = useAgentRuns(agentId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={t('agent.runHistory')} size="lg">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !runs || runs.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('agent.noRuns')}
|
||||||
|
description={t('agent.noRunsDesc')}
|
||||||
|
icon={<History className="h-8 w-8" />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{runs.map((run) => (
|
||||||
|
<div
|
||||||
|
key={run.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant={runStatusBadgeVariant(run.status)}>
|
||||||
|
{run.status}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
{new Date(run.started_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{run.error && (
|
||||||
|
<span className="text-xs text-danger-600 max-w-xs truncate" title={run.error}>
|
||||||
|
{run.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionHistoryModal({
|
||||||
|
agentId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
agentId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { data: versions, isLoading } = useAgentVersions(agentId);
|
||||||
|
const restoreMutation = useRestoreAgentVersion();
|
||||||
|
|
||||||
|
const handleRestore = async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
await restoreMutation.mutateAsync({ id: agentId, versionId });
|
||||||
|
toast.success(t('agent.versionRestored'));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={t('agent.versionHistory')} size="lg">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !versions || versions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('agent.noVersions')}
|
||||||
|
icon={<GitBranch className="h-8 w-8" />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{versions.map((ver) => (
|
||||||
|
<div
|
||||||
|
key={ver.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="primary">v{ver.version}</Badge>
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
{new Date(ver.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRestore(ver.id)}
|
||||||
|
isLoading={restoreMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('common.restore')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentDashboardPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { data: agents, isLoading, isError, refetch } = useAgents();
|
||||||
|
const createMutation = useCreateAgent();
|
||||||
|
const updateMutation = useUpdateAgent();
|
||||||
|
const deleteMutation = useDeleteAgent();
|
||||||
|
const executeMutation = useExecuteAgent();
|
||||||
|
const testRunMutation = useTestRunAgent();
|
||||||
|
const sendMessageMutation = useSendAgentMessage();
|
||||||
|
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingAgent, setEditingAgent] = useState<AgentDefinition | undefined>(undefined);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<AgentDefinition | null>(null);
|
||||||
|
const [runHistoryId, setRunHistoryId] = useState<string | null>(null);
|
||||||
|
const [versionHistoryId, setVersionHistoryId] = useState<string | null>(null);
|
||||||
|
const [showAgentChat, setShowAgentChat] = useState(false);
|
||||||
|
const [chatTargetAgent, setChatTargetAgent] = useState('');
|
||||||
|
const [chatMessage, setChatMessage] = useState('');
|
||||||
|
|
||||||
|
const handleSave = async (data: Partial<AgentDefinition>) => {
|
||||||
|
try {
|
||||||
|
if (editingAgent) {
|
||||||
|
await updateMutation.mutateAsync({ id: editingAgent.id, data });
|
||||||
|
toast.success(t('agent.updated'));
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(data);
|
||||||
|
toast.success(t('agent.created'));
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingAgent(undefined);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(confirmDelete.id);
|
||||||
|
toast.success(t('agent.deleted'));
|
||||||
|
setConfirmDelete(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await executeMutation.mutateAsync(id);
|
||||||
|
toast.success(t('agent.executed'));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestRun = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await testRunMutation.mutateAsync(id);
|
||||||
|
toast.success(t('agent.testRunSuccess'));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!chatTargetAgent || !chatMessage.trim()) return;
|
||||||
|
try {
|
||||||
|
await sendMessageMutation.mutateAsync({
|
||||||
|
id: chatTargetAgent,
|
||||||
|
toAgentName: chatTargetAgent,
|
||||||
|
message: chatMessage,
|
||||||
|
});
|
||||||
|
toast.success(t('agent.messageSent'));
|
||||||
|
setChatMessage('');
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (agent: AgentDefinition) => {
|
||||||
|
setEditingAgent(agent);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingAgent(undefined);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto p-6" data-testid="agent-dashboard">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">{t('agent.title')}</h1>
|
||||||
|
<p className="text-sm text-secondary-500 mt-1">{t('agent.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||||
|
{t('agent.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{isError && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 text-danger-600">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<span>{t('common.errorLoading')}</span>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => refetch()}>
|
||||||
|
{t('common.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!isLoading && !isError && (!agents || agents.length === 0) && (
|
||||||
|
<EmptyState
|
||||||
|
title={t('agent.noAgents')}
|
||||||
|
description={t('agent.noAgentsDesc')}
|
||||||
|
icon={<Bot className="h-8 w-8" />}
|
||||||
|
action={
|
||||||
|
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||||
|
{t('agent.createFirst')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent List */}
|
||||||
|
{!isLoading && !isError && agents && agents.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<Card key={agent.id} className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900">{agent.name}</h3>
|
||||||
|
<Badge variant={statusBadgeVariant(agent.active ? 'active' : 'inactive')}>
|
||||||
|
{agent.active ? t('agent.active') : t('agent.inactive')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={agent.mode === 'proactive' ? 'info' : 'secondary'}>
|
||||||
|
{agent.mode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{agent.description && (
|
||||||
|
<p className="text-sm text-secondary-500 mb-2">{agent.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-secondary-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Brain className="h-3 w-3" />
|
||||||
|
{agent.model}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
{agent.tools?.length || 0} tools
|
||||||
|
</span>
|
||||||
|
{agent.heartbeat_interval > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{agent.heartbeat_interval}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{agent.budget_limit > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<DollarSign className="h-3 w-3" />
|
||||||
|
{agent.budget_limit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleExecute(agent.id)}
|
||||||
|
isLoading={executeMutation.isPending}
|
||||||
|
title={t('agent.execute')}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleTestRun(agent.id)}
|
||||||
|
isLoading={testRunMutation.isPending}
|
||||||
|
title={t('agent.testRun')}
|
||||||
|
>
|
||||||
|
<TestTube className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setRunHistoryId(agent.id)}
|
||||||
|
title={t('agent.runHistory')}
|
||||||
|
>
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setVersionHistoryId(agent.id)}
|
||||||
|
title={t('agent.versionHistory')}
|
||||||
|
>
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => openEdit(agent)}
|
||||||
|
title={t('common.edit')}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmDelete(agent)}
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-danger-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
open={showForm}
|
||||||
|
onClose={() => { setShowForm(false); setEditingAgent(undefined); }}
|
||||||
|
title={editingAgent ? t('agent.edit') : t('agent.create')}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<AgentForm
|
||||||
|
initial={editingAgent}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={() => { setShowForm(false); setEditingAgent(undefined); }}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Run History Modal */}
|
||||||
|
{runHistoryId && (
|
||||||
|
<RunHistoryModal
|
||||||
|
agentId={runHistoryId}
|
||||||
|
open={!!runHistoryId}
|
||||||
|
onClose={() => setRunHistoryId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version History Modal */}
|
||||||
|
{versionHistoryId && (
|
||||||
|
<VersionHistoryModal
|
||||||
|
agentId={versionHistoryId}
|
||||||
|
open={!!versionHistoryId}
|
||||||
|
onClose={() => setVersionHistoryId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDelete}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title={t('agent.deleteConfirmTitle')}
|
||||||
|
message={t('agent.deleteConfirmMessage', { name: confirmDelete?.name })}
|
||||||
|
confirmLabel={t('common.delete')}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Agent Chat Section */}
|
||||||
|
<div className="mt-8 border-t border-secondary-200 pt-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-secondary-900">{t('agent.agentChat')}</h2>
|
||||||
|
<p className="text-sm text-secondary-500 mt-1">{t('agent.agentChatSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={showAgentChat ? 'secondary' : 'primary'}
|
||||||
|
onClick={() => setShowAgentChat(!showAgentChat)}
|
||||||
|
icon={<MessageCircle className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
{showAgentChat ? t('common.hide') : t('agent.openChat')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAgentChat && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.targetAgent')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={chatTargetAgent}
|
||||||
|
onChange={(e) => setChatTargetAgent(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">{t('agent.selectTargetAgent')}</option>
|
||||||
|
{agents?.map((agent) => (
|
||||||
|
<option key={agent.id} value={agent.id}>
|
||||||
|
{agent.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.message')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={chatMessage}
|
||||||
|
onChange={(e) => setChatMessage(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder={t('agent.messagePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
isLoading={sendMessageMutation.isPending}
|
||||||
|
disabled={!chatTargetAgent || !chatMessage.trim()}
|
||||||
|
icon={<Send className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
{t('agent.sendMessage')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,777 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
useAutomations,
|
||||||
|
useCreateAutomation,
|
||||||
|
useUpdateAutomation,
|
||||||
|
useDeleteAutomation,
|
||||||
|
useExecuteAutomation,
|
||||||
|
useDryRunAutomation,
|
||||||
|
useAutomationRuns,
|
||||||
|
useAutomationVersions,
|
||||||
|
useRestoreAutomationVersion,
|
||||||
|
} from '@/api/automation';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import type { AutomationDefinition, AutomationCondition, AutomationAction } from '@/types/automation';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
History,
|
||||||
|
GitBranch,
|
||||||
|
Trash2,
|
||||||
|
Settings2,
|
||||||
|
Workflow,
|
||||||
|
Clock,
|
||||||
|
Zap,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const triggerTypeOptions = [
|
||||||
|
{ value: 'event', label: 'Event' },
|
||||||
|
{ value: 'schedule', label: 'Schedule' },
|
||||||
|
{ value: 'manual', label: 'Manual' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actionTypeOptions = [
|
||||||
|
{ value: 'api_call', label: 'API Call' },
|
||||||
|
{ value: 'notification', label: 'Notification' },
|
||||||
|
{ value: 'workflow_start', label: 'Workflow Start' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const conditionOperatorOptions = [
|
||||||
|
{ value: 'equals', label: 'Equals' },
|
||||||
|
{ value: 'not_equals', label: 'Not Equals' },
|
||||||
|
{ value: 'contains', label: 'Contains' },
|
||||||
|
{ value: 'greater_than', label: 'Greater Than' },
|
||||||
|
{ value: 'less_than', label: 'Less Than' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'secondary' {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'success';
|
||||||
|
case 'inactive': return 'warning';
|
||||||
|
default: return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStatusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success';
|
||||||
|
case 'running': return 'info';
|
||||||
|
case 'failed': return 'danger';
|
||||||
|
case 'cancelled': return 'warning';
|
||||||
|
default: return 'warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerIcon(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'event': return <Zap className="h-4 w-4" />;
|
||||||
|
case 'schedule': return <Clock className="h-4 w-4" />;
|
||||||
|
default: return <Play className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutomationFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
trigger_type: 'event' | 'schedule' | 'manual';
|
||||||
|
trigger_config: Record<string, string>;
|
||||||
|
conditions: AutomationCondition[];
|
||||||
|
actions: AutomationAction[];
|
||||||
|
active: boolean;
|
||||||
|
dry_run: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: AutomationFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
trigger_type: 'manual',
|
||||||
|
trigger_config: {},
|
||||||
|
conditions: [],
|
||||||
|
actions: [],
|
||||||
|
active: true,
|
||||||
|
dry_run: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function AutomationForm({
|
||||||
|
initial,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isSaving,
|
||||||
|
}: {
|
||||||
|
initial?: AutomationDefinition;
|
||||||
|
onSave: (data: Partial<AutomationDefinition>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form, setForm] = useState<AutomationFormData>(() => {
|
||||||
|
if (initial) {
|
||||||
|
return {
|
||||||
|
name: initial.name,
|
||||||
|
description: initial.description || '',
|
||||||
|
trigger_type: initial.trigger_type,
|
||||||
|
trigger_config: (initial.trigger_config as Record<string, string>) || {},
|
||||||
|
conditions: initial.conditions || [],
|
||||||
|
actions: initial.actions || [],
|
||||||
|
active: initial.active,
|
||||||
|
dry_run: initial.dry_run,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...emptyForm };
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateField = <K extends keyof AutomationFormData>(key: K, value: AutomationFormData[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCondition = () => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
conditions: [...prev.conditions, { field: '', operator: 'equals', value: '' }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCondition = (index: number, field: keyof AutomationCondition, value: string) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const conditions = [...prev.conditions];
|
||||||
|
conditions[index] = { ...conditions[index], [field]: value };
|
||||||
|
return { ...prev, conditions };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCondition = (index: number) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
conditions: prev.conditions.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAction = () => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
actions: [...prev.actions, { type: 'api_call', config: {} }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAction = (index: number, field: 'type' | 'config', value: string | Record<string, unknown>) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const actions = [...prev.actions];
|
||||||
|
if (field === 'type') {
|
||||||
|
actions[index] = { ...actions[index], type: value as 'api_call' | 'notification' | 'workflow_start' };
|
||||||
|
} else {
|
||||||
|
actions[index] = { ...actions[index], config: value as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
return { ...prev, actions };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAction = (index: number) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
actions: prev.actions.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || undefined,
|
||||||
|
trigger_type: form.trigger_type,
|
||||||
|
trigger_config: form.trigger_config,
|
||||||
|
conditions: form.conditions,
|
||||||
|
actions: form.actions,
|
||||||
|
active: form.active,
|
||||||
|
dry_run: form.dry_run,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Name & Description */}
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('automation.name')} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="My Automation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('automation.description')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trigger Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('automation.triggerType')}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
options={triggerTypeOptions}
|
||||||
|
value={form.trigger_type}
|
||||||
|
onChange={(e) => updateField('trigger_type', e.target.value as 'event' | 'schedule' | 'manual')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trigger Config */}
|
||||||
|
{form.trigger_type === 'event' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('automation.eventName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.trigger_config.event_name || ''}
|
||||||
|
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, event_name: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="contact.created"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.trigger_type === 'schedule' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('automation.cronExpression')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.trigger_config.cron || ''}
|
||||||
|
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, cron: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="0 9 * * *"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-secondary-400 mt-1">Cron expression (e.g. 0 9 * * * for daily at 9am)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Conditions */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-secondary-700">
|
||||||
|
{t('automation.conditions')}
|
||||||
|
</label>
|
||||||
|
<Button size="sm" variant="ghost" onClick={addCondition} type="button">
|
||||||
|
+ {t('common.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{form.conditions.length === 0 && (
|
||||||
|
<p className="text-sm text-secondary-400 italic">{t('automation.noConditions')}</p>
|
||||||
|
)}
|
||||||
|
{form.conditions.map((cond, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cond.field}
|
||||||
|
onChange={(e) => updateCondition(i, 'field', e.target.value)}
|
||||||
|
placeholder="Field"
|
||||||
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={conditionOperatorOptions}
|
||||||
|
value={cond.operator}
|
||||||
|
onChange={(e) => updateCondition(i, 'operator', e.target.value)}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cond.value}
|
||||||
|
onChange={(e) => updateCondition(i, 'value', e.target.value)}
|
||||||
|
placeholder="Value"
|
||||||
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeCondition(i)}
|
||||||
|
className="text-danger-500 hover:text-danger-700 p-1"
|
||||||
|
aria-label="Remove condition"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-secondary-700">
|
||||||
|
{t('automation.actions')}
|
||||||
|
</label>
|
||||||
|
<Button size="sm" variant="ghost" onClick={addAction} type="button">
|
||||||
|
+ {t('common.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{form.actions.length === 0 && (
|
||||||
|
<p className="text-sm text-secondary-400 italic">{t('automation.noActions')}</p>
|
||||||
|
)}
|
||||||
|
{form.actions.map((action, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 mb-2">
|
||||||
|
<Select
|
||||||
|
options={actionTypeOptions}
|
||||||
|
value={action.type}
|
||||||
|
onChange={(e) => updateAction(i, 'type', e.target.value)}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={JSON.stringify(action.config)}
|
||||||
|
onChange={(e) => {
|
||||||
|
try {
|
||||||
|
updateAction(i, 'config', JSON.parse(e.target.value));
|
||||||
|
} catch {
|
||||||
|
// ignore invalid JSON while typing
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder='{"url": "..."}'
|
||||||
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAction(i)}
|
||||||
|
className="text-danger-500 hover:text-danger-700 p-1"
|
||||||
|
aria-label="Remove action"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggles */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.active}
|
||||||
|
onChange={(e) => updateField('active', e.target.checked)}
|
||||||
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{t('automation.active')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.dry_run}
|
||||||
|
onChange={(e) => updateField('dry_run', e.target.checked)}
|
||||||
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{t('automation.dryRun')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||||
|
<Button variant="secondary" onClick={onCancel} type="button">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isSaving}>
|
||||||
|
{initial ? t('common.save') : t('common.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunHistoryModal({
|
||||||
|
automationId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
automationId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: runs, isLoading } = useAutomationRuns(automationId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={t('automation.runHistory')} size="lg">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !runs || runs.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('automation.noRuns')}
|
||||||
|
description={t('automation.noRunsDesc')}
|
||||||
|
icon={<History className="h-8 w-8" />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{runs.map((run) => (
|
||||||
|
<div
|
||||||
|
key={run.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant={runStatusBadgeVariant(run.status)}>
|
||||||
|
{run.status}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
{new Date(run.started_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{run.error && (
|
||||||
|
<span className="text-xs text-danger-600 max-w-xs truncate" title={run.error}>
|
||||||
|
{run.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionHistoryModal({
|
||||||
|
automationId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
automationId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { data: versions, isLoading } = useAutomationVersions(automationId);
|
||||||
|
const restoreMutation = useRestoreAutomationVersion();
|
||||||
|
|
||||||
|
const handleRestore = async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
await restoreMutation.mutateAsync({ id: automationId, versionId });
|
||||||
|
toast.success(t('automation.versionRestored'));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={t('automation.versionHistory')} size="lg">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !versions || versions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('automation.noVersions')}
|
||||||
|
icon={<GitBranch className="h-8 w-8" />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{versions.map((ver) => (
|
||||||
|
<div
|
||||||
|
key={ver.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="primary">v{ver.version}</Badge>
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
{new Date(ver.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRestore(ver.id)}
|
||||||
|
isLoading={restoreMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('common.restore')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomationDashboardPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { data: automations, isLoading, isError, refetch } = useAutomations();
|
||||||
|
const createMutation = useCreateAutomation();
|
||||||
|
const updateMutation = useUpdateAutomation();
|
||||||
|
const deleteMutation = useDeleteAutomation();
|
||||||
|
const executeMutation = useExecuteAutomation();
|
||||||
|
const dryRunMutation = useDryRunAutomation();
|
||||||
|
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingAutomation, setEditingAutomation] = useState<AutomationDefinition | undefined>(undefined);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<AutomationDefinition | null>(null);
|
||||||
|
const [runHistoryId, setRunHistoryId] = useState<string | null>(null);
|
||||||
|
const [versionHistoryId, setVersionHistoryId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSave = async (data: Partial<AutomationDefinition>) => {
|
||||||
|
try {
|
||||||
|
if (editingAutomation) {
|
||||||
|
await updateMutation.mutateAsync({ id: editingAutomation.id, data });
|
||||||
|
toast.success(t('automation.updated'));
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(data);
|
||||||
|
toast.success(t('automation.created'));
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingAutomation(undefined);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(confirmDelete.id);
|
||||||
|
toast.success(t('automation.deleted'));
|
||||||
|
setConfirmDelete(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await executeMutation.mutateAsync(id);
|
||||||
|
toast.success(t('automation.executed'));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDryRun = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await dryRunMutation.mutateAsync(id);
|
||||||
|
toast.success(t('automation.dryRunSuccess'));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (automation: AutomationDefinition) => {
|
||||||
|
setEditingAutomation(automation);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingAutomation(undefined);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto p-6" data-testid="automation-dashboard">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">{t('automation.title')}</h1>
|
||||||
|
<p className="text-sm text-secondary-500 mt-1">{t('automation.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||||
|
{t('automation.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{isError && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 text-danger-600">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<span>{t('common.errorLoading')}</span>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => refetch()}>
|
||||||
|
{t('common.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!isLoading && !isError && (!automations || automations.length === 0) && (
|
||||||
|
<EmptyState
|
||||||
|
title={t('automation.noAutomations')}
|
||||||
|
description={t('automation.noAutomationsDesc')}
|
||||||
|
icon={<Workflow className="h-8 w-8" />}
|
||||||
|
action={
|
||||||
|
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||||
|
{t('automation.createFirst')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Automation List */}
|
||||||
|
{!isLoading && !isError && automations && automations.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{automations.map((automation) => (
|
||||||
|
<Card key={automation.id} className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900">{automation.name}</h3>
|
||||||
|
<Badge variant={statusBadgeVariant(automation.active ? 'active' : 'inactive')}>
|
||||||
|
{automation.active ? t('automation.active') : t('automation.inactive')}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-secondary-400">
|
||||||
|
{triggerIcon(automation.trigger_type)}
|
||||||
|
<span className="capitalize">{automation.trigger_type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{automation.description && (
|
||||||
|
<p className="text-sm text-secondary-500 mb-2">{automation.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-secondary-400">
|
||||||
|
<span>{automation.conditions?.length || 0} conditions</span>
|
||||||
|
<span>{automation.actions?.length || 0} actions</span>
|
||||||
|
{automation.dry_run && (
|
||||||
|
<Badge variant="warning" dot>{t('automation.dryRun')}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleExecute(automation.id)}
|
||||||
|
isLoading={executeMutation.isPending}
|
||||||
|
title={t('automation.execute')}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDryRun(automation.id)}
|
||||||
|
isLoading={dryRunMutation.isPending}
|
||||||
|
title={t('automation.dryRun')}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setRunHistoryId(automation.id)}
|
||||||
|
title={t('automation.runHistory')}
|
||||||
|
>
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setVersionHistoryId(automation.id)}
|
||||||
|
title={t('automation.versionHistory')}
|
||||||
|
>
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => openEdit(automation)}
|
||||||
|
title={t('common.edit')}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmDelete(automation)}
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-danger-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
open={showForm}
|
||||||
|
onClose={() => { setShowForm(false); setEditingAutomation(undefined); }}
|
||||||
|
title={editingAutomation ? t('automation.edit') : t('automation.create')}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<AutomationForm
|
||||||
|
initial={editingAutomation}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={() => { setShowForm(false); setEditingAutomation(undefined); }}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Run History Modal */}
|
||||||
|
{runHistoryId && (
|
||||||
|
<RunHistoryModal
|
||||||
|
automationId={runHistoryId}
|
||||||
|
open={!!runHistoryId}
|
||||||
|
onClose={() => setRunHistoryId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version History Modal */}
|
||||||
|
{versionHistoryId && (
|
||||||
|
<VersionHistoryModal
|
||||||
|
automationId={versionHistoryId}
|
||||||
|
open={!!versionHistoryId}
|
||||||
|
onClose={() => setVersionHistoryId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDelete}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title={t('automation.deleteConfirmTitle')}
|
||||||
|
message={t('automation.deleteConfirmMessage', { name: confirmDelete?.name })}
|
||||||
|
confirmLabel={t('common.delete')}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
useAutomationSettings,
|
||||||
|
useUpdateAutomationSettings,
|
||||||
|
useMiniApps,
|
||||||
|
useCreateMiniApp,
|
||||||
|
useDeleteMiniApp,
|
||||||
|
} from '@/api/automation';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import type { AutomationSettings as AutomationSettingsType, MiniAppDef } from '@/types/automation';
|
||||||
|
import { Save, AlertCircle, Plus, Trash2, AppWindow } from 'lucide-react';
|
||||||
|
|
||||||
|
const logLevelOptions = [
|
||||||
|
{ value: 'debug', label: 'Debug' },
|
||||||
|
{ value: 'info', label: 'Info' },
|
||||||
|
{ value: 'warning', label: 'Warning' },
|
||||||
|
{ value: 'error', label: 'Error' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AutomationSettingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { data: settings, isLoading, isError, refetch } = useAutomationSettings();
|
||||||
|
const updateMutation = useUpdateAutomationSettings();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<AutomationSettingsType>({
|
||||||
|
default_llm_model: '',
|
||||||
|
heartbeat_default_interval: 0,
|
||||||
|
max_concurrent_agents: 0,
|
||||||
|
log_level: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
setForm({
|
||||||
|
default_llm_model: settings.default_llm_model || '',
|
||||||
|
heartbeat_default_interval: settings.heartbeat_default_interval || 0,
|
||||||
|
max_concurrent_agents: settings.max_concurrent_agents || 0,
|
||||||
|
log_level: settings.log_level || 'info',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateField = <K extends keyof AutomationSettingsType>(key: K, value: AutomationSettingsType[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync(form);
|
||||||
|
toast.success(t('automation.settingsSaved'));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: miniapps, isLoading: miniappsLoading, refetch: refetchMiniapps } = useMiniApps();
|
||||||
|
const createMiniAppMutation = useCreateMiniApp();
|
||||||
|
const deleteMiniAppMutation = useDeleteMiniApp();
|
||||||
|
const [showMiniAppForm, setShowMiniAppForm] = useState(false);
|
||||||
|
const [miniAppForm, setMiniAppForm] = useState({
|
||||||
|
app_id: '',
|
||||||
|
name: '',
|
||||||
|
icon: 'AppWindow',
|
||||||
|
description: '',
|
||||||
|
render_schema: '{}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateMiniApp = async () => {
|
||||||
|
try {
|
||||||
|
let render_schema = {};
|
||||||
|
try {
|
||||||
|
render_schema = JSON.parse(miniAppForm.render_schema);
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.invalidJson'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createMiniAppMutation.mutateAsync({
|
||||||
|
app_id: miniAppForm.app_id,
|
||||||
|
name: miniAppForm.name,
|
||||||
|
icon: miniAppForm.icon,
|
||||||
|
description: miniAppForm.description,
|
||||||
|
render_schema,
|
||||||
|
});
|
||||||
|
toast.success(t('automation.miniAppCreated'));
|
||||||
|
setShowMiniAppForm(false);
|
||||||
|
setMiniAppForm({ app_id: '', name: '', icon: 'AppWindow', description: '', render_schema: '{}' });
|
||||||
|
refetchMiniapps();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteMiniApp = async (appId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteMiniAppMutation.mutateAsync(appId);
|
||||||
|
toast.success(t('automation.miniAppDeleted'));
|
||||||
|
refetchMiniapps();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto" data-testid="automation-settings">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">{t('automation.settingsTitle')}</h1>
|
||||||
|
<p className="text-sm text-secondary-500 mt-1">{t('automation.settingsSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 text-danger-600">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<span>{t('common.errorLoading')}</span>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => refetch()}>
|
||||||
|
{t('common.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Default LLM Model */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('automation.defaultLlmModel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.default_llm_model}
|
||||||
|
onChange={(e) => updateField('default_llm_model', e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="gpt-4"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-secondary-400 mt-1">{t('automation.defaultLlmModelHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heartbeat Default Interval */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('automation.heartbeatDefaultInterval')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.heartbeat_default_interval}
|
||||||
|
onChange={(e) => updateField('heartbeat_default_interval', parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-secondary-400 mt-1">{t('automation.heartbeatDefaultIntervalHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Concurrent Agents */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('automation.maxConcurrentAgents')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.max_concurrent_agents}
|
||||||
|
onChange={(e) => updateField('max_concurrent_agents', parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-secondary-400 mt-1">{t('automation.maxConcurrentAgentsHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Level */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('automation.logLevel')}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
options={logLevelOptions}
|
||||||
|
value={form.log_level}
|
||||||
|
onChange={(e) => updateField('log_level', e.target.value as 'debug' | 'info' | 'warning' | 'error')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="pt-4 border-t border-secondary-200">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
isLoading={updateMutation.isPending}
|
||||||
|
icon={<Save className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MiniApp Builder Section */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-secondary-900">{t('automation.miniAppsTitle')}</h2>
|
||||||
|
<p className="text-sm text-secondary-500 mt-1">{t('automation.miniAppsSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowMiniAppForm(true)} icon={<Plus className="h-4 w-4" />}>
|
||||||
|
{t('automation.createMiniApp')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{miniappsLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !miniapps || miniapps.length === 0 ? (
|
||||||
|
<Card className="p-6 text-center">
|
||||||
|
<AppWindow className="h-8 w-8 mx-auto text-secondary-400 mb-2" />
|
||||||
|
<p className="text-sm text-secondary-500">{t('automation.noMiniApps')}</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{miniapps.map((app: MiniAppDef) => (
|
||||||
|
<Card key={app.app_id} className="p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AppWindow className="h-5 w-5 text-primary-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-secondary-900">{app.name}</p>
|
||||||
|
<p className="text-xs text-secondary-400">{app.app_id} — {app.description || t('common.noDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDeleteMiniApp(app.app_id)}
|
||||||
|
isLoading={deleteMiniAppMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-danger-500" />
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create MiniApp Modal */}
|
||||||
|
<Modal
|
||||||
|
open={showMiniAppForm}
|
||||||
|
onClose={() => setShowMiniAppForm(false)}
|
||||||
|
title={t('automation.createMiniApp')}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppId')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={miniAppForm.app_id}
|
||||||
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, app_id: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||||
|
placeholder="my_custom_app"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppName')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={miniAppForm.name}
|
||||||
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, name: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||||
|
placeholder="My Custom App"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppIcon')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={miniAppForm.icon}
|
||||||
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, icon: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||||
|
placeholder="AppWindow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppDescription')}</label>
|
||||||
|
<textarea
|
||||||
|
value={miniAppForm.description}
|
||||||
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, description: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppSchema')}</label>
|
||||||
|
<textarea
|
||||||
|
value={miniAppForm.render_schema}
|
||||||
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, render_schema: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono"
|
||||||
|
placeholder='{"type": "form", "fields": []}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||||
|
<Button variant="secondary" onClick={() => setShowMiniAppForm(false)}>{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={handleCreateMiniApp} isLoading={createMiniAppMutation.isPending}>{t('common.create')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { AgentDashboardPage } from '../AgentDashboard';
|
||||||
|
|
||||||
|
// Helper to create a mock mutation result
|
||||||
|
const mockMutation = (overrides = {}) => ({
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
data: undefined,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the API hooks
|
||||||
|
vi.mock('@/api/automation', () => ({
|
||||||
|
useAgents: vi.fn(),
|
||||||
|
useCreateAgent: vi.fn(() => mockMutation()),
|
||||||
|
useUpdateAgent: vi.fn(() => mockMutation()),
|
||||||
|
useDeleteAgent: vi.fn(() => mockMutation()),
|
||||||
|
useExecuteAgent: vi.fn(() => mockMutation()),
|
||||||
|
useTestRunAgent: vi.fn(() => mockMutation()),
|
||||||
|
useAgentRuns: vi.fn(),
|
||||||
|
useAgentVersions: vi.fn(),
|
||||||
|
useRestoreAgentVersion: vi.fn(() => mockMutation()),
|
||||||
|
useAgentTools: vi.fn(() => ({ data: [] })),
|
||||||
|
useSendAgentMessage: vi.fn(() => mockMutation()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { language: 'en' },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock toast
|
||||||
|
vi.mock('@/components/ui/Toast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock lucide-react icons
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
Activity: () => <div data-testid="icon-activity" />,
|
||||||
|
AlertCircle: () => <div data-testid="icon-alert" />,
|
||||||
|
Bot: () => <div data-testid="icon-bot" />,
|
||||||
|
Brain: () => <div data-testid="icon-brain" />,
|
||||||
|
Clock: () => <div data-testid="icon-clock" />,
|
||||||
|
DollarSign: () => <div data-testid="icon-dollar" />,
|
||||||
|
GitBranch: () => <div data-testid="icon-git-branch" />,
|
||||||
|
History: () => <div data-testid="icon-history" />,
|
||||||
|
Loader2: () => <div data-testid="icon-loader" />,
|
||||||
|
MessageCircle: () => <div data-testid="icon-message-circle" />,
|
||||||
|
Play: () => <div data-testid="icon-play" />,
|
||||||
|
Plus: () => <div data-testid="icon-plus" />,
|
||||||
|
RefreshCw: () => <div data-testid="icon-refresh" />,
|
||||||
|
Send: () => <div data-testid="icon-send" />,
|
||||||
|
Settings2: () => <div data-testid="icon-settings2" />,
|
||||||
|
TestTube: () => <div data-testid="icon-test-tube" />,
|
||||||
|
Trash2: () => <div data-testid="icon-trash" />,
|
||||||
|
Zap: () => <div data-testid="icon-zap" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Modal component
|
||||||
|
vi.mock('@/components/ui/Modal', () => ({
|
||||||
|
Modal: ({ open, title, children }: any) => open ? <div data-testid="modal"><h2>{title}</h2>{children}</div> : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock AgentForm
|
||||||
|
vi.mock('@/components/automation/AgentForm', () => ({
|
||||||
|
AgentForm: () => <div data-testid="agent-form" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Skeleton
|
||||||
|
vi.mock('@/components/ui/Skeleton', () => ({
|
||||||
|
Skeleton: ({ className }: any) => <div data-testid="skeleton" className={className} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Card
|
||||||
|
vi.mock('@/components/ui/Card', () => ({
|
||||||
|
Card: ({ children, className }: any) => <div className={className}>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock EmptyState
|
||||||
|
vi.mock('@/components/ui/EmptyState', () => ({
|
||||||
|
EmptyState: ({ title, description, icon, action }: any) => (
|
||||||
|
<div data-testid="empty-state">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{description}</p>
|
||||||
|
{icon}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Badge
|
||||||
|
vi.mock('@/components/ui/Badge', () => ({
|
||||||
|
Badge: ({ children, variant }: any) => <span data-testid="badge" data-variant={variant}>{children}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAgents = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'A test agent',
|
||||||
|
llm_model: 'gpt-4',
|
||||||
|
mode: 'reactive',
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Inactive Agent',
|
||||||
|
description: 'An inactive agent',
|
||||||
|
llm_model: 'gpt-3.5',
|
||||||
|
mode: 'scheduled',
|
||||||
|
is_active: false,
|
||||||
|
created_at: '2024-01-02T00:00:00Z',
|
||||||
|
updated_at: '2024-01-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>{ui}</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentDashboard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the agent list', async () => {
|
||||||
|
const { useAgents } = await import('@/api/automation');
|
||||||
|
(useAgents as any).mockReturnValue({
|
||||||
|
data: mockAgents,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDashboardPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Agent')).toBeDefined();
|
||||||
|
expect(screen.getByText('Inactive Agent')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', async () => {
|
||||||
|
const { useAgents } = await import('@/api/automation');
|
||||||
|
(useAgents as any).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId('skeleton').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no agents', async () => {
|
||||||
|
const { useAgents } = await import('@/api/automation');
|
||||||
|
(useAgents as any).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDashboardPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('agent.noAgents')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens create form when create button is clicked', async () => {
|
||||||
|
const { useAgents } = await import('@/api/automation');
|
||||||
|
(useAgents as any).mockReturnValue({
|
||||||
|
data: mockAgents,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDashboardPage />);
|
||||||
|
|
||||||
|
const createButton = screen.getByText('agent.create');
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('modal')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls execute API when execute button is clicked', async () => {
|
||||||
|
const { useAgents, useExecuteAgent } = await import('@/api/automation');
|
||||||
|
const mockExecute = vi.fn().mockResolvedValue({});
|
||||||
|
(useAgents as any).mockReturnValue({
|
||||||
|
data: mockAgents,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
(useExecuteAgent as any).mockReturnValue({
|
||||||
|
mutateAsync: mockExecute,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDashboardPage />);
|
||||||
|
|
||||||
|
// Execute buttons have title="agent.execute" - find by title attribute
|
||||||
|
const executeButtons = screen.getAllByTitle('agent.execute');
|
||||||
|
expect(executeButtons.length).toBeGreaterThan(0);
|
||||||
|
fireEvent.click(executeButtons[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockExecute).toHaveBeenCalledWith('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { AutomationDashboardPage } from '../AutomationDashboard';
|
||||||
|
|
||||||
|
// Helper to create a mock mutation result
|
||||||
|
const mockMutation = (overrides = {}) => ({
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
data: undefined,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the API hooks
|
||||||
|
vi.mock('@/api/automation', () => ({
|
||||||
|
useAutomations: vi.fn(),
|
||||||
|
useCreateAutomation: vi.fn(() => mockMutation()),
|
||||||
|
useUpdateAutomation: vi.fn(() => mockMutation()),
|
||||||
|
useDeleteAutomation: vi.fn(() => mockMutation()),
|
||||||
|
useExecuteAutomation: vi.fn(() => mockMutation()),
|
||||||
|
useDryRunAutomation: vi.fn(() => mockMutation()),
|
||||||
|
useAutomationRuns: vi.fn(),
|
||||||
|
useAutomationVersions: vi.fn(),
|
||||||
|
useRestoreAutomationVersion: vi.fn(() => mockMutation()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { language: 'en' },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock toast
|
||||||
|
vi.mock('@/components/ui/Toast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock lucide-react icons
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
AlertCircle: () => <div data-testid="icon-alert" />,
|
||||||
|
CheckCircle2: () => <div data-testid="icon-check-circle" />,
|
||||||
|
Clock: () => <div data-testid="icon-clock" />,
|
||||||
|
GitBranch: () => <div data-testid="icon-git-branch" />,
|
||||||
|
History: () => <div data-testid="icon-history" />,
|
||||||
|
Loader2: () => <div data-testid="icon-loader" />,
|
||||||
|
Play: () => <div data-testid="icon-play" />,
|
||||||
|
Plus: () => <div data-testid="icon-plus" />,
|
||||||
|
RotateCcw: () => <div data-testid="icon-rotate-ccw" />,
|
||||||
|
Settings2: () => <div data-testid="icon-settings2" />,
|
||||||
|
Trash2: () => <div data-testid="icon-trash" />,
|
||||||
|
Workflow: () => <div data-testid="icon-workflow" />,
|
||||||
|
XCircle: () => <div data-testid="icon-x-circle" />,
|
||||||
|
Zap: () => <div data-testid="icon-zap" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Modal component
|
||||||
|
vi.mock('@/components/ui/Modal', () => ({
|
||||||
|
Modal: ({ open, title, children }: any) => open ? <div data-testid="modal"><h2>{title}</h2>{children}</div> : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock AutomationForm
|
||||||
|
vi.mock('@/components/automation/AutomationForm', () => ({
|
||||||
|
AutomationForm: () => <div data-testid="automation-form" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Skeleton
|
||||||
|
vi.mock('@/components/ui/Skeleton', () => ({
|
||||||
|
Skeleton: ({ className }: any) => <div data-testid="skeleton" className={className} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Card
|
||||||
|
vi.mock('@/components/ui/Card', () => ({
|
||||||
|
Card: ({ children, className }: any) => <div className={className}>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock EmptyState
|
||||||
|
vi.mock('@/components/ui/EmptyState', () => ({
|
||||||
|
EmptyState: ({ title, description, icon, action }: any) => (
|
||||||
|
<div data-testid="empty-state">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{description}</p>
|
||||||
|
{icon}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Badge
|
||||||
|
vi.mock('@/components/ui/Badge', () => ({
|
||||||
|
Badge: ({ children, variant }: any) => <span data-testid="badge" data-variant={variant}>{children}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAutomations = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Automation',
|
||||||
|
description: 'A test automation',
|
||||||
|
trigger_type: 'manual',
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Inactive Automation',
|
||||||
|
description: 'An inactive automation',
|
||||||
|
trigger_type: 'event',
|
||||||
|
is_active: false,
|
||||||
|
created_at: '2024-01-02T00:00:00Z',
|
||||||
|
updated_at: '2024-01-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>{ui}</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AutomationDashboard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the automation list', async () => {
|
||||||
|
const { useAutomations } = await import('@/api/automation');
|
||||||
|
(useAutomations as any).mockReturnValue({
|
||||||
|
data: mockAutomations,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AutomationDashboardPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Automation')).toBeDefined();
|
||||||
|
expect(screen.getByText('Inactive Automation')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', async () => {
|
||||||
|
const { useAutomations } = await import('@/api/automation');
|
||||||
|
(useAutomations as any).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AutomationDashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId('skeleton').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no automations', async () => {
|
||||||
|
const { useAutomations } = await import('@/api/automation');
|
||||||
|
(useAutomations as any).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AutomationDashboardPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('automation.noAutomations')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens create form when create button is clicked', async () => {
|
||||||
|
const { useAutomations } = await import('@/api/automation');
|
||||||
|
(useAutomations as any).mockReturnValue({
|
||||||
|
data: mockAutomations,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AutomationDashboardPage />);
|
||||||
|
|
||||||
|
const createButton = screen.getByText('automation.create');
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('modal')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls execute API when execute button is clicked', async () => {
|
||||||
|
const { useAutomations, useExecuteAutomation } = await import('@/api/automation');
|
||||||
|
const mockExecute = vi.fn().mockResolvedValue({});
|
||||||
|
(useAutomations as any).mockReturnValue({
|
||||||
|
data: mockAutomations,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
(useExecuteAutomation as any).mockReturnValue({
|
||||||
|
mutateAsync: mockExecute,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AutomationDashboardPage />);
|
||||||
|
|
||||||
|
// Execute buttons have title="automation.execute" - find by title attribute
|
||||||
|
const executeButtons = screen.getAllByTitle('automation.execute');
|
||||||
|
expect(executeButtons.length).toBeGreaterThan(0);
|
||||||
|
fireEvent.click(executeButtons[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockExecute).toHaveBeenCalledWith('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,9 @@ const AIAssistantPage = React.lazy(() => import('@/pages/AIAssistant').then(m =>
|
|||||||
const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage })));
|
const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage })));
|
||||||
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
|
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
|
||||||
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
|
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
|
||||||
|
const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
|
||||||
|
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
|
||||||
|
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
|
||||||
|
|
||||||
/** Centered spinner fallback for lazy-loaded routes */
|
/** Centered spinner fallback for lazy-loaded routes */
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
@@ -83,6 +86,8 @@ const router = createBrowserRouter([
|
|||||||
{ path: '/mail', element: withSuspense(<MailPage />) },
|
{ path: '/mail', element: withSuspense(<MailPage />) },
|
||||||
{ path: '/mail/settings', element: withSuspense(<MailSettingsPage />) },
|
{ path: '/mail/settings', element: withSuspense(<MailSettingsPage />) },
|
||||||
{ path: '/ai-assistant', element: withSuspense(<AIAssistantPage />) },
|
{ path: '/ai-assistant', element: withSuspense(<AIAssistantPage />) },
|
||||||
|
{ path: '/automation', element: withSuspense(<AutomationDashboardPage />) },
|
||||||
|
{ path: '/agents', element: withSuspense(<AgentDashboardPage />) },
|
||||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -101,6 +106,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'ai', element: withSuspense(<AISettingsPage />) },
|
{ path: 'ai', element: withSuspense(<AISettingsPage />) },
|
||||||
{ path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
|
{ path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
|
||||||
{ path: 'theme', element: withSuspense(<SettingsThemePage />) },
|
{ path: 'theme', element: withSuspense(<SettingsThemePage />) },
|
||||||
|
{ path: 'automation', element: withSuspense(<AutomationSettingsPage />) },
|
||||||
{ path: '*', element: <PluginRouteRenderer /> },
|
{ path: '*', element: <PluginRouteRenderer /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* TypeScript interfaces for Automation & Agents module.
|
||||||
|
* Matches backend schemas from /api/v1/automation and /api/v1/agents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AutomationCondition {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationAction {
|
||||||
|
type: 'api_call' | 'notification' | 'workflow_start';
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
trigger_type: 'event' | 'schedule' | 'manual';
|
||||||
|
trigger_config?: Record<string, unknown>;
|
||||||
|
conditions?: AutomationCondition[];
|
||||||
|
actions?: AutomationAction[];
|
||||||
|
active: boolean;
|
||||||
|
dry_run: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationRun {
|
||||||
|
id: string;
|
||||||
|
automation_id: string;
|
||||||
|
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
started_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationVersion {
|
||||||
|
id: string;
|
||||||
|
automation_id: string;
|
||||||
|
version: number;
|
||||||
|
created_at: string;
|
||||||
|
data: AutomationDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
model: string;
|
||||||
|
system_prompt?: string;
|
||||||
|
tools?: string[];
|
||||||
|
heartbeat_interval: number;
|
||||||
|
mode: 'proactive' | 'reactive';
|
||||||
|
max_executions_per_hour: number;
|
||||||
|
max_duration: number;
|
||||||
|
budget_limit: number;
|
||||||
|
active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRun {
|
||||||
|
id: string;
|
||||||
|
agent_id: string;
|
||||||
|
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
started_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentVersion {
|
||||||
|
id: string;
|
||||||
|
agent_id: string;
|
||||||
|
version: number;
|
||||||
|
created_at: string;
|
||||||
|
data: AgentDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationSettings {
|
||||||
|
default_llm_model: string;
|
||||||
|
heartbeat_default_interval: number;
|
||||||
|
max_concurrent_agents: number;
|
||||||
|
log_level: 'debug' | 'info' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiniAppDef {
|
||||||
|
app_id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
plugin_name: string;
|
||||||
|
render_schema: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecentRun = (AutomationRun | AgentRun) & {
|
||||||
|
type: 'automation' | 'agent';
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user