mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-22 11:51:04 +02:00
Feat/Add API Trigger and Webhooks in Agent Builder (#83)
* feat: add api trigger node for agent runs * feat: add webhook node * Execute webhook nodes post workflow run * Add hint to go to API keys
This commit is contained in:
parent
4ddb144dd0
commit
55b727a872
37 changed files with 3667 additions and 494 deletions
118
api/db/agent_trigger_client.py
Normal file
118
api/db/agent_trigger_client.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Database client for managing agent triggers."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import and_, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import AgentTriggerModel
|
||||
from api.enums import TriggerState
|
||||
|
||||
|
||||
class AgentTriggerClient(BaseDBClient):
|
||||
"""Client for managing agent triggers (UUID -> workflow_id mappings)."""
|
||||
|
||||
async def get_agent_trigger_by_path(
|
||||
self, trigger_path: str, active_only: bool = True
|
||||
) -> Optional[AgentTriggerModel]:
|
||||
"""Get an agent trigger by its unique path (UUID).
|
||||
|
||||
Args:
|
||||
trigger_path: The unique trigger UUID
|
||||
active_only: If True, only return active triggers
|
||||
|
||||
Returns:
|
||||
AgentTriggerModel if found, None otherwise
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
query = select(AgentTriggerModel).where(
|
||||
AgentTriggerModel.trigger_path == trigger_path
|
||||
)
|
||||
|
||||
if active_only:
|
||||
query = query.where(
|
||||
AgentTriggerModel.state == TriggerState.ACTIVE.value
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def sync_triggers_for_workflow(
|
||||
self, workflow_id: int, organization_id: int, trigger_paths: List[str]
|
||||
) -> None:
|
||||
"""Sync triggers for a workflow based on the trigger nodes in the workflow definition.
|
||||
|
||||
This creates/reactivates triggers that are in the workflow definition
|
||||
and archives triggers that are no longer in the workflow.
|
||||
|
||||
Args:
|
||||
workflow_id: ID of the workflow
|
||||
organization_id: ID of the organization
|
||||
trigger_paths: List of trigger UUIDs from the workflow definition
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# Get all existing triggers for this workflow (including archived)
|
||||
result = await session.execute(
|
||||
select(AgentTriggerModel).where(
|
||||
AgentTriggerModel.workflow_id == workflow_id
|
||||
)
|
||||
)
|
||||
existing_triggers = {t.trigger_path: t for t in result.scalars().all()}
|
||||
|
||||
existing_paths = set(existing_triggers.keys())
|
||||
new_paths = set(trigger_paths)
|
||||
|
||||
# Archive triggers that are no longer in the workflow definition
|
||||
paths_to_archive = existing_paths - new_paths
|
||||
if paths_to_archive:
|
||||
await session.execute(
|
||||
update(AgentTriggerModel)
|
||||
.where(AgentTriggerModel.trigger_path.in_(paths_to_archive))
|
||||
.values(state=TriggerState.ARCHIVED.value)
|
||||
)
|
||||
logger.info(
|
||||
f"Archived {len(paths_to_archive)} triggers for workflow {workflow_id}"
|
||||
)
|
||||
|
||||
# Reactivate existing triggers that are back in the workflow
|
||||
paths_to_reactivate = new_paths & existing_paths
|
||||
if paths_to_reactivate:
|
||||
await session.execute(
|
||||
update(AgentTriggerModel)
|
||||
.where(
|
||||
and_(
|
||||
AgentTriggerModel.trigger_path.in_(paths_to_reactivate),
|
||||
AgentTriggerModel.state == TriggerState.ARCHIVED.value,
|
||||
)
|
||||
)
|
||||
.values(state=TriggerState.ACTIVE.value)
|
||||
)
|
||||
|
||||
# Add new triggers
|
||||
paths_to_add = new_paths - existing_paths
|
||||
for trigger_path in paths_to_add:
|
||||
stmt = insert(AgentTriggerModel).values(
|
||||
trigger_path=trigger_path,
|
||||
workflow_id=workflow_id,
|
||||
organization_id=organization_id,
|
||||
state=TriggerState.ACTIVE.value,
|
||||
)
|
||||
# Handle race condition where trigger might already exist for another workflow
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["trigger_path"],
|
||||
set_={
|
||||
"workflow_id": workflow_id,
|
||||
"organization_id": organization_id,
|
||||
"state": TriggerState.ACTIVE.value,
|
||||
},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
if paths_to_add:
|
||||
logger.info(
|
||||
f"Added {len(paths_to_add)} triggers for workflow {workflow_id}"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
from api.db.agent_trigger_client import AgentTriggerClient
|
||||
from api.db.api_key_client import APIKeyClient
|
||||
from api.db.campaign_client import CampaignClient
|
||||
from api.db.embed_token_client import EmbedTokenClient
|
||||
|
|
@ -8,6 +9,7 @@ from api.db.organization_configuration_client import OrganizationConfigurationCl
|
|||
from api.db.organization_usage_client import OrganizationUsageClient
|
||||
from api.db.reports_client import ReportsClient
|
||||
from api.db.user_client import UserClient
|
||||
from api.db.webhook_credential_client import WebhookCredentialClient
|
||||
from api.db.workflow_client import WorkflowClient
|
||||
from api.db.workflow_run_client import WorkflowRunClient
|
||||
from api.db.workflow_template_client import WorkflowTemplateClient
|
||||
|
|
@ -27,6 +29,8 @@ class DBClient(
|
|||
ReportsClient,
|
||||
APIKeyClient,
|
||||
EmbedTokenClient,
|
||||
AgentTriggerClient,
|
||||
WebhookCredentialClient,
|
||||
):
|
||||
"""
|
||||
Unified database client that combines all specialized database operations.
|
||||
|
|
@ -45,6 +49,8 @@ class DBClient(
|
|||
- ReportsClient: handles reports and analytics operations
|
||||
- APIKeyClient: handles API key operations
|
||||
- EmbedTokenClient: handles embed token and session operations
|
||||
- AgentTriggerClient: handles agent trigger operations for API-based call triggering
|
||||
- WebhookCredentialClient: handles webhook credential operations
|
||||
"""
|
||||
|
||||
pass
|
||||
|
|
|
|||
126
api/db/models.py
126
api/db/models.py
|
|
@ -1,3 +1,4 @@
|
|||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from loguru import logger
|
||||
|
|
@ -19,7 +20,14 @@ from sqlalchemy import (
|
|||
)
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
from ..enums import IntegrationAction, WorkflowRunMode, WorkflowRunState, WorkflowStatus
|
||||
from ..enums import (
|
||||
IntegrationAction,
|
||||
TriggerState,
|
||||
WebhookCredentialType,
|
||||
WorkflowRunMode,
|
||||
WorkflowRunState,
|
||||
WorkflowStatus,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
|
@ -676,3 +684,119 @@ class EmbedSessionModel(Base):
|
|||
# Relationships
|
||||
embed_token = relationship("EmbedTokenModel", back_populates="sessions")
|
||||
workflow_run = relationship("WorkflowRunModel")
|
||||
|
||||
|
||||
class AgentTriggerModel(Base):
|
||||
"""Model for storing agent trigger mappings (UUID -> workflow_id).
|
||||
|
||||
This is a minimal lookup table that maps trigger UUIDs to workflows.
|
||||
The trigger node in the workflow definition is the source of truth.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_triggers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Unique trigger path (UUID format) - generated by UI when trigger node is created
|
||||
trigger_path = Column(String(36), unique=True, nullable=False, index=True)
|
||||
|
||||
# Link to workflow
|
||||
workflow_id = Column(
|
||||
Integer, ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
organization_id = Column(
|
||||
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# State management (active/archived)
|
||||
state = Column(
|
||||
Enum(*[state.value for state in TriggerState], name="trigger_state"),
|
||||
nullable=False,
|
||||
default=TriggerState.ACTIVE.value,
|
||||
server_default=text("'active'::trigger_state"),
|
||||
)
|
||||
|
||||
# Audit
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
# Relationships
|
||||
workflow = relationship("WorkflowModel")
|
||||
organization = relationship("OrganizationModel")
|
||||
|
||||
# Indexes for performance
|
||||
__table_args__ = (
|
||||
Index("ix_agent_triggers_workflow_id", "workflow_id"),
|
||||
Index("ix_agent_triggers_state", "state"),
|
||||
)
|
||||
|
||||
|
||||
class ExternalCredentialModel(Base):
|
||||
"""Model for storing external authentication credentials.
|
||||
|
||||
Credentials are stored separately from webhook configurations to allow
|
||||
reuse across multiple workflows and secure storage of sensitive data.
|
||||
"""
|
||||
|
||||
__tablename__ = "external_credentials"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Public UUID reference (used in APIs and workflow definitions)
|
||||
# This prevents enumeration attacks and hides internal IDs
|
||||
credential_uuid = Column(
|
||||
String(36),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
default=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
# Organization scoping
|
||||
organization_id = Column(
|
||||
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Credential metadata
|
||||
name = Column(String, nullable=False) # Display name, e.g., "Salesforce API"
|
||||
description = Column(String, nullable=True) # Optional description
|
||||
|
||||
# Credential type - uses enum from api/enums.py
|
||||
credential_type = Column(
|
||||
Enum(
|
||||
*[t.value for t in WebhookCredentialType],
|
||||
name="webhook_credential_type",
|
||||
),
|
||||
nullable=False,
|
||||
default=WebhookCredentialType.NONE.value,
|
||||
)
|
||||
|
||||
# Encrypted credential data (JSON)
|
||||
# Structure depends on credential_type:
|
||||
# - api_key: {"header_name": "X-API-Key", "api_key": "value"}
|
||||
# - bearer_token: {"token": "value"}
|
||||
# - basic_auth: {"username": "user", "password": "value"}
|
||||
# - custom_header: {"header_name": "X-Custom", "header_value": "value"}
|
||||
credential_data = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
# Audit fields
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Soft delete for safety
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Relationships
|
||||
organization = relationship("OrganizationModel")
|
||||
created_by_user = relationship("UserModel")
|
||||
|
||||
# Indexes and constraints
|
||||
__table_args__ = (
|
||||
Index("ix_webhook_credentials_organization_id", "organization_id"),
|
||||
Index("ix_webhook_credentials_uuid", "credential_uuid"),
|
||||
UniqueConstraint("organization_id", "name", name="unique_org_credential_name"),
|
||||
)
|
||||
|
|
|
|||
220
api/db/webhook_credential_client.py
Normal file
220
api/db/webhook_credential_client.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Database client for managing webhook credentials."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import ExternalCredentialModel
|
||||
|
||||
|
||||
class WebhookCredentialClient(BaseDBClient):
|
||||
"""Client for managing webhook credentials (organization-scoped, UUID-referenced)."""
|
||||
|
||||
async def create_credential(
|
||||
self,
|
||||
organization_id: int,
|
||||
user_id: int,
|
||||
name: str,
|
||||
credential_type: str,
|
||||
credential_data: dict,
|
||||
description: Optional[str] = None,
|
||||
) -> ExternalCredentialModel:
|
||||
"""Create a new webhook credential.
|
||||
|
||||
Args:
|
||||
organization_id: ID of the organization
|
||||
user_id: ID of the user creating the credential
|
||||
name: Display name for the credential
|
||||
credential_type: Type of credential (none, api_key, bearer_token, basic_auth, custom_header)
|
||||
credential_data: JSON data containing the credential details
|
||||
description: Optional description
|
||||
|
||||
Returns:
|
||||
The created ExternalCredentialModel with auto-generated UUID
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
credential = ExternalCredentialModel(
|
||||
organization_id=organization_id,
|
||||
created_by=user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
credential_type=credential_type,
|
||||
credential_data=credential_data,
|
||||
)
|
||||
|
||||
session.add(credential)
|
||||
await session.commit()
|
||||
await session.refresh(credential)
|
||||
|
||||
logger.info(
|
||||
f"Created webhook credential '{name}' ({credential.credential_uuid}) "
|
||||
f"for organization {organization_id}"
|
||||
)
|
||||
return credential
|
||||
|
||||
async def get_credentials_for_organization(
|
||||
self, organization_id: int, active_only: bool = True
|
||||
) -> List[ExternalCredentialModel]:
|
||||
"""Get all credentials for an organization.
|
||||
|
||||
Args:
|
||||
organization_id: ID of the organization
|
||||
active_only: If True, only return active (non-deleted) credentials
|
||||
|
||||
Returns:
|
||||
List of ExternalCredentialModel instances
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
query = select(ExternalCredentialModel).where(
|
||||
ExternalCredentialModel.organization_id == organization_id
|
||||
)
|
||||
|
||||
if active_only:
|
||||
query = query.where(ExternalCredentialModel.is_active.is_(True))
|
||||
|
||||
query = query.order_by(ExternalCredentialModel.name)
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_credential_by_uuid(
|
||||
self, credential_uuid: str, organization_id: int, active_only: bool = True
|
||||
) -> Optional[ExternalCredentialModel]:
|
||||
"""Get a credential by its UUID, scoped to organization.
|
||||
|
||||
Args:
|
||||
credential_uuid: The unique credential UUID
|
||||
organization_id: ID of the organization (for authorization)
|
||||
active_only: If True, only return if active
|
||||
|
||||
Returns:
|
||||
ExternalCredentialModel if found and authorized, None otherwise
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
query = select(ExternalCredentialModel).where(
|
||||
ExternalCredentialModel.credential_uuid == credential_uuid,
|
||||
ExternalCredentialModel.organization_id == organization_id,
|
||||
)
|
||||
|
||||
if active_only:
|
||||
query = query.where(ExternalCredentialModel.is_active.is_(True))
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_credential(
|
||||
self,
|
||||
credential_uuid: str,
|
||||
organization_id: int,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
credential_type: Optional[str] = None,
|
||||
credential_data: Optional[dict] = None,
|
||||
) -> Optional[ExternalCredentialModel]:
|
||||
"""Update a credential by UUID.
|
||||
|
||||
Args:
|
||||
credential_uuid: The unique credential UUID
|
||||
organization_id: ID of the organization (for authorization)
|
||||
name: New name (if provided)
|
||||
description: New description (if provided)
|
||||
credential_type: New credential type (if provided)
|
||||
credential_data: New credential data (if provided)
|
||||
|
||||
Returns:
|
||||
Updated ExternalCredentialModel if found, None otherwise
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# First check if credential exists and belongs to organization
|
||||
credential = await self.get_credential_by_uuid(
|
||||
credential_uuid, organization_id
|
||||
)
|
||||
if not credential:
|
||||
return None
|
||||
|
||||
# Build update values
|
||||
update_values = {"updated_at": datetime.now(UTC)}
|
||||
if name is not None:
|
||||
update_values["name"] = name
|
||||
if description is not None:
|
||||
update_values["description"] = description
|
||||
if credential_type is not None:
|
||||
update_values["credential_type"] = credential_type
|
||||
if credential_data is not None:
|
||||
update_values["credential_data"] = credential_data
|
||||
|
||||
await session.execute(
|
||||
update(ExternalCredentialModel)
|
||||
.where(
|
||||
ExternalCredentialModel.credential_uuid == credential_uuid,
|
||||
ExternalCredentialModel.organization_id == organization_id,
|
||||
)
|
||||
.values(**update_values)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Fetch updated credential
|
||||
result = await session.execute(
|
||||
select(ExternalCredentialModel).where(
|
||||
ExternalCredentialModel.credential_uuid == credential_uuid
|
||||
)
|
||||
)
|
||||
updated_credential = result.scalar_one()
|
||||
|
||||
logger.info(
|
||||
f"Updated webhook credential {credential_uuid} "
|
||||
f"for organization {organization_id}"
|
||||
)
|
||||
return updated_credential
|
||||
|
||||
async def delete_credential(
|
||||
self, credential_uuid: str, organization_id: int
|
||||
) -> bool:
|
||||
"""Soft delete a credential by UUID.
|
||||
|
||||
Args:
|
||||
credential_uuid: The unique credential UUID
|
||||
organization_id: ID of the organization (for authorization)
|
||||
|
||||
Returns:
|
||||
True if credential was deleted, False if not found
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
update(ExternalCredentialModel)
|
||||
.where(
|
||||
ExternalCredentialModel.credential_uuid == credential_uuid,
|
||||
ExternalCredentialModel.organization_id == organization_id,
|
||||
ExternalCredentialModel.is_active.is_(True),
|
||||
)
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
logger.info(
|
||||
f"Soft deleted webhook credential {credential_uuid} "
|
||||
f"for organization {organization_id}"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def validate_credential_uuid(
|
||||
self, credential_uuid: str, organization_id: int
|
||||
) -> bool:
|
||||
"""Check if a credential UUID exists and belongs to the organization.
|
||||
|
||||
This is useful for workflow validation to ensure referenced credentials exist.
|
||||
|
||||
Args:
|
||||
credential_uuid: The credential UUID to validate
|
||||
organization_id: ID of the organization
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
credential = await self.get_credential_by_uuid(credential_uuid, organization_id)
|
||||
return credential is not None
|
||||
|
|
@ -394,8 +394,9 @@ class WorkflowRunClient(BaseDBClient):
|
|||
result = await session.execute(
|
||||
select(WorkflowRunModel)
|
||||
.options(
|
||||
selectinload(WorkflowRunModel.workflow).selectinload(
|
||||
WorkflowModel.user
|
||||
selectinload(WorkflowRunModel.workflow).options(
|
||||
selectinload(WorkflowModel.user),
|
||||
selectinload(WorkflowModel.current_definition),
|
||||
)
|
||||
)
|
||||
.where(WorkflowRunModel.id == workflow_run_id)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue